cd /news/machine-learning/adding-semantic-search-to-an-existin… · home topics machine-learning article
[ARTICLE · art-105234] src=dev.to ↗ pub= topic=machine-learning verified=true sentiment=↑ positive

Adding Semantic Search to an Existing DynamoDB Table with Vector Indexes

A developer demonstrates how to add semantic search to an existing DynamoDB table using the new Vector Search feature, eliminating the need for separate search infrastructure. The approach combines recipe fields into a single text string, generates embeddings with Amazon Bedrock's Titan Text Embeddings V2 model, and stores them directly in DynamoDB for natural language queries.

read6 min views2 publishedAug 20, 2026

Whenever someone asks me to add search to an application, I try to find ways around it. The implementation itself isn't the problem, it's everything that comes with it: extra components to manage, more failure points, and the constant challenge of keeping data in sync. For the past few years, I've worked a lot with DynamoDB and with the introduction of Vector Search I feel a lot more comfortable to add this type of functionality. I wrote last week about why AWS released another vector store and where DynamoDB Vector Search fits in the landscape. In this post I want to show you how you can take an existing DynamoDB table and add vector search to it.

The current API uses a serverless setup. It includes SAM for infrastructure, API Gateway in front, Lambda functions behind, and DynamoDB for storage. The API contains plain CRUD operations to manage recipes: create, read, update, delete, and list.

The complexity arises when you want to add filters to query exactly for what you need. In DynamoDB this means you need to add Global Secondary Indexes (GSI) for every permutation... this is really not scalable. So the only option until now was to have a data pipeline to index the data separately and provide search. That is not the case anymore! With vector search in DynamoDB, we can store vector embeddings alongside our data and search them directly. Now, users can search our recipes using natural language queries and find recipes based on the meaning, not just exact keyword matches.

Semantic search works by turning text into embeddings, which are lists of numbers that represent the meaning of the text. Two pieces of text that mean similar things end up close together in the vector space, so "spicy chicken stew" lands near "hot and hearty poultry dish" even though they share almost no words. The closeness is what lets you search by intent instead of by keyword.

To store an embedding we first need to generate one. For that you need an embedding model, which converts your text into a numerical representation.

I picked Amazon Bedrock's Titan Text Embeddings V2 model. It produces 1024-dimension vectors, returns them normalized, and pairs naturally with cosine similarity.

The first thing you need to figure out is what text you actually want to embed. Our recipes use structured data, that's why I created a single string that combines key fields: name, description, cuisine, dietary tags, and ingredients. Combining them into a single representation means a search can match on any of those fields at once.

function buildEmbeddingText(recipe: RecipeInput): string {
  const ingredientNames = recipe.ingredients.map((i) => i.name).join(", ");
  const dietaryInfo = recipe.dietary?.length ? `Dietary: ${recipe.dietary.join(", ")}.` : "";

  return [
    recipe.name,
    recipe.description,
    `Cuisine: ${recipe.cuisine}.`,
    dietaryInfo,
    `Ingredients: ${ingredientNames}.`,
    `Prep time: ${recipe.prepTimeMinutes} minutes. Cook time: ${recipe.cookTimeMinutes} minutes.`,
  ]
    .filter(Boolean)
    .join(" ");
}

async function generateEmbedding(text: string): Promise<number[]> {
  const response = await bedrock.send(
    new InvokeModelCommand({
      modelId: amazon.titan-embed-text-v2:0,
      contentType: "application/json",
      accept: "application/json",
      body: JSON.stringify({ inputText: text, dimensions: 1024, normalize: true }),
    })
  );
  const result = JSON.parse(new TextDecoder().decode(response.body));
  return result.embedding;
}

Because the embedding is generated inline, every item is searchable the moment it's written.

The piece that makes this work without a separate service is that DynamoDB now supports vector indexes natively. You store the embedding as an attribute on the item, create a vector index over that attribute, and query it with a dedicated similarity API. It's very similar to how we already create a GSI and call it using the Query command.

