dynamodb-haystack 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1004 @@
1
+ # SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ import asyncio
6
+ import base64
7
+ import dataclasses
8
+ import json
9
+ import time
10
+ from collections.abc import AsyncIterator, Iterator
11
+ from contextlib import asynccontextmanager
12
+ from typing import Any
13
+
14
+ import aiobotocore.session
15
+ import boto3
16
+ from botocore.exceptions import ClientError
17
+ from haystack import default_from_dict, default_to_dict, logging
18
+ from haystack.dataclasses import Document
19
+ from haystack.document_stores.errors import DuplicateDocumentError
20
+ from haystack.document_stores.types import DuplicatePolicy
21
+ from haystack.utils.auth import Secret, deserialize_secrets_inplace
22
+ from haystack.utils.filters import document_matches_filter
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ # DynamoDB `SearchVectors` returns a `Score` whose meaning depends on the index's distance
27
+ # function. For a COSINE index the score is a *distance*: per the AWS docs it ranges from 0
28
+ # (identical) to 2 (opposite), and DynamoDB returns the k *smallest* scores — lower means more
29
+ # similar. Haystack's `Document.score` uses the opposite convention (higher = more relevant), so
30
+ # the distance is converted to a similarity below. Verified on real AWS: an identical vector
31
+ # scored 0.0 and an orthogonal one scored 1.0.
32
+ _COSINE_MAX_DISTANCE = 2.0
33
+
34
+ # Hard, non-adjustable service quota on `TopK` per `SearchVectors` request; see "Vector indexes" in
35
+ # https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/ServiceQuotas.html
36
+ SEARCH_VECTORS_MAX_TOP_K = 100
37
+ _VECTOR_ATTRIBUTE = "embedding"
38
+ # `id` is referenced through a placeholder in expressions so it can never clash with DynamoDB reserved words.
39
+ _ID_PLACEHOLDER = {"#id": "id"}
40
+
41
+
42
+ class DynamoDBDocumentStore:
43
+ """
44
+ A Haystack DocumentStore backed by Amazon DynamoDB native vector search.
45
+
46
+ Uses the `SearchVectors` API (GA 2026-08-05). Documents are stored as items in a
47
+ DynamoDB table with a vector index, and retrieved via cosine similarity search. Every
48
+ method has an `_async` counterpart built on `aiobotocore`.
49
+
50
+ Limitations to weigh before choosing this store:
51
+
52
+ - `filter_documents`, `count_documents` and the filter-based bulk operations run a
53
+ consistent full-table `Scan` and evaluate Haystack filters client-side, so their cost
54
+ grows with the table size. `SearchVectors` can only filter on attributes fixed in the
55
+ index `SearchSchema` at creation time, which arbitrary Haystack filters cannot use.
56
+ - `SearchVectors` returns at most 100 candidates per request
57
+ (`SEARCH_VECTORS_MAX_TOP_K`), so `top_k` cannot exceed 100 and filtered retrieval can
58
+ only choose among those candidates.
59
+ - A DynamoDB item is limited to 400 KB, which bounds a document's content, metadata and
60
+ embedding together.
61
+
62
+ Example usage:
63
+
64
+ ```python
65
+ from haystack_integrations.document_stores.dynamodb import DynamoDBDocumentStore
66
+
67
+ store = DynamoDBDocumentStore(
68
+ table_name="haystack-documents",
69
+ index_name="haystack-vector-index",
70
+ embedding_dimension=768,
71
+ region_name="us-east-1",
72
+ )
73
+ ```
74
+ """
75
+
76
+ def __init__(
77
+ self,
78
+ *,
79
+ table_name: str = "haystack_documents",
80
+ index_name: str = "haystack_vector_index",
81
+ embedding_dimension: int = 768,
82
+ region_name: str | None = None,
83
+ aws_access_key_id: Secret = Secret.from_env_var("AWS_ACCESS_KEY_ID", strict=False),
84
+ aws_secret_access_key: Secret = Secret.from_env_var("AWS_SECRET_ACCESS_KEY", strict=False),
85
+ aws_session_token: Secret = Secret.from_env_var("AWS_SESSION_TOKEN", strict=False),
86
+ create_table_if_not_exists: bool = True,
87
+ similarity_function: str = "cosine",
88
+ ) -> None:
89
+ """
90
+ Creates a new DynamoDBDocumentStore instance.
91
+
92
+ :param table_name: Name of the DynamoDB table to store documents in. Created if it
93
+ does not exist and `create_table_if_not_exists` is `True`.
94
+ :param index_name: Name of the vector index on the table.
95
+ :param embedding_dimension: Dimensionality of document embeddings.
96
+ :param region_name: AWS region. Defaults to the boto3 session's configured region.
97
+ :param aws_access_key_id: AWS access key as a `Secret`. Defaults to `AWS_ACCESS_KEY_ID`
98
+ env var, falling back to the default boto3 credential chain if not set.
99
+ :param aws_secret_access_key: AWS secret key as a `Secret`. Defaults to
100
+ `AWS_SECRET_ACCESS_KEY` env var.
101
+ :param aws_session_token: AWS session token as a `Secret`, for temporary credentials.
102
+ Defaults to `AWS_SESSION_TOKEN` env var.
103
+ :param create_table_if_not_exists: If `True`, create the table and vector index on
104
+ first use if they don't already exist.
105
+ :param similarity_function: Vector similarity function. This integration currently supports
106
+ only `"cosine"`. DynamoDB itself also offers `DOT_PRODUCT` and `EUCLIDEAN` indexes, but
107
+ their score conversion is not implemented yet.
108
+ :raises ValueError: If `similarity_function` is not `"cosine"`.
109
+ """
110
+ if similarity_function != "cosine":
111
+ msg = (
112
+ f"This integration currently supports only 'cosine', got {similarity_function!r}. "
113
+ "DynamoDB also offers DOT_PRODUCT and EUCLIDEAN vector indexes, but they are not wired up yet."
114
+ )
115
+ raise ValueError(msg)
116
+
117
+ self.table_name = table_name
118
+ self.index_name = index_name
119
+ self.embedding_dimension = embedding_dimension
120
+ self.region_name = region_name
121
+ self.aws_access_key_id = aws_access_key_id
122
+ self.aws_secret_access_key = aws_secret_access_key
123
+ self.aws_session_token = aws_session_token
124
+ self.create_table_if_not_exists = create_table_if_not_exists
125
+ self.similarity_function = similarity_function
126
+ # Vector-index readiness polling (see `_wait_for_vector_index_ready`). With the index
127
+ # declared inline on CreateTable it is queryable in ~20s, so poll frequently; the timeout
128
+ # stays generous to tolerate a slower region or a pre-existing table still backfilling.
129
+ self.index_ready_timeout = 900.0
130
+ self.index_ready_poll_interval = 5.0
131
+ # `SearchVectors` availability probe after creating a table (see `_wait_for_vector_search_available`).
132
+ self.search_available_timeout = 60.0
133
+ self.search_available_poll_interval = 2.0
134
+ self._client: Any | None = None
135
+ self._async_session: Any | None = None
136
+ self._table_ready = False
137
+
138
+ # ------------------------------------------------------------------ clients
139
+
140
+ def _client_kwargs(self) -> dict[str, Any]:
141
+ kwargs: dict[str, Any] = {}
142
+ if self.region_name:
143
+ kwargs["region_name"] = self.region_name
144
+ access_key = self.aws_access_key_id.resolve_value()
145
+ secret_key = self.aws_secret_access_key.resolve_value()
146
+ session_token = self.aws_session_token.resolve_value()
147
+ if access_key:
148
+ kwargs["aws_access_key_id"] = access_key
149
+ if secret_key:
150
+ kwargs["aws_secret_access_key"] = secret_key
151
+ if session_token:
152
+ kwargs["aws_session_token"] = session_token
153
+ return kwargs
154
+
155
+ def _get_client(self) -> Any:
156
+ if self._client is None:
157
+ self._client = boto3.client("dynamodb", **self._client_kwargs())
158
+ return self._client
159
+
160
+ @asynccontextmanager
161
+ async def _async_client(self) -> AsyncIterator[Any]:
162
+ """
163
+ Yields an aiobotocore DynamoDB client for the duration of one operation.
164
+
165
+ aiobotocore clients are async context managers bound to the running event loop, so one
166
+ is created per call instead of being cached like the sync client.
167
+ """
168
+ if self._async_session is None:
169
+ self._async_session = aiobotocore.session.AioSession()
170
+ async with self._async_session.create_client("dynamodb", **self._client_kwargs()) as client:
171
+ yield client
172
+
173
+ # ------------------------------------------------------------------ table lifecycle
174
+
175
+ def _ensure_table(self) -> None:
176
+ if self._table_ready:
177
+ return
178
+ client = self._get_client()
179
+ description = self._describe_table(client)
180
+ if description is None:
181
+ if not self.create_table_if_not_exists:
182
+ raise self._missing_table_error()
183
+ self._create_table(client)
184
+ client.get_waiter("table_exists").wait(TableName=self.table_name)
185
+ description = client.describe_table(TableName=self.table_name)["Table"]
186
+ self._validate_table(description)
187
+ self._wait_for_vector_index_ready(client, description)
188
+ self._wait_for_vector_search_available(client)
189
+ else:
190
+ self._validate_table(description)
191
+ self._wait_for_vector_index_ready(client, description)
192
+ self._table_ready = True
193
+
194
+ async def _ensure_table_async(self) -> None:
195
+ if self._table_ready:
196
+ return
197
+ async with self._async_client() as client:
198
+ description = await self._describe_table_async(client)
199
+ if description is None:
200
+ if not self.create_table_if_not_exists:
201
+ raise self._missing_table_error()
202
+ await self._create_table_async(client)
203
+ await client.get_waiter("table_exists").wait(TableName=self.table_name)
204
+ description = (await client.describe_table(TableName=self.table_name))["Table"]
205
+ self._validate_table(description)
206
+ await self._wait_for_vector_index_ready_async(client, description)
207
+ await self._wait_for_vector_search_available_async(client)
208
+ else:
209
+ self._validate_table(description)
210
+ await self._wait_for_vector_index_ready_async(client, description)
211
+ self._table_ready = True
212
+
213
+ def _missing_table_error(self) -> ValueError:
214
+ msg = f"Table '{self.table_name}' does not exist and create_table_if_not_exists is False."
215
+ return ValueError(msg)
216
+
217
+ @staticmethod
218
+ def _error_code(error: ClientError) -> str:
219
+ return error.response["Error"]["Code"]
220
+
221
+ def _describe_table(self, client: Any) -> dict[str, Any] | None:
222
+ """Returns the `DescribeTable` payload for this store's table, or `None` if it does not exist."""
223
+ try:
224
+ return client.describe_table(TableName=self.table_name)["Table"]
225
+ except ClientError as e:
226
+ if self._error_code(e) != "ResourceNotFoundException":
227
+ raise
228
+ return None
229
+
230
+ async def _describe_table_async(self, client: Any) -> dict[str, Any] | None:
231
+ try:
232
+ return (await client.describe_table(TableName=self.table_name))["Table"]
233
+ except ClientError as e:
234
+ if self._error_code(e) != "ResourceNotFoundException":
235
+ raise
236
+ return None
237
+
238
+ def _create_table(self, client: Any) -> None:
239
+ self._log_table_creation()
240
+ try:
241
+ client.create_table(**self._create_table_params())
242
+ except ClientError as e:
243
+ # Another process created the table between our DescribeTable and CreateTable; the
244
+ # caller waits for it to become active just like for a table we created ourselves.
245
+ if self._error_code(e) != "ResourceInUseException":
246
+ raise
247
+
248
+ async def _create_table_async(self, client: Any) -> None:
249
+ self._log_table_creation()
250
+ try:
251
+ await client.create_table(**self._create_table_params())
252
+ except ClientError as e:
253
+ if self._error_code(e) != "ResourceInUseException":
254
+ raise
255
+
256
+ def _log_table_creation(self) -> None:
257
+ logger.info(
258
+ "Creating DynamoDB table '{table}' with vector index '{index}'",
259
+ table=self.table_name,
260
+ index=self.index_name,
261
+ )
262
+
263
+ def _create_table_params(self) -> dict[str, Any]:
264
+ # Declare the vector index inline on `CreateTable` rather than adding it afterwards with
265
+ # `UpdateTable(VectorIndexUpdates=...)`. Adding an index to an existing table triggers a
266
+ # *backfill* that keeps it in `IndexStatus=CREATING`/`Backfilling=True`, and therefore
267
+ # unqueryable, for many minutes (measured: >6 min even for an empty table). An index
268
+ # created with the table has nothing to backfill and is queryable in ~20s.
269
+ # Vector indexes are a distinct index type from GSIs/LSIs, hence `VectorIndexes` here.
270
+ return {
271
+ "TableName": self.table_name,
272
+ "AttributeDefinitions": [{"AttributeName": "id", "AttributeType": "S"}],
273
+ "KeySchema": [{"AttributeName": "id", "KeyType": "HASH"}],
274
+ "BillingMode": "PAY_PER_REQUEST",
275
+ "VectorIndexes": [
276
+ {
277
+ "IndexName": self.index_name,
278
+ "VectorAttribute": {"AttributeName": _VECTOR_ATTRIBUTE},
279
+ "Dimensions": self.embedding_dimension,
280
+ "DistanceFunction": "COSINE",
281
+ "Projection": {"ProjectionType": "ALL"},
282
+ }
283
+ ],
284
+ }
285
+
286
+ def _find_vector_index(self, description: dict[str, Any]) -> dict[str, Any] | None:
287
+ indexes = description.get("VectorIndexes") or []
288
+ return next((idx for idx in indexes if idx.get("IndexName") == self.index_name), None)
289
+
290
+ def _validate_table(self, description: dict[str, Any]) -> None:
291
+ """
292
+ Checks that an existing table matches this store's configuration.
293
+
294
+ Without this check a mismatched table only fails later, on the first `SearchVectors` or
295
+ `PutItem` call, with an opaque service error.
296
+
297
+ :param description: The `Table` payload returned by `DescribeTable`.
298
+ :raises ValueError: If the key schema or the vector index is incompatible with this store.
299
+ """
300
+ key_schema = description.get("KeySchema", [])
301
+ if [(k.get("AttributeName"), k.get("KeyType")) for k in key_schema] != [("id", "HASH")]:
302
+ msg = (
303
+ f"Table '{self.table_name}' must have a single partition key named 'id' and no sort key, "
304
+ f"found key schema {key_schema}."
305
+ )
306
+ raise ValueError(msg)
307
+
308
+ index = self._find_vector_index(description)
309
+ if index is None:
310
+ existing = [idx.get("IndexName") for idx in description.get("VectorIndexes") or []]
311
+ msg = (
312
+ f"Table '{self.table_name}' has no vector index named '{self.index_name}' "
313
+ f"(existing vector indexes: {existing}). Create one with DistanceFunction=COSINE, "
314
+ f"Dimensions={self.embedding_dimension} on attribute '{_VECTOR_ATTRIBUTE}', "
315
+ "or point the store at a different table."
316
+ )
317
+ raise ValueError(msg)
318
+
319
+ problems = []
320
+ if index.get("Dimensions") != self.embedding_dimension:
321
+ problems.append(f"Dimensions={index.get('Dimensions')} (expected {self.embedding_dimension})")
322
+ if index.get("DistanceFunction") != "COSINE":
323
+ problems.append(f"DistanceFunction={index.get('DistanceFunction')} (expected COSINE)")
324
+ vector_attribute = (index.get("VectorAttribute") or {}).get("AttributeName")
325
+ if vector_attribute != _VECTOR_ATTRIBUTE:
326
+ problems.append(f"VectorAttribute={vector_attribute!r} (expected {_VECTOR_ATTRIBUTE!r})")
327
+ if problems:
328
+ msg = (
329
+ f"Vector index '{self.index_name}' on table '{self.table_name}' does not match this store's "
330
+ f"configuration: {', '.join(problems)}."
331
+ )
332
+ raise ValueError(msg)
333
+
334
+ def _index_readiness(self, description: dict[str, Any]) -> tuple[bool, str, Any]:
335
+ """Returns `(ready, status, backfilling)` for this store's vector index from a `DescribeTable` payload."""
336
+ index = self._find_vector_index(description)
337
+ if index is None:
338
+ return False, "not reported", "n/a"
339
+ status = index.get("IndexStatus", "unknown")
340
+ # `Backfilling` is absent entirely for an index that never had to backfill.
341
+ backfilling = index.get("Backfilling", False)
342
+ return status == "ACTIVE" and not backfilling, status, backfilling
343
+
344
+ def _index_not_ready_error(self, status: str, backfilling: Any) -> TimeoutError:
345
+ msg = (
346
+ f"Vector index '{self.index_name}' on table '{self.table_name}' did not become "
347
+ f"queryable within {self.index_ready_timeout}s "
348
+ f"(last status: {status}, backfilling: {backfilling})."
349
+ )
350
+ return TimeoutError(msg)
351
+
352
+ def _wait_for_vector_index_ready(self, client: Any, description: dict[str, Any]) -> None:
353
+ """
354
+ Blocks until the vector index is queryable.
355
+
356
+ A vector index has its own lifecycle that is *not* captured by the table's status or the
357
+ ``table_exists`` waiter, and there is no dedicated boto3 waiter for it, so we poll
358
+ ``DescribeTable`` until the index reports ``IndexStatus=ACTIVE`` and is not backfilling.
359
+ ``SearchVectors`` may still return ``ResourceNotFoundException`` for a few seconds after
360
+ that while the vector-search endpoint catches up; see `_wait_for_vector_search_available`.
361
+
362
+ :param client: The DynamoDB client to poll with.
363
+ :param description: The most recent `Table` payload from `DescribeTable`.
364
+ :raises TimeoutError: If the index does not become queryable within `index_ready_timeout`.
365
+ """
366
+ deadline = time.monotonic() + self.index_ready_timeout
367
+ while True:
368
+ ready, status, backfilling = self._index_readiness(description)
369
+ if ready:
370
+ return
371
+ if time.monotonic() >= deadline:
372
+ raise self._index_not_ready_error(status, backfilling)
373
+ time.sleep(self.index_ready_poll_interval)
374
+ description = client.describe_table(TableName=self.table_name)["Table"]
375
+
376
+ async def _wait_for_vector_index_ready_async(self, client: Any, description: dict[str, Any]) -> None:
377
+ deadline = time.monotonic() + self.index_ready_timeout
378
+ while True:
379
+ ready, status, backfilling = self._index_readiness(description)
380
+ if ready:
381
+ return
382
+ if time.monotonic() >= deadline:
383
+ raise self._index_not_ready_error(status, backfilling)
384
+ await asyncio.sleep(self.index_ready_poll_interval)
385
+ description = (await client.describe_table(TableName=self.table_name))["Table"]
386
+
387
+ def _probe_search_kwargs(self) -> dict[str, Any]:
388
+ # A unit vector keeps the probe valid for a COSINE index (a zero vector has no direction).
389
+ probe = [1.0] + [0.0] * (self.embedding_dimension - 1)
390
+ return self._search_vectors_kwargs(probe, top_k=1)
391
+
392
+ def _log_probe_failure(self, error: ClientError) -> None:
393
+ logger.warning(
394
+ "SearchVectors probe on '{table}'/'{index}' failed with {code}; continuing without confirmation "
395
+ "that the index is queryable.",
396
+ table=self.table_name,
397
+ index=self.index_name,
398
+ code=self._error_code(error),
399
+ )
400
+
401
+ def _wait_for_vector_search_available(self, client: Any) -> None:
402
+ """
403
+ Blocks until `SearchVectors` accepts requests for a freshly created index.
404
+
405
+ After the index reports `ACTIVE`, the vector-search endpoint can still answer
406
+ `ResourceNotFoundException` for a few seconds while it catches up. Probing here means
407
+ a store that just created its table is immediately usable for retrieval. Any other
408
+ error is left to the real calls, which report it in context.
409
+
410
+ :param client: The DynamoDB client to probe with.
411
+ """
412
+ deadline = time.monotonic() + self.search_available_timeout
413
+ while True:
414
+ try:
415
+ client.search_vectors(**self._probe_search_kwargs())
416
+ except ClientError as e:
417
+ if self._error_code(e) == "ResourceNotFoundException" and time.monotonic() < deadline:
418
+ time.sleep(self.search_available_poll_interval)
419
+ continue
420
+ self._log_probe_failure(e)
421
+ return
422
+
423
+ async def _wait_for_vector_search_available_async(self, client: Any) -> None:
424
+ deadline = time.monotonic() + self.search_available_timeout
425
+ while True:
426
+ try:
427
+ await client.search_vectors(**self._probe_search_kwargs())
428
+ except ClientError as e:
429
+ if self._error_code(e) == "ResourceNotFoundException" and time.monotonic() < deadline:
430
+ await asyncio.sleep(self.search_available_poll_interval)
431
+ continue
432
+ self._log_probe_failure(e)
433
+ return
434
+
435
+ # ------------------------------------------------------------------ item conversion
436
+
437
+ @staticmethod
438
+ def _sanitize_metadata_value(value: Any) -> Any:
439
+ """
440
+ Recursively coerces a metadata value into a JSON-serializable shape.
441
+
442
+ Values DynamoDB and JSON cannot represent natively (e.g. `UUID`, `datetime`) are stored
443
+ as their string form.
444
+ """
445
+ if isinstance(value, dict):
446
+ return {k: DynamoDBDocumentStore._sanitize_metadata_value(v) for k, v in value.items()}
447
+ if isinstance(value, list):
448
+ return [DynamoDBDocumentStore._sanitize_metadata_value(v) for v in value]
449
+ if isinstance(value, (str, int, float, bool)) or value is None:
450
+ return value
451
+ return str(value)
452
+
453
+ def _doc_to_item(self, doc: Document) -> dict[str, Any]:
454
+ # `score` is a retrieval-time value, not part of the stored document; persisting it would
455
+ # hand a stale score back out of `filter_documents`. Other stores drop it the same way.
456
+ d = {k: v for k, v in doc.to_dict(flatten=False).items() if k != "score"}
457
+ doc_id = d.pop("id")
458
+ embedding = d.pop("embedding", None)
459
+ if (blob := d.get("blob")) is not None:
460
+ # `to_dict` renders the bytes as a list of ints, which JSON stores at ~4 bytes per byte.
461
+ # Base64 keeps the blob within the 400 KB item limit for much longer.
462
+ blob["data"] = base64.b64encode(bytes(blob["data"])).decode()
463
+ # Everything except the key and the vector is stored as one JSON payload attribute, which
464
+ # gives a stable, order-independent round trip of arbitrary nested metadata.
465
+ payload = self._sanitize_metadata_value(d)
466
+ item: dict[str, Any] = {
467
+ "id": doc_id,
468
+ "payload": json.dumps(payload),
469
+ }
470
+ if embedding is not None:
471
+ item[_VECTOR_ATTRIBUTE] = embedding
472
+ return item
473
+
474
+ @staticmethod
475
+ def _item_to_doc(item: dict[str, Any]) -> Document:
476
+ payload = json.loads(item["payload"]) if "payload" in item else {}
477
+ payload["id"] = item["id"]
478
+ if (blob := payload.get("blob")) is not None:
479
+ blob["data"] = base64.b64decode(blob["data"])
480
+ if _VECTOR_ATTRIBUTE in item:
481
+ payload["embedding"] = item[_VECTOR_ATTRIBUTE]
482
+ return Document.from_dict(payload)
483
+
484
+ # ------------------------------------------------------------------ request shapes
485
+
486
+ def _scan_kwargs(self) -> dict[str, Any]:
487
+ # A `Scan` is eventually consistent by default, which surfaced as read-after-write races
488
+ # right after `write_documents` during real-AWS validation, hence `ConsistentRead=True`.
489
+ return {"TableName": self.table_name, "ConsistentRead": True}
490
+
491
+ def _count_scan_kwargs(self) -> dict[str, Any]:
492
+ # `describe_table`'s `ItemCount` is only refreshed about every six hours, so count with a
493
+ # consistent `Scan` to reflect a just-completed write immediately.
494
+ return {**self._scan_kwargs(), "Select": "COUNT"}
495
+
496
+ def _id_scan_kwargs(self) -> dict[str, Any]:
497
+ return {**self._scan_kwargs(), "ProjectionExpression": "#id", "ExpressionAttributeNames": _ID_PLACEHOLDER}
498
+
499
+ def _delete_kwargs(self, doc_id: str) -> dict[str, Any]:
500
+ return {"TableName": self.table_name, "Key": {"id": {"S": doc_id}}}
501
+
502
+ def _put_item_kwargs(self, doc: Document, policy: DuplicatePolicy) -> dict[str, Any]:
503
+ kwargs: dict[str, Any] = {"TableName": self.table_name, "Item": _to_dynamodb_item(self._doc_to_item(doc))}
504
+ if policy != DuplicatePolicy.OVERWRITE:
505
+ # A conditional write makes FAIL and SKIP atomic and saves a separate GetItem per document.
506
+ kwargs["ConditionExpression"] = "attribute_not_exists(#id)"
507
+ kwargs["ExpressionAttributeNames"] = _ID_PLACEHOLDER
508
+ return kwargs
509
+
510
+ def _search_vectors_kwargs(self, query_embedding: list[float], *, top_k: int) -> dict[str, Any]:
511
+ return {
512
+ "TableName": self.table_name,
513
+ "IndexName": self.index_name,
514
+ "SearchVector": [{"N": str(v)} for v in query_embedding],
515
+ "TopK": top_k,
516
+ }
517
+
518
+ # ------------------------------------------------------------------ shared logic
519
+
520
+ @staticmethod
521
+ def _matches(filters: dict[str, Any] | None, doc: Document) -> bool:
522
+ return not filters or document_matches_filter(filters, doc)
523
+
524
+ @staticmethod
525
+ def _validate_documents(documents: list[Document]) -> None:
526
+ if not isinstance(documents, list) or any(not isinstance(doc, Document) for doc in documents):
527
+ msg = "param 'documents' must contain a list of objects of type Document"
528
+ raise ValueError(msg)
529
+
530
+ @staticmethod
531
+ def _resolve_policy(policy: DuplicatePolicy) -> DuplicatePolicy:
532
+ return DuplicatePolicy.FAIL if policy == DuplicatePolicy.NONE else policy
533
+
534
+ def _handle_put_error(self, error: ClientError, doc: Document, policy: DuplicatePolicy) -> None:
535
+ """
536
+ Re-raises `error` unless it is the conditional-check failure that signals a duplicate.
537
+
538
+ For a duplicate, raises `DuplicateDocumentError` under `FAIL` and returns under `SKIP`.
539
+ """
540
+ if self._error_code(error) != "ConditionalCheckFailedException":
541
+ raise error
542
+ if policy == DuplicatePolicy.FAIL:
543
+ msg = f"Document with id '{doc.id}' already exists."
544
+ raise DuplicateDocumentError(msg) from error
545
+
546
+ @staticmethod
547
+ def _require_filters(filters: dict[str, Any] | None, purpose: str) -> None:
548
+ if not filters:
549
+ msg = f"filters must not be empty when {purpose}."
550
+ raise ValueError(msg)
551
+
552
+ def _validate_embedding_query(self, query_embedding: list[float], top_k: int) -> None:
553
+ if not query_embedding:
554
+ msg = "query_embedding must be a non-empty list of floats"
555
+ raise ValueError(msg)
556
+ if len(query_embedding) != self.embedding_dimension:
557
+ msg = (
558
+ f"query_embedding has {len(query_embedding)} dimensions, but the store is configured for "
559
+ f"{self.embedding_dimension}."
560
+ )
561
+ raise ValueError(msg)
562
+ if not 1 <= top_k <= SEARCH_VECTORS_MAX_TOP_K:
563
+ msg = f"top_k must be between 1 and {SEARCH_VECTORS_MAX_TOP_K} (DynamoDB SearchVectors limit), got {top_k}."
564
+ raise ValueError(msg)
565
+
566
+ @staticmethod
567
+ def _fetch_k(top_k: int, filters: dict[str, Any] | None) -> int:
568
+ # Over-fetch up to the service limit when filtering client-side, so that a selective filter
569
+ # can still fill `top_k` from the candidates DynamoDB is able to return.
570
+ return SEARCH_VECTORS_MAX_TOP_K if filters else top_k
571
+
572
+ def _search_results_to_documents(
573
+ self, response: dict[str, Any], filters: dict[str, Any] | None, top_k: int
574
+ ) -> list[Document]:
575
+ docs: list[Document] = []
576
+ for match in response.get("SearchResults", []):
577
+ doc = self._item_to_doc(_from_dynamodb_item(match["Item"]))
578
+ scored = dataclasses.replace(doc, score=self._distance_to_similarity(match.get("Score")))
579
+ if self._matches(filters, scored):
580
+ docs.append(scored)
581
+ if len(docs) >= top_k:
582
+ break
583
+ return docs
584
+
585
+ @staticmethod
586
+ def _distance_to_similarity(score: float | None) -> float | None:
587
+ """
588
+ Converts a DynamoDB COSINE distance into a Haystack similarity score.
589
+
590
+ DynamoDB returns a cosine *distance* in ``[0, 2]`` where 0 is identical, while Haystack's
591
+ `Document.score` convention is higher-is-more-relevant. Mapping to ``1 - distance / 2``
592
+ yields ``1.0`` for an identical vector and ``0.0`` for an opposite one, preserving
593
+ DynamoDB's ordering while matching Haystack's semantics.
594
+
595
+ :param score: The raw `Score` returned by `SearchVectors`, if any.
596
+ :returns: The corresponding similarity in ``[0, 1]``, or `None` if no score was returned.
597
+ """
598
+ if score is None:
599
+ return None
600
+ return 1.0 - (float(score) / _COSINE_MAX_DISTANCE)
601
+
602
+ # ------------------------------------------------------------------ sync API
603
+
604
+ def _scan_documents(self, client: Any, filters: dict[str, Any] | None = None) -> Iterator[Document]:
605
+ """
606
+ Yields every stored document matching `filters` via a consistent full-table `Scan`.
607
+
608
+ :param client: The DynamoDB client to scan with.
609
+ :param filters: Haystack metadata filters applied client-side; `None` or `{}` matches everything.
610
+ """
611
+ paginator = client.get_paginator("scan")
612
+ for page in paginator.paginate(**self._scan_kwargs()):
613
+ for raw_item in page.get("Items", []):
614
+ doc = self._item_to_doc(_from_dynamodb_item(raw_item))
615
+ if self._matches(filters, doc):
616
+ yield doc
617
+
618
+ def count_documents(self) -> int:
619
+ """
620
+ Returns the number of documents in the store.
621
+
622
+ Counts with a consistent `Scan`, so the cost grows with the table size.
623
+
624
+ :returns: Exact document count.
625
+ """
626
+ self._ensure_table()
627
+ client = self._get_client()
628
+ total = 0
629
+ paginator = client.get_paginator("scan")
630
+ for page in paginator.paginate(**self._count_scan_kwargs()):
631
+ total += page.get("Count", 0)
632
+ return total
633
+
634
+ def filter_documents(self, filters: dict[str, Any] | None = None) -> list[Document]:
635
+ """
636
+ Returns documents matching the provided filters.
637
+
638
+ DynamoDB's `SearchVectors`/`Query` filter expressions can only reference attributes
639
+ declared in the index's `SearchSchema` at index-creation time. Since Haystack's metadata
640
+ filters are arbitrary and not known at index-creation time, filtering here is applied
641
+ client-side after a consistent full-table scan, so the cost grows with the table size.
642
+
643
+ :param filters: Haystack metadata filters. If `None`, all documents are returned.
644
+ :returns: List of matching `Document` objects.
645
+ """
646
+ self._ensure_table()
647
+ return list(self._scan_documents(self._get_client(), filters))
648
+
649
+ def write_documents(self, documents: list[Document], policy: DuplicatePolicy = DuplicatePolicy.NONE) -> int:
650
+ """
651
+ Writes documents to the store.
652
+
653
+ Documents are written one by one. With `FAIL`, documents preceding the first duplicate
654
+ stay written.
655
+
656
+ :param documents: Documents to write.
657
+ :param policy: How to handle duplicates: `OVERWRITE`, `SKIP`, or `FAIL`. `NONE` (the
658
+ default) behaves like `FAIL`.
659
+ :raises ValueError: If `documents` contains non-`Document` objects.
660
+ :raises DuplicateDocumentError: If a duplicate is found and policy is `FAIL`.
661
+ :returns: Number of documents written.
662
+ """
663
+ self._validate_documents(documents)
664
+ if not documents:
665
+ return 0
666
+ policy = self._resolve_policy(policy)
667
+
668
+ self._ensure_table()
669
+ client = self._get_client()
670
+ written = 0
671
+ for doc in documents:
672
+ try:
673
+ client.put_item(**self._put_item_kwargs(doc, policy))
674
+ except ClientError as e:
675
+ self._handle_put_error(e, doc, policy)
676
+ continue
677
+ written += 1
678
+ return written
679
+
680
+ def delete_documents(self, document_ids: list[str]) -> None:
681
+ """
682
+ Deletes documents by their IDs.
683
+
684
+ :param document_ids: List of document IDs to delete.
685
+ """
686
+ if not document_ids:
687
+ return
688
+ self._ensure_table()
689
+ client = self._get_client()
690
+ for doc_id in document_ids:
691
+ client.delete_item(**self._delete_kwargs(doc_id))
692
+
693
+ def delete_all_documents(self) -> None:
694
+ """
695
+ Deletes all documents in the store.
696
+
697
+ Items are deleted one by one after a consistent scan; the table and its vector index are kept.
698
+ """
699
+ self._ensure_table()
700
+ client = self._get_client()
701
+ paginator = client.get_paginator("scan")
702
+ for page in paginator.paginate(**self._id_scan_kwargs()):
703
+ for raw_item in page.get("Items", []):
704
+ client.delete_item(TableName=self.table_name, Key={"id": raw_item["id"]})
705
+
706
+ def delete_by_filter(self, filters: dict[str, Any]) -> int:
707
+ """
708
+ Deletes all documents matching the filters.
709
+
710
+ :param filters: Haystack metadata filters selecting the documents to delete. Must not be
711
+ empty; use `delete_all_documents` to clear the store.
712
+ :returns: The number of documents deleted.
713
+ :raises ValueError: If `filters` is empty.
714
+ """
715
+ self._require_filters(filters, "deleting by filter; use delete_all_documents() to delete every document")
716
+ self._ensure_table()
717
+ client = self._get_client()
718
+ deleted = 0
719
+ for doc in self._scan_documents(client, filters):
720
+ client.delete_item(**self._delete_kwargs(doc.id))
721
+ deleted += 1
722
+ return deleted
723
+
724
+ def update_by_filter(self, filters: dict[str, Any], meta: dict[str, Any]) -> int:
725
+ """
726
+ Merges `meta` into the metadata of all documents matching the filters.
727
+
728
+ Existing metadata keys not present in `meta` are kept; matching keys are overwritten.
729
+
730
+ :param filters: Haystack metadata filters selecting the documents to update. Must not be empty.
731
+ :param meta: The metadata fields to set on each matching document.
732
+ :returns: The number of documents updated.
733
+ :raises ValueError: If `filters` is empty.
734
+ """
735
+ self._require_filters(filters, "updating documents by filter")
736
+ self._ensure_table()
737
+ client = self._get_client()
738
+ updated = 0
739
+ for doc in self._scan_documents(client, filters):
740
+ updated_doc = dataclasses.replace(doc, meta={**doc.meta, **meta})
741
+ client.put_item(**self._put_item_kwargs(updated_doc, DuplicatePolicy.OVERWRITE))
742
+ updated += 1
743
+ return updated
744
+
745
+ def _embedding_retrieval(
746
+ self,
747
+ query_embedding: list[float],
748
+ *,
749
+ top_k: int = 10,
750
+ filters: dict[str, Any] | None = None,
751
+ ) -> list[Document]:
752
+ """
753
+ Retrieves documents most similar to the query embedding using cosine similarity.
754
+
755
+ Uses DynamoDB's native `SearchVectors` API. This method is used internally by
756
+ `DynamoDBEmbeddingRetriever`. Metadata filters are applied client-side after the
757
+ vector search returns, for the same `SearchSchema` constraint documented on
758
+ `filter_documents`. When filters are set, the maximum number of candidates DynamoDB
759
+ allows (`SEARCH_VECTORS_MAX_TOP_K`, currently 100) is fetched and filtered down to
760
+ `top_k`. Matches ranked below those candidates are not reachable, so a selective filter
761
+ can return fewer than `top_k` documents even when more matching documents exist.
762
+
763
+ :param query_embedding: The query vector; must have `embedding_dimension` entries.
764
+ :param top_k: Number of top results to return, between 1 and `SEARCH_VECTORS_MAX_TOP_K`.
765
+ :param filters: Optional metadata filters, applied client-side.
766
+ :returns: List of `Document` objects ordered most-similar-first, with `score` set to a
767
+ similarity in ``[0, 1]`` (converted from DynamoDB's cosine distance).
768
+ :raises ValueError: If `query_embedding` is empty or has the wrong dimensionality, or if
769
+ `top_k` is outside the allowed range.
770
+ """
771
+ self._validate_embedding_query(query_embedding, top_k)
772
+ self._ensure_table()
773
+ client = self._get_client()
774
+ response = client.search_vectors(
775
+ **self._search_vectors_kwargs(query_embedding, top_k=self._fetch_k(top_k, filters))
776
+ )
777
+ return self._search_results_to_documents(response, filters, top_k)
778
+
779
+ # ------------------------------------------------------------------ async API
780
+
781
+ async def _scan_documents_async(
782
+ self, client: Any, filters: dict[str, Any] | None = None
783
+ ) -> AsyncIterator[Document]:
784
+ paginator = client.get_paginator("scan")
785
+ async for page in paginator.paginate(**self._scan_kwargs()):
786
+ for raw_item in page.get("Items", []):
787
+ doc = self._item_to_doc(_from_dynamodb_item(raw_item))
788
+ if self._matches(filters, doc):
789
+ yield doc
790
+
791
+ async def count_documents_async(self) -> int:
792
+ """
793
+ Asynchronously returns the number of documents in the store.
794
+
795
+ :returns: Exact document count.
796
+ """
797
+ await self._ensure_table_async()
798
+ total = 0
799
+ async with self._async_client() as client:
800
+ paginator = client.get_paginator("scan")
801
+ async for page in paginator.paginate(**self._count_scan_kwargs()):
802
+ total += page.get("Count", 0)
803
+ return total
804
+
805
+ async def filter_documents_async(self, filters: dict[str, Any] | None = None) -> list[Document]:
806
+ """
807
+ Asynchronously returns documents matching the provided filters.
808
+
809
+ See `filter_documents` for how filters are evaluated.
810
+
811
+ :param filters: Haystack metadata filters. If `None`, all documents are returned.
812
+ :returns: List of matching `Document` objects.
813
+ """
814
+ await self._ensure_table_async()
815
+ async with self._async_client() as client:
816
+ return [doc async for doc in self._scan_documents_async(client, filters)]
817
+
818
+ async def write_documents_async(
819
+ self, documents: list[Document], policy: DuplicatePolicy = DuplicatePolicy.NONE
820
+ ) -> int:
821
+ """
822
+ Asynchronously writes documents to the store.
823
+
824
+ See `write_documents` for the duplicate handling semantics.
825
+
826
+ :param documents: Documents to write.
827
+ :param policy: How to handle duplicates: `OVERWRITE`, `SKIP`, or `FAIL`. `NONE` (the
828
+ default) behaves like `FAIL`.
829
+ :raises ValueError: If `documents` contains non-`Document` objects.
830
+ :raises DuplicateDocumentError: If a duplicate is found and policy is `FAIL`.
831
+ :returns: Number of documents written.
832
+ """
833
+ self._validate_documents(documents)
834
+ if not documents:
835
+ return 0
836
+ policy = self._resolve_policy(policy)
837
+
838
+ await self._ensure_table_async()
839
+ written = 0
840
+ async with self._async_client() as client:
841
+ for doc in documents:
842
+ try:
843
+ await client.put_item(**self._put_item_kwargs(doc, policy))
844
+ except ClientError as e:
845
+ self._handle_put_error(e, doc, policy)
846
+ continue
847
+ written += 1
848
+ return written
849
+
850
+ async def delete_documents_async(self, document_ids: list[str]) -> None:
851
+ """
852
+ Asynchronously deletes documents by their IDs.
853
+
854
+ :param document_ids: List of document IDs to delete.
855
+ """
856
+ if not document_ids:
857
+ return
858
+ await self._ensure_table_async()
859
+ async with self._async_client() as client:
860
+ for doc_id in document_ids:
861
+ await client.delete_item(**self._delete_kwargs(doc_id))
862
+
863
+ async def delete_all_documents_async(self) -> None:
864
+ """
865
+ Asynchronously deletes all documents in the store.
866
+
867
+ Items are deleted one by one after a consistent scan; the table and its vector index are kept.
868
+ """
869
+ await self._ensure_table_async()
870
+ async with self._async_client() as client:
871
+ paginator = client.get_paginator("scan")
872
+ async for page in paginator.paginate(**self._id_scan_kwargs()):
873
+ for raw_item in page.get("Items", []):
874
+ await client.delete_item(TableName=self.table_name, Key={"id": raw_item["id"]})
875
+
876
+ async def delete_by_filter_async(self, filters: dict[str, Any]) -> int:
877
+ """
878
+ Asynchronously deletes all documents matching the filters.
879
+
880
+ :param filters: Haystack metadata filters selecting the documents to delete. Must not be
881
+ empty; use `delete_all_documents_async` to clear the store.
882
+ :returns: The number of documents deleted.
883
+ :raises ValueError: If `filters` is empty.
884
+ """
885
+ self._require_filters(filters, "deleting by filter; use delete_all_documents_async() to delete every document")
886
+ await self._ensure_table_async()
887
+ deleted = 0
888
+ async with self._async_client() as client:
889
+ async for doc in self._scan_documents_async(client, filters):
890
+ await client.delete_item(**self._delete_kwargs(doc.id))
891
+ deleted += 1
892
+ return deleted
893
+
894
+ async def update_by_filter_async(self, filters: dict[str, Any], meta: dict[str, Any]) -> int:
895
+ """
896
+ Asynchronously merges `meta` into the metadata of all documents matching the filters.
897
+
898
+ :param filters: Haystack metadata filters selecting the documents to update. Must not be empty.
899
+ :param meta: The metadata fields to set on each matching document.
900
+ :returns: The number of documents updated.
901
+ :raises ValueError: If `filters` is empty.
902
+ """
903
+ self._require_filters(filters, "updating documents by filter")
904
+ await self._ensure_table_async()
905
+ updated = 0
906
+ async with self._async_client() as client:
907
+ async for doc in self._scan_documents_async(client, filters):
908
+ updated_doc = dataclasses.replace(doc, meta={**doc.meta, **meta})
909
+ await client.put_item(**self._put_item_kwargs(updated_doc, DuplicatePolicy.OVERWRITE))
910
+ updated += 1
911
+ return updated
912
+
913
+ async def _embedding_retrieval_async(
914
+ self,
915
+ query_embedding: list[float],
916
+ *,
917
+ top_k: int = 10,
918
+ filters: dict[str, Any] | None = None,
919
+ ) -> list[Document]:
920
+ """
921
+ Asynchronously retrieves documents most similar to the query embedding.
922
+
923
+ See `_embedding_retrieval` for the candidate limit that applies when filters are set.
924
+
925
+ :param query_embedding: The query vector; must have `embedding_dimension` entries.
926
+ :param top_k: Number of top results to return, between 1 and `SEARCH_VECTORS_MAX_TOP_K`.
927
+ :param filters: Optional metadata filters, applied client-side.
928
+ :returns: List of `Document` objects ordered most-similar-first, with `score` set to a
929
+ similarity in ``[0, 1]``.
930
+ :raises ValueError: If `query_embedding` is empty or has the wrong dimensionality, or if
931
+ `top_k` is outside the allowed range.
932
+ """
933
+ self._validate_embedding_query(query_embedding, top_k)
934
+ await self._ensure_table_async()
935
+ async with self._async_client() as client:
936
+ response = await client.search_vectors(
937
+ **self._search_vectors_kwargs(query_embedding, top_k=self._fetch_k(top_k, filters))
938
+ )
939
+ return self._search_results_to_documents(response, filters, top_k)
940
+
941
+ # ------------------------------------------------------------------ serialization
942
+
943
+ def to_dict(self) -> dict[str, Any]:
944
+ """
945
+ Serializes the component to a dictionary.
946
+
947
+ :returns: Dictionary with serialized data.
948
+ """
949
+ return default_to_dict(
950
+ self,
951
+ table_name=self.table_name,
952
+ index_name=self.index_name,
953
+ embedding_dimension=self.embedding_dimension,
954
+ region_name=self.region_name,
955
+ aws_access_key_id=self.aws_access_key_id.to_dict(),
956
+ aws_secret_access_key=self.aws_secret_access_key.to_dict(),
957
+ aws_session_token=self.aws_session_token.to_dict(),
958
+ create_table_if_not_exists=self.create_table_if_not_exists,
959
+ similarity_function=self.similarity_function,
960
+ )
961
+
962
+ @classmethod
963
+ def from_dict(cls, data: dict[str, Any]) -> "DynamoDBDocumentStore":
964
+ """
965
+ Deserializes the component from a dictionary.
966
+
967
+ :param data: Dictionary to deserialize from.
968
+ :returns: Deserialized component.
969
+ """
970
+ deserialize_secrets_inplace(
971
+ data["init_parameters"],
972
+ ["aws_access_key_id", "aws_secret_access_key", "aws_session_token"],
973
+ )
974
+ return default_from_dict(cls, data)
975
+
976
+
977
+ def _to_dynamodb_item(item: dict[str, Any]) -> dict[str, Any]:
978
+ """Converts a plain-Python item dict into DynamoDB's typed attribute-value format."""
979
+ out: dict[str, Any] = {}
980
+ for key, value in item.items():
981
+ if key == _VECTOR_ATTRIBUTE and isinstance(value, list):
982
+ out[key] = {"L": [{"N": str(v)} for v in value]}
983
+ elif isinstance(value, str):
984
+ out[key] = {"S": value}
985
+ elif isinstance(value, bool):
986
+ out[key] = {"BOOL": value}
987
+ elif isinstance(value, (int, float)):
988
+ out[key] = {"N": str(value)}
989
+ return out
990
+
991
+
992
+ def _from_dynamodb_item(item: dict[str, Any]) -> dict[str, Any]:
993
+ """Converts DynamoDB's typed attribute-value format back into a plain-Python dict."""
994
+ out: dict[str, Any] = {}
995
+ for key, value in item.items():
996
+ if "S" in value:
997
+ out[key] = value["S"]
998
+ elif "N" in value:
999
+ out[key] = float(value["N"]) if "." in value["N"] else int(value["N"])
1000
+ elif "BOOL" in value:
1001
+ out[key] = value["BOOL"]
1002
+ elif "L" in value and key == _VECTOR_ATTRIBUTE:
1003
+ out[key] = [float(v["N"]) for v in value["L"]]
1004
+ return out