DynamoDB vector search, end to end: a CDK field guide
DynamoDB can do semantic search natively now. A complete, code-heavy guide to the new vector indexes - the custom resource CloudFormation makes you write, and the score semantics you don't want to get backwards.
On August 5th, DynamoDB shipped native vector search. For years the standard answer to “I want semantic search over my DynamoDB data” was a second database - OpenSearch, Pinecone, pgvector - plus a sync pipeline to keep it updated. Now the answer can be an index on the table you already have.
To find the edges of the feature I built a small but complete recipe API around it: five CRUD endpoints plus a search endpoint where “hot and hearty poultry dish” finds the spicy chicken stew despite sharing zero keywords with it. AWS CDK in TypeScript, API Gateway, Lambda, Bedrock for embeddings, delivered through a CDK Pipeline with real tests. Small enough to read in a sitting, complete enough that I hit the traps you’ll hit. The full source is at github.com/djheru/ddb-vector.
This post is the guide I wanted at the start: everything you need to stand the feature up, in dependency order, with the failure modes labeled. It’s long because the feature has more edges than the announcement suggests.
What actually shipped
A new index type on ordinary DynamoDB tables. The mental model is close to a GSI, with different physics:
- Vectors are stored in the existing List type. An embedding is just an attribute shaped
{ L: [{ N: "0.123" }, { N: "-0.456" }, ...] }. No new data type, no special writer -PutItemas usual. - You create a vector index over that attribute, declaring dimensions, a distance function (
COSINE,EUCLIDEAN, orDOT_PRODUCT), a projection, and optionally which scalar attributes are available as inline filters. - You query it with a new API,
SearchVectors, giving it a query vector and aTopK(max 100). It returns the nearest items with aScore.
And the constraints, all of which shape the build:
| Constraint | Consequence |
|---|---|
| On-demand capacity only | Your table must be PAY_PER_REQUEST |
| No CloudFormation support | You create the index out-of-band via UpdateTable |
| Index config is immutable | Changing dimensions etc. means delete-and-recreate |
| Inline filters are equality-only | No ranges, no IN, in SearchConditionExpression |
TopK caps at 100 | There is no pagination; you build it or live without it |
dynamodb:SearchVectors is a new IAM action | No read-policy bundle includes it yet |
The Score for COSINE is a distance | Lower is better; present it raw and your UI sorts backwards |
DynamoDB doesn’t generate embeddings for you. You bring your own model; I used Titan Text Embeddings V2 through Bedrock (amazon.titan-embed-text-v2:0, 1024 dimensions), which turns out to have its own personality - more on that when we get to thresholds.
The shape of the thing
Writes embed at write time: the create and update handlers call Bedrock, then PutItem the recipe with the embedding as one more attribute. Search embeds the query with the same model and calls SearchVectors. The index itself is created at deploy time by a CDK custom resource, because CloudFormation can’t do it - that’s the next section, and it’s most of the new work in the whole build.
The index CloudFormation can’t create
There is no VectorIndexes property on AWS::DynamoDB::Table yet. The index is created through the UpdateTable API, which leaves you a choice: a post-deploy script you have to remember to run, or a custom resource that makes the index a first-class part of the stack. Take the custom resource. It buys you three things a script can’t: the deployment fails loudly if index creation fails, the deployment doesn’t report success until the index is queryable, and deletes roll back cleanly.
The construct wraps CDK’s provider framework with two small Lambda handlers - onEvent to make changes, isComplete to poll readiness:
const provider = new Provider(this, "Provider", {
onEventHandler: onEvent,
isCompleteHandler: isComplete,
queryInterval: Duration.seconds(15),
// Backfill on a pre-populated table can take a while; fresh tables are fast.
totalTimeout: Duration.minutes(30),
});
// Every config value is a property so CloudFormation detects changes.
this.customResource = new CustomResource(this, "Resource", {
serviceToken: provider.serviceToken,
resourceType: "Custom::DynamoDBVectorIndex",
properties: {
TableName: props.table.tableName,
IndexName: props.indexName,
VectorAttributeName: props.vectorAttributeName,
Dimensions: props.dimensions,
DistanceFunction: props.distanceFunction,
InlineFilterAttributes: props.inlineFilterAttributes,
ProjectionType: props.projectionType,
},
});
Wired into the stack it reads like any other construct:
new VectorIndex(this, "RecipeEmbeddingIndex", {
table,
indexName: "RecipeEmbeddingIndex",
vectorAttributeName: "embedding",
dimensions: 1024, // must match your embedding model
distanceFunction: "COSINE",
inlineFilterAttributes: [{ name: "cuisine", type: "S" }],
projectionType: "ALL",
});
Creating the index
The Create branch of onEvent is one UpdateTable call. This shape is verified working; I’m reproducing it in full because when I started there was almost nothing to copy from:
await dynamodb.send(
new UpdateTableCommand({
TableName: props.TableName,
// Filter attributes must be declared, same as GSI key attributes.
AttributeDefinitions: [{ AttributeName: "cuisine", AttributeType: "S" }],
VectorIndexUpdates: [
{
Create: {
IndexName: props.IndexName,
VectorAttribute: { AttributeName: props.VectorAttributeName },
SearchSchema: [
{
AttributeName: "cuisine",
SearchSchemaElementType: "INLINE_FILTER",
},
],
Projection: { ProjectionType: "ALL" },
Dimensions: Number(props.Dimensions),
DistanceFunction: "COSINE",
},
},
],
}),
);
Two traps hide in that snippet.
First, Number(props.Dimensions). CloudFormation stringifies scalar custom resource properties, so the 1024 you passed in CDK arrives in your handler as "1024". Send that string to the API and you get a validation error that will make you doubt everything else first. Re-parse numerics at the handler boundary.
Second, idempotency. The provider framework retries after partial failures, and “the index already exists” on a retry is success, not an error. The handler catches the create failure, checks DescribeTable for an index under that name, and swallows the error if it finds one.
The Delete branch is the mirror image - VectorIndexUpdates: [{ Delete: { IndexName } }] - and it swallows both index-not-found and table-not-found, because the one thing a delete handler must never do is wedge a stack teardown over a resource that’s already gone.
Readiness is not table status
Here’s the trap that produces flaky deployments if you miss it: after UpdateTable kicks off index creation, the table’s own TableStatus returns to ACTIVE while the index is still building. If your readiness check polls table status, it reports done while the index is minutes from queryable, and the first search requests fail.
The real signal is the index’s own entry in DescribeTable:
const entry = (table.VectorIndexes ?? []).find(
(index) => index.IndexName === indexName,
);
// Table status returns to ACTIVE while the index is still building; the
// index's own status, with no backfill in progress, is the readiness signal.
return {
IsComplete: entry?.IndexStatus === "ACTIVE" && entry.Backfilling !== true,
};
Because isComplete gates the CloudFormation resource, the deployment holds “in progress” until the index can serve queries. The downstream effect on delivery is pleasant: my pipeline’s post-deploy validation step runs a smoke test immediately after the Dev deploy with zero sleeps or retry loops, and it has never needed one. I left a comment in the pipeline saying exactly that, so nobody “fixes” it later by adding a wait.
One operational note: the handler logs the raw DescribeTable output on every poll. The SDK types for a brand-new API surface can lag the wire format, and when they drift you want the actual response in your logs, not a guess.
Immutability, made loud
Everything about a vector index’s configuration - dimensions, distance function, projection, filter attributes - is immutable after creation. There’s no in-place migration; a config change means a new index.
You can let people discover this at deploy time with a confusing API error, or you can make the construct say it plainly. The Update branch compares everything except the index name, and throws with instructions:
if (oldProps && props.IndexName === oldProps.IndexName) {
if (normalizeMaterialConfig(props) !== normalizeMaterialConfig(oldProps)) {
throw new Error(
"Vector index configuration is immutable (dimensions, distance function, " +
"projection, filters). Change indexName to force replacement.",
);
}
return { PhysicalResourceId: props.IndexName };
}
// Renamed: create the replacement now; CloudFormation follows up with a
// Delete for the old physical id, which removes the old index.
await createIndex(props);
return { PhysicalResourceId: props.IndexName };
The trick that makes replacement work is using the index name as the PhysicalResourceId. CloudFormation’s contract is: if an update returns a different physical id, it creates the new resource first, then sends a Delete for the old id. So renaming the index gets you a correct create-then-delete replacement with no extra code. Changing config without renaming fails the deployment on purpose, with a message telling you what to do instead. That error has already paid for itself once.
Getting vectors in
Embedding happens in one shared module, and there’s exactly one invariant that matters: the same model and the same dimensions at write time and query time. Drift either one and searches don’t error - they just return garbage rankings, which is worse. Put the model id and dimensions in one place and import them everywhere.
export const generateEmbedding = async (text: string): Promise<number[]> => {
const response = await bedrock.send(
new InvokeModelCommand({
modelId: process.env.EMBEDDING_MODEL_ID, // amazon.titan-embed-text-v2:0
contentType: "application/json",
accept: "application/json",
body: JSON.stringify({
inputText: text,
dimensions: 1024,
normalize: true,
}),
}),
);
const { embedding } = JSON.parse(new TextDecoder().decode(response.body));
return embedding;
};
What you embed deserves as much thought as how. I embed a composed description of each recipe - name, description, cuisine, dietary tags, ingredient names - and deliberately leave the prep and cook times out:
export const buildEmbeddingText = (recipe: RecipeInput): string =>
[
recipe.name,
recipe.description,
`Cuisine: ${recipe.cuisine}.`,
recipe.dietary?.length ? `Dietary: ${recipe.dietary.join(", ")}.` : "",
`Ingredients: ${recipe.ingredients.map((i) => i.name).join(", ")}.`,
]
.filter(Boolean)
.join(" ");
Numbers like “25 minutes” add noise to a sentence-embedding vector without adding meaning a query would use; nobody searches “recipes that take exactly 25 minutes” semantically. The times stay on the item as regular attributes where a filter could reach them.
The write itself is ordinary. The create handler generates the embedding, then does a single conditional PutItem where embedding is just another attribute in the item. The update handler regenerates the embedding (the text changed, so the vector must) and does one conditional put - attribute_exists(recipeId), mapped to 404 on failure. No read-modify-write.
One asymmetry to know: SearchVectors never returns the vector attribute, even with Projection: ALL - so search responses are clean for free. Plain GetItem returns everything, embedding included, so the get handler deletes the field before responding. A 1024-float array in a JSON response is 20KB of noise nobody asked for.
Searching, and reading the score correctly
The search handler embeds the query, then calls the new API:
const response = await dynamodb.send(
new SearchVectorsCommand({
TableName: process.env.TABLE_NAME,
IndexName: process.env.VECTOR_INDEX_NAME,
SearchVector: queryVector.map((component) => ({ N: String(component) })),
TopK: 100,
...(cuisine !== undefined && {
SearchConditionExpression: "cuisine = :cuisine",
ExpressionAttributeValues: { ":cuisine": { S: cuisine } },
}),
}),
);
Note the query vector goes over the wire as a marshalled AttributeValue list - { N: "0.123" } per component - matching how the embedding is stored.
The score is a distance
Each result carries Item and Score, and here is the mistake I expect to see in half the blog posts written about this feature: for the COSINE distance function, Score is a distance. Lower is better. Zero means identical. It is not a similarity. (Only DOT_PRODUCT scores higher-is-better.) Sort descending by the raw score and you’ve ranked worst-first.
My API converts at the boundary and returns both numbers:
// COSINE Score is a distance: lower is better, 0 means identical.
const distance = result.Score;
const similarity = round(1 - distance); // higher is better; sort & threshold on this
Clients sort and threshold on similarity; distance rides along so anyone comparing against the engine’s raw output can. Whatever convention you pick, write it into your API contract explicitly, because “score” with no stated direction is a bug generator.
Calibrate the threshold to your model, not your intuition
You’ll want a floor under similarity so a search for “chocolate cake” doesn’t proudly return a beef stew at similarity 0.04. My first instinct was a threshold around 0.5. That instinct was wrong, and the reason is model-specific: Titan Text Embeddings V2 runs compressed. Measured on real data, paraphrase-style matches with no shared vocabulary - “hot and hearty poultry dish” against a spicy chicken stew - land around 0.21 to 0.26 cosine similarity. Only queries that share literal words with the document score much higher. A 0.5 threshold silently filters out every good semantic match; 0.3 still kills the paraphrases. I shipped 0.15: keeps cross-vocabulary matches, cuts the noise floor.
Two practical corollaries. Phrase-shaped queries (“hearty chicken dinner”) behave much better than single keywords (“poultry”). And if you switch embedding models, your threshold is meaningless until you re-measure - it’s a property of the model’s geometry, not of your data.
Filters are equality-only
SearchConditionExpression supports =. That’s the list. No ranges, no IN, no begins_with. Filterable attributes must have been declared in the index’s SearchSchema at creation time (and remember: immutable, so decide up front). For “vegetarian italian recipes under 30 minutes” you filter cuisine inline and do the time cut in your own code after results come back.
There is no pagination, so decide what you’ll do about that
SearchVectors has no cursor and TopK caps at 100. Those 100 nearest candidates are all you will ever see for a given query. I built API-level pagination on top: each request fetches the full 100-candidate pool, converts, thresholds, sorts, and slices at an offset carried in an opaque cursor. The cursor embeds a fingerprint of the query and filter, so replaying it against a different query returns 400 instead of nonsense. It works, and it’s honest about its ceiling - you cannot page past the engine’s 100 candidates, and I’d rather the API contract say so than fake it.
For most semantic-search UIs, five to twenty-five results is the product anyway. Know the ceiling exists; don’t fight it.
Two version traps: IAM and the bundled SDK
dynamodb:SearchVectors is its own IAM action. No existing read-oriented managed policy or CDK grantReadData bundle includes it. You grant it explicitly, on the table ARN and the index ARN pattern:
grant(
searchFn,
["dynamodb:SearchVectors"],
[table.tableArn, `${table.tableArn}/index/*`],
);
grant(searchFn, ["bedrock:InvokeModel"], [bedrockModelArn]);
That’s the search function’s entire policy, and a CDK assertion test pins it there - it asserts the policy contains SearchVectors and does not contain dynamodb:* or any write action. On a new API surface it’s tempting to grant broadly “until things settle.” Resist; the assertion test costs ten lines. (Related quirk: the Bedrock model ARN has an empty account field - arn:aws:bedrock:us-east-1::foundation-model/... - because foundation models are account-less. Also, bedrock:InvokeModel on the write path surprises people: create and update embed, so they call Bedrock too.)
The Lambda runtime’s built-in SDK may predate the feature. Vector search landed in the SDKs on August 4th; for JavaScript you need @aws-sdk/client-dynamodb >= 3.1105.0 for SearchVectorsCommand to exist at all. The Node.js Lambda runtimes ship a convenience copy of the SDK, and the usual bundling advice is to externalize it for smaller bundles. Follow that advice here and your code can compile against your local, current SDK, then fail at runtime against the runtime’s older copy, where SearchVectorsCommand may simply not exist. Bundle the SDK in every function that touches the feature:
export const lambdaBundling: BundlingOptions = {
format: OutputFormat.ESM,
target: "node24",
minify: true,
externalModules: [], // bundle the SDK; the runtime copy may predate vector search
banner:
"import { createRequire } from 'module'; const require = createRequire(import.meta.url);",
};
This applies doubly to the custom resource handlers - controlling their SDK version is half the reason they’re bundled the same way. And if TypeScript ever tells you SearchVectorsCommand doesn’t exist, the fix is upgrading the SDK, never “correcting” the call to something that typechecks.
One prerequisite that fails at runtime rather than deploy time: Bedrock model access is a per-account, per-region console toggle. If your first embedding call throws an access error, check that before anything else. And check region coverage - DynamoDB vector search is everywhere, Bedrock’s model lineup isn’t; us-east-1 and us-west-2 have both.
Deployment ordering, stated once
The dependency chain that makes a fresh deploy safe:
- The table exists (on-demand billing - the index requires it).
- The custom resource creates the vector index and blocks until
ACTIVE. - The API deployment depends on the custom resource (
node.addDependency), so the gateway can’t serve a search request before the index can answer one. - Post-deploy validation runs immediately, with no readiness polling of its own.
None of this is clever, which is the point. Structural ordering in the stack beats sleeps in the pipeline every time you’d otherwise be tuning a timeout.
What it costs
Three meters run. Every create, update, and search invokes Bedrock (embeddings are cheap, but it’s a per-request charge on your write path, which is a new thing for a DynamoDB app). Vector writes, vector storage, and SearchVectors calls are billed by DynamoDB separately from standard table usage. And on-demand mode is mandatory, so if you were on provisioned capacity with reserved pricing, this table can’t be.
Scale note: vector indexes support an optional partition key, which scopes each search to a partition. Single-tenant demo scale doesn’t need one; a multi-tenant product almost certainly does. It’s a scale mechanism, not access control - IAM still gates who can call SearchVectors at all.
The checklist I’d hand you
If you’re standing this up next week, in order:
- Table on
PAY_PER_REQUEST. Non-negotiable. - Enable Bedrock model access (or wherever your embeddings come from) in your target region, before writing code.
- Pin
@aws-sdk/client-dynamodb>= 3.1105.0 and bundle it into every Lambda.externalModules: []. - Create the index via a custom resource, not a post-deploy script. Physical id = index name. Poll the index’s status, not the table’s. Re-parse stringified numeric properties.
- Decide index config like it’s permanent, because it is: dimensions, distance function, projection, filter attributes. Make the immutability error loud.
- One shared embedding module. Same model, same dimensions, write and query. Think about what text you embed; leave the numbers out.
- Convert the score at the boundary. For COSINE,
similarity = 1 - Score. Name both fields in your contract. - Measure your model’s similarity range on real paraphrases before picking a threshold. Titan V2: good matches at 0.21-0.26, threshold 0.15.
- Grant
dynamodb:SearchVectorsexplicitly and pin the policy with an assertion test. - Accept the 100-candidate ceiling. Build cursor pagination over the pool if you need pages; don’t pretend to page past it.
The feature is what it looks like: DynamoDB-flavored vector search, with DynamoDB-flavored tradeoffs - operationally boring in the ways you want, opinionated in the ways you have to design around. For the large class of applications whose data already lives in a table and whose search needs fit inside 100 candidates, the second database just became optional. That’s a good trade, and now you know where the edges are.