faiss-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,39 @@
1
+ Metadata-Version: 2.4
2
+ Name: faiss-haystack
3
+ Version: 1.0.0
4
+ Project-URL: Documentation, https://github.com/deepset-ai/haystack-core-integrations/tree/main/integrations/faiss#readme
5
+ Project-URL: Issues, https://github.com/deepset-ai/haystack-core-integrations/issues
6
+ Project-URL: Source, https://github.com/deepset-ai/haystack-core-integrations/tree/main/integrations/faiss
7
+ Author-email: Deepset <info@deepset.ai>
8
+ License-Expression: Apache-2.0
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: License :: OSI Approved :: Apache Software License
11
+ Classifier: Programming Language :: Python
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: Implementation :: CPython
16
+ Classifier: Programming Language :: Python :: Implementation :: PyPy
17
+ Requires-Python: >=3.10
18
+ Requires-Dist: faiss-cpu>=1.8.0
19
+ Requires-Dist: haystack-ai>=2.24.0
20
+ Requires-Dist: numpy<2,>=1.22; python_version < '3.13'
21
+ Description-Content-Type: text/markdown
22
+
23
+ # faiss-haystack
24
+
25
+ This package provides a [FAISS](https://github.com/facebookresearch/faiss) document store for [Haystack](https://github.com/deepset-ai/haystack).
26
+
27
+ ## Installation
28
+
29
+ ```bash
30
+ pip install faiss-haystack
31
+ ```
32
+
33
+ ## Usage
34
+
35
+ ```python
36
+ from haystack_integrations.document_stores.faiss import FAISSDocumentStore
37
+
38
+ document_store = FAISSDocumentStore(index_path="my_index")
39
+ ```
@@ -0,0 +1,13 @@
1
+ haystack_integrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ haystack_integrations/components/__init__.py,sha256=6GZ8Y3Lw0rLOsOAqi6Tu5mZC977UzQvgDxKpOWr8IQw,110
3
+ haystack_integrations/components/retrievers/__init__.py,sha256=6GZ8Y3Lw0rLOsOAqi6Tu5mZC977UzQvgDxKpOWr8IQw,110
4
+ haystack_integrations/components/retrievers/faiss/__init__.py,sha256=4kZDoOkRRxRaXjgJi9ZEz6ckHsORzVLbIz6-gdons_Q,206
5
+ haystack_integrations/components/retrievers/faiss/embedding_retriever.py,sha256=dCrbxeT31o7dLZbLVtEDud9ceSrsd3oKOeFK921BhB8,6784
6
+ haystack_integrations/components/retrievers/faiss/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ haystack_integrations/document_stores/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ haystack_integrations/document_stores/faiss/__init__.py,sha256=JkdbPAs4g1LnUaAPpws8qS4DCGZkFYjfoOuQltUZOYQ,81
9
+ haystack_integrations/document_stores/faiss/document_store.py,sha256=Mz7GvPVTZsKkn7tLA4xEH9-BUCBnVrSTACIXg45-CMc,22432
10
+ haystack_integrations/document_stores/faiss/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ faiss_haystack-1.0.0.dist-info/METADATA,sha256=7SngrnIUiikeQAYTG6Ap_EZJK4i53jLLpX6l6N9vVhY,1453
12
+ faiss_haystack-1.0.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
13
+ faiss_haystack-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
File without changes
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: 2023-present deepset GmbH <info@deepset.ai>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: 2023-present deepset GmbH <info@deepset.ai>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
@@ -0,0 +1,6 @@
1
+ # SPDX-FileCopyrightText: 2023-present deepset GmbH <info@deepset.ai>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+ from .embedding_retriever import FAISSEmbeddingRetriever
5
+
6
+ __all__ = ["FAISSEmbeddingRetriever"]
@@ -0,0 +1,153 @@
1
+ # SPDX-FileCopyrightText: 2023-present deepset GmbH <info@deepset.ai>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ from typing import Any
6
+
7
+ from haystack import component, default_from_dict, default_to_dict
8
+ from haystack.dataclasses import Document
9
+ from haystack.document_stores.types import FilterPolicy
10
+ from haystack.document_stores.types.filter_policy import apply_filter_policy
11
+
12
+ from haystack_integrations.document_stores.faiss import FAISSDocumentStore
13
+
14
+
15
+ @component
16
+ class FAISSEmbeddingRetriever:
17
+ """
18
+ Retrieves documents from the `FAISSDocumentStore`, based on their dense embeddings.
19
+
20
+ Example usage:
21
+ ```python
22
+ from haystack import Document, Pipeline
23
+ from haystack.components.embedders import SentenceTransformersTextEmbedder, SentenceTransformersDocumentEmbedder
24
+ from haystack.document_stores.types import DuplicatePolicy
25
+
26
+ from haystack_integrations.document_stores.faiss import FAISSDocumentStore
27
+ from haystack_integrations.components.retrievers.faiss import FAISSEmbeddingRetriever
28
+
29
+ document_store = FAISSDocumentStore(embedding_dim=768)
30
+
31
+ documents = [
32
+ Document(content="There are over 7,000 languages spoken around the world today."),
33
+ Document(content="Elephants have been observed to behave in a way that indicates a high level of intelligence."),
34
+ Document(content="In certain places, you can witness the phenomenon of bioluminescent waves."),
35
+ ]
36
+
37
+ document_embedder = SentenceTransformersDocumentEmbedder()
38
+ document_embedder.warm_up()
39
+ documents_with_embeddings = document_embedder.run(documents)["documents"]
40
+
41
+ document_store.write_documents(documents_with_embeddings, policy=DuplicatePolicy.OVERWRITE)
42
+
43
+ query_pipeline = Pipeline()
44
+ query_pipeline.add_component("text_embedder", SentenceTransformersTextEmbedder())
45
+ query_pipeline.add_component("retriever", FAISSEmbeddingRetriever(document_store=document_store))
46
+ query_pipeline.connect("text_embedder.embedding", "retriever.query_embedding")
47
+
48
+ query = "How many languages are there?"
49
+ res = query_pipeline.run({"text_embedder": {"text": query}})
50
+
51
+ assert res["retriever"]["documents"][0].content == "There are over 7,000 languages spoken around the world today."
52
+ ```
53
+ """ # noqa: E501
54
+
55
+ def __init__(
56
+ self,
57
+ *,
58
+ document_store: FAISSDocumentStore,
59
+ filters: dict[str, Any] | None = None,
60
+ top_k: int = 10,
61
+ filter_policy: str | FilterPolicy = FilterPolicy.REPLACE,
62
+ ):
63
+ """
64
+ :param document_store: An instance of `FAISSDocumentStore`.
65
+ :param filters: Filters applied to the retrieved Documents at initialisation time. At runtime, these are merged
66
+ with any runtime filters according to the `filter_policy`.
67
+ :param top_k: Maximum number of Documents to return.
68
+ :param filter_policy: Policy to determine how init-time and runtime filters are combined.
69
+ See `FilterPolicy` for details. Defaults to `FilterPolicy.REPLACE`.
70
+ :raises ValueError: If `document_store` is not an instance of `FAISSDocumentStore`.
71
+ """
72
+ if not isinstance(document_store, FAISSDocumentStore):
73
+ msg = "document_store must be an instance of FAISSDocumentStore"
74
+ raise ValueError(msg)
75
+
76
+ self.document_store = document_store
77
+ self.filters = filters or {}
78
+ self.top_k = top_k
79
+ self.filter_policy = (
80
+ filter_policy if isinstance(filter_policy, FilterPolicy) else FilterPolicy.from_str(filter_policy)
81
+ )
82
+
83
+ def to_dict(self) -> dict[str, Any]:
84
+ """
85
+ Serializes the component to a dictionary.
86
+
87
+ :returns: Dictionary with serialized data.
88
+ """
89
+ return default_to_dict(
90
+ self,
91
+ filters=self.filters,
92
+ top_k=self.top_k,
93
+ filter_policy=self.filter_policy.value,
94
+ document_store=self.document_store.to_dict(),
95
+ )
96
+
97
+ @classmethod
98
+ def from_dict(cls, data: dict[str, Any]) -> "FAISSEmbeddingRetriever":
99
+ """
100
+ Deserializes the component from a dictionary.
101
+
102
+ :param data: Dictionary to deserialize from.
103
+ :returns: Deserialized component.
104
+ """
105
+ doc_store_params = data["init_parameters"]["document_store"]
106
+ data["init_parameters"]["document_store"] = FAISSDocumentStore.from_dict(doc_store_params)
107
+ return default_from_dict(cls, data)
108
+
109
+ @component.output_types(documents=list[Document])
110
+ def run(
111
+ self,
112
+ query_embedding: list[float],
113
+ filters: dict[str, Any] | None = None,
114
+ top_k: int | None = None,
115
+ ) -> dict[str, list[Document]]:
116
+ """
117
+ Retrieve documents from the `FAISSDocumentStore`, based on their embeddings.
118
+
119
+ :param query_embedding: Embedding of the query.
120
+ :param filters: Filters applied to the retrieved Documents. The way runtime filters are applied depends on
121
+ the `filter_policy` chosen at retriever initialization. See init method docstring for more
122
+ details.
123
+ :param top_k: Maximum number of Documents to return. Overrides the value set at initialization.
124
+ :returns: A dictionary with the following keys:
125
+ - `documents`: List of `Document`s that are similar to `query_embedding`.
126
+ """
127
+ filters = apply_filter_policy(self.filter_policy, self.filters, filters)
128
+ top_k = top_k or self.top_k
129
+ docs = self.document_store.search(query_embedding=query_embedding, top_k=top_k, filters=filters)
130
+ return {"documents": docs}
131
+
132
+ @component.output_types(documents=list[Document])
133
+ async def run_async(
134
+ self,
135
+ query_embedding: list[float],
136
+ filters: dict[str, Any] | None = None,
137
+ top_k: int | None = None,
138
+ ) -> dict[str, list[Document]]:
139
+ """
140
+ Asynchronously retrieve documents from the `FAISSDocumentStore`, based on their embeddings.
141
+
142
+ Since FAISS search is CPU-bound and fully in-memory, this delegates directly to the synchronous
143
+ `run()` method. No I/O or network calls are involved.
144
+
145
+ :param query_embedding: Embedding of the query.
146
+ :param filters: Filters applied to the retrieved Documents. The way runtime filters are applied depends on
147
+ the `filter_policy` chosen at retriever initialization. See init method docstring for more
148
+ details.
149
+ :param top_k: Maximum number of Documents to return. Overrides the value set at initialization.
150
+ :returns: A dictionary with the following keys:
151
+ - `documents`: List of `Document`s that are similar to `query_embedding`.
152
+ """
153
+ return self.run(query_embedding=query_embedding, filters=filters, top_k=top_k)
File without changes
@@ -0,0 +1,3 @@
1
+ from .document_store import FAISSDocumentStore
2
+
3
+ __all__ = ["FAISSDocumentStore"]
@@ -0,0 +1,578 @@
1
+ # SPDX-FileCopyrightText: 2023-present deepset GmbH <info@deepset.ai>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ import json
6
+ import logging
7
+ from collections.abc import Iterable
8
+ from dataclasses import replace
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import faiss # type: ignore[import-untyped]
13
+ import numpy as np
14
+ from haystack import default_from_dict, default_to_dict
15
+ from haystack.dataclasses import Document
16
+ from haystack.document_stores.errors import DocumentStoreError, DuplicateDocumentError
17
+ from haystack.document_stores.types import DuplicatePolicy
18
+ from haystack.errors import FilterError
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class FAISSDocumentStore:
24
+ """
25
+ A Document Store using FAISS for vector search and a simple JSON file for metadata storage.
26
+
27
+ This Document Store is suitable for small to medium-sized datasets where simplicity is preferred over scalability.
28
+ It supports basic persistence by saving the FAISS index to a `.faiss` file and documents to a `.json` file.
29
+ """
30
+
31
+ def __init__(
32
+ self,
33
+ index_path: str | None = None,
34
+ index_string: str = "Flat",
35
+ embedding_dim: int = 768,
36
+ ):
37
+ """
38
+ Initializes the FAISSDocumentStore.
39
+
40
+ :param index_path: Path to save/load the index and documents. If None, the store is in-memory only.
41
+ :param index_string: The FAISS index factory string. Default is "Flat".
42
+ :param embedding_dim: The dimension of the embeddings. Default is 768.
43
+ :raises DocumentStoreError: If the FAISS index cannot be initialized.
44
+ :raises ValueError: If `index_path` points to a missing `.faiss` file when loading persisted data.
45
+ """
46
+ self.index_path = index_path
47
+ self.embedding_dim = embedding_dim
48
+ self.index_string = index_string
49
+
50
+ # Initialize in-memory storage
51
+ self.documents: dict[str, Document] = {}
52
+ self.id_map: dict[int, str] = {} # Map integer IDs (FAISS) to string IDs (Documents)
53
+ self.inverse_id_map: dict[str, int] = {} # Map string IDs to integer IDs
54
+ self._next_id = 0
55
+
56
+ # Initialize FAISS index
57
+ self.index: faiss.Index | None = None
58
+ if self.index_path and Path(self.index_path).with_suffix(".faiss").exists():
59
+ self.load(self.index_path)
60
+ else:
61
+ self._create_new_index()
62
+
63
+ def _create_new_index(self):
64
+ """Creates a new FAISS index."""
65
+ try:
66
+ # We use IndexIDMap to support add_with_ids
67
+ base_index = faiss.index_factory(self.embedding_dim, self.index_string)
68
+ self.index = faiss.IndexIDMap(base_index)
69
+ except RuntimeError as e:
70
+ msg = f"Could not create FAISS index with factory string '{self.index_string}': {e}"
71
+ raise DocumentStoreError(msg) from e
72
+
73
+ def _get_index_or_raise(self) -> Any:
74
+ """Return the FAISS index or raise if it is unexpectedly missing."""
75
+ if self.index is None:
76
+ msg = "FAISS index has not been initialized."
77
+ raise DocumentStoreError(msg)
78
+ return self.index
79
+
80
+ def count_documents(self) -> int:
81
+ """
82
+ Returns the number of documents in the store.
83
+ """
84
+ return len(self.documents)
85
+
86
+ def filter_documents(self, filters: dict[str, Any] | None = None) -> list[Document]:
87
+ """
88
+ Returns documents that match the provided filters.
89
+
90
+ :param filters: A dictionary of filters to apply.
91
+ :return: A list of matching Documents.
92
+ :raises FilterError: If the filter structure is invalid.
93
+ """
94
+ if not filters:
95
+ return list(self.documents.values())
96
+
97
+ filtered_docs = []
98
+ for doc in self.documents.values():
99
+ if self._matches_filters(doc, filters):
100
+ filtered_docs.append(doc)
101
+ return filtered_docs
102
+
103
+ def _matches_filters(self, doc: Document, filters: dict[str, Any]) -> bool:
104
+ """
105
+ Checks if a document matches the given filters.
106
+
107
+ Currently, supports simple equality and comparison checks.
108
+ """
109
+ return self._check_condition(doc, filters)
110
+
111
+ @staticmethod
112
+ def _get_doc_value(doc: Document, field: str) -> Any:
113
+ """Helper to get value from doc, handling 'meta.' prefix."""
114
+ if field == "content":
115
+ return doc.content
116
+ if field == "id":
117
+ return doc.id
118
+ if field.startswith("meta."):
119
+ key = field[5:]
120
+ return doc.meta.get(key)
121
+ # Fallback: check top level attributes then meta
122
+ if hasattr(doc, field):
123
+ return getattr(doc, field)
124
+ return doc.meta.get(field)
125
+
126
+ def write_documents(self, documents: list[Document], policy: DuplicatePolicy = DuplicatePolicy.FAIL) -> int:
127
+ """
128
+ Writes documents to the store.
129
+
130
+ :param documents: The list of documents to write.
131
+ :param policy: The policy to handle duplicate documents.
132
+ :return: The number of documents written.
133
+ :raises ValueError: If `documents` is not an iterable of `Document` objects.
134
+ :raises DuplicateDocumentError: If a duplicate document is found and `policy` is `DuplicatePolicy.FAIL`.
135
+ :raises DocumentStoreError: If the FAISS index is unexpectedly unavailable when adding embeddings.
136
+ """
137
+ if not isinstance(documents, Iterable) or isinstance(documents, (str, bytes)):
138
+ msg = "param 'documents' must contain an iterable of objects of type Document."
139
+ raise ValueError(msg)
140
+
141
+ if any(not isinstance(doc, Document) for doc in documents):
142
+ msg = "param 'documents' must contain an iterable of objects of type Document."
143
+ raise ValueError(msg)
144
+
145
+ if not documents:
146
+ return 0
147
+
148
+ # Check for duplicates first if policy is FAIL
149
+ if policy == DuplicatePolicy.FAIL:
150
+ for doc in documents:
151
+ if doc.id in self.documents:
152
+ msg = f"Document with id '{doc.id}' already exists."
153
+ raise DuplicateDocumentError(msg)
154
+
155
+ # Process documents
156
+ ids_to_add_to_index = []
157
+ vectors_to_add = []
158
+
159
+ docs_written = 0
160
+
161
+ for doc in documents:
162
+ if policy == DuplicatePolicy.SKIP and doc.id in self.documents:
163
+ continue
164
+
165
+ # Handle overwrite or new
166
+ if doc.id in self.documents:
167
+ # If overwriting, we need to remove the old vector from index first?
168
+ # FAISS doesn't support easy update. We'd have to remove and add.
169
+ # For MVP, let's implement remove then add for overwrite.
170
+ self.delete_documents([doc.id])
171
+
172
+ self.documents[doc.id] = doc
173
+
174
+ if doc.embedding is not None:
175
+ # Assign a new integer ID
176
+ int_id = self._next_id
177
+ self._next_id += 1
178
+
179
+ self.id_map[int_id] = doc.id
180
+ self.inverse_id_map[doc.id] = int_id
181
+
182
+ ids_to_add_to_index.append(int_id)
183
+ vectors_to_add.append(doc.embedding)
184
+
185
+ docs_written += 1
186
+
187
+ # Add to FAISS
188
+ if vectors_to_add:
189
+ vectors = np.array(vectors_to_add, dtype="float32")
190
+ ids = np.array(ids_to_add_to_index, dtype="int64")
191
+ index = self._get_index_or_raise()
192
+ index.add_with_ids(vectors, ids)
193
+
194
+ return docs_written
195
+
196
+ def delete_documents(self, document_ids: list[str]) -> None:
197
+ """
198
+ Deletes documents from the store.
199
+
200
+ :raises DocumentStoreError: If the FAISS index is unexpectedly unavailable when removing embeddings.
201
+ """
202
+ if not document_ids:
203
+ return
204
+
205
+ ids_to_remove_from_index = []
206
+
207
+ for doc_id in document_ids:
208
+ if doc_id in self.documents:
209
+ del self.documents[doc_id]
210
+
211
+ if doc_id in self.inverse_id_map:
212
+ int_id = self.inverse_id_map.pop(doc_id)
213
+ del self.id_map[int_id]
214
+ ids_to_remove_from_index.append(int_id)
215
+
216
+ index = self._get_index_or_raise()
217
+ if ids_to_remove_from_index and index.ntotal > 0:
218
+ ids_array = np.array(ids_to_remove_from_index, dtype="int64")
219
+ index.remove_ids(ids_array)
220
+
221
+ def delete_all_documents(self) -> None:
222
+ """
223
+ Deletes all documents from the store.
224
+ """
225
+ self.documents = {}
226
+ self.id_map = {}
227
+ self.inverse_id_map = {}
228
+ self._next_id = 0
229
+ self._create_new_index()
230
+
231
+ def search(
232
+ self, query_embedding: list[float], top_k: int = 10, filters: dict[str, Any] | None = None
233
+ ) -> list[Document]:
234
+ """
235
+ Performs a vector search.
236
+
237
+ :param query_embedding: The query embedding.
238
+ :param top_k: The number of results to return.
239
+ :param filters: Filters to apply.
240
+ :return: A list of matching Documents.
241
+ :raises FilterError: If the filter structure is invalid.
242
+ """
243
+ if not self.index or self.index.ntotal == 0:
244
+ return []
245
+
246
+ # Ensure embedding format
247
+ query_vec = np.array([query_embedding], dtype="float32")
248
+
249
+ # Search in FAISS
250
+ # Valid strategy for pre-filtering vs post-filtering:
251
+ # Since FAISS `IndexIDMap` doesn't support pre-filtering natively comfortably
252
+ # without `RangeSearch` or specialized impls, we fetch more and filter post-retrieval.
253
+
254
+ fetch_k = top_k
255
+ if filters:
256
+ fetch_k = min(self.index.ntotal, top_k * 10) # Simple heuristic
257
+
258
+ distances, indices = self.index.search(query_vec, fetch_k)
259
+
260
+ results = []
261
+ for dist, int_id in zip(distances[0], indices[0], strict=False):
262
+ if int_id == -1:
263
+ continue
264
+
265
+ doc_id = self.id_map.get(int_id)
266
+ if not doc_id or doc_id not in self.documents:
267
+ continue
268
+
269
+ doc = self.documents[doc_id]
270
+
271
+ if filters and not self._matches_filters(doc, filters):
272
+ continue
273
+
274
+ # Build a new instance instead of mutating score in place.
275
+ score = float(1 / (1 + dist)) if self.index_string == "Flat" else float(dist)
276
+ result_doc = replace(doc, score=score)
277
+
278
+ results.append(result_doc)
279
+
280
+ if len(results) >= top_k:
281
+ break
282
+
283
+ return results
284
+
285
+ def _check_condition(self, doc: Document, condition: dict[str, Any]) -> bool:
286
+ if "operator" not in condition and "conditions" not in condition:
287
+ # This might be a legacy or malformed filter from tests like test_missing_top_level_operator_key
288
+ # The standard Haystack filter structure enforces keys.
289
+ # On failure to parse standard structure, we should raise FilterError as per tests?
290
+ # Actually, looking at the tests (e.g. TestFAISSDocumentStore.test_missing_top_level_operator_key),
291
+ # they expect FilterError if "operator" is missing from a condition block.
292
+ msg = "Filter condition missing 'operator'"
293
+ raise FilterError(msg)
294
+
295
+ operator = condition.get("operator", "==")
296
+
297
+ if operator == "AND":
298
+ if "conditions" not in condition:
299
+ msg = "Missing 'conditions' for AND operator"
300
+ raise FilterError(msg)
301
+ return all(self._check_condition(doc, cond) for cond in condition.get("conditions", []))
302
+ elif operator == "OR":
303
+ if "conditions" not in condition:
304
+ msg = "Missing 'conditions' for OR operator"
305
+ raise FilterError(msg)
306
+ return any(self._check_condition(doc, cond) for cond in condition.get("conditions", []))
307
+ elif operator == "NOT":
308
+ if "conditions" not in condition:
309
+ msg = "Missing 'conditions' for NOT operator"
310
+ raise FilterError(msg)
311
+ conditions = condition.get("conditions")
312
+ if not isinstance(conditions, list) or not conditions:
313
+ msg = "NOT operator expects at least one condition"
314
+ raise FilterError(msg)
315
+ return not all(self._check_condition(doc, cond) for cond in conditions)
316
+
317
+ # Leaf condition
318
+ if "field" not in condition:
319
+ msg = "Missing 'field' in filter condition"
320
+ raise FilterError(msg)
321
+ field = condition.get("field")
322
+ if not isinstance(field, str):
323
+ msg = "'field' in filter condition must be a string"
324
+ raise FilterError(msg)
325
+ if "value" not in condition:
326
+ msg = "Missing 'value' in filter condition"
327
+ raise FilterError(msg)
328
+ value = condition.get("value")
329
+
330
+ doc_val = FAISSDocumentStore._get_doc_value(doc, field)
331
+
332
+ # Type check for comparison operators
333
+ if operator in [">", ">=", "<", "<="]:
334
+ if doc_val is None:
335
+ # Haystack specific: if field is missing/None, and we compare, it generally shouldn't match.
336
+ return False
337
+
338
+ if value is None:
339
+ # Comparing anything with None using inequalities is invalid,
340
+ # but tests expect efficient handling (no match)
341
+ return False
342
+
343
+ # Check for compatibility
344
+ # We allow int/float comparison
345
+ is_number_doc = isinstance(doc_val, (int, float))
346
+ is_number_val = isinstance(value, (int, float))
347
+
348
+ if is_number_doc and is_number_val:
349
+ # Compatible
350
+ pass
351
+ elif type(doc_val) is not type(value):
352
+ # Incompatible types for inequality implementation (like str vs int, or list vs int)
353
+ msg = f"Type mismatch: cannot compare {type(doc_val)} with {type(value)}"
354
+ raise FilterError(msg)
355
+
356
+ try:
357
+ if operator == ">":
358
+ return doc_val > value
359
+ if operator == ">=":
360
+ return doc_val >= value
361
+ if operator == "<":
362
+ return doc_val < value
363
+ if operator == "<=":
364
+ return doc_val <= value
365
+ except TypeError as e:
366
+ msg = f"Type mismatch in filter: {e}"
367
+ raise FilterError(msg) from e
368
+
369
+ if operator == "==":
370
+ return doc_val == value
371
+ elif operator == "!=":
372
+ return doc_val != value
373
+ elif operator == "in":
374
+ if not isinstance(value, list):
375
+ msg = "Value for 'in' must be a list"
376
+ raise FilterError(msg)
377
+ return doc_val in value
378
+ elif operator == "not in":
379
+ if not isinstance(value, list):
380
+ msg = "Value for 'not in' must be a list"
381
+ raise FilterError(msg)
382
+ return doc_val not in value
383
+
384
+ return False
385
+
386
+ # Mixin implementations
387
+
388
+ def delete_by_filter(self, filters: dict[str, Any]) -> int:
389
+ """
390
+ Deletes documents that match the provided filters from the store.
391
+
392
+ :param filters: A dictionary of filters to apply to find documents to delete.
393
+ :returns: The number of documents deleted.
394
+ :raises FilterError: If the filter structure is invalid.
395
+ :raises DocumentStoreError: If the FAISS index is unexpectedly unavailable when removing embeddings.
396
+ """
397
+ docs_to_delete = self.filter_documents(filters)
398
+ ids = [doc.id for doc in docs_to_delete]
399
+ self.delete_documents(ids)
400
+ return len(ids)
401
+
402
+ def count_documents_by_filter(self, filters: dict[str, Any]) -> int:
403
+ """
404
+ Returns the number of documents that match the provided filters.
405
+
406
+ :param filters: A dictionary of filters to apply.
407
+ :returns: The number of matching documents.
408
+ :raises FilterError: If the filter structure is invalid.
409
+ """
410
+ return len(self.filter_documents(filters))
411
+
412
+ def update_by_filter(self, filters: dict[str, Any], meta: dict[str, Any]) -> int:
413
+ """
414
+ Updates documents that match the provided filters with the new metadata.
415
+
416
+ Note: Updates are performed in-memory only. To persist these changes,
417
+ you must explicitly call `save()` after updating.
418
+
419
+ :param filters: A dictionary of filters to apply to find documents to update.
420
+ :param meta: A dictionary of metadata key-value pairs to update in the matching documents.
421
+ :returns: The number of documents updated.
422
+ :raises FilterError: If the filter structure is invalid.
423
+ """
424
+ docs_to_update = self.filter_documents(filters)
425
+ for doc in docs_to_update:
426
+ doc.meta.update(meta)
427
+ # In this dict implementation, it's updated in place in memory.
428
+ return len(docs_to_update)
429
+
430
+ def get_metadata_fields_info(self) -> dict[str, dict[str, Any]]:
431
+ """
432
+ Infers and returns the types of all metadata fields from the stored documents.
433
+
434
+ :returns: A dictionary mapping field names to dictionaries with a "type" key
435
+ (e.g. `{"field": {"type": "long"}}`).
436
+ """
437
+ fields_idx = {}
438
+ for doc in self.documents.values():
439
+ for key, value in doc.meta.items():
440
+ if key not in fields_idx:
441
+ type_name = type(value).__name__
442
+ if type_name == "str":
443
+ type_name = "keyword"
444
+ elif type_name == "int":
445
+ type_name = "long"
446
+ elif type_name == "bool":
447
+ type_name = "boolean"
448
+ fields_idx[key] = {"type": type_name}
449
+ return fields_idx
450
+
451
+ def get_metadata_field_min_max(self, field_name: str) -> dict[str, Any]:
452
+ """
453
+ Returns the minimum and maximum values for a specific metadata field.
454
+
455
+ :param field_name: The name of the metadata field.
456
+ :returns: A dictionary with keys "min" and "max" containing the respective min and max values.
457
+ """
458
+ values = []
459
+ for doc in self.documents.values():
460
+ val = (
461
+ FAISSDocumentStore._get_doc_value(doc, field_name)
462
+ if not field_name.startswith("meta.")
463
+ else doc.meta.get(field_name[5:])
464
+ )
465
+ if val is not None:
466
+ values.append(val)
467
+
468
+ if not values:
469
+ return {"min": None, "max": None}
470
+
471
+ return {"min": min(values), "max": max(values)}
472
+
473
+ def get_metadata_field_unique_values(self, field_name: str) -> list[Any]:
474
+ """
475
+ Returns all unique values for a specific metadata field.
476
+
477
+ :param field_name: The name of the metadata field.
478
+ :returns: A list of unique values for the specified field.
479
+ """
480
+ values = set()
481
+ for doc in self.documents.values():
482
+ val = (
483
+ FAISSDocumentStore._get_doc_value(doc, field_name)
484
+ if not field_name.startswith("meta.")
485
+ else doc.meta.get(field_name[5:])
486
+ )
487
+ if val is not None:
488
+ values.add(val)
489
+ return list(values)
490
+
491
+ def count_unique_metadata_by_filter(self, filters: dict[str, Any], fields: list[str]) -> dict[str, int]:
492
+ """
493
+ Returns a count of unique values for multiple metadata fields, optionally scoped by a filter.
494
+
495
+ :param filters: A dictionary of filters to apply.
496
+ :param fields: A list of metadata field names to count unique values for.
497
+ :returns: A dictionary mapping each field name to the count of its unique values.
498
+ """
499
+ filtered_docs = self.filter_documents(filters)
500
+ counts = {}
501
+
502
+ for field in fields:
503
+ unique_vals = set()
504
+ for doc in filtered_docs:
505
+ val = FAISSDocumentStore._get_doc_value(doc, field)
506
+ if val is not None:
507
+ unique_vals.add(val)
508
+ counts[field] = len(unique_vals)
509
+
510
+ return dict(counts)
511
+
512
+ def to_dict(self) -> dict[str, Any]:
513
+ """
514
+ Serializes the store to a dictionary.
515
+ """
516
+ return default_to_dict(
517
+ self,
518
+ index_path=self.index_path,
519
+ index_string=self.index_string,
520
+ embedding_dim=self.embedding_dim,
521
+ )
522
+
523
+ @classmethod
524
+ def from_dict(cls, data: dict[str, Any]) -> "FAISSDocumentStore":
525
+ """
526
+ Deserializes the store from a dictionary.
527
+ """
528
+ return default_from_dict(cls, data)
529
+
530
+ def save(self, index_path: str | Path) -> None:
531
+ """
532
+ Saves the index and documents to disk.
533
+
534
+ :raises DocumentStoreError: If the FAISS index is unexpectedly unavailable.
535
+ """
536
+ path = Path(index_path)
537
+ faiss.write_index(self._get_index_or_raise(), str(path.with_suffix(".faiss")))
538
+
539
+ # Save documents and ID mapping
540
+ data = {
541
+ "documents": [doc.to_dict() for doc in self.documents.values()],
542
+ "id_map": self.id_map,
543
+ "inverse_id_map": self.inverse_id_map,
544
+ "next_id": self._next_id,
545
+ }
546
+
547
+ with open(path.with_suffix(".json"), "w", encoding="utf-8") as f:
548
+ json.dump(data, f)
549
+
550
+ def load(self, index_path: str | Path) -> None:
551
+ """
552
+ Loads the index and documents from disk.
553
+
554
+ :raises ValueError: If the `.faiss` file does not exist.
555
+ """
556
+ path = Path(index_path)
557
+ if not path.with_suffix(".faiss").exists():
558
+ msg = f"File not found: {path.with_suffix('.faiss')}"
559
+ raise ValueError(msg)
560
+
561
+ self.index = faiss.read_index(str(path.with_suffix(".faiss")))
562
+
563
+ with open(path.with_suffix(".json"), encoding="utf-8") as f:
564
+ data = json.load(f)
565
+
566
+ self.documents = {doc_dict["id"]: Document.from_dict(doc_dict) for doc_dict in data["documents"]}
567
+ self.id_map = {int(k): v for k, v in data["id_map"].items()}
568
+ # inverse_id_map keys are strings, values are ints. JSON keys are strings.
569
+ self.inverse_id_map = data["inverse_id_map"]
570
+ self._next_id = data["next_id"]
571
+
572
+ # Verify sync
573
+ if len(self.documents) != len(self.id_map):
574
+ logger.warning(
575
+ "Loaded %d documents but %d ID mappings. Index might be out of sync.",
576
+ len(self.documents),
577
+ len(self.id_map),
578
+ )
File without changes