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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any