Note: Vector index isn't supported by CloudFormation yet, so I couldn't define it in my SAM template. Instead, I added a script that runs after deployment and creates the index using the

UpdateTable

command if it doesn't already exist.

I created the index with cosine distance, 1024 dimensions to match the Titan output, and an inline filter on cuisine. The inline filter lets you prefilter results, think of it like the partition key in a regular DynamoDB index but without it being required.

  await dynamodb.send(
    new UpdateTableCommand({
      TableName: tableName,
      AttributeDefinitions: [
        { AttributeName: "cuisine", AttributeType: "S" },
      ],
      VectorIndexUpdates: [
        {
          Create: {
            IndexName: VECTOR_INDEX_NAME,
            VectorAttribute: { AttributeName: "embedding" },
            SearchSchema: [
              { AttributeName: "cuisine", SearchSchemaElementType: "INLINE_FILTER" },
            ],
            Projection: { ProjectionType: "ALL" },
            Dimensions: VECTOR_DIMENSIONS,
            DistanceFunction: "COSINE",
          },
        },
      ],
    })
  );

With the index in place, the search is pretty straightforward. I receive the user's query, embed it with the exact same Titan model I used at write time (using a different model, or different dimensions, would put the query in a different space and the results would be meaningless), then call the SearchVectors

API with that query vector and a TopK

for how many matches I want back.

    // Embed the search query
    const queryVector = await generateEmbedding(query);

    // Vector search across all recipes
    const response = await dynamodb.send(
      new SearchVectorsCommand({
        TableName: TABLE_NAME,
        IndexName: INDEX_NAME,
        SearchVector: queryVector.map((v) => ({ N: String(v) })),
        TopK: TOP_K,
      })
    );

    const results = (response.SearchResults ?? []).map((result) => {
      const item = result.Item!;
      return {
        recipeId: item.recipeId?.S,
        name: item.name?.S,
        cuisine: item.cuisine?.S,
        description: item.description?.S,
        dietary: item.dietary?.L?.map((d) => d.S).filter(Boolean) ?? [],
        prepTimeMinutes: item.prepTimeMinutes?.N ? Number(item.prepTimeMinutes.N) : null,
        cookTimeMinutes: item.cookTimeMinutes?.N ? Number(item.cookTimeMinutes.N) : null,
        servings: item.servings?.N ? Number(item.servings.N) : null,
        score: result.Score,
      };
    });

DynamoDB returns the closest items along with a similarity score, and I pass that score straight through to the caller. For the end user, a request for "something spicy with chicken" now comes back with a ranked list of recipes that actually fit the intent, each with a score that shows how strong the match is.

For apps that can't handle the 100-150ms delay from the embedding call, you'll need to generate the embedding asynchronously. This can be done using DynamoDB streams and a Lambda function to update the item with the embedding. Be careful though, updating the item with the embedding will trigger the stream again, which can create an infinite loop.

If your table has existing data, those items won't have embeddings. So, they won't show up in vector search results. In this case you will need to run a backfill. This is a one-time script that will scan your table, generate embeddings for each item, and update them. If a full scan isn't possible, you can use DynamoDB Export to S3 and then run batches asynchronously.

If you want a detailed walkthrough of either solution, just let me know. I can write it up.

The whole thing lives in the same DynamoDB table where the data already was. No separate search service, no synchronization pipeline, no extra infrastructure to operate. Generate the embedding at write time, store it on the item, and query it with a vector index.

The biggest takeaway for me is that choosing what text to embed matters more than I expected. Combining multiple fields into a single representation gives the search much more to work with than just a name or description alone.

In the next post, I'll dive into AgentCore Gateway and how it can turn this REST API into an MCP server without rewriting anything.

If you've been putting off adding semantic search because you didn't want to run a separate search stack, it's worth a fresh look if you are already using DynamoDB. The data can stay right where it is.

You can find the full code in the recipe-catalogue repository.

Andres Moreno

── more in #machine-learning 4 stories · sorted by recency
── more on @dynamodb 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/adding-semantic-sear…] indexed:0 read:6min 2026-08-20 ·