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.
- dynamodb_haystack-1.0.0.dist-info/METADATA +71 -0
- dynamodb_haystack-1.0.0.dist-info/RECORD +10 -0
- dynamodb_haystack-1.0.0.dist-info/WHEEL +4 -0
- dynamodb_haystack-1.0.0.dist-info/licenses/LICENSE.txt +201 -0
- haystack_integrations/components/py.typed +0 -0
- haystack_integrations/components/retrievers/dynamodb/__init__.py +7 -0
- haystack_integrations/components/retrievers/dynamodb/embedding_retriever.py +144 -0
- haystack_integrations/document_stores/dynamodb/__init__.py +7 -0
- haystack_integrations/document_stores/dynamodb/document_store.py +1004 -0
- haystack_integrations/document_stores/py.typed +0 -0
|
@@ -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
|