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.
- faiss_haystack-1.0.0.dist-info/METADATA +39 -0
- faiss_haystack-1.0.0.dist-info/RECORD +13 -0
- faiss_haystack-1.0.0.dist-info/WHEEL +4 -0
- haystack_integrations/__init__.py +0 -0
- haystack_integrations/components/__init__.py +3 -0
- haystack_integrations/components/retrievers/__init__.py +3 -0
- haystack_integrations/components/retrievers/faiss/__init__.py +6 -0
- haystack_integrations/components/retrievers/faiss/embedding_retriever.py +153 -0
- haystack_integrations/components/retrievers/faiss/py.typed +0 -0
- haystack_integrations/document_stores/__init__.py +0 -0
- haystack_integrations/document_stores/faiss/__init__.py +3 -0
- haystack_integrations/document_stores/faiss/document_store.py +578 -0
- haystack_integrations/document_stores/faiss/py.typed +0 -0
|
@@ -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,,
|
|
File without changes
|
|
@@ -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
|
|
File without changes
|
|
@@ -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
|