citadeldb-haystack 2.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,14 @@
|
|
|
1
|
+
"""Haystack document storage backed by Citadel, encrypted at rest."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
from .document_store import CitadelDocumentStore
|
|
6
|
+
|
|
7
|
+
__all__ = ["CitadelDocumentStore", "__version__"]
|
|
8
|
+
|
|
9
|
+
# Haystack wires stores by construction, so there is nothing to register.
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
__version__ = version("citadeldb-haystack")
|
|
13
|
+
except PackageNotFoundError: # running from a source tree, never installed
|
|
14
|
+
__version__ = "0+unknown"
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"""Haystack DocumentStore over an encrypted Citadel region."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import hashlib
|
|
6
|
+
from dataclasses import replace
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import citadeldb
|
|
10
|
+
from haystack import default_from_dict, default_to_dict
|
|
11
|
+
from haystack.dataclasses import Document
|
|
12
|
+
from haystack.document_stores.errors import DuplicateDocumentError
|
|
13
|
+
from haystack.document_stores.types import DuplicatePolicy
|
|
14
|
+
from haystack.utils import Secret, deserialize_secrets_inplace
|
|
15
|
+
from haystack.utils.filters import document_matches_filter
|
|
16
|
+
|
|
17
|
+
KIND = "doc"
|
|
18
|
+
DEFAULT_PATH = "haystack.cdl"
|
|
19
|
+
DEFAULT_REGION = "documents"
|
|
20
|
+
# The default width of Haystack's own embedders and of its conformance fixtures.
|
|
21
|
+
DEFAULT_DIM = 768
|
|
22
|
+
PAGE = 10_000
|
|
23
|
+
# Haystack prefixes metadata fields; bare names address the document.
|
|
24
|
+
_META_PREFIX = "meta."
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# A Database is pinned to its opening thread, so workers take Memory, not self.
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _placeholder(text: str, dim: int) -> list[float]:
|
|
31
|
+
"""A deterministic vector for a document Haystack did not embed."""
|
|
32
|
+
digest = hashlib.sha256(text.encode()).digest()
|
|
33
|
+
return [digest[i % len(digest)] / 255.0 for i in range(dim)]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _fetch(mem: Any, region: str, criterion: dict[str, Any] | None) -> list[Any]:
|
|
37
|
+
"""Page to the end: one fetch is bounded, and a partial erase must not look whole."""
|
|
38
|
+
out: list[Any] = []
|
|
39
|
+
after = None
|
|
40
|
+
while True:
|
|
41
|
+
page = mem.fetch(region, KIND, payload_filter=criterion, limit=PAGE, after_id=after)
|
|
42
|
+
out.extend(page)
|
|
43
|
+
if len(page) < PAGE:
|
|
44
|
+
return out
|
|
45
|
+
after = page[-1].id
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _erase(mem: Any, region: str, hits: list[Any]) -> int:
|
|
49
|
+
if not hits:
|
|
50
|
+
return 0
|
|
51
|
+
return mem.forget(region, [h.id for h in hits]).erased_count
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _document(hit: Any) -> Document:
|
|
55
|
+
# Unflattened so the document round-trips whole.
|
|
56
|
+
return Document.from_dict(hit.payload["doc"])
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _pushdown(filters: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
60
|
+
"""Equality leaves of a top-level AND; this narrows and never decides."""
|
|
61
|
+
if not filters:
|
|
62
|
+
return None
|
|
63
|
+
if filters.get("operator") == "AND":
|
|
64
|
+
conditions = filters.get("conditions", [])
|
|
65
|
+
elif "field" in filters:
|
|
66
|
+
conditions = [filters] # a bare condition is an AND of one
|
|
67
|
+
else:
|
|
68
|
+
return None
|
|
69
|
+
leaves: list[tuple[tuple[str, ...], Any]] = []
|
|
70
|
+
for c in conditions:
|
|
71
|
+
field = c.get("field", "")
|
|
72
|
+
if (
|
|
73
|
+
c.get("operator") == "=="
|
|
74
|
+
and field.startswith(_META_PREFIX)
|
|
75
|
+
# Only strings: containment compares JSON types exactly where
|
|
76
|
+
# Haystack's `==` is Python's, which holds 1 == 1.0 == True, so
|
|
77
|
+
# pushing a number would decide rather than narrow. None means
|
|
78
|
+
# absent, which containment cannot express at all.
|
|
79
|
+
and isinstance(c.get("value"), str)
|
|
80
|
+
):
|
|
81
|
+
path = tuple(p for p in field[len(_META_PREFIX):].split(".") if p)
|
|
82
|
+
if path:
|
|
83
|
+
leaves.append((path, c["value"]))
|
|
84
|
+
eq = _needle(leaves)
|
|
85
|
+
# The needle is shaped like the payload, not like the filter.
|
|
86
|
+
return {"doc": {"meta": eq}} if eq else None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _needle(leaves: list[tuple[tuple[str, ...], Any]]) -> dict[str, Any]:
|
|
90
|
+
"""Nested-object needle for dotted metadata paths.
|
|
91
|
+
|
|
92
|
+
`meta.person.name` addresses a nested value, so it has to become
|
|
93
|
+
`{"person": {"name": v}}`; one flat key spelled with a dot can never exist in
|
|
94
|
+
the payload and would match nothing. Two leaves that collide, or one whose
|
|
95
|
+
path runs through another's, are dropped rather than merged - containment
|
|
96
|
+
holds one value per position, and guessing which wins would decide.
|
|
97
|
+
"""
|
|
98
|
+
out: dict[str, Any] = {}
|
|
99
|
+
for path, value in leaves:
|
|
100
|
+
if any(
|
|
101
|
+
other is not path and (other[: len(path)] == path or path[: len(other)] == other)
|
|
102
|
+
for other, _ in leaves
|
|
103
|
+
):
|
|
104
|
+
continue
|
|
105
|
+
node = out
|
|
106
|
+
for part in path[:-1]:
|
|
107
|
+
node = node.setdefault(part, {})
|
|
108
|
+
node[path[-1]] = value
|
|
109
|
+
return out
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _filter(mem: Any, region: str, filters: dict[str, Any] | None) -> list[Document]:
|
|
113
|
+
docs = [_document(h) for h in _fetch(mem, region, _pushdown(filters))]
|
|
114
|
+
if not filters:
|
|
115
|
+
return docs
|
|
116
|
+
return [d for d in docs if document_matches_filter(filters, d)]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _atom(doc: Document, dim: int) -> dict[str, Any]:
|
|
120
|
+
if doc.embedding is not None and len(doc.embedding) != dim:
|
|
121
|
+
raise ValueError(
|
|
122
|
+
f"document {doc.id} has a {len(doc.embedding)}-dimension embedding but "
|
|
123
|
+
f"this region is {dim}. Build the store with dim={len(doc.embedding)} to "
|
|
124
|
+
f"match your embedding model."
|
|
125
|
+
)
|
|
126
|
+
return {
|
|
127
|
+
"kind": KIND,
|
|
128
|
+
"text": doc.content or "",
|
|
129
|
+
"embedding": (
|
|
130
|
+
list(doc.embedding)
|
|
131
|
+
if doc.embedding is not None
|
|
132
|
+
else _placeholder(doc.content or doc.id, dim)
|
|
133
|
+
),
|
|
134
|
+
# Unflattened so the document round-trips whole. `emb` records whether
|
|
135
|
+
# the vector above is Haystack's or the placeholder, which is what keeps
|
|
136
|
+
# a hash of the text out of embedding_retrieval's answers.
|
|
137
|
+
"payload": {
|
|
138
|
+
"did": doc.id,
|
|
139
|
+
"emb": doc.embedding is not None,
|
|
140
|
+
"doc": doc.to_dict(flatten=False),
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _write(
|
|
146
|
+
mem: Any,
|
|
147
|
+
region: str,
|
|
148
|
+
dim: int,
|
|
149
|
+
documents: list[Document],
|
|
150
|
+
policy: DuplicatePolicy,
|
|
151
|
+
) -> int:
|
|
152
|
+
if policy == DuplicatePolicy.NONE:
|
|
153
|
+
policy = DuplicatePolicy.FAIL # as InMemoryDocumentStore defaults
|
|
154
|
+
|
|
155
|
+
# OVERWRITE decides nothing from a prior read, so it does not pay for one:
|
|
156
|
+
# the keyed write below supersedes whatever the id already named.
|
|
157
|
+
seen: set[str] = set()
|
|
158
|
+
if policy != DuplicatePolicy.OVERWRITE:
|
|
159
|
+
# A live id set: a second copy in one batch collides like a stored one.
|
|
160
|
+
seen = {
|
|
161
|
+
h.payload["did"]
|
|
162
|
+
for doc in documents
|
|
163
|
+
for h in _fetch(mem, region, {"did": doc.id})
|
|
164
|
+
}
|
|
165
|
+
written = len(documents)
|
|
166
|
+
atoms: dict[str, dict[str, Any]] = {}
|
|
167
|
+
for doc in documents:
|
|
168
|
+
if policy != DuplicatePolicy.OVERWRITE and doc.id in seen:
|
|
169
|
+
if policy == DuplicatePolicy.FAIL:
|
|
170
|
+
raise DuplicateDocumentError(f"ID '{doc.id}' already exists.")
|
|
171
|
+
written -= 1 # SKIP: the copy already accepted stands
|
|
172
|
+
continue
|
|
173
|
+
# Last copy of an id in one batch wins, as the reference's dict does.
|
|
174
|
+
atoms[doc.id] = _atom(doc, dim)
|
|
175
|
+
seen.add(doc.id)
|
|
176
|
+
if atoms:
|
|
177
|
+
# Keyed on the document id and committed as one transaction: two writers
|
|
178
|
+
# of one id supersede rather than each adding a row, which a read out
|
|
179
|
+
# here cannot prevent. Every policy binds the key, so a document written
|
|
180
|
+
# under SKIP is still the one a later OVERWRITE replaces.
|
|
181
|
+
mem.remember_replacing_keyed_batch(
|
|
182
|
+
region, [(atom, did) for did, atom in atoms.items()]
|
|
183
|
+
)
|
|
184
|
+
return written
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _delete(mem: Any, region: str, document_ids: list[str]) -> int:
|
|
188
|
+
# Contracted not to fail on an id that is not present.
|
|
189
|
+
return _erase(
|
|
190
|
+
mem,
|
|
191
|
+
region,
|
|
192
|
+
[h for did in dict.fromkeys(document_ids) for h in _fetch(mem, region, {"did": did})],
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _count(mem: Any, region: str) -> int:
|
|
197
|
+
return len(_fetch(mem, region, None))
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class CitadelDocumentStore:
|
|
201
|
+
"""A Haystack `DocumentStore` backed by one encrypted Citadel region."""
|
|
202
|
+
|
|
203
|
+
def __init__(
|
|
204
|
+
self,
|
|
205
|
+
path: str = DEFAULT_PATH,
|
|
206
|
+
key: Secret | str = Secret.from_env_var("CITADEL_KEY"),
|
|
207
|
+
*,
|
|
208
|
+
region: str = DEFAULT_REGION,
|
|
209
|
+
dim: int = DEFAULT_DIM,
|
|
210
|
+
) -> None:
|
|
211
|
+
self._key = Secret.from_token(key) if isinstance(key, str) else key
|
|
212
|
+
passphrase = self._key.resolve_value()
|
|
213
|
+
if not passphrase:
|
|
214
|
+
raise ValueError("a passphrase is required: the corpus is the payload")
|
|
215
|
+
self._path = path
|
|
216
|
+
self._region = region
|
|
217
|
+
self._dim = dim
|
|
218
|
+
try:
|
|
219
|
+
self._db = citadeldb.connect(path, key=passphrase, region_keys=True)
|
|
220
|
+
except citadeldb.OperationalError as e:
|
|
221
|
+
if "locked" not in str(e):
|
|
222
|
+
raise
|
|
223
|
+
raise RuntimeError(
|
|
224
|
+
f"{path} is open in another process. Citadel is embedded, so one "
|
|
225
|
+
f"process owns the file."
|
|
226
|
+
) from e
|
|
227
|
+
self._mem = self._db.memory()
|
|
228
|
+
# Idempotent for a region of the same width, so a dim clash raises here.
|
|
229
|
+
self._mem.create_encrypted_region(region, citadeldb.MockEmbedder(dim=dim))
|
|
230
|
+
|
|
231
|
+
# ---- serialization ----------------------------------------------------
|
|
232
|
+
|
|
233
|
+
def to_dict(self) -> dict[str, Any]:
|
|
234
|
+
"""Serialize for a pipeline file; a literal passphrase refuses."""
|
|
235
|
+
return default_to_dict(
|
|
236
|
+
self,
|
|
237
|
+
path=self._path,
|
|
238
|
+
key=self._key.to_dict(),
|
|
239
|
+
region=self._region,
|
|
240
|
+
dim=self._dim,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
@classmethod
|
|
244
|
+
def from_dict(cls, data: dict[str, Any]) -> CitadelDocumentStore:
|
|
245
|
+
deserialize_secrets_inplace(data["init_parameters"], keys=["key"])
|
|
246
|
+
return default_from_dict(cls, data)
|
|
247
|
+
|
|
248
|
+
# ---- the protocol -----------------------------------------------------
|
|
249
|
+
|
|
250
|
+
def count_documents(self) -> int:
|
|
251
|
+
return _count(self._mem, self._region)
|
|
252
|
+
|
|
253
|
+
def filter_documents(self, filters: dict[str, Any] | None = None) -> list[Document]:
|
|
254
|
+
return _filter(self._mem, self._region, filters)
|
|
255
|
+
|
|
256
|
+
def write_documents(
|
|
257
|
+
self, documents: list[Document], policy: DuplicatePolicy = DuplicatePolicy.NONE
|
|
258
|
+
) -> int:
|
|
259
|
+
if not isinstance(documents, list) or any(
|
|
260
|
+
not isinstance(d, Document) for d in documents
|
|
261
|
+
):
|
|
262
|
+
raise ValueError("Please provide a list of Documents.")
|
|
263
|
+
return _write(self._mem, self._region, self._dim, documents, policy)
|
|
264
|
+
|
|
265
|
+
def delete_documents(self, document_ids: list[str]) -> None:
|
|
266
|
+
_delete(self._mem, self._region, document_ids)
|
|
267
|
+
|
|
268
|
+
# ---- async ------------------------------------------------------------
|
|
269
|
+
# The bindings are sync, so a worker thread keeps the event loop free.
|
|
270
|
+
|
|
271
|
+
async def count_documents_async(self) -> int:
|
|
272
|
+
return await asyncio.to_thread(_count, self._mem, self._region)
|
|
273
|
+
|
|
274
|
+
async def filter_documents_async(
|
|
275
|
+
self, filters: dict[str, Any] | None = None
|
|
276
|
+
) -> list[Document]:
|
|
277
|
+
return await asyncio.to_thread(_filter, self._mem, self._region, filters)
|
|
278
|
+
|
|
279
|
+
async def write_documents_async(
|
|
280
|
+
self, documents: list[Document], policy: DuplicatePolicy = DuplicatePolicy.NONE
|
|
281
|
+
) -> int:
|
|
282
|
+
if not isinstance(documents, list) or any(
|
|
283
|
+
not isinstance(d, Document) for d in documents
|
|
284
|
+
):
|
|
285
|
+
raise ValueError("Please provide a list of Documents.")
|
|
286
|
+
return await asyncio.to_thread(
|
|
287
|
+
_write, self._mem, self._region, self._dim, documents, policy
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
async def delete_documents_async(self, document_ids: list[str]) -> None:
|
|
291
|
+
await asyncio.to_thread(_delete, self._mem, self._region, document_ids)
|
|
292
|
+
|
|
293
|
+
# ---- beyond the protocol ----------------------------------------------
|
|
294
|
+
|
|
295
|
+
def embedding_retrieval(
|
|
296
|
+
self,
|
|
297
|
+
query_embedding: list[float],
|
|
298
|
+
top_k: int = 10,
|
|
299
|
+
filters: dict[str, Any] | None = None,
|
|
300
|
+
) -> list[Document]:
|
|
301
|
+
"""Documents ranked by recall, best first, with their score set.
|
|
302
|
+
|
|
303
|
+
Only documents Haystack embedded take part, as its own store does: an
|
|
304
|
+
unembedded one carries a hash of its text, and ranking that against a
|
|
305
|
+
real query vector produces a plausible score with no meaning behind it.
|
|
306
|
+
"""
|
|
307
|
+
criterion = _pushdown(filters) or {}
|
|
308
|
+
criterion = {**criterion, "emb": True}
|
|
309
|
+
options = citadeldb.RecallOptions(payload_filter=criterion)
|
|
310
|
+
|
|
311
|
+
def surviving(hits: list[Any]) -> list[Document]:
|
|
312
|
+
out: list[Document] = []
|
|
313
|
+
for h in hits:
|
|
314
|
+
doc = _document(h)
|
|
315
|
+
if filters and not document_matches_filter(filters, doc):
|
|
316
|
+
continue
|
|
317
|
+
# Mutating a shared Document would affect other pipeline steps.
|
|
318
|
+
out.append(
|
|
319
|
+
replace(
|
|
320
|
+
doc,
|
|
321
|
+
score=min(1.0, 1.0 - h.distance)
|
|
322
|
+
if h.distance is not None
|
|
323
|
+
else h.score,
|
|
324
|
+
)
|
|
325
|
+
)
|
|
326
|
+
return out
|
|
327
|
+
|
|
328
|
+
# A filter containment cannot express is settled above, so the window has
|
|
329
|
+
# to widen until top_k survive it rather than answer short.
|
|
330
|
+
k = max(top_k, 32)
|
|
331
|
+
while True:
|
|
332
|
+
hits = self._mem.recall(
|
|
333
|
+
self._region,
|
|
334
|
+
embedding=query_embedding,
|
|
335
|
+
k=k,
|
|
336
|
+
kinds=[KIND],
|
|
337
|
+
options=options,
|
|
338
|
+
)
|
|
339
|
+
out = surviving(hits)
|
|
340
|
+
if len(out) >= top_k or len(hits) < k:
|
|
341
|
+
return out[:top_k]
|
|
342
|
+
k *= 2
|
|
343
|
+
|
|
344
|
+
def delete_all(self) -> int:
|
|
345
|
+
"""Destroy every document's key, returning the number erased."""
|
|
346
|
+
return _erase(self._mem, self._region, _fetch(self._mem, self._region, None))
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: citadeldb-haystack
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: Haystack document store backed by Citadel: encrypted at rest, with deletes that destroy the key
|
|
5
|
+
Project-URL: Homepage, https://citadeldb.dev
|
|
6
|
+
Project-URL: Repository, https://github.com/yp3y5akh0v/citadel
|
|
7
|
+
Author: Yuriy Peysakhov
|
|
8
|
+
License-Expression: Apache-2.0
|
|
9
|
+
Keywords: document-store,encryption,haystack,rag,vector-store
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Database
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Requires-Dist: citadeldb<3,>=2.0
|
|
17
|
+
Requires-Dist: haystack-ai<4,>=2.9
|
|
18
|
+
Provides-Extra: test
|
|
19
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'test'
|
|
20
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# citadeldb-haystack
|
|
24
|
+
|
|
25
|
+
A [Haystack](https://github.com/deepset-ai/haystack) `DocumentStore` backed by
|
|
26
|
+
[Citadel](https://citadeldb.dev). Encrypted at rest, embedded in your process, and deletes
|
|
27
|
+
that destroy the key, not just the row.
|
|
28
|
+
|
|
29
|
+
Passes deepset's own `DocumentStoreBaseTests` conformance suite.
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
pip install citadeldb-haystack
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from haystack import Document
|
|
37
|
+
from haystack.utils import Secret
|
|
38
|
+
from citadeldb_haystack import CitadelDocumentStore
|
|
39
|
+
|
|
40
|
+
store = CitadelDocumentStore("corpus.cdl", Secret.from_env_var("CITADEL_KEY"))
|
|
41
|
+
# CITADEL_KEY must be set: an env-var secret is what lets a pipeline serialize.
|
|
42
|
+
|
|
43
|
+
store.write_documents([Document(id="d1", content="...", meta={"chapter": "intro"})])
|
|
44
|
+
store.filter_documents({"field": "meta.chapter", "operator": "==", "value": "intro"})
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`dim` defaults to 768 and must match your embedding model.
|
|
48
|
+
|
|
49
|
+
## The passphrase never lands in a pipeline file
|
|
50
|
+
|
|
51
|
+
The passphrase is a Haystack `Secret`. Pipelines are serialized to disk, and a literal
|
|
52
|
+
token refuses to serialize, so a passphrase cannot be written into a pipeline by accident:
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
CitadelDocumentStore("literal.cdl", "literal-passphrase").to_dict()
|
|
56
|
+
# ValueError: Cannot serialize token-based secret.
|
|
57
|
+
|
|
58
|
+
CitadelDocumentStore("corpus.cdl", Secret.from_env_var("CITADEL_KEY")).to_dict()
|
|
59
|
+
# {... "key": {"type": "env_var", "env_vars": ["CITADEL_KEY"], ...}}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Use `Secret.from_env_var` for any store that goes into a saved pipeline.
|
|
63
|
+
|
|
64
|
+
## Deletes destroy the key
|
|
65
|
+
|
|
66
|
+
Every document is sealed under its own key. Deleting destroys that key and then removes the
|
|
67
|
+
row, so any ciphertext surviving elsewhere stays unreadable.
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
store.delete_documents(["d1"])
|
|
71
|
+
store.delete_all() # returns the number erased
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`DuplicatePolicy.NONE` falls back to `FAIL`, as `InMemoryDocumentStore` does, so an
|
|
75
|
+
accidental re-write is reported rather than silently replacing a document whose key would
|
|
76
|
+
then be destroyed.
|
|
77
|
+
|
|
78
|
+
## Retrieval
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
query_embedding = [0.0] * 768 # from your Haystack text embedder, `dim` wide
|
|
82
|
+
|
|
83
|
+
store.embedding_retrieval(query_embedding, top_k=5,
|
|
84
|
+
filters={"field": "meta.chapter", "operator": "==", "value": "intro"})
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Filtering uses Haystack's own evaluator, so the whole filter language, date comparisons
|
|
88
|
+
included, matches `InMemoryDocumentStore` operator for operator. `top_k` is `top_k`: a
|
|
89
|
+
filter matching only distant documents still returns them, however many others outrank
|
|
90
|
+
them.
|
|
91
|
+
|
|
92
|
+
A top-level `AND` of string equality conditions is pushed into the scan, including nested
|
|
93
|
+
paths like `meta.person.name`. Everything else is evaluated afterwards, so the two agree:
|
|
94
|
+
nothing is pushed under `OR` or `NOT`, and numbers are not pushed either, because `==` here
|
|
95
|
+
is Python's (`1 == 1.0`) where the stored comparison is JSON-type exact.
|
|
96
|
+
|
|
97
|
+
## Notes
|
|
98
|
+
|
|
99
|
+
Documents Haystack did not embed are stored, filterable and countable, but take no part in
|
|
100
|
+
`embedding_retrieval`, as in `InMemoryDocumentStore`, which retrieves only documents that
|
|
101
|
+
have embeddings.
|
|
102
|
+
|
|
103
|
+
Citadel is embedded and one process owns the file. A path already open on this thread,
|
|
104
|
+
under the same passphrase, is shared, so this can sit on the same database as another
|
|
105
|
+
Citadel adapter; construct them on the same thread.
|
|
106
|
+
|
|
107
|
+
## License
|
|
108
|
+
|
|
109
|
+
Apache-2.0
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
citadeldb_haystack/__init__.py,sha256=3u0CHgzghhooVTqaaxmTYgASXhOihe3oL9UthxaLYIE,468
|
|
2
|
+
citadeldb_haystack/document_store.py,sha256=GSTxTv97VRLXlN4s56ZddCpV18iFyM_eyK5Of6Che7o,13182
|
|
3
|
+
citadeldb_haystack-2.0.0.dist-info/METADATA,sha256=74gOayCk5v1eGxDOyQCSSbQ_QypLbGaFSUjfQTUI2fA,4144
|
|
4
|
+
citadeldb_haystack-2.0.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
5
|
+
citadeldb_haystack-2.0.0.dist-info/RECORD,,
|