citadeldb-llamaindex 2.0.0__tar.gz

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,18 @@
1
+ /target
2
+ **/*.rs.bk
3
+ *.swp
4
+ *.swo
5
+ *~
6
+ .DS_Store
7
+ site/public/
8
+ site/static/wasm/*.wasm
9
+ site/static/wasm/*.js
10
+ /notes/
11
+ __pycache__/
12
+ *.py[cod]
13
+ # maturin build output; the .pyd is caught by the line above only by accident.
14
+ *.pdb
15
+ .pytest_cache/
16
+ .mypy_cache/
17
+ /dist/
18
+ packaging/*/dist/
@@ -0,0 +1,106 @@
1
+ Metadata-Version: 2.5
2
+ Name: citadeldb-llamaindex
3
+ Version: 2.0.0
4
+ Summary: LlamaIndex vector storage 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: encryption,llama-index,llamaindex,memory,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: llama-index-core<0.15,>=0.12
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-llamaindex
24
+
25
+ A [LlamaIndex](https://github.com/run-llama/llama_index) vector store 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
+ ```
30
+ pip install citadeldb-llamaindex
31
+ ```
32
+
33
+ ```python
34
+ from llama_index.core import Document, StorageContext, VectorStoreIndex
35
+ from citadeldb_llamaindex import CitadelVectorStore
36
+
37
+ store = CitadelVectorStore("corpus.cdl", key="your-passphrase", dim=1536)
38
+ documents = [Document(text="the deployment failed because the disk was full")]
39
+
40
+ index = VectorStoreIndex.from_documents(
41
+ documents,
42
+ storage_context=StorageContext.from_defaults(vector_store=store),
43
+ )
44
+
45
+ index.as_query_engine().query("why did the release break?")
46
+ ```
47
+
48
+ `dim` must match your embedding model: 1536 for OpenAI `text-embedding-3-small`, 3072 for
49
+ `text-embedding-3-large`, 1024 for `e5-large`.
50
+
51
+ ## Deletes destroy the key
52
+
53
+ Every node is sealed under its own key. Deleting destroys that key and then removes the
54
+ row, so any ciphertext surviving elsewhere stays unreadable.
55
+
56
+ ```python
57
+ index.delete_ref_doc("doc-42") # every node from that document
58
+ store.forget_document("doc-42") # the same, returning a count for the record
59
+ store.clear() # the whole corpus
60
+ ```
61
+
62
+ The node's key is gone, not just its entry in an index.
63
+
64
+ ## Filters
65
+
66
+ Filtering matches LlamaIndex's own evaluator, and `similarity_top_k` is honoured: a filter
67
+ matching only distant nodes still returns them, however many others outrank them. The same
68
+ holds for `node_ids` and `doc_ids`, which name nodes exactly.
69
+
70
+ String equality under a top-level `AND` is pushed into the scan so it narrows candidates
71
+ before top-k. Everything else is evaluated afterwards, so the two agree: numbers are not
72
+ pushed, because `EQ` here is Python's `==` (`1 == 1.0`) where the stored comparison is
73
+ JSON-type exact.
74
+
75
+ ```python
76
+ from llama_index.core.vector_stores.types import (
77
+ FilterOperator, MetadataFilter, MetadataFilters,
78
+ )
79
+
80
+ index.as_retriever(
81
+ filters=MetadataFilters(filters=[
82
+ MetadataFilter(key="year", value=2026, operator=FilterOperator.EQ),
83
+ ])
84
+ ).retrieve("...")
85
+ ```
86
+
87
+ Filter semantics come from LlamaIndex's own evaluator, so every operator behaves exactly
88
+ as it does with the reference store. Under `OR` or `NOT` nothing is pushed, because a
89
+ pushed leaf would drop rows the filter keeps.
90
+
91
+ ## Notes
92
+
93
+ LlamaIndex embeds before it calls a store, so a node arrives with its vector already
94
+ computed and that vector is written straight onto the atom. Nothing here re-embeds, so
95
+ writes and queries stay in one vector space.
96
+
97
+ The node is stored whole, minus its text, which is kept once as the atom's searchable
98
+ content and restored on read. Metadata, relationships and node type all round-trip.
99
+
100
+ Citadel is embedded and one process owns the file. A path already open on this thread,
101
+ under the same passphrase, is shared, so this can sit on the same database as another
102
+ Citadel adapter; construct them on the same thread.
103
+
104
+ ## License
105
+
106
+ Apache-2.0
@@ -0,0 +1,84 @@
1
+ # citadeldb-llamaindex
2
+
3
+ A [LlamaIndex](https://github.com/run-llama/llama_index) vector store backed by
4
+ [Citadel](https://citadeldb.dev). Encrypted at rest, embedded in your process, and deletes
5
+ that destroy the key, not just the row.
6
+
7
+ ```
8
+ pip install citadeldb-llamaindex
9
+ ```
10
+
11
+ ```python
12
+ from llama_index.core import Document, StorageContext, VectorStoreIndex
13
+ from citadeldb_llamaindex import CitadelVectorStore
14
+
15
+ store = CitadelVectorStore("corpus.cdl", key="your-passphrase", dim=1536)
16
+ documents = [Document(text="the deployment failed because the disk was full")]
17
+
18
+ index = VectorStoreIndex.from_documents(
19
+ documents,
20
+ storage_context=StorageContext.from_defaults(vector_store=store),
21
+ )
22
+
23
+ index.as_query_engine().query("why did the release break?")
24
+ ```
25
+
26
+ `dim` must match your embedding model: 1536 for OpenAI `text-embedding-3-small`, 3072 for
27
+ `text-embedding-3-large`, 1024 for `e5-large`.
28
+
29
+ ## Deletes destroy the key
30
+
31
+ Every node is sealed under its own key. Deleting destroys that key and then removes the
32
+ row, so any ciphertext surviving elsewhere stays unreadable.
33
+
34
+ ```python
35
+ index.delete_ref_doc("doc-42") # every node from that document
36
+ store.forget_document("doc-42") # the same, returning a count for the record
37
+ store.clear() # the whole corpus
38
+ ```
39
+
40
+ The node's key is gone, not just its entry in an index.
41
+
42
+ ## Filters
43
+
44
+ Filtering matches LlamaIndex's own evaluator, and `similarity_top_k` is honoured: a filter
45
+ matching only distant nodes still returns them, however many others outrank them. The same
46
+ holds for `node_ids` and `doc_ids`, which name nodes exactly.
47
+
48
+ String equality under a top-level `AND` is pushed into the scan so it narrows candidates
49
+ before top-k. Everything else is evaluated afterwards, so the two agree: numbers are not
50
+ pushed, because `EQ` here is Python's `==` (`1 == 1.0`) where the stored comparison is
51
+ JSON-type exact.
52
+
53
+ ```python
54
+ from llama_index.core.vector_stores.types import (
55
+ FilterOperator, MetadataFilter, MetadataFilters,
56
+ )
57
+
58
+ index.as_retriever(
59
+ filters=MetadataFilters(filters=[
60
+ MetadataFilter(key="year", value=2026, operator=FilterOperator.EQ),
61
+ ])
62
+ ).retrieve("...")
63
+ ```
64
+
65
+ Filter semantics come from LlamaIndex's own evaluator, so every operator behaves exactly
66
+ as it does with the reference store. Under `OR` or `NOT` nothing is pushed, because a
67
+ pushed leaf would drop rows the filter keeps.
68
+
69
+ ## Notes
70
+
71
+ LlamaIndex embeds before it calls a store, so a node arrives with its vector already
72
+ computed and that vector is written straight onto the atom. Nothing here re-embeds, so
73
+ writes and queries stay in one vector space.
74
+
75
+ The node is stored whole, minus its text, which is kept once as the atom's searchable
76
+ content and restored on read. Metadata, relationships and node type all round-trip.
77
+
78
+ Citadel is embedded and one process owns the file. A path already open on this thread,
79
+ under the same passphrase, is shared, so this can sit on the same database as another
80
+ Citadel adapter; construct them on the same thread.
81
+
82
+ ## License
83
+
84
+ Apache-2.0
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["hatchling", "hatch-vcs"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "citadeldb-llamaindex"
7
+ dynamic = ["version"]
8
+ description = "LlamaIndex vector storage backed by Citadel: encrypted at rest, with deletes that destroy the key"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "Apache-2.0"
12
+ authors = [{ name = "Yuriy Peysakhov" }]
13
+ keywords = ["llamaindex", "llama-index", "rag", "vector-store", "memory", "encryption"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "Programming Language :: Python :: 3",
18
+ "Topic :: Database",
19
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
20
+ ]
21
+ dependencies = ["citadeldb>=2.0,<3", "llama-index-core>=0.12,<0.15"]
22
+
23
+ [project.optional-dependencies]
24
+ test = ["pytest>=8", "pytest-asyncio>=0.23"]
25
+
26
+ [project.urls]
27
+ Homepage = "https://citadeldb.dev"
28
+ Repository = "https://github.com/yp3y5akh0v/citadel"
29
+
30
+ # The version comes from the release tag, so there is nothing to bump.
31
+ [tool.hatch.version]
32
+ source = "vcs"
33
+ raw-options = { root = "../..", tag_regex = '^v(?P<version>\d+\.\d+\.\d+)$' }
34
+
35
+ [tool.hatch.build.targets.wheel]
36
+ packages = ["src/citadeldb_llamaindex"]
37
+
38
+ [tool.pytest.ini_options]
39
+ asyncio_mode = "auto"
@@ -0,0 +1,13 @@
1
+ """LlamaIndex vector storage backed by Citadel, encrypted at rest."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ from .vector_store import CitadelVectorStore
6
+
7
+ __all__ = ["CitadelVectorStore", "__version__"]
8
+
9
+
10
+ try:
11
+ __version__ = version("citadeldb-llamaindex")
12
+ except PackageNotFoundError: # running from a source tree, never installed
13
+ __version__ = "0+unknown"
@@ -0,0 +1,374 @@
1
+ """LlamaIndex vector store over an encrypted Citadel region."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ from typing import Any
6
+
7
+ import citadeldb
8
+ from llama_index.core.schema import BaseNode, MetadataMode
9
+ from llama_index.core.vector_stores.types import (
10
+ BasePydanticVectorStore,
11
+ FilterCondition,
12
+ FilterOperator,
13
+ MetadataFilters,
14
+ VectorStoreQuery,
15
+ VectorStoreQueryMode,
16
+ VectorStoreQueryResult,
17
+ )
18
+ from llama_index.core.vector_stores.utils import (
19
+ build_metadata_filter_fn,
20
+ metadata_dict_to_node,
21
+ node_to_metadata_dict,
22
+ )
23
+ from pydantic import PrivateAttr
24
+
25
+ KIND = "node"
26
+ DEFAULT_PATH = "llamaindex.cdl"
27
+ DEFAULT_REGION = "nodes"
28
+ # OpenAI text-embedding-ada-002 / 3-small, LlamaIndex's default width.
29
+ DEFAULT_DIM = 1536
30
+ # The region is read whole where containment cannot express the predicate.
31
+ PAGE = 10_000
32
+
33
+ # Recall fuses vector, keyword and recency, so hybrid needs no extra call.
34
+ _SUPPORTED_MODES = (VectorStoreQueryMode.DEFAULT, VectorStoreQueryMode.HYBRID)
35
+
36
+
37
+ # A Database is pinned to its opening thread, so workers take Memory, not self.
38
+
39
+
40
+ def _pushdown(filters: MetadataFilters | None) -> dict[str, Any] | None:
41
+ """Equality leaves of a top-level AND; this narrows and never decides.
42
+
43
+ Only string leaves are pushed. Containment compares JSON types exactly where
44
+ EQ is Python `==`, which holds `1 == 1.0 == True`, so pushing a number or a
45
+ bool would drop rows `_keep` would have kept - deciding, not narrowing.
46
+ Equal strings are the one case where the two agree.
47
+ """
48
+ if filters is None or not filters.filters:
49
+ return None
50
+ if filters.condition != FilterCondition.AND:
51
+ return None
52
+ eq: dict[str, Any] = {}
53
+ for f in filters.filters:
54
+ if isinstance(f, MetadataFilters):
55
+ return None # nested filters are refused downstream; do not guess here
56
+ if f.operator == FilterOperator.EQ and isinstance(f.value, str):
57
+ eq[f.key] = f.value
58
+ return {"meta": eq} if eq else None
59
+
60
+
61
+ def _keep(hits: list[Any], filters: MetadataFilters | None) -> list[Any]:
62
+ """Hits whose metadata satisfies `filters`."""
63
+ if filters is None or not filters.filters:
64
+ return hits
65
+ by_id = {h.payload["nid"]: h.payload.get("meta", {}) for h in hits}
66
+ matches = build_metadata_filter_fn(lambda nid: by_id.get(nid, {}), filters)
67
+ return [h for h in hits if matches(h.payload["nid"])]
68
+
69
+
70
+ def _ranked(
71
+ mem: Any,
72
+ region: str,
73
+ *,
74
+ want: int,
75
+ surviving: Any,
76
+ **recall: Any,
77
+ ) -> list[Any]:
78
+ """Ranked hits that pass `surviving`, widening until `want` of them or the
79
+ region runs out.
80
+
81
+ The engine answers `k` rows matching what was pushed to it; a predicate
82
+ containment cannot express is settled out here, and a fixed `k` would answer
83
+ short whenever the survivors rank below it. Fewer hits than asked for means
84
+ the region is exhausted, so the loop ends on the data rather than a constant.
85
+ Widening only appends: recall is ranked, so a larger `k` returns a superset
86
+ in the same order.
87
+ """
88
+ k = max(want, 32)
89
+ while True:
90
+ hits = mem.recall(region, k=k, **recall)
91
+ kept = surviving(hits)
92
+ if len(kept) >= want or len(hits) < k:
93
+ return kept[:want]
94
+ k *= 2
95
+
96
+
97
+ def _node_of(hit: Any) -> BaseNode:
98
+ """Rebuild the node; the text is restored from the atom."""
99
+ return metadata_dict_to_node(hit.payload["meta"], text=hit.text)
100
+
101
+
102
+ def _fetch(mem: Any, region: str, criterion: dict[str, Any] | None) -> list[Any]:
103
+ """Page to the end: one fetch is bounded, and a partial erase must not look whole."""
104
+ out: list[Any] = []
105
+ after = None
106
+ while True:
107
+ page = mem.fetch(region, KIND, payload_filter=criterion, limit=PAGE, after_id=after)
108
+ out.extend(page)
109
+ if len(page) < PAGE:
110
+ return out
111
+ after = page[-1].id
112
+
113
+
114
+ def _add(mem: Any, region: str, dim: int, nodes: list[BaseNode]) -> list[str]:
115
+ atoms: dict[str, dict[str, Any]] = {}
116
+ ids: list[str] = []
117
+ for node in nodes:
118
+ if node.embedding is None:
119
+ raise ValueError(
120
+ f"node {node.node_id} has no embedding. LlamaIndex embeds before it "
121
+ f"calls a store, so index through VectorStoreIndex or set "
122
+ f"node.embedding yourself."
123
+ )
124
+ if len(node.embedding) != dim:
125
+ raise ValueError(
126
+ f"node {node.node_id} has a {len(node.embedding)}-dimension embedding "
127
+ f"but this region is {dim}. Build the store with "
128
+ f"dim={len(node.embedding)} to match your embedding model."
129
+ )
130
+ # An id repeated in one call keeps its last value, like the reference.
131
+ atoms[node.node_id] = {
132
+ "kind": KIND,
133
+ # What recall matches on, and the text the node is rebuilt with.
134
+ "text": node.get_content(metadata_mode=MetadataMode.NONE) or "",
135
+ # Storing the framework's vector keeps one vector space.
136
+ "embedding": list(node.embedding),
137
+ "payload": {
138
+ "nid": node.node_id,
139
+ "ref": node.ref_doc_id or "",
140
+ # remove_text: the text would otherwise be stored twice.
141
+ "meta": node_to_metadata_dict(node, remove_text=True),
142
+ },
143
+ }
144
+ ids.append(node.node_id)
145
+ if not atoms:
146
+ return ids
147
+ # Re-adding a stored id replaces it, as the reference store's dict does.
148
+ # Keyed on the node id and committed as one transaction, so two indexers
149
+ # adding the same node supersede rather than each leaving a row behind.
150
+ mem.remember_replacing_keyed_batch(
151
+ region, [(atom, nid) for nid, atom in atoms.items()]
152
+ )
153
+ # One id per input node, duplicates included.
154
+ return ids
155
+
156
+
157
+ def _erase(mem: Any, region: str, hits: list[Any]) -> int:
158
+ if not hits:
159
+ return 0
160
+ return mem.forget(region, [h.id for h in hits]).erased_count
161
+
162
+
163
+ def _delete_ref(mem: Any, region: str, ref_doc_id: str) -> int:
164
+ return _erase(mem, region, _fetch(mem, region, {"ref": ref_doc_id}))
165
+
166
+
167
+ def _selected(
168
+ mem: Any,
169
+ region: str,
170
+ node_ids: list[str] | None,
171
+ filters: MetadataFilters | None,
172
+ ) -> list[Any]:
173
+ """Atoms named by `node_ids`, narrowed by `filters`. Both may be absent."""
174
+ if node_ids is not None:
175
+ wanted = set(node_ids)
176
+ # One indexed lookup per id beats scanning a region to find a handful.
177
+ hits = [h for nid in wanted for h in _fetch(mem, region, {"nid": nid})]
178
+ else:
179
+ hits = _fetch(mem, region, _pushdown(filters))
180
+ return _keep(hits, filters)
181
+
182
+
183
+ def _get_nodes(
184
+ mem: Any, region: str, node_ids: list[str] | None, filters: MetadataFilters | None
185
+ ) -> list[BaseNode]:
186
+ return [_node_of(h) for h in _selected(mem, region, node_ids, filters)]
187
+
188
+
189
+ def _delete_nodes(
190
+ mem: Any, region: str, node_ids: list[str] | None, filters: MetadataFilters | None
191
+ ) -> int:
192
+ return _erase(mem, region, _selected(mem, region, node_ids, filters))
193
+
194
+
195
+ def _clear(mem: Any, region: str) -> int:
196
+ return _erase(mem, region, _fetch(mem, region, None))
197
+
198
+
199
+ def _query(
200
+ mem: Any, region: str, query: VectorStoreQuery
201
+ ) -> VectorStoreQueryResult:
202
+ if query.mode not in _SUPPORTED_MODES:
203
+ raise NotImplementedError(
204
+ f"query mode {query.mode} is not supported; Citadel serves "
205
+ f"{' and '.join(m.value for m in _SUPPORTED_MODES)}"
206
+ )
207
+ if query.query_embedding is None:
208
+ raise ValueError(
209
+ "query_embedding is required: this store does not embed, so the query "
210
+ "vector has to come from the index's embed_model"
211
+ )
212
+ top_k = max(query.similarity_top_k, 0)
213
+ if top_k == 0:
214
+ return VectorStoreQueryResult(nodes=[], similarities=[], ids=[])
215
+
216
+ criterion = _pushdown(query.filters)
217
+ options = (
218
+ citadeldb.RecallOptions(payload_filter=criterion) if criterion else None
219
+ )
220
+
221
+ def surviving(hits: list[Any]) -> list[Any]:
222
+ hits = _keep(hits, query.filters)
223
+ if query.node_ids:
224
+ allowed = set(query.node_ids)
225
+ hits = [h for h in hits if h.payload["nid"] in allowed]
226
+ if query.doc_ids:
227
+ allowed = set(query.doc_ids)
228
+ hits = [h for h in hits if h.payload.get("ref") in allowed]
229
+ return hits
230
+
231
+ hits = _ranked(
232
+ mem,
233
+ region,
234
+ want=top_k,
235
+ surviving=surviving,
236
+ embedding=query.query_embedding,
237
+ kinds=[KIND],
238
+ options=options,
239
+ )
240
+ return VectorStoreQueryResult(
241
+ nodes=[_node_of(h) for h in hits],
242
+ # Distance can dip below zero at real widths; cosine cannot exceed 1.
243
+ similarities=[
244
+ min(1.0, 1.0 - h.distance) if h.distance is not None else h.score
245
+ for h in hits
246
+ ],
247
+ ids=[h.payload["nid"] for h in hits],
248
+ )
249
+
250
+
251
+ class CitadelVectorStore(BasePydanticVectorStore):
252
+ """A LlamaIndex vector store backed by one encrypted Citadel region."""
253
+
254
+ stores_text: bool = True
255
+ is_embedding_query: bool = True
256
+
257
+ _db: Any = PrivateAttr(default=None)
258
+ _mem: Any = PrivateAttr(default=None)
259
+ _region: str = PrivateAttr(default=DEFAULT_REGION)
260
+ _dim: int = PrivateAttr(default=DEFAULT_DIM)
261
+
262
+ def __init__(
263
+ self,
264
+ path: str = DEFAULT_PATH,
265
+ key: str = "",
266
+ *,
267
+ region: str = DEFAULT_REGION,
268
+ dim: int = DEFAULT_DIM,
269
+ **kwargs: Any,
270
+ ) -> None:
271
+ super().__init__(**kwargs)
272
+ if not key:
273
+ raise ValueError("a passphrase is required: the corpus is the payload")
274
+ try:
275
+ self._db = citadeldb.connect(path, key=key, region_keys=True)
276
+ except citadeldb.OperationalError as e:
277
+ if "locked" not in str(e):
278
+ raise
279
+ raise RuntimeError(
280
+ f"{path} is open in another process. Citadel is embedded, so one "
281
+ f"process owns the file."
282
+ ) from e
283
+ self._mem = self._db.memory()
284
+ self._region = region
285
+ self._dim = dim
286
+ # Idempotent for a region of the same width, so a dim clash raises here.
287
+ self._mem.create_encrypted_region(region, citadeldb.MockEmbedder(dim=dim))
288
+
289
+ @classmethod
290
+ def class_name(cls) -> str:
291
+ return "CitadelVectorStore"
292
+
293
+ @property
294
+ def client(self) -> Any:
295
+ """The Citadel memory engine backing this store."""
296
+ return self._mem
297
+
298
+ # ---- the abstract surface --------------------------------------------
299
+
300
+ def add(self, nodes: list[BaseNode], **kwargs: Any) -> list[str]:
301
+ return _add(self._mem, self._region, self._dim, list(nodes))
302
+
303
+ def delete(self, ref_doc_id: str, **delete_kwargs: Any) -> None:
304
+ _delete_ref(self._mem, self._region, ref_doc_id)
305
+
306
+ def query(self, query: VectorStoreQuery, **kwargs: Any) -> VectorStoreQueryResult:
307
+ return _query(self._mem, self._region, query)
308
+
309
+ # ---- the optional surface --------------------------------------------
310
+
311
+ def get_nodes(
312
+ self,
313
+ node_ids: list[str] | None = None,
314
+ filters: MetadataFilters | None = None,
315
+ ) -> list[BaseNode]:
316
+ return _get_nodes(self._mem, self._region, node_ids, filters)
317
+
318
+ def delete_nodes(
319
+ self,
320
+ node_ids: list[str] | None = None,
321
+ filters: MetadataFilters | None = None,
322
+ **delete_kwargs: Any,
323
+ ) -> None:
324
+ _delete_nodes(self._mem, self._region, node_ids, filters)
325
+
326
+ def clear(self) -> None:
327
+ _clear(self._mem, self._region)
328
+
329
+ # ---- async -----------------------------------------------------------
330
+ # The bindings are sync, so a worker thread keeps the event loop free.
331
+
332
+ async def async_add(self, nodes: list[BaseNode], **kwargs: Any) -> list[str]:
333
+ return await asyncio.to_thread(
334
+ _add, self._mem, self._region, self._dim, list(nodes)
335
+ )
336
+
337
+ async def adelete(self, ref_doc_id: str, **delete_kwargs: Any) -> None:
338
+ await asyncio.to_thread(_delete_ref, self._mem, self._region, ref_doc_id)
339
+
340
+ async def aquery(
341
+ self, query: VectorStoreQuery, **kwargs: Any
342
+ ) -> VectorStoreQueryResult:
343
+ return await asyncio.to_thread(_query, self._mem, self._region, query)
344
+
345
+ async def aget_nodes(
346
+ self,
347
+ node_ids: list[str] | None = None,
348
+ filters: MetadataFilters | None = None,
349
+ ) -> list[BaseNode]:
350
+ return await asyncio.to_thread(
351
+ _get_nodes, self._mem, self._region, node_ids, filters
352
+ )
353
+
354
+ async def adelete_nodes(
355
+ self,
356
+ node_ids: list[str] | None = None,
357
+ filters: MetadataFilters | None = None,
358
+ **delete_kwargs: Any,
359
+ ) -> None:
360
+ await asyncio.to_thread(
361
+ _delete_nodes, self._mem, self._region, node_ids, filters
362
+ )
363
+
364
+ async def aclear(self) -> None:
365
+ await asyncio.to_thread(_clear, self._mem, self._region)
366
+
367
+ # ---- beyond the protocol ---------------------------------------------
368
+
369
+ def forget_document(self, ref_doc_id: str) -> int:
370
+ """Destroy a source document's nodes, returning the number erased."""
371
+ return _delete_ref(self._mem, self._region, ref_doc_id)
372
+
373
+ def count(self) -> int:
374
+ return len(_fetch(self._mem, self._region, None))
@@ -0,0 +1,527 @@
1
+ import pytest
2
+ from llama_index.core.schema import NodeRelationship, RelatedNodeInfo, TextNode
3
+ from llama_index.core.vector_stores.types import (
4
+ BasePydanticVectorStore,
5
+ FilterCondition,
6
+ FilterOperator,
7
+ MetadataFilter,
8
+ MetadataFilters,
9
+ VectorStoreQuery,
10
+ VectorStoreQueryMode,
11
+ )
12
+
13
+ from citadeldb_llamaindex import CitadelVectorStore
14
+ from citadeldb_llamaindex.vector_store import DEFAULT_REGION, KIND
15
+
16
+ DIM = 8
17
+
18
+
19
+ def vec(axis: int) -> list[float]:
20
+ v = [0.0] * DIM
21
+ v[axis] = 1.0
22
+ return v
23
+
24
+
25
+ def node(nid, text, axis, *, meta=None, ref=None):
26
+ n = TextNode(id_=nid, text=text, metadata=dict(meta or {}))
27
+ n.embedding = vec(axis)
28
+ if ref:
29
+ n.relationships[NodeRelationship.SOURCE] = RelatedNodeInfo(node_id=ref)
30
+ return n
31
+
32
+
33
+ @pytest.fixture()
34
+ def store(tmp_path):
35
+ # Each test gets its own file: a store is a corpus, not a scratch pad.
36
+ return CitadelVectorStore(str(tmp_path / "v.cdl"), key="test-passphrase", dim=DIM)
37
+
38
+
39
+ def query(axis, k=10, **kw):
40
+ return VectorStoreQuery(query_embedding=vec(axis), similarity_top_k=k, **kw)
41
+
42
+
43
+ # ---- conformance ---------------------------------------------------------
44
+
45
+
46
+ def test_is_a_pydantic_vector_store(store):
47
+ assert isinstance(store, BasePydanticVectorStore)
48
+ assert store.stores_text is True
49
+ assert store.client is not None
50
+
51
+
52
+ # ---- the embedding contract ----------------------------------------------
53
+
54
+
55
+ def test_the_supplied_vector_is_the_one_stored(store):
56
+ """Identical text, different vectors: the only proof of no re-embed."""
57
+ store.add([node("a", "same text", 0), node("b", "same text", 7)])
58
+ res = store.query(query(7, k=1))
59
+ assert res.ids == ["b"]
60
+ assert res.similarities[0] == pytest.approx(1.0, abs=1e-3)
61
+
62
+
63
+ def test_duplicate_content_keeps_its_own_vector(store):
64
+ """Vectors travel with their atom, so equal text cannot swap them."""
65
+ store.add([node("d1", "identical", 0), node("d2", "identical", 7)])
66
+ assert store.query(query(0, k=1)).ids == ["d1"]
67
+ assert store.query(query(7, k=1)).ids == ["d2"]
68
+
69
+
70
+ def test_similarity_never_exceeds_one_at_a_realistic_width(tmp_path):
71
+ """Small test widths do not surface the sub-zero distance."""
72
+ import random
73
+
74
+ wide = 1536
75
+ s = CitadelVectorStore(str(tmp_path / "wide.cdl"), key="pw", dim=wide)
76
+ nodes = []
77
+ for seed in range(10):
78
+ r = random.Random(seed)
79
+ n = TextNode(id_=f"w{seed}", text="x")
80
+ n.embedding = [r.random() for _ in range(wide)]
81
+ nodes.append(n)
82
+ s.add(nodes)
83
+ for n in nodes:
84
+ res = s.query(VectorStoreQuery(query_embedding=n.embedding, similarity_top_k=1))
85
+ assert res.similarities[0] <= 1.0, (n.node_id, res.similarities[0])
86
+
87
+
88
+ def test_a_node_without_an_embedding_names_the_fix(store):
89
+ bare = TextNode(id_="bare", text="no vector")
90
+ with pytest.raises(ValueError, match="no embedding"):
91
+ store.add([bare])
92
+
93
+
94
+ def test_a_wrong_width_embedding_names_the_fix(store):
95
+ wide = TextNode(id_="wide", text="x")
96
+ wide.embedding = [0.1, 0.2, 0.3]
97
+ with pytest.raises(ValueError, match="dim=3"):
98
+ store.add([wide])
99
+
100
+
101
+ # ---- round-trip ----------------------------------------------------------
102
+
103
+
104
+ def test_text_and_metadata_round_trip(store):
105
+ store.add([node("m1", "the payload text", 0, meta={"page": 4, "tag": "intro"})])
106
+ got = store.query(query(0, k=1)).nodes[0]
107
+ assert got.get_content() == "the payload text"
108
+ assert got.metadata["page"] == 4
109
+ assert got.metadata["tag"] == "intro"
110
+ assert got.node_id == "m1"
111
+
112
+
113
+ def test_relationships_round_trip(store):
114
+ """The node is stored whole, so its graph edges survive with it."""
115
+ store.add([node("r1", "child", 0, ref="parent-doc")])
116
+ got = store.query(query(0, k=1)).nodes[0]
117
+ assert got.ref_doc_id == "parent-doc"
118
+
119
+
120
+ def test_text_is_not_stored_twice(store):
121
+ """The node JSON is stored with its text stripped."""
122
+ store.add([node("t1", "unique body text", 0)])
123
+ raw = store.client.fetch(DEFAULT_REGION, KIND, limit=5)[0]
124
+ assert "unique body text" not in raw.payload["meta"]["_node_content"]
125
+ assert raw.text == "unique body text"
126
+
127
+
128
+ # ---- query ---------------------------------------------------------------
129
+
130
+
131
+ def test_similarity_orders_results(store):
132
+ store.add([node("near", "a", 0), node("far", "b", 7)])
133
+ res = store.query(query(0, k=2))
134
+ assert res.ids[0] == "near"
135
+ assert res.similarities[0] > res.similarities[1]
136
+
137
+
138
+ def test_top_k_is_respected(store):
139
+ store.add([node(f"n{i}", "t", i % DIM) for i in range(6)])
140
+ assert len(store.query(query(0, k=2)).ids) == 2
141
+
142
+
143
+ def test_top_k_zero_is_empty(store):
144
+ store.add([node("z", "t", 0)])
145
+ res = store.query(query(0, k=0))
146
+ assert res.ids == [] and res.nodes == []
147
+
148
+
149
+ def test_a_query_without_an_embedding_is_refused(store):
150
+ with pytest.raises(ValueError, match="query_embedding is required"):
151
+ store.query(VectorStoreQuery(similarity_top_k=1))
152
+
153
+
154
+ def test_an_unsupported_mode_is_refused(store):
155
+ with pytest.raises(NotImplementedError, match="not supported"):
156
+ store.query(query(0, mode=VectorStoreQueryMode.SPARSE))
157
+
158
+
159
+ def test_query_narrows_by_node_ids(store):
160
+ store.add([node("k1", "a", 0), node("k2", "b", 0)])
161
+ assert store.query(query(0, node_ids=["k2"])).ids == ["k2"]
162
+
163
+
164
+ def test_query_narrows_by_doc_ids(store):
165
+ store.add([node("c1", "a", 0, ref="doc-a"), node("c2", "b", 0, ref="doc-b")])
166
+ assert store.query(query(0, doc_ids=["doc-b"])).ids == ["c2"]
167
+
168
+
169
+ # ---- filters -------------------------------------------------------------
170
+
171
+
172
+ def filters(*fs, condition=FilterCondition.AND):
173
+ return MetadataFilters(filters=list(fs), condition=condition)
174
+
175
+
176
+ def seeded(store):
177
+ store.add([
178
+ node("x1", "one", 0, meta={"cat": "x", "n": 1}),
179
+ node("y2", "two", 1, meta={"cat": "y", "n": 2}),
180
+ node("z3", "three", 2, meta={"cat": "z", "n": 3}),
181
+ ])
182
+ return store
183
+
184
+
185
+ def test_equality_filter(store):
186
+ seeded(store)
187
+ f = filters(MetadataFilter(key="cat", value="y", operator=FilterOperator.EQ))
188
+ assert store.query(query(0, filters=f)).ids == ["y2"]
189
+
190
+
191
+ def test_comparison_filter(store):
192
+ seeded(store)
193
+ f = filters(MetadataFilter(key="n", value=2, operator=FilterOperator.GT))
194
+ assert store.query(query(0, filters=f)).ids == ["z3"]
195
+
196
+
197
+ def test_in_filter(store):
198
+ seeded(store)
199
+ f = filters(MetadataFilter(key="cat", value=["x", "z"], operator=FilterOperator.IN))
200
+ assert sorted(store.query(query(0, filters=f)).ids) == ["x1", "z3"]
201
+
202
+
203
+ def test_and_of_two_equalities(store):
204
+ seeded(store)
205
+ f = filters(
206
+ MetadataFilter(key="cat", value="x", operator=FilterOperator.EQ),
207
+ MetadataFilter(key="n", value=1, operator=FilterOperator.EQ),
208
+ )
209
+ assert store.query(query(0, filters=f)).ids == ["x1"]
210
+
211
+
212
+ def test_or_does_not_push_equality_into_the_scan(store):
213
+ """Pushing a leaf of an OR would drop rows the filter keeps."""
214
+ seeded(store)
215
+ f = filters(
216
+ MetadataFilter(key="cat", value="x", operator=FilterOperator.EQ),
217
+ MetadataFilter(key="n", value=3, operator=FilterOperator.EQ),
218
+ condition=FilterCondition.OR,
219
+ )
220
+ assert sorted(store.query(query(0, filters=f)).ids) == ["x1", "z3"]
221
+
222
+
223
+ def test_not_does_not_push_equality_into_the_scan(store):
224
+ seeded(store)
225
+ f = filters(
226
+ MetadataFilter(key="cat", value="x", operator=FilterOperator.EQ),
227
+ condition=FilterCondition.NOT,
228
+ )
229
+ assert sorted(store.query(query(0, filters=f)).ids) == ["y2", "z3"]
230
+
231
+
232
+ def test_a_pushed_filter_agrees_with_an_unpushed_one(store):
233
+ """Pushdown must narrow, never decide: both paths answer the same."""
234
+ seeded(store)
235
+ pushed = filters(MetadataFilter(key="cat", value="z", operator=FilterOperator.EQ))
236
+ unpushed = filters(
237
+ MetadataFilter(key="cat", value="z", operator=FilterOperator.TEXT_MATCH)
238
+ )
239
+ assert store.query(query(0, filters=pushed)).ids == \
240
+ store.query(query(0, filters=unpushed)).ids
241
+
242
+
243
+ def test_a_numeric_filter_is_not_decided_by_the_pushdown(store):
244
+ """Containment compares JSON types exactly where EQ is Python `==`, which
245
+ holds 1 == 1.0. Pushing a number would drop a row the filter keeps, on both
246
+ the query path and the erase path."""
247
+ store.add([node("n-int", "stored as an int", 0, meta={"year": 2024})])
248
+ same = filters(MetadataFilter(key="year", value=2024.0, operator=FilterOperator.EQ))
249
+
250
+ assert store.query(query(0, filters=same)).ids == ["n-int"]
251
+ assert [n.node_id for n in store.get_nodes(filters=same)] == ["n-int"]
252
+ # A silently partial erase is the worst direction for a store whose delete
253
+ # is irreversible key destruction.
254
+ store.delete_nodes(filters=same)
255
+ assert store.count() == 0
256
+
257
+
258
+ def test_a_named_node_is_reachable_however_it_ranks(store):
259
+ """node_ids is an exact restriction, so a named node must come back even
260
+ when it ranks below a window of better-matching rows."""
261
+ store.add([node("wanted", "the one asked for", 1)])
262
+ store.add([node(f"chaff{i}", f"nearer {i}", 0) for i in range(400)])
263
+ got = store.query(query(0, k=1, node_ids=["wanted"]))
264
+ assert got.ids == ["wanted"]
265
+
266
+
267
+ def test_an_unpushable_filter_survives_a_window_of_other_rows(store):
268
+ """TEXT_MATCH cannot be pushed, so it is settled after ranking; the only
269
+ matching row must still be found under a corpus that outranks it."""
270
+ store.add([node("gold", "the needle", 1, meta={"cat": "zebra"})])
271
+ store.add([node(f"c{i}", f"nearer {i}", 0, meta={"cat": "other"}) for i in range(400)])
272
+ unpushable = filters(
273
+ MetadataFilter(key="cat", value="zeb", operator=FilterOperator.TEXT_MATCH)
274
+ )
275
+ assert store.query(query(0, k=1, filters=unpushable)).ids == ["gold"]
276
+
277
+
278
+ # ---- reads and deletes ---------------------------------------------------
279
+
280
+
281
+ def test_get_nodes_by_id(store):
282
+ seeded(store)
283
+ got = store.get_nodes(node_ids=["y2"])
284
+ assert [n.node_id for n in got] == ["y2"]
285
+
286
+
287
+ def test_get_nodes_by_filter(store):
288
+ seeded(store)
289
+ f = filters(MetadataFilter(key="n", value=1, operator=FilterOperator.EQ))
290
+ assert [n.node_id for n in store.get_nodes(filters=f)] == ["x1"]
291
+
292
+
293
+ def test_delete_removes_a_whole_document(store):
294
+ store.add([
295
+ node("p1", "a", 0, ref="doc-1"),
296
+ node("p2", "b", 1, ref="doc-1"),
297
+ node("p3", "c", 2, ref="doc-2"),
298
+ ])
299
+ store.delete("doc-1")
300
+ assert sorted(store.query(query(0, k=9)).ids) == ["p3"]
301
+
302
+
303
+ def test_delete_destroys_keys_rather_than_rows(store):
304
+ store.add([node("s1", "secret", 0, ref="doc-x")])
305
+ assert store.forget_document("doc-x") == 1
306
+ assert store.count() == 0
307
+
308
+
309
+ def test_delete_nodes_by_id_and_filter(store):
310
+ seeded(store)
311
+ store.delete_nodes(node_ids=["x1"])
312
+ assert sorted(store.query(query(0, k=9)).ids) == ["y2", "z3"]
313
+ store.delete_nodes(
314
+ filters=filters(MetadataFilter(key="n", value=2, operator=FilterOperator.EQ))
315
+ )
316
+ assert store.query(query(0, k=9)).ids == ["z3"]
317
+
318
+
319
+ def test_clear_empties_the_store(store):
320
+ seeded(store)
321
+ store.clear()
322
+ assert store.count() == 0
323
+ assert store.query(query(0, k=9)).ids == []
324
+
325
+
326
+ def test_deleting_nothing_is_not_an_error(store):
327
+ store.delete("no-such-doc")
328
+ assert store.count() == 0
329
+
330
+
331
+ def test_a_passphrase_is_required(tmp_path):
332
+ with pytest.raises(ValueError, match="passphrase"):
333
+ CitadelVectorStore(str(tmp_path / "nokey.cdl"), key="")
334
+
335
+
336
+ # ---- async ---------------------------------------------------------------
337
+
338
+
339
+ async def test_async_surface_round_trips(store):
340
+ await store.async_add([node("a1", "async body", 0)])
341
+ res = await store.aquery(query(0, k=1))
342
+ assert res.ids == ["a1"]
343
+ assert [n.node_id for n in await store.aget_nodes(node_ids=["a1"])] == ["a1"]
344
+ await store.adelete_nodes(node_ids=["a1"])
345
+ assert store.count() == 0
346
+ await store.aclear()
347
+
348
+
349
+ async def test_the_event_loop_is_not_blocked(store):
350
+ """The base class would call the sync method straight through."""
351
+ import asyncio
352
+
353
+ ticks = 0
354
+
355
+ async def tick():
356
+ nonlocal ticks
357
+ while True:
358
+ ticks += 1
359
+ await asyncio.sleep(0)
360
+
361
+ ticker = asyncio.create_task(tick())
362
+ await asyncio.sleep(0)
363
+ await store.async_add([node(f"L{i}", "loop body", i % DIM) for i in range(40)])
364
+ await store.aquery(query(0, k=5))
365
+ ticker.cancel()
366
+ assert ticks > 1, "the loop made no progress during a store call"
367
+
368
+
369
+ # ---- through LlamaIndex's own layer --------------------------------------
370
+
371
+
372
+ # ---- edges ---------------------------------------------------------------
373
+
374
+
375
+ def test_re_adding_the_same_node_id_replaces_it(store):
376
+ """Appending instead of replacing would double the corpus."""
377
+ store.add([node("same", "first version", 0)])
378
+ store.add([node("same", "second version", 0)])
379
+ assert store.count() == 1
380
+ assert store.query(query(0, k=5)).nodes[0].get_content() == "second version"
381
+
382
+
383
+ def test_duplicate_ids_within_one_call_keep_the_last(store):
384
+ """The reference is dict-keyed by node id, so the last write wins."""
385
+ store.add([node("same", "first", 0), node("same", "second", 7)])
386
+ assert store.count() == 1
387
+ assert store.query(query(7, k=1)).nodes[0].get_content() == "second"
388
+
389
+
390
+ def test_a_node_with_empty_text_is_storable(store):
391
+ """An image or placeholder node can carry no text at all."""
392
+ store.add([node("blank", "", 0)])
393
+ assert store.query(query(0, k=1)).ids == ["blank"]
394
+
395
+
396
+ def test_adding_nothing_is_not_an_error(store):
397
+ assert store.add([]) == []
398
+ assert store.count() == 0
399
+
400
+
401
+ def test_query_on_an_empty_store_is_empty(store):
402
+ res = store.query(query(0, k=5))
403
+ assert res.ids == [] and res.nodes == [] and res.similarities == []
404
+
405
+
406
+ def test_empty_node_ids_selects_nothing_unlike_none(store):
407
+ """`[]` means none of them; `None` means no id restriction."""
408
+ store.add([node("a", "x", 0), node("b", "y", 1)])
409
+ assert store.get_nodes(node_ids=[]) == []
410
+ assert len(store.get_nodes()) == 2
411
+
412
+
413
+ def test_delete_nodes_with_no_arguments_clears_everything(store):
414
+ """Matches SimpleVectorStore, whose filter is true for every node."""
415
+ store.add([node("a", "x", 0), node("b", "y", 1)])
416
+ store.delete_nodes()
417
+ assert store.count() == 0
418
+
419
+
420
+ def test_a_negative_top_k_is_empty_not_a_tail_slice(store):
421
+ store.add([node("a", "x", 0)])
422
+ assert store.query(query(0, k=-1)).ids == []
423
+
424
+
425
+ def test_nested_and_list_metadata_round_trip(store):
426
+ store.add([node("n", "t", 0, meta={"outer": {"inner": [1, 2]}})])
427
+ assert store.query(query(0, k=1)).nodes[0].metadata["outer"] == {"inner": [1, 2]}
428
+
429
+
430
+ def test_contains_filter_on_a_list(store):
431
+ store.add([
432
+ node("t1", "a", 0, meta={"tags": ["red", "blue"]}),
433
+ node("t2", "b", 1, meta={"tags": ["green"]}),
434
+ ])
435
+ f = filters(MetadataFilter(key="tags", value="blue", operator=FilterOperator.CONTAINS))
436
+ assert store.query(query(0, filters=f)).ids == ["t1"]
437
+
438
+
439
+ def test_a_filter_on_a_missing_key_excludes_rather_than_raises(store):
440
+ store.add([node("a", "x", 0, meta={"present": 1})])
441
+ f = filters(MetadataFilter(key="absent", value=1, operator=FilterOperator.EQ))
442
+ assert store.query(query(0, filters=f)).ids == []
443
+
444
+
445
+ def test_unicode_round_trips(store):
446
+ store.add([node("u", "unicode - a and b", 0, meta={"k": "value"})])
447
+ got = store.query(query(0, k=1)).nodes[0]
448
+ assert got.get_content() == "unicode - a and b"
449
+
450
+
451
+ def test_two_stores_share_one_database_file(tmp_path):
452
+ """The composition the shared-handle work exists to allow."""
453
+ path = str(tmp_path / "shared.cdl")
454
+ a = CitadelVectorStore(path, key="pw", region="corpus_a", dim=DIM)
455
+ b = CitadelVectorStore(path, key="pw", region="corpus_b", dim=DIM)
456
+ a.add([node("a1", "in a", 0)])
457
+ b.add([node("b1", "in b", 0)])
458
+ assert a.query(query(0, k=5)).ids == ["a1"]
459
+ assert b.query(query(0, k=5)).ids == ["b1"]
460
+
461
+
462
+ def test_a_large_batch_keeps_every_node(store):
463
+ store.add([node(f"b{i}", f"body {i}", i % DIM) for i in range(300)])
464
+ assert store.count() == 300
465
+ assert len(store.query(query(0, k=50)).ids) == 50
466
+
467
+
468
+ def test_it_survives_a_reopen(tmp_path):
469
+ """A region's embedder lives in memory, so reattach is the risk."""
470
+ import gc
471
+
472
+ p = str(tmp_path / "reopen.cdl")
473
+ first = CitadelVectorStore(p, key="pw", dim=DIM)
474
+ first.add([node("n1", "the disk was full", 0, meta={"page": 3})])
475
+ del first
476
+ gc.collect()
477
+
478
+ again = CitadelVectorStore(p, key="pw", dim=DIM)
479
+ assert again.count() == 1
480
+ got = again.get_nodes(node_ids=["n1"])[0]
481
+ assert got.get_content() == "the disk was full" and got.metadata["page"] == 3
482
+ assert again.query(query(0, k=1)).ids == ["n1"], "recall did not survive the reopen"
483
+
484
+
485
+ def test_a_wrong_passphrase_cannot_reopen(tmp_path):
486
+ """Pins the encryption claim rather than inferring it from a reopen."""
487
+ import gc
488
+
489
+ import citadeldb
490
+
491
+ p = str(tmp_path / "enc.cdl")
492
+ first = CitadelVectorStore(p, key="right", dim=DIM)
493
+ first.add([node("n1", "the disk was full", 0)])
494
+ del first
495
+ gc.collect()
496
+
497
+ with pytest.raises(citadeldb.EncryptionError):
498
+ CitadelVectorStore(p, key="wrong", dim=DIM)
499
+
500
+
501
+ def test_concurrent_writes_all_land(tmp_path):
502
+ """Indexing pipelines share one engine across threads."""
503
+ import concurrent.futures as cf
504
+
505
+ s = CitadelVectorStore(str(tmp_path / "conc.cdl"), key="pw", dim=DIM)
506
+ with cf.ThreadPoolExecutor(max_workers=4) as ex:
507
+ list(ex.map(lambda i: s.add([node(f"c{i}", f"body {i}", i % DIM)]), range(40)))
508
+ assert s.count() == 40
509
+
510
+
511
+ def test_end_to_end_through_vector_store_index(tmp_path):
512
+ """The ABC is only half the integration; this drives LlamaIndex itself."""
513
+ from llama_index.core import StorageContext, VectorStoreIndex
514
+ from llama_index.core.embeddings import MockEmbedding
515
+ from llama_index.core.schema import Document
516
+
517
+ embed = MockEmbedding(embed_dim=DIM)
518
+ store = CitadelVectorStore(str(tmp_path / "e2e.cdl"), key="pw", dim=DIM)
519
+ index = VectorStoreIndex.from_documents(
520
+ [Document(text="the deploy failed because the disk was full"),
521
+ Document(text="lunch plans for friday")],
522
+ storage_context=StorageContext.from_defaults(vector_store=store),
523
+ embed_model=embed,
524
+ )
525
+ hits = index.as_retriever(similarity_top_k=2).retrieve("why did the release break?")
526
+ assert hits, "retrieval through VectorStoreIndex returned nothing"
527
+ assert store.count() == 2