Subchapter 65.6
references/vector-search.mdMarkdown26 KBView on GitHub
SearchVectorsDynamoDB supports native vector similarity search. A vector index is a third index type
alongside GSIs and LSIs: you declare it on CreateTable or UpdateTable, store embeddings as
an attribute on your items, and query it with the API using approximate
nearest neighbour (ANN) search. The vectors live on the same items as your operational data —
there is no second datastore and no replication pipeline.
Scripts
Benchmark LambdaSearchVectorsRead this file when a design involves semantic similarity, RAG retrieval, recommendations, AI-agent memory, or anomaly detection over data in DynamoDB. Skip it otherwise.
Every error string quoted below is reproduced verbatim from the service, so you can match on it when diagnosing.
Vector search changed where semantic similarity belongs. It changed nothing about lexical search. When a request bundles several kinds of “search,” split it:
| Requirement | Where it goes |
|---|---|
| Semantic / “find similar” / “more like this” / RAG retrieval | Vector index on the DynamoDB table |
| Full-text, keyword, typo-tolerant / fuzzy matching, faceting, aggregations | Zero-ETL to Amazon OpenSearch Service (Integration #8) |
| Geospatial / radius search | Zero-ETL to Amazon OpenSearch Service |
| SQL analytics and BI | Zero-ETL to Amazon Redshift |
Do not route lexical or geo search to a vector index — ANN over embeddings does not do substring, prefix or edit-distance matching. A workload needing both semantic and lexical search legitimately uses both systems.
PAY_PER_REQUEST first — and note the
24-hour capacity-mode cooldown (Fact #3).aws dynamodb search-vectors fails with Found invalid choice, and
hasattr(client, "search_vectors") is False. Prefer that hasattr probe over a version
comparison — it tests the thing you actually need and cannot go stale. A stale SDK is not
evidence the feature does not exist — check before concluding anything, and tell the user
to upgrade rather than reporting the feature as unavailable.TopK must be
1–100. At most one HASH search-schema element per index, and up to 18 inline
filters. Responses cap at 16 MB with no pagination. These values are subject to change and
quotas of this kind generally rise rather than fall, so treat them as the design envelope to
stay inside, not as a fact to quote back to a user who is near a limit — confirm the current
figure in AWS Service Quotas or the DynamoDB developer guide before telling anyone their design
is impossible.Create
actions in a single UpdateTable fail with LimitExceededException: Subscriber limit exceeded: Only 1 online index can be created or deleted simultaneously per table.Query, Scan, PartiQL and DAX do not work against a vector index. Query is
rejected with ValidationException: Query operation not supported on this index type.Every attribute named in SearchSchema must also appear in the table’s
AttributeDefinitions, exactly as a GSI key attribute must. Omitting it fails with
ValidationException: One or more parameter values were invalid: One element in SearchSchema is not defined in attribute definitions.
On a new table, use CreateTable with --vector-indexes:
aws dynamodb create-table \
--table-name Products \
--attribute-definitions AttributeName=ProductId,AttributeType=S \
AttributeName=Category,AttributeType=S \
AttributeName=Brand,AttributeType=S \
--key-schema AttributeName=ProductId,KeyType=HASH \
--billing-mode PAY_PER_REQUEST \
--vector-indexes '[{
"IndexName": "ProductEmbeddingIndex",
"VectorAttribute": {"AttributeName": "Embedding"},
"SearchSchema": [
{"AttributeName": "Category", "SearchSchemaElementType": "HASH"},
{"AttributeName": "Brand", "SearchSchemaElementType": "INLINE_FILTER"}
],
"Projection": {"ProjectionType": "INCLUDE", "NonKeyAttributes": ["Title"]},
"Dimensions": 1024,
"DistanceFunction": "COSINE"
}]'On an existing table, use UpdateTable with --vector-index-updates:
aws dynamodb update-table \
--table-name Products \
--vector-index-updates '[{"Create": {
"IndexName": "ProductEmbeddingIndex",
"VectorAttribute": {"AttributeName": "Embedding"},
"Projection": {"ProjectionType": "ALL"},
"Dimensions": 1024,
"DistanceFunction": "COSINE"
}}]'Delete with --vector-index-updates '[{"Delete": {"IndexName": "ProductEmbeddingIndex"}}]'.
Deleting an index does not touch the base table or its items. A vector index is removed with
its base table, so a table teardown needs no separate index step.
VectorAttribute is an object carrying AttributeName, not a bare string. The distance
function key is DistanceFunction. There is no VectorIndexConfig, no VectorAttributeName,
no DistanceMetric, and no way to configure a vector index through a GSI.
A HASH search-schema element partitions the index so each SearchVectors call examines
only one partition-key value’s vectors. That lowers latency and cost and scales throughput
horizontally — the per-partition-key limits (1 GBps search, 10 MBps write) multiply across
distinct values. Choose low-to-medium cardinality that matches how you query: a tenant id, a
category, a region. Avoid a unique per-item id (each partition has no neighbours, so recall
collapses) and avoid a boolean (no spread).
If you define a HASH element you must supply its value on every search. Omitting it
fails with ValidationException: SearchConditionExpression must be provided when SearchSchema has a HASH key. Defining no SearchSchema at all is simpler and needs no
SearchConditionExpression, but every search then examines the whole index.
INLINE_FILTER elements are optional at search time and are filtered at the storage layer.
The correct readiness predicate is IndexStatus == "ACTIVE" and Backfilling is not
true. There is no BACKFILLING index status value.
Both obvious shortcuts are wrong, because the two creation paths report Backfilling
differently:
| Wait condition | Inline CreateTable | UpdateTable on existing data |
|---|---|---|
“until the Backfilling key disappears” | wrong — already absent while still CREATING | works |
“until Backfilling == false“ | works by accident | wrong — true immediately, minutes early |
IndexStatus == ACTIVE and not Backfilling | correct | correct |
Searching too early fails with ValidationException: Cannot search backfilling vector index: <indexName>.
Backfill duration is driven by index construction, not item count. Adding an index to a
table holding six items took 8m33s in testing, against 20s for an inline
CreateTable on a new table. Poll; do not assume a small table is quick.
ACTIVE is not sufficient on its own. SearchVectors is served by a dedicated search
endpoint, separate from the one serving DescribeTable, and it can lag briefly after the
index reports ACTIVE. During that window searches fail with ValidationException: The table does not have the specified index: <indexName> — the same message you get for a genuinely
wrong index name. Treat a ValidationException on the first searches as retryable and
take the first successful SearchVectors response as the real readiness signal. Code that
searches immediately after observing ACTIVE works in one account and fails in another.
Store the embedding as a list (L) of numbers (N) on the item, written with the
ordinary PutItem / UpdateItem / BatchWriteItem / TransactWriteItems APIs. Because a
vector is long, write it from a file rather than inline on the command line.
| Condition | Behaviour |
|---|---|
Vector length ≠ index Dimensions | Rejected: ValidationException: ... Invalid size for parameter Embedding, Expected: 1024, Actual: 512. IndexName: ... |
Vector sent as a Number Set (NS) instead of L | Rejected, but with a misleading message: ValidationException: Input collection contains duplicates — the set collapsed repeated values. Nothing mentions the type mistake. |
Search-schema HASH attribute missing from the item | Write succeeds. Item is silently excluded from the index. |
Search-schema HASH attribute wrong type or empty string | Rejected |
INLINE_FILTER attribute missing | Write succeeds and the item is indexed |
| Vector attribute deleted from an item | Index entry removed |
If a vector index defines a HASH search-schema element, an item written without that
attribute — or with it later removed via UpdateItem ... REMOVE — is accepted on the base
table with no error and is never replicated into the index. It will not appear in
SearchVectors results even though the item and its embedding are intact and GetItem
returns them normally.
This is expected behaviour, not a defect — it is the same semantics as a sparse GSI, where an item lacking the index key simply is not in the index. It bites harder here because the attribute is usually one every item is supposed to have, and the symptom is retrieval quality quietly degrading rather than a visible error.
Symptom signature. Items absent from similarity search; GetItem returns them; embeddings
valid; IndexStatus ACTIVE; nothing in CloudWatch; no failed writes. When you see that
combination, go straight to “which items are missing the search-schema partition key” — not
to corruption, not to replication lag.
Fix. Re-write the affected items with the attribute restored, preserving the existing embedding. Re-creating an item without its vector leaves it permanently unsearchable, which is worse than the original bug. Do not drop and recreate the index: backfill re-derives from the same base-table items, so nothing changes.
Prevent it on the write path. Treat a search-schema HASH attribute as mandatory on every
item that must be searchable — guard it at write time in the same spirit as the
attribute_exists / attribute_not_exists guards in Patterns #2 — or define no partition key
at all if you cannot guarantee the attribute is always present.
DynamoDB does not recompute embeddings. Change the source content and the stored vector still reflects the old text, so the index keeps returning matches against content that no longer exists. Regenerate the embedding with the same model and write it back whenever the source changes — capturing content changes with DynamoDB Streams and re-embedding downstream is the usual shape (Integration #1 applies: make that consumer idempotent).
aws dynamodb search-vectors \
--table-name Products \
--index-name ProductEmbeddingIndex \
--search-vector file://query-vector.json \
--top-k 10 \
--search-condition-expression "Category = :cat AND Brand = :brand" \
--expression-attribute-values '{":cat": {"S": "Electronics"}, ":brand": {"S": "Acme"}}' \
--projection-expression "ProductId, Title"SearchVector is a plain JSON array of number objects — [{"N": "0.1234"}, ...] — not
wrapped in a DynamoDB L. The L wrapper is only for the stored item attribute. Wrapping it
is rejected client-side before any API call: Invalid type for parameter SearchVector ... valid types: <class 'list'>, <class 'tuple'>.
The query vector must come from the same embedding model and have the same dimensionality
as the index. A mismatch fails with ValidationException: Input search vector dimension 3 does not match vector index dimension 4.
SearchConditionExpression supports the equality operator only. Comparison, range and
set-membership operators (<>, <, <=, >, >=, IN) are rejected with
ValidationException: Invalid SearchConditionExpression: Invalid comparator used in SearchConditionExpression. There is no FilterExpression or Filter parameter on
SearchVectors.
TopK outside 1–100 is rejected — above the range server-side (Provided TopK value '101' is out of valid range. The value must be between 1 and 100 inclusive), below it client-side.
Results arrive sorted most-similar-first, each with the projected attributes and a Score.
Two things surprise people:
ProjectionType: ALL. Request
it explicitly with ProjectionExpression if you want it — and note it is large.GetItem/BatchGetItem after searching.| Function | Direction | Notes |
|---|---|---|
COSINE | Lower is more similar (0 identical → 2 opposite) | Ignores magnitude. The safe default for text embeddings. |
EUCLIDEAN | Lower is more similar | Sensitive to magnitude. Good for near-duplicate detection, image/audio embeddings. |
DOT_PRODUCT | Higher is more similar | Sensitive to magnitude, and scores can be negative. |
Do not assume “higher score is a better match” — that is true only for DOT_PRODUCT, and it
is the reverse for the other two. Do not assume scores are non-negative when applying a
threshold. The simplest safe approach is to rely on the returned ordering rather than
re-sorting by raw score.
The choice is a real ranking decision, not a formality. Measured on identical data with the
identical query vector [1,0,0,0], the stored vector [10,0,0,0] ranks tied-first under
COSINE (0.0) and last under EUCLIDEAN (9.0) — because COSINE ignores its magnitude
and EUCLIDEAN does not. If you use DOT_PRODUCT and want direction-based similarity,
normalise embeddings to unit length first; normalised, it ranks the same as COSINE.
Dimensions, DistanceFunction, SearchSchema (partition key and inline filters) and
Projection are all fixed when the index is created. UpdateTable cannot alter any of them.
Changing one means creating a new index under a new name, waiting for it to backfill,
cutting reads over, then deleting the old one — the same additive-migration shape as a GSI
key change (Fact #9). Because only one index operation runs at a time per table, this is
sequential and slow.
Choose the embedding model before creating the index, since the model fixes the dimensionality. Validate the distance function against a representative dataset first. You can run up to 5 indexes on one table, which is the supported way to A/B two embedding models over the same data: store each model’s vectors in a different attribute, index each, and compare relevance before migrating.
IAM controls vector operations as follows:
dynamodb:CreateTable or dynamodb:UpdateTable on the table.dynamodb:SearchVectors on the index resource, ARN shape
arn:aws:dynamodb:<region>:<account-id>:table/<table-name>/index/<index-name>.Fine-grained access control does not apply to SearchVectors. The dynamodb: condition
keys that enforce FGAC — dynamodb:LeadingKeys, dynamodb:Attributes, dynamodb:Select —
are absent from the SearchVectors request context. A policy statement whose Condition
references one of them therefore does not match, and the result is denial rather than a
grant. Adding dynamodb:SearchVectors to an existing FGAC-conditioned statement silently
breaks vector search while the statement’s other actions keep working normally — which makes
it look like a broken index rather than a policy problem.
Grant dynamodb:SearchVectors in its own statement, scoped to the index ARN, with no
dynamodb: FGAC conditions attached:
{
"Effect": "Allow",
"Action": "dynamodb:SearchVectors",
"Resource": "arn:aws:dynamodb:us-east-1:<account-id>:table/Products/index/ProductEmbeddingIndex",
"Condition": {
"StringEquals": { "aws:PrincipalOrgID": "o-XXXXXXXXXX" }
}
}Note what that Condition is and is not. aws:PrincipalOrgID is a global condition key, so it
evaluates normally and the statement still matches. The keys that must stay out of this statement are
the dynamodb: FGAC ones above. Scope the Resource to the specific index rather than
.../index/* — a wildcard grants search over every index on the table, including ones added later.
“No dynamodb: conditions” does not mean “no conditions”. Only the FGAC keys are missing
from the request context; the standard global condition keys still evaluate normally and should
be used, particularly on a multi-tenant index. aws:PrincipalOrgID and aws:PrincipalTag/*
restrict who may search, aws:SourceVpce and aws:SourceIp restrict from where, and
aws:RequestedRegion bounds the region. What none of them can do is restrict which
partition-key values a permitted principal reaches — that is the gap, and it is narrower than
“this statement cannot be conditioned at all”.
A search-schema partition key is not a security boundary. Scoping searches to one tenant’s
partition-key value is a data-locality and performance optimisation. Any principal holding
dynamodb:SearchVectors on the index can search any partition-key value, and because FGAC
condition keys do not apply you cannot restrict which values they reach at the IAM layer. If
the workload requires strict tenant isolation at the data layer, use separate tables or
separate indexes with distinct IAM grants per tenant. An application layer that injects the
caller’s tenant value and never accepts it from the client is a useful complement, not a
substitute.
Log the searches, because IAM cannot gate them. Preventive controls have a known hole
here, so the detective control carries more weight than it normally would: enable CloudTrail
data-event logging on any table whose vector index serves more than one tenant. It is the
only record of which partition-key value each principal searched, which is exactly the
dimension IAM cannot constrain — so without it a cross-tenant search caused by a compromised
principal or a bug in the application-layer injection leaves no trace at all. Pair it with a
CloudWatch alarm on VectorSearchRequestBytes per principal: a caller enumerating other
tenants’ partitions shows up as a volume anomaly before it shows up anywhere else. Encrypt the
trail’s S3 bucket and any log group with a customer-managed KMS key when the logged
partition-key values are themselves sensitive.
Rate-limit SearchVectors per principal at the application layer. Because every call returns
similarity scores, a caller with legitimate search access can probe the embedding space of a
partition through repeated targeted queries and infer relationships it was never shown directly.
That makes the throttle a confidentiality control as well as a cost one — which matters,
because a throttle filed under “cost” gets dropped from any design where cost is not a concern,
and multi-tenant indexes holding valuable content are exactly where it should not be.
Encryption at rest is inherited from the base table — AWS-owned, AWS-managed or a customer managed KMS key. There is no separate vector index encryption setting.
Treat the embeddings themselves as sensitive data. An embedding is a lossy but real
encoding of the content it was derived from, and similarity results leak information about
neighbouring items even when no raw text is returned — an attacker with search access can
learn that two records are semantically close without ever reading either. Two consequences
worth acting on: the vector attribute being excluded from results by default is a security
default, not just a bandwidth one, so do not add it to ProjectionExpression without a
reason; and anything that logs a full item — Lambda consumers, debug traces, DLQ payloads —
should be treated as handling sensitive data, with KMS encryption on the log group.
Vector index usage is metered in bytes, separately from base-table RCU/WCU, across three dimensions. Rates below are us-east-1 Standard; confirm against the DynamoDB pricing page for your region before quoting a figure.
| Dimension | Usage type | Rate | Derivable from a design? |
|---|---|---|---|
| Index storage | rolls into table storage | $0.25 / GB-month | Yes — dimensions × 4 bytes × indexed items, plus the projected-attribute share |
| Writes | VectorWriteRequest | $0.52 / GB | Yes — per write touching the vector attribute |
| Searches | VectorSearch | $0.002 / GB | No — bills on bytes examined inside the index |
Standard-IA is 125% on both request dimensions and 40% on storage.
Two things make search cost unmodellable from a design alone: it scales with the volume of vector data the search examines, which depends on index size and partition layout rather than on your query vector; and there is a 1 KB minimum per request for both writes and searches. That minimum is per request, not per index — one write touching several of a table’s indexes incurs it once. It also means low-dimension vectors do not meter proportionally lower: a 4-dimension vector holds 16 bytes of f32 data and still meters at 1,024 bytes.
So do not estimate search cost from dimension count. Get VectorSearchRequestBytes and
VectorWriteRequestBytes from ReturnConsumedCapacity on your own workload, or from the
CloudWatch metrics of the same names (dimensioned by TableName and VectorIndexName), and
validate against a representative dataset before sizing.
CreateTable.SearchVectors. Send searches directly to DynamoDB.| Symptom | Cause | Fix |
|---|---|---|
SearchConditionExpression must be provided when SearchSchema has a HASH key | Index defines a partition key; the search omitted its value | Add the equality condition and a matching ExpressionAttributeValues entry |
One element in SearchSchema is not defined in attribute definitions | Search-schema attribute not in AttributeDefinitions | Declare it with its type, as for a GSI key |
Invalid comparator used in SearchConditionExpression | An operator other than = | Rewrite with equality only |
Provided TopK value '<n>' is out of valid range | TopK outside 1–100 | Clamp to 1–100 |
Cannot search backfilling vector index: <name> | Index still CREATING / backfilling | Wait for ACTIVE and not Backfilling, then retry |
The table does not have the specified index: <name> while DescribeTable says ACTIVE | Search endpoint lagging ACTIVE | Retry — treat as transient right after creation. Only suspect a typo if it persists |
Input search vector dimension N does not match vector index dimension M | Query vector wrong length | Use the same model and dimensionality as the index |
Invalid size for parameter <attr>, Expected: N, Actual: M | Stored vector wrong length | Match the index Dimensions |
Input collection contains duplicates on a write | Vector sent as a Number Set (NS); duplicates collapsed | Send it as a List (L) of Numbers (N) |
Query/Scan rejected: ... operation not supported on this index type | Wrong read API | Use SearchVectors |
Item absent from search, GetItem fine, no errors | Missing search-schema HASH attribute — silent de-indexing | Re-write the item with the attribute, preserving its embedding |
| An attribute missing from results, no error | Not projected into the index | Widen the projection (new index) or hydrate from the base table |
| Ranking looks inverted; threshold checks misbehave | Score direction assumed wrong, or negative DOT_PRODUCT scores | Rely on the returned order; see the distance-function table |
AccessDenied on search while other actions work | dynamodb:SearchVectors sits in an FGAC-conditioned statement | Move it to its own condition-free statement scoped to the index ARN |
aws dynamodb search-vectors → Found invalid choice | CLI older than 2.36.16 | Upgrade the CLI; the feature exists |