haystack-velesdb 3.12.0__tar.gz → 4.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.
- {haystack_velesdb-3.12.0/src/haystack_velesdb.egg-info → haystack_velesdb-4.0.0}/PKG-INFO +32 -22
- {haystack_velesdb-3.12.0 → haystack_velesdb-4.0.0}/README.md +31 -21
- {haystack_velesdb-3.12.0 → haystack_velesdb-4.0.0}/pyproject.toml +1 -1
- {haystack_velesdb-3.12.0 → haystack_velesdb-4.0.0}/src/haystack_velesdb/__init__.py +1 -1
- {haystack_velesdb-3.12.0 → haystack_velesdb-4.0.0}/src/haystack_velesdb/document_store.py +156 -14
- {haystack_velesdb-3.12.0 → haystack_velesdb-4.0.0/src/haystack_velesdb.egg-info}/PKG-INFO +32 -22
- {haystack_velesdb-3.12.0 → haystack_velesdb-4.0.0}/tests/test_document_store.py +262 -0
- {haystack_velesdb-3.12.0 → haystack_velesdb-4.0.0}/tests/test_real_haystack_integration.py +104 -6
- {haystack_velesdb-3.12.0 → haystack_velesdb-4.0.0}/LICENSE +0 -0
- {haystack_velesdb-3.12.0 → haystack_velesdb-4.0.0}/setup.cfg +0 -0
- {haystack_velesdb-3.12.0 → haystack_velesdb-4.0.0}/src/haystack_velesdb/py.typed +0 -0
- {haystack_velesdb-3.12.0 → haystack_velesdb-4.0.0}/src/haystack_velesdb/retriever.py +0 -0
- {haystack_velesdb-3.12.0 → haystack_velesdb-4.0.0}/src/haystack_velesdb.egg-info/SOURCES.txt +0 -0
- {haystack_velesdb-3.12.0 → haystack_velesdb-4.0.0}/src/haystack_velesdb.egg-info/dependency_links.txt +0 -0
- {haystack_velesdb-3.12.0 → haystack_velesdb-4.0.0}/src/haystack_velesdb.egg-info/requires.txt +0 -0
- {haystack_velesdb-3.12.0 → haystack_velesdb-4.0.0}/src/haystack_velesdb.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: haystack-velesdb
|
|
3
|
-
Version:
|
|
3
|
+
Version: 4.0.0
|
|
4
4
|
Summary: Haystack 2.x DocumentStore for VelesDB: The Local AI Memory Database.
|
|
5
5
|
Author-email: VelesDB Team <contact@wiscale.fr>
|
|
6
6
|
License: MIT
|
|
@@ -29,7 +29,9 @@ Dynamic: license-file
|
|
|
29
29
|
# haystack-velesdb
|
|
30
30
|
|
|
31
31
|
A Haystack 2.x `DocumentStore` backed by [VelesDB](https://github.com/cyberlife-coder/VelesDB) —
|
|
32
|
-
the local-first
|
|
32
|
+
**the explainable, local-first memory engine for AI agents** (microsecond vector
|
|
33
|
+
search is the proof, not the pitch). For the connected `why()` recall trail
|
|
34
|
+
across typed links, see [velesdb-memory](../../crates/velesdb-memory/README.md).
|
|
33
35
|
|
|
34
36
|
This integration joins the existing [LangChain](../langchain/) and [LlamaIndex](../llamaindex/)
|
|
35
37
|
connectors, completing the trio of major Python RAG frameworks supported by VelesDB.
|
|
@@ -102,26 +104,15 @@ indexer.connect("embedder", "writer")
|
|
|
102
104
|
indexer.run({"converter": {"sources": ["paper.pdf"]}})
|
|
103
105
|
|
|
104
106
|
# Query pipeline. `InMemoryEmbeddingRetriever` is bound to `InMemoryDocumentStore`
|
|
105
|
-
# and would NOT work against a custom DocumentStore —
|
|
106
|
-
#
|
|
107
|
-
# `
|
|
108
|
-
|
|
109
|
-
from
|
|
110
|
-
from typing import List
|
|
111
|
-
|
|
112
|
-
@component
|
|
113
|
-
class VelesRetriever:
|
|
114
|
-
def __init__(self, document_store, top_k: int = 10):
|
|
115
|
-
self._store = document_store
|
|
116
|
-
self._top_k = top_k
|
|
117
|
-
|
|
118
|
-
@component.output_types(documents=List[Document])
|
|
119
|
-
def run(self, query_embedding: List[float]):
|
|
120
|
-
return {"documents": self._store.embedding_retrieval(query_embedding, top_k=self._top_k)}
|
|
107
|
+
# and would NOT work against a custom DocumentStore — use the shipped
|
|
108
|
+
# `VelesDBEmbeddingRetriever` instead (like `QdrantEmbeddingRetriever` in the
|
|
109
|
+
# Qdrant integration, no hand-rolled `@component` wrapper needed). Full working
|
|
110
|
+
# example in `integrations/haystack/examples/rag_pipeline.py`.
|
|
111
|
+
from haystack_velesdb import VelesDBEmbeddingRetriever
|
|
121
112
|
|
|
122
113
|
querier = Pipeline()
|
|
123
114
|
querier.add_component("embedder", SentenceTransformersTextEmbedder(model="all-MiniLM-L6-v2"))
|
|
124
|
-
querier.add_component("retriever",
|
|
115
|
+
querier.add_component("retriever", VelesDBEmbeddingRetriever(document_store=store))
|
|
125
116
|
querier.connect("embedder.embedding", "retriever.query_embedding")
|
|
126
117
|
result = querier.run({"embedder": {"text": "What is VelesDB?"}})
|
|
127
118
|
print(result["retriever"]["documents"])
|
|
@@ -137,16 +128,25 @@ print(result["retriever"]["documents"])
|
|
|
137
128
|
| `collection_name` | `"haystack_documents"` | VelesDB collection name |
|
|
138
129
|
| `embedding_dim` | `768` | Embedding vector dimension |
|
|
139
130
|
| `metric` | `"cosine"` | Distance metric: `"cosine"`, `"euclidean"`, `"dot"`, `"hamming"`, or `"jaccard"` |
|
|
131
|
+
| `scroll_limit` | `10_000` | Max documents returned by `filter_documents()`; raise for collections bigger than this |
|
|
140
132
|
|
|
141
133
|
### Methods
|
|
142
134
|
|
|
143
135
|
| Method | Description |
|
|
144
136
|
|--------|-------------|
|
|
145
|
-
| `write_documents(documents, policy)` | Upsert documents; returns count written |
|
|
146
|
-
| `
|
|
147
|
-
| `
|
|
137
|
+
| `write_documents(documents, policy, sparse_vectors=None)` | Upsert documents; returns count written. `sparse_vectors` is an optional list aligned with `documents` — each entry a flat `dict[int, float]` or a named `dict[str, dict[int, float]]` mapping (e.g. `{"bge_m3": {0: 1.5}}`); a named mapping creates that sparse index for later hybrid retrieval |
|
|
138
|
+
| `stream_insert(documents, sparse_vectors=None)` | Insert documents through VelesDB's streaming ingestion channel in one call (append-only, no `DuplicatePolicy` check); returns count inserted. See "Note on streaming ingestion" below |
|
|
139
|
+
| `write_documents_streaming(documents, policy, sparse_vectors=None, batch_size=100)` | Same `DuplicatePolicy` semantics as `write_documents`, but sent through the streaming channel in `batch_size`-sized chunks — better throughput for large bulk loads. See "Note on streaming ingestion" below |
|
|
140
|
+
| `flush()` | Flush pending changes to disk. See "Note on streaming ingestion" below |
|
|
141
|
+
| `filter_documents(filters)` | Scroll documents matching a VelesDB filter dict, up to `scroll_limit` |
|
|
142
|
+
| `embedding_retrieval(query_embedding, top_k, filters, scale_score, fusion=None, fusion_params=None)` | Vector similarity search. `fusion` selects a `velesdb.FusionStrategy` (`"average"`, `"maximum"`, `"rrf"`, `"weighted"`, `"relative_score"` / `"rsf"`) applied via `Collection.multi_query_search`, changing the ranking; `fusion_params` configures it (e.g. `{"dense_weight": 0.7, "sparse_weight": 0.3}` for `"rsf"`). `filters` cannot be combined with `fusion` |
|
|
148
143
|
| `count_documents()` | Total document count |
|
|
149
144
|
| `delete_documents(document_ids)` | Delete by Haystack string IDs |
|
|
145
|
+
| `train_pq(m=8, k=256, opq=False)` | Train Product Quantization on the collection |
|
|
146
|
+
| `analyze_collection()` | Compute and persist collection statistics (point/row counts, column stats) |
|
|
147
|
+
| `get_collection_stats()` | Return cached statistics from the last `analyze_collection()` call, or `None` |
|
|
148
|
+
| `is_metadata_only()` | Whether the current collection holds no vectors |
|
|
149
|
+
| `create_metadata_collection(name)` | Create a metadata-only collection (no vectors), joinable with vector collections |
|
|
150
150
|
| `to_dict()` / `from_dict()` | Haystack pipeline serialisation |
|
|
151
151
|
|
|
152
152
|
**Note on `DuplicatePolicy`:** `NONE` and `OVERWRITE` use VelesDB upsert semantics
|
|
@@ -164,6 +164,16 @@ if a collision is detected between a new document and an existing one.
|
|
|
164
164
|
are normalised from `[-1, 1]` to `[0, 1]` so they behave like probabilities
|
|
165
165
|
in downstream re-ranking.
|
|
166
166
|
|
|
167
|
+
**Note on streaming ingestion:** `stream_insert()` and `write_documents_streaming()`
|
|
168
|
+
forward to the underlying `velesdb.Collection.stream_insert`, which requires
|
|
169
|
+
the collection to have `enable_streaming()` called on it first — the same
|
|
170
|
+
caller-managed contract as the LangChain and LlamaIndex integrations (see
|
|
171
|
+
`docs/reference/ECOSYSTEM_PARITY.md`). The channel batches asynchronously on
|
|
172
|
+
its own background interval (engine default ~50ms), so a point may not be
|
|
173
|
+
immediately visible to reads right after either call returns; `flush()`
|
|
174
|
+
flushes pending changes to disk but does **not** guarantee streaming-channel
|
|
175
|
+
visibility.
|
|
176
|
+
|
|
167
177
|
## Running tests
|
|
168
178
|
|
|
169
179
|
```bash
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
# haystack-velesdb
|
|
2
2
|
|
|
3
3
|
A Haystack 2.x `DocumentStore` backed by [VelesDB](https://github.com/cyberlife-coder/VelesDB) —
|
|
4
|
-
the local-first
|
|
4
|
+
**the explainable, local-first memory engine for AI agents** (microsecond vector
|
|
5
|
+
search is the proof, not the pitch). For the connected `why()` recall trail
|
|
6
|
+
across typed links, see [velesdb-memory](../../crates/velesdb-memory/README.md).
|
|
5
7
|
|
|
6
8
|
This integration joins the existing [LangChain](../langchain/) and [LlamaIndex](../llamaindex/)
|
|
7
9
|
connectors, completing the trio of major Python RAG frameworks supported by VelesDB.
|
|
@@ -74,26 +76,15 @@ indexer.connect("embedder", "writer")
|
|
|
74
76
|
indexer.run({"converter": {"sources": ["paper.pdf"]}})
|
|
75
77
|
|
|
76
78
|
# Query pipeline. `InMemoryEmbeddingRetriever` is bound to `InMemoryDocumentStore`
|
|
77
|
-
# and would NOT work against a custom DocumentStore —
|
|
78
|
-
#
|
|
79
|
-
# `
|
|
80
|
-
|
|
81
|
-
from
|
|
82
|
-
from typing import List
|
|
83
|
-
|
|
84
|
-
@component
|
|
85
|
-
class VelesRetriever:
|
|
86
|
-
def __init__(self, document_store, top_k: int = 10):
|
|
87
|
-
self._store = document_store
|
|
88
|
-
self._top_k = top_k
|
|
89
|
-
|
|
90
|
-
@component.output_types(documents=List[Document])
|
|
91
|
-
def run(self, query_embedding: List[float]):
|
|
92
|
-
return {"documents": self._store.embedding_retrieval(query_embedding, top_k=self._top_k)}
|
|
79
|
+
# and would NOT work against a custom DocumentStore — use the shipped
|
|
80
|
+
# `VelesDBEmbeddingRetriever` instead (like `QdrantEmbeddingRetriever` in the
|
|
81
|
+
# Qdrant integration, no hand-rolled `@component` wrapper needed). Full working
|
|
82
|
+
# example in `integrations/haystack/examples/rag_pipeline.py`.
|
|
83
|
+
from haystack_velesdb import VelesDBEmbeddingRetriever
|
|
93
84
|
|
|
94
85
|
querier = Pipeline()
|
|
95
86
|
querier.add_component("embedder", SentenceTransformersTextEmbedder(model="all-MiniLM-L6-v2"))
|
|
96
|
-
querier.add_component("retriever",
|
|
87
|
+
querier.add_component("retriever", VelesDBEmbeddingRetriever(document_store=store))
|
|
97
88
|
querier.connect("embedder.embedding", "retriever.query_embedding")
|
|
98
89
|
result = querier.run({"embedder": {"text": "What is VelesDB?"}})
|
|
99
90
|
print(result["retriever"]["documents"])
|
|
@@ -109,16 +100,25 @@ print(result["retriever"]["documents"])
|
|
|
109
100
|
| `collection_name` | `"haystack_documents"` | VelesDB collection name |
|
|
110
101
|
| `embedding_dim` | `768` | Embedding vector dimension |
|
|
111
102
|
| `metric` | `"cosine"` | Distance metric: `"cosine"`, `"euclidean"`, `"dot"`, `"hamming"`, or `"jaccard"` |
|
|
103
|
+
| `scroll_limit` | `10_000` | Max documents returned by `filter_documents()`; raise for collections bigger than this |
|
|
112
104
|
|
|
113
105
|
### Methods
|
|
114
106
|
|
|
115
107
|
| Method | Description |
|
|
116
108
|
|--------|-------------|
|
|
117
|
-
| `write_documents(documents, policy)` | Upsert documents; returns count written |
|
|
118
|
-
| `
|
|
119
|
-
| `
|
|
109
|
+
| `write_documents(documents, policy, sparse_vectors=None)` | Upsert documents; returns count written. `sparse_vectors` is an optional list aligned with `documents` — each entry a flat `dict[int, float]` or a named `dict[str, dict[int, float]]` mapping (e.g. `{"bge_m3": {0: 1.5}}`); a named mapping creates that sparse index for later hybrid retrieval |
|
|
110
|
+
| `stream_insert(documents, sparse_vectors=None)` | Insert documents through VelesDB's streaming ingestion channel in one call (append-only, no `DuplicatePolicy` check); returns count inserted. See "Note on streaming ingestion" below |
|
|
111
|
+
| `write_documents_streaming(documents, policy, sparse_vectors=None, batch_size=100)` | Same `DuplicatePolicy` semantics as `write_documents`, but sent through the streaming channel in `batch_size`-sized chunks — better throughput for large bulk loads. See "Note on streaming ingestion" below |
|
|
112
|
+
| `flush()` | Flush pending changes to disk. See "Note on streaming ingestion" below |
|
|
113
|
+
| `filter_documents(filters)` | Scroll documents matching a VelesDB filter dict, up to `scroll_limit` |
|
|
114
|
+
| `embedding_retrieval(query_embedding, top_k, filters, scale_score, fusion=None, fusion_params=None)` | Vector similarity search. `fusion` selects a `velesdb.FusionStrategy` (`"average"`, `"maximum"`, `"rrf"`, `"weighted"`, `"relative_score"` / `"rsf"`) applied via `Collection.multi_query_search`, changing the ranking; `fusion_params` configures it (e.g. `{"dense_weight": 0.7, "sparse_weight": 0.3}` for `"rsf"`). `filters` cannot be combined with `fusion` |
|
|
120
115
|
| `count_documents()` | Total document count |
|
|
121
116
|
| `delete_documents(document_ids)` | Delete by Haystack string IDs |
|
|
117
|
+
| `train_pq(m=8, k=256, opq=False)` | Train Product Quantization on the collection |
|
|
118
|
+
| `analyze_collection()` | Compute and persist collection statistics (point/row counts, column stats) |
|
|
119
|
+
| `get_collection_stats()` | Return cached statistics from the last `analyze_collection()` call, or `None` |
|
|
120
|
+
| `is_metadata_only()` | Whether the current collection holds no vectors |
|
|
121
|
+
| `create_metadata_collection(name)` | Create a metadata-only collection (no vectors), joinable with vector collections |
|
|
122
122
|
| `to_dict()` / `from_dict()` | Haystack pipeline serialisation |
|
|
123
123
|
|
|
124
124
|
**Note on `DuplicatePolicy`:** `NONE` and `OVERWRITE` use VelesDB upsert semantics
|
|
@@ -136,6 +136,16 @@ if a collision is detected between a new document and an existing one.
|
|
|
136
136
|
are normalised from `[-1, 1]` to `[0, 1]` so they behave like probabilities
|
|
137
137
|
in downstream re-ranking.
|
|
138
138
|
|
|
139
|
+
**Note on streaming ingestion:** `stream_insert()` and `write_documents_streaming()`
|
|
140
|
+
forward to the underlying `velesdb.Collection.stream_insert`, which requires
|
|
141
|
+
the collection to have `enable_streaming()` called on it first — the same
|
|
142
|
+
caller-managed contract as the LangChain and LlamaIndex integrations (see
|
|
143
|
+
`docs/reference/ECOSYSTEM_PARITY.md`). The channel batches asynchronously on
|
|
144
|
+
its own background interval (engine default ~50ms), so a point may not be
|
|
145
|
+
immediately visible to reads right after either call returns; `flush()`
|
|
146
|
+
flushes pending changes to disk but does **not** guarantee streaming-channel
|
|
147
|
+
visibility.
|
|
148
|
+
|
|
139
149
|
## Running tests
|
|
140
150
|
|
|
141
151
|
```bash
|
|
@@ -14,6 +14,7 @@ from haystack.document_stores.errors import DuplicateDocumentError
|
|
|
14
14
|
from haystack.document_stores.types import DuplicatePolicy
|
|
15
15
|
|
|
16
16
|
import velesdb
|
|
17
|
+
from velesdb_common.collection_admin import CollectionAdminMixin
|
|
17
18
|
from velesdb_common.fusion import build_fusion_strategy
|
|
18
19
|
from velesdb_common.ids import stable_hash_id
|
|
19
20
|
from velesdb_common.security import (
|
|
@@ -381,7 +382,48 @@ def _documents_to_points(
|
|
|
381
382
|
return points
|
|
382
383
|
|
|
383
384
|
|
|
384
|
-
|
|
385
|
+
def _resolve_survivors_for_policy(
|
|
386
|
+
col: Any,
|
|
387
|
+
documents: List[Document],
|
|
388
|
+
int_id_map: Dict[int, str],
|
|
389
|
+
policy: DuplicatePolicy,
|
|
390
|
+
) -> Optional[List[Document]]:
|
|
391
|
+
"""Apply *policy* to *documents* against the collection's existing points.
|
|
392
|
+
|
|
393
|
+
Shared by :meth:`VelesDBDocumentStore.write_documents` and
|
|
394
|
+
:meth:`VelesDBDocumentStore.write_documents_streaming` — both need the
|
|
395
|
+
exact same FAIL/SKIP/NONE·OVERWRITE resolution before choosing how to
|
|
396
|
+
send the surviving points (single ``upsert`` vs. batched
|
|
397
|
+
``stream_insert``).
|
|
398
|
+
|
|
399
|
+
Returns:
|
|
400
|
+
The list of documents to write, or ``None`` when ``policy`` is
|
|
401
|
+
``SKIP`` and every incoming document already exists (caller should
|
|
402
|
+
return 0 without touching the collection).
|
|
403
|
+
"""
|
|
404
|
+
if policy == DuplicatePolicy.FAIL:
|
|
405
|
+
_enforce_fail_policy(col, int_id_map)
|
|
406
|
+
return documents
|
|
407
|
+
if policy == DuplicatePolicy.SKIP:
|
|
408
|
+
survivors = _filter_skip_policy(col, int_id_map, documents)
|
|
409
|
+
return survivors or None
|
|
410
|
+
return documents
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _flush_stream_batches(collection: Any, points: List[dict], batch_size: int) -> None:
|
|
414
|
+
"""Send *points* to *collection* in batches via the streaming channel.
|
|
415
|
+
|
|
416
|
+
Mirrors ``langchain_velesdb.point_builder.flush_stream_batches`` and
|
|
417
|
+
``llamaindex_velesdb.node_builder.flush_in_batches`` — kept local (rather
|
|
418
|
+
than imported cross-package) so this MIT-licensed integration does not
|
|
419
|
+
take on a dependency on the sibling LangChain/LlamaIndex distributions.
|
|
420
|
+
Only calls the ``velesdb`` binding's ``stream_insert``; no core logic.
|
|
421
|
+
"""
|
|
422
|
+
for start in range(0, len(points), batch_size):
|
|
423
|
+
collection.stream_insert(points[start : start + batch_size])
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
class VelesDBDocumentStore(CollectionAdminMixin):
|
|
385
427
|
"""Haystack 2.x DocumentStore backed by a local VelesDB collection.
|
|
386
428
|
|
|
387
429
|
Stores documents (with optional embeddings) in VelesDB and exposes the
|
|
@@ -418,18 +460,32 @@ class VelesDBDocumentStore:
|
|
|
418
460
|
# Internal connection management
|
|
419
461
|
# ------------------------------------------------------------------
|
|
420
462
|
|
|
421
|
-
|
|
422
|
-
|
|
463
|
+
@property
|
|
464
|
+
def collection_name(self) -> str:
|
|
465
|
+
"""Return the collection name (satisfies the CollectionAdminMixin contract)."""
|
|
466
|
+
return self._collection_name
|
|
467
|
+
|
|
468
|
+
def _get_db(self) -> Any:
|
|
469
|
+
"""Return the VelesDB database handle, opening it as needed.
|
|
470
|
+
|
|
471
|
+
Required by ``CollectionAdminMixin`` (``get_stats``/``analyze``/
|
|
472
|
+
``train_pq``/``create_metadata_collection`` all go through this).
|
|
473
|
+
"""
|
|
423
474
|
if self._db is None:
|
|
424
475
|
self._db = velesdb.Database(self._path)
|
|
476
|
+
return self._db
|
|
477
|
+
|
|
478
|
+
def _get_collection(self) -> Any:
|
|
479
|
+
"""Return the VelesDB collection, opening or creating it as needed."""
|
|
480
|
+
db = self._get_db()
|
|
425
481
|
if self._collection is None:
|
|
426
482
|
col: Optional[Any] = None
|
|
427
483
|
try:
|
|
428
|
-
col =
|
|
484
|
+
col = db.get_collection(self._collection_name)
|
|
429
485
|
except KeyError:
|
|
430
486
|
pass
|
|
431
487
|
if col is None:
|
|
432
|
-
col =
|
|
488
|
+
col = db.create_collection(
|
|
433
489
|
self._collection_name,
|
|
434
490
|
dimension=self._embedding_dim,
|
|
435
491
|
metric=self._metric,
|
|
@@ -522,19 +578,90 @@ class VelesDBDocumentStore:
|
|
|
522
578
|
sparse_by_id = _build_sparse_by_id(documents, sparse_vectors)
|
|
523
579
|
int_id_map = _build_int_id_map(documents)
|
|
524
580
|
col = self._get_collection()
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
elif policy == DuplicatePolicy.SKIP:
|
|
529
|
-
survivors = _filter_skip_policy(col, int_id_map, documents)
|
|
530
|
-
if not survivors:
|
|
531
|
-
return 0
|
|
532
|
-
else:
|
|
533
|
-
survivors = documents
|
|
581
|
+
survivors = _resolve_survivors_for_policy(col, documents, int_id_map, policy)
|
|
582
|
+
if survivors is None:
|
|
583
|
+
return 0
|
|
534
584
|
points = _documents_to_points(survivors, sparse_by_id)
|
|
535
585
|
result = col.upsert(points)
|
|
536
586
|
return result if isinstance(result, int) else len(points)
|
|
537
587
|
|
|
588
|
+
def stream_insert(
|
|
589
|
+
self,
|
|
590
|
+
documents: List[Document],
|
|
591
|
+
sparse_vectors: Optional[List[dict]] = None,
|
|
592
|
+
) -> int:
|
|
593
|
+
"""Insert *documents* via VelesDB's streaming ingestion channel.
|
|
594
|
+
|
|
595
|
+
Sends every point through ``Collection.stream_insert`` in a single
|
|
596
|
+
call. Unlike :meth:`write_documents`, this bypasses the
|
|
597
|
+
``DuplicatePolicy`` checks (streaming is an append-only bulk-loading
|
|
598
|
+
path) — pre-dedupe on the caller side if that matters. For very
|
|
599
|
+
large batches where the bounded streaming channel's backpressure
|
|
600
|
+
should be respected across multiple sends, use
|
|
601
|
+
:meth:`write_documents_streaming` instead.
|
|
602
|
+
|
|
603
|
+
Mirrors ``langchain_velesdb.VelesDBVectorStore.stream_insert`` and
|
|
604
|
+
``llamaindex_velesdb.VelesDBVectorStore.stream_insert``, which
|
|
605
|
+
forward the same way to ``collection.stream_insert``.
|
|
606
|
+
|
|
607
|
+
Args:
|
|
608
|
+
documents: Documents to insert.
|
|
609
|
+
sparse_vectors: Optional list aligned with *documents*; see
|
|
610
|
+
:meth:`write_documents` for the accepted shapes.
|
|
611
|
+
|
|
612
|
+
Returns:
|
|
613
|
+
Number of points inserted.
|
|
614
|
+
"""
|
|
615
|
+
if not documents:
|
|
616
|
+
return 0
|
|
617
|
+
sparse_by_id = _build_sparse_by_id(documents, sparse_vectors)
|
|
618
|
+
points = _documents_to_points(documents, sparse_by_id)
|
|
619
|
+
self._get_collection().stream_insert(points)
|
|
620
|
+
return len(points)
|
|
621
|
+
|
|
622
|
+
def write_documents_streaming(
|
|
623
|
+
self,
|
|
624
|
+
documents: List[Document],
|
|
625
|
+
policy: DuplicatePolicy = DuplicatePolicy.NONE,
|
|
626
|
+
sparse_vectors: Optional[List[dict]] = None,
|
|
627
|
+
batch_size: int = 100,
|
|
628
|
+
) -> int:
|
|
629
|
+
"""Write *documents* using the streaming ingestion channel, batched.
|
|
630
|
+
|
|
631
|
+
Same ``DuplicatePolicy`` semantics as :meth:`write_documents`, but
|
|
632
|
+
points are sent through ``Collection.stream_insert`` in
|
|
633
|
+
``batch_size``-sized chunks instead of one ``upsert`` call — better
|
|
634
|
+
throughput for large bulk loads, with built-in backpressure. Mirrors
|
|
635
|
+
the ``add_texts_streaming`` (LangChain) / ``add_streaming``
|
|
636
|
+
(LlamaIndex) pattern.
|
|
637
|
+
|
|
638
|
+
Args:
|
|
639
|
+
documents: Documents to write.
|
|
640
|
+
policy: Duplicate-handling policy (see :meth:`write_documents`).
|
|
641
|
+
sparse_vectors: Optional list aligned with *documents*; see
|
|
642
|
+
:meth:`write_documents` for the accepted shapes.
|
|
643
|
+
batch_size: Number of points per streaming batch. Defaults to 100.
|
|
644
|
+
|
|
645
|
+
Returns:
|
|
646
|
+
Number of points written.
|
|
647
|
+
|
|
648
|
+
Raises:
|
|
649
|
+
DuplicateDocumentError: When *policy* is ``FAIL`` and at least
|
|
650
|
+
one document already exists in the store.
|
|
651
|
+
ValueError: When a SHA-256 hash collision is detected.
|
|
652
|
+
"""
|
|
653
|
+
if not documents:
|
|
654
|
+
return 0
|
|
655
|
+
sparse_by_id = _build_sparse_by_id(documents, sparse_vectors)
|
|
656
|
+
int_id_map = _build_int_id_map(documents)
|
|
657
|
+
col = self._get_collection()
|
|
658
|
+
survivors = _resolve_survivors_for_policy(col, documents, int_id_map, policy)
|
|
659
|
+
if survivors is None:
|
|
660
|
+
return 0
|
|
661
|
+
points = _documents_to_points(survivors, sparse_by_id)
|
|
662
|
+
_flush_stream_batches(col, points, batch_size)
|
|
663
|
+
return len(points)
|
|
664
|
+
|
|
538
665
|
def delete_documents(
|
|
539
666
|
self,
|
|
540
667
|
document_ids: Optional[List[str]] = None,
|
|
@@ -545,6 +672,21 @@ class VelesDBDocumentStore:
|
|
|
545
672
|
int_ids = [stable_hash_id(did) for did in document_ids]
|
|
546
673
|
self._get_collection().delete(int_ids)
|
|
547
674
|
|
|
675
|
+
def flush(self) -> None:
|
|
676
|
+
"""Flush pending changes to disk.
|
|
677
|
+
|
|
678
|
+
Mirrors ``VelesDBVectorStore.flush()`` in the LangChain and
|
|
679
|
+
LlamaIndex integrations. Note this does **not** guarantee
|
|
680
|
+
read-your-writes visibility for :meth:`stream_insert` /
|
|
681
|
+
:meth:`write_documents_streaming`: those go through a bounded async
|
|
682
|
+
channel with its own background flush interval (engine default
|
|
683
|
+
~50ms) that is independent of this disk flush. A point inserted via
|
|
684
|
+
the streaming channel may only become visible to reads after that
|
|
685
|
+
interval elapses.
|
|
686
|
+
"""
|
|
687
|
+
if self._collection is not None:
|
|
688
|
+
self._collection.flush()
|
|
689
|
+
|
|
548
690
|
def embedding_retrieval(
|
|
549
691
|
self,
|
|
550
692
|
query_embedding: List[float],
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: haystack-velesdb
|
|
3
|
-
Version:
|
|
3
|
+
Version: 4.0.0
|
|
4
4
|
Summary: Haystack 2.x DocumentStore for VelesDB: The Local AI Memory Database.
|
|
5
5
|
Author-email: VelesDB Team <contact@wiscale.fr>
|
|
6
6
|
License: MIT
|
|
@@ -29,7 +29,9 @@ Dynamic: license-file
|
|
|
29
29
|
# haystack-velesdb
|
|
30
30
|
|
|
31
31
|
A Haystack 2.x `DocumentStore` backed by [VelesDB](https://github.com/cyberlife-coder/VelesDB) —
|
|
32
|
-
the local-first
|
|
32
|
+
**the explainable, local-first memory engine for AI agents** (microsecond vector
|
|
33
|
+
search is the proof, not the pitch). For the connected `why()` recall trail
|
|
34
|
+
across typed links, see [velesdb-memory](../../crates/velesdb-memory/README.md).
|
|
33
35
|
|
|
34
36
|
This integration joins the existing [LangChain](../langchain/) and [LlamaIndex](../llamaindex/)
|
|
35
37
|
connectors, completing the trio of major Python RAG frameworks supported by VelesDB.
|
|
@@ -102,26 +104,15 @@ indexer.connect("embedder", "writer")
|
|
|
102
104
|
indexer.run({"converter": {"sources": ["paper.pdf"]}})
|
|
103
105
|
|
|
104
106
|
# Query pipeline. `InMemoryEmbeddingRetriever` is bound to `InMemoryDocumentStore`
|
|
105
|
-
# and would NOT work against a custom DocumentStore —
|
|
106
|
-
#
|
|
107
|
-
# `
|
|
108
|
-
|
|
109
|
-
from
|
|
110
|
-
from typing import List
|
|
111
|
-
|
|
112
|
-
@component
|
|
113
|
-
class VelesRetriever:
|
|
114
|
-
def __init__(self, document_store, top_k: int = 10):
|
|
115
|
-
self._store = document_store
|
|
116
|
-
self._top_k = top_k
|
|
117
|
-
|
|
118
|
-
@component.output_types(documents=List[Document])
|
|
119
|
-
def run(self, query_embedding: List[float]):
|
|
120
|
-
return {"documents": self._store.embedding_retrieval(query_embedding, top_k=self._top_k)}
|
|
107
|
+
# and would NOT work against a custom DocumentStore — use the shipped
|
|
108
|
+
# `VelesDBEmbeddingRetriever` instead (like `QdrantEmbeddingRetriever` in the
|
|
109
|
+
# Qdrant integration, no hand-rolled `@component` wrapper needed). Full working
|
|
110
|
+
# example in `integrations/haystack/examples/rag_pipeline.py`.
|
|
111
|
+
from haystack_velesdb import VelesDBEmbeddingRetriever
|
|
121
112
|
|
|
122
113
|
querier = Pipeline()
|
|
123
114
|
querier.add_component("embedder", SentenceTransformersTextEmbedder(model="all-MiniLM-L6-v2"))
|
|
124
|
-
querier.add_component("retriever",
|
|
115
|
+
querier.add_component("retriever", VelesDBEmbeddingRetriever(document_store=store))
|
|
125
116
|
querier.connect("embedder.embedding", "retriever.query_embedding")
|
|
126
117
|
result = querier.run({"embedder": {"text": "What is VelesDB?"}})
|
|
127
118
|
print(result["retriever"]["documents"])
|
|
@@ -137,16 +128,25 @@ print(result["retriever"]["documents"])
|
|
|
137
128
|
| `collection_name` | `"haystack_documents"` | VelesDB collection name |
|
|
138
129
|
| `embedding_dim` | `768` | Embedding vector dimension |
|
|
139
130
|
| `metric` | `"cosine"` | Distance metric: `"cosine"`, `"euclidean"`, `"dot"`, `"hamming"`, or `"jaccard"` |
|
|
131
|
+
| `scroll_limit` | `10_000` | Max documents returned by `filter_documents()`; raise for collections bigger than this |
|
|
140
132
|
|
|
141
133
|
### Methods
|
|
142
134
|
|
|
143
135
|
| Method | Description |
|
|
144
136
|
|--------|-------------|
|
|
145
|
-
| `write_documents(documents, policy)` | Upsert documents; returns count written |
|
|
146
|
-
| `
|
|
147
|
-
| `
|
|
137
|
+
| `write_documents(documents, policy, sparse_vectors=None)` | Upsert documents; returns count written. `sparse_vectors` is an optional list aligned with `documents` — each entry a flat `dict[int, float]` or a named `dict[str, dict[int, float]]` mapping (e.g. `{"bge_m3": {0: 1.5}}`); a named mapping creates that sparse index for later hybrid retrieval |
|
|
138
|
+
| `stream_insert(documents, sparse_vectors=None)` | Insert documents through VelesDB's streaming ingestion channel in one call (append-only, no `DuplicatePolicy` check); returns count inserted. See "Note on streaming ingestion" below |
|
|
139
|
+
| `write_documents_streaming(documents, policy, sparse_vectors=None, batch_size=100)` | Same `DuplicatePolicy` semantics as `write_documents`, but sent through the streaming channel in `batch_size`-sized chunks — better throughput for large bulk loads. See "Note on streaming ingestion" below |
|
|
140
|
+
| `flush()` | Flush pending changes to disk. See "Note on streaming ingestion" below |
|
|
141
|
+
| `filter_documents(filters)` | Scroll documents matching a VelesDB filter dict, up to `scroll_limit` |
|
|
142
|
+
| `embedding_retrieval(query_embedding, top_k, filters, scale_score, fusion=None, fusion_params=None)` | Vector similarity search. `fusion` selects a `velesdb.FusionStrategy` (`"average"`, `"maximum"`, `"rrf"`, `"weighted"`, `"relative_score"` / `"rsf"`) applied via `Collection.multi_query_search`, changing the ranking; `fusion_params` configures it (e.g. `{"dense_weight": 0.7, "sparse_weight": 0.3}` for `"rsf"`). `filters` cannot be combined with `fusion` |
|
|
148
143
|
| `count_documents()` | Total document count |
|
|
149
144
|
| `delete_documents(document_ids)` | Delete by Haystack string IDs |
|
|
145
|
+
| `train_pq(m=8, k=256, opq=False)` | Train Product Quantization on the collection |
|
|
146
|
+
| `analyze_collection()` | Compute and persist collection statistics (point/row counts, column stats) |
|
|
147
|
+
| `get_collection_stats()` | Return cached statistics from the last `analyze_collection()` call, or `None` |
|
|
148
|
+
| `is_metadata_only()` | Whether the current collection holds no vectors |
|
|
149
|
+
| `create_metadata_collection(name)` | Create a metadata-only collection (no vectors), joinable with vector collections |
|
|
150
150
|
| `to_dict()` / `from_dict()` | Haystack pipeline serialisation |
|
|
151
151
|
|
|
152
152
|
**Note on `DuplicatePolicy`:** `NONE` and `OVERWRITE` use VelesDB upsert semantics
|
|
@@ -164,6 +164,16 @@ if a collision is detected between a new document and an existing one.
|
|
|
164
164
|
are normalised from `[-1, 1]` to `[0, 1]` so they behave like probabilities
|
|
165
165
|
in downstream re-ranking.
|
|
166
166
|
|
|
167
|
+
**Note on streaming ingestion:** `stream_insert()` and `write_documents_streaming()`
|
|
168
|
+
forward to the underlying `velesdb.Collection.stream_insert`, which requires
|
|
169
|
+
the collection to have `enable_streaming()` called on it first — the same
|
|
170
|
+
caller-managed contract as the LangChain and LlamaIndex integrations (see
|
|
171
|
+
`docs/reference/ECOSYSTEM_PARITY.md`). The channel batches asynchronously on
|
|
172
|
+
its own background interval (engine default ~50ms), so a point may not be
|
|
173
|
+
immediately visible to reads right after either call returns; `flush()`
|
|
174
|
+
flushes pending changes to disk but does **not** guarantee streaming-channel
|
|
175
|
+
visibility.
|
|
176
|
+
|
|
167
177
|
## Running tests
|
|
168
178
|
|
|
169
179
|
```bash
|
|
@@ -221,6 +221,20 @@ class _FakeCollection:
|
|
|
221
221
|
def count(self) -> int:
|
|
222
222
|
return len(self._points)
|
|
223
223
|
|
|
224
|
+
def stream_insert(self, points: list) -> None:
|
|
225
|
+
"""Fake streaming ingestion channel — mirrors ``upsert`` semantics
|
|
226
|
+
(the real SDK forwards through a bounded channel with backpressure;
|
|
227
|
+
this fake is synchronous and deterministic for tests).
|
|
228
|
+
"""
|
|
229
|
+
for p in points:
|
|
230
|
+
self._points[p["id"]] = p
|
|
231
|
+
|
|
232
|
+
def is_metadata_only(self) -> bool:
|
|
233
|
+
return False
|
|
234
|
+
|
|
235
|
+
def flush(self) -> None:
|
|
236
|
+
pass
|
|
237
|
+
|
|
224
238
|
|
|
225
239
|
class _FakeDatabase:
|
|
226
240
|
def __init__(self, path: str) -> None:
|
|
@@ -293,6 +307,20 @@ def _load_module() -> types.ModuleType:
|
|
|
293
307
|
sys.modules["velesdb_common.ids"] = vc_ids
|
|
294
308
|
_ids_spec.loader.exec_module(vc_ids)
|
|
295
309
|
|
|
310
|
+
# Load the REAL velesdb_common.collection_admin (pure stdlib, same reasoning
|
|
311
|
+
# as ids.py above) so the store exercises the canonical CollectionAdminMixin
|
|
312
|
+
# rather than a forked copy — single-source-of-truth + license hygiene.
|
|
313
|
+
_admin_path = (
|
|
314
|
+
Path(__file__).resolve().parents[2] / "common" / "src" / "velesdb_common" / "collection_admin.py"
|
|
315
|
+
)
|
|
316
|
+
_admin_spec = importlib.util.spec_from_file_location(
|
|
317
|
+
"velesdb_common.collection_admin", _admin_path
|
|
318
|
+
)
|
|
319
|
+
assert _admin_spec and _admin_spec.loader
|
|
320
|
+
vc_admin = importlib.util.module_from_spec(_admin_spec)
|
|
321
|
+
sys.modules["velesdb_common.collection_admin"] = vc_admin
|
|
322
|
+
_admin_spec.loader.exec_module(vc_admin)
|
|
323
|
+
|
|
296
324
|
vc_sec = types.ModuleType("velesdb_common.security")
|
|
297
325
|
vc_sec.validate_path = _passthrough # type: ignore[attr-defined]
|
|
298
326
|
vc_sec.validate_collection_name = _passthrough # type: ignore[attr-defined]
|
|
@@ -1052,3 +1080,237 @@ def test_id_hashing_uses_canonical_stable_hash_id():
|
|
|
1052
1080
|
)
|
|
1053
1081
|
assert _MOD.stable_hash_id(doc_id) == expected
|
|
1054
1082
|
assert 0 <= _MOD.stable_hash_id(doc_id) <= 0x7FFFFFFFFFFFFFFF
|
|
1083
|
+
|
|
1084
|
+
|
|
1085
|
+
# ---------------------------------------------------------------------------
|
|
1086
|
+
# Streaming ingestion — stream_insert() / write_documents_streaming()
|
|
1087
|
+
#
|
|
1088
|
+
# Mirrors the pattern already shipped by langchain_velesdb.stream_insert /
|
|
1089
|
+
# add_texts_streaming and llamaindex_velesdb.stream_insert / add_streaming:
|
|
1090
|
+
# both forward to Collection.stream_insert and are tested with mocked
|
|
1091
|
+
# collections that record the points they receive (issue #1548).
|
|
1092
|
+
# ---------------------------------------------------------------------------
|
|
1093
|
+
|
|
1094
|
+
|
|
1095
|
+
def test_stream_insert_calls_collection_stream_insert() -> None:
|
|
1096
|
+
"""stream_insert must build points and forward them in one call to
|
|
1097
|
+
Collection.stream_insert, returning the number of points inserted."""
|
|
1098
|
+
store = _MOD.VelesDBDocumentStore(path="/tmp/hs", collection_name="t_stream")
|
|
1099
|
+
inserted_points: List[dict] = []
|
|
1100
|
+
|
|
1101
|
+
class _MockCollection:
|
|
1102
|
+
def stream_insert(self, points: list) -> None:
|
|
1103
|
+
inserted_points.extend(points)
|
|
1104
|
+
|
|
1105
|
+
store._collection = _MockCollection()
|
|
1106
|
+
docs = [
|
|
1107
|
+
Document(id="a", content="hello", embedding=[0.1, 0.2]),
|
|
1108
|
+
Document(id="b", content="world", embedding=[0.3, 0.4]),
|
|
1109
|
+
]
|
|
1110
|
+
count = store.stream_insert(docs)
|
|
1111
|
+
|
|
1112
|
+
assert count == 2
|
|
1113
|
+
assert len(inserted_points) == 2
|
|
1114
|
+
assert inserted_points[0]["payload"]["_doc_id"] == "a"
|
|
1115
|
+
assert inserted_points[0]["payload"]["content"] == "hello"
|
|
1116
|
+
assert inserted_points[0]["vector"] == [0.1, 0.2]
|
|
1117
|
+
|
|
1118
|
+
|
|
1119
|
+
def test_stream_insert_empty_list_returns_zero() -> None:
|
|
1120
|
+
store = _MOD.VelesDBDocumentStore(path="/tmp/hs", collection_name="t_stream_empty")
|
|
1121
|
+
assert store.stream_insert([]) == 0
|
|
1122
|
+
|
|
1123
|
+
|
|
1124
|
+
def test_stream_insert_forwards_named_sparse_vectors() -> None:
|
|
1125
|
+
store = _MOD.VelesDBDocumentStore(path="/tmp/hs", collection_name="t_stream_sparse")
|
|
1126
|
+
inserted_points: List[dict] = []
|
|
1127
|
+
|
|
1128
|
+
class _MockCollection:
|
|
1129
|
+
def stream_insert(self, points: list) -> None:
|
|
1130
|
+
inserted_points.extend(points)
|
|
1131
|
+
|
|
1132
|
+
store._collection = _MockCollection()
|
|
1133
|
+
store.stream_insert(
|
|
1134
|
+
[Document(id="s1", content="hi", embedding=[0.5])],
|
|
1135
|
+
sparse_vectors=[{"bge_m3": {0: 1.5, 7: 0.8}}],
|
|
1136
|
+
)
|
|
1137
|
+
assert inserted_points[0]["sparse_vector"] == {"bge_m3": {0: 1.5, 7: 0.8}}
|
|
1138
|
+
|
|
1139
|
+
|
|
1140
|
+
def test_write_documents_streaming_batches_via_stream_insert() -> None:
|
|
1141
|
+
"""write_documents_streaming must chunk points into batch_size-sized
|
|
1142
|
+
calls to Collection.stream_insert (backpressure-friendly bulk loading)."""
|
|
1143
|
+
store = _MOD.VelesDBDocumentStore(path="/tmp/hs", collection_name="t_stream_batches")
|
|
1144
|
+
call_batches: List[list] = []
|
|
1145
|
+
|
|
1146
|
+
class _MockCollection:
|
|
1147
|
+
def stream_insert(self, points: list) -> None:
|
|
1148
|
+
call_batches.append(list(points))
|
|
1149
|
+
|
|
1150
|
+
store._collection = _MockCollection()
|
|
1151
|
+
docs = [
|
|
1152
|
+
Document(id=f"d{i}", content=f"text {i}", embedding=[float(i)])
|
|
1153
|
+
for i in range(5)
|
|
1154
|
+
]
|
|
1155
|
+
written = store.write_documents_streaming(docs, batch_size=2)
|
|
1156
|
+
|
|
1157
|
+
assert written == 5
|
|
1158
|
+
assert len(call_batches) == 3 # 2 + 2 + 1
|
|
1159
|
+
assert [len(b) for b in call_batches] == [2, 2, 1]
|
|
1160
|
+
assert sum(len(b) for b in call_batches) == 5
|
|
1161
|
+
|
|
1162
|
+
|
|
1163
|
+
def test_write_documents_streaming_empty_returns_zero() -> None:
|
|
1164
|
+
store = _MOD.VelesDBDocumentStore(path="/tmp/hs", collection_name="t_stream_batches_empty")
|
|
1165
|
+
assert store.write_documents_streaming([]) == 0
|
|
1166
|
+
|
|
1167
|
+
|
|
1168
|
+
def test_write_documents_streaming_skip_policy_does_not_overwrite_existing() -> None:
|
|
1169
|
+
"""write_documents_streaming must honour DuplicatePolicy.SKIP exactly like
|
|
1170
|
+
write_documents: existing documents are left untouched."""
|
|
1171
|
+
store = _MOD.VelesDBDocumentStore(path="/tmp/hs", collection_name="t_stream_skip")
|
|
1172
|
+
original = Document(id="dup", content="original content", embedding=[0.1, 0.2])
|
|
1173
|
+
store.write_documents([original])
|
|
1174
|
+
|
|
1175
|
+
new = Document(id="dup", content="REPLACED CONTENT", embedding=[0.9, 0.9])
|
|
1176
|
+
fresh = Document(id="brand_new", content="fresh", embedding=[0.5, 0.5])
|
|
1177
|
+
written = store.write_documents_streaming([new, fresh], policy=DuplicatePolicy.SKIP)
|
|
1178
|
+
|
|
1179
|
+
assert written == 1, "SKIP should report only the genuinely-new doc as written"
|
|
1180
|
+
docs = {d.id: d for d in store.filter_documents()}
|
|
1181
|
+
assert docs["dup"].content == "original content"
|
|
1182
|
+
assert docs["brand_new"].content == "fresh"
|
|
1183
|
+
|
|
1184
|
+
|
|
1185
|
+
def test_write_documents_streaming_forwards_sparse_vectors() -> None:
|
|
1186
|
+
store = _MOD.VelesDBDocumentStore(path="/tmp/hs", collection_name="t_stream_sparse_batch")
|
|
1187
|
+
call_batches: List[list] = []
|
|
1188
|
+
|
|
1189
|
+
class _MockCollection:
|
|
1190
|
+
def stream_insert(self, points: list) -> None:
|
|
1191
|
+
call_batches.append(list(points))
|
|
1192
|
+
|
|
1193
|
+
store._collection = _MockCollection()
|
|
1194
|
+
store.write_documents_streaming(
|
|
1195
|
+
[Document(id="p1", content="x", embedding=[0.5])],
|
|
1196
|
+
sparse_vectors=[{"bge_m3": {0: 1.5}}],
|
|
1197
|
+
)
|
|
1198
|
+
assert call_batches[0][0]["sparse_vector"] == {"bge_m3": {0: 1.5}}
|
|
1199
|
+
|
|
1200
|
+
|
|
1201
|
+
# ---------------------------------------------------------------------------
|
|
1202
|
+
# CollectionAdminMixin — get_stats / analyze / train_pq / metadata-only
|
|
1203
|
+
#
|
|
1204
|
+
# Mirrors the mixin already shared with the LangChain and LlamaIndex stores
|
|
1205
|
+
# (velesdb_common.collection_admin.CollectionAdminMixin), now also mixed
|
|
1206
|
+
# into VelesDBDocumentStore so all three integrations expose the same
|
|
1207
|
+
# admin surface (issue #1548).
|
|
1208
|
+
# ---------------------------------------------------------------------------
|
|
1209
|
+
|
|
1210
|
+
|
|
1211
|
+
def test_collection_name_property_exposes_constructor_arg() -> None:
|
|
1212
|
+
store = _MOD.VelesDBDocumentStore(path="/tmp/hs", collection_name="my_collection")
|
|
1213
|
+
assert store.collection_name == "my_collection"
|
|
1214
|
+
|
|
1215
|
+
|
|
1216
|
+
def test_train_pq_calls_db_train_pq() -> None:
|
|
1217
|
+
store = _MOD.VelesDBDocumentStore(path="/tmp/hs", collection_name="t_pq")
|
|
1218
|
+
calls: List[dict] = []
|
|
1219
|
+
|
|
1220
|
+
class _MockDb:
|
|
1221
|
+
def train_pq(self, collection_name: str, m: int, k: int, opq: bool) -> str:
|
|
1222
|
+
calls.append({"collection_name": collection_name, "m": m, "k": k, "opq": opq})
|
|
1223
|
+
return "trained"
|
|
1224
|
+
|
|
1225
|
+
store._db = _MockDb()
|
|
1226
|
+
result = store.train_pq(m=16, k=128, opq=True)
|
|
1227
|
+
|
|
1228
|
+
assert result == "trained"
|
|
1229
|
+
assert calls == [{"collection_name": "t_pq", "m": 16, "k": 128, "opq": True}]
|
|
1230
|
+
|
|
1231
|
+
|
|
1232
|
+
def test_analyze_collection_calls_db_analyze_collection() -> None:
|
|
1233
|
+
store = _MOD.VelesDBDocumentStore(path="/tmp/hs", collection_name="t_analyze")
|
|
1234
|
+
calls: List[str] = []
|
|
1235
|
+
|
|
1236
|
+
class _MockDb:
|
|
1237
|
+
def analyze_collection(self, collection_name: str) -> dict:
|
|
1238
|
+
calls.append(collection_name)
|
|
1239
|
+
return {"total_points": 3, "row_count": 3}
|
|
1240
|
+
|
|
1241
|
+
store._db = _MockDb()
|
|
1242
|
+
result = store.analyze_collection()
|
|
1243
|
+
|
|
1244
|
+
assert result == {"total_points": 3, "row_count": 3}
|
|
1245
|
+
assert calls == ["t_analyze"]
|
|
1246
|
+
|
|
1247
|
+
|
|
1248
|
+
def test_get_collection_stats_calls_db_get_collection_stats() -> None:
|
|
1249
|
+
store = _MOD.VelesDBDocumentStore(path="/tmp/hs", collection_name="t_stats")
|
|
1250
|
+
calls: List[str] = []
|
|
1251
|
+
|
|
1252
|
+
class _MockDb:
|
|
1253
|
+
def get_collection_stats(self, collection_name: str) -> Optional[dict]:
|
|
1254
|
+
calls.append(collection_name)
|
|
1255
|
+
return None
|
|
1256
|
+
|
|
1257
|
+
store._db = _MockDb()
|
|
1258
|
+
assert store.get_collection_stats() is None
|
|
1259
|
+
assert calls == ["t_stats"]
|
|
1260
|
+
|
|
1261
|
+
|
|
1262
|
+
def test_create_metadata_collection_calls_db_create_metadata_collection() -> None:
|
|
1263
|
+
store = _MOD.VelesDBDocumentStore(path="/tmp/hs", collection_name="t_meta_coll")
|
|
1264
|
+
calls: List[str] = []
|
|
1265
|
+
|
|
1266
|
+
class _MockDb:
|
|
1267
|
+
def create_metadata_collection(self, name: str) -> None:
|
|
1268
|
+
calls.append(name)
|
|
1269
|
+
|
|
1270
|
+
store._db = _MockDb()
|
|
1271
|
+
store.create_metadata_collection("refs")
|
|
1272
|
+
assert calls == ["refs"]
|
|
1273
|
+
|
|
1274
|
+
|
|
1275
|
+
def test_is_metadata_only_false_when_no_collection_yet() -> None:
|
|
1276
|
+
store = _MOD.VelesDBDocumentStore(path="/tmp/hs", collection_name="t_meta_only_none")
|
|
1277
|
+
assert store.is_metadata_only() is False
|
|
1278
|
+
|
|
1279
|
+
|
|
1280
|
+
def test_is_metadata_only_delegates_to_collection() -> None:
|
|
1281
|
+
store = _MOD.VelesDBDocumentStore(path="/tmp/hs", collection_name="t_meta_only")
|
|
1282
|
+
|
|
1283
|
+
class _MockCollection:
|
|
1284
|
+
def is_metadata_only(self) -> bool:
|
|
1285
|
+
return True
|
|
1286
|
+
|
|
1287
|
+
store._collection = _MockCollection()
|
|
1288
|
+
assert store.is_metadata_only() is True
|
|
1289
|
+
|
|
1290
|
+
|
|
1291
|
+
def test_flush_delegates_to_collection() -> None:
|
|
1292
|
+
store = _MOD.VelesDBDocumentStore(path="/tmp/hs", collection_name="t_flush")
|
|
1293
|
+
calls: List[bool] = []
|
|
1294
|
+
|
|
1295
|
+
class _MockCollection:
|
|
1296
|
+
def flush(self) -> None:
|
|
1297
|
+
calls.append(True)
|
|
1298
|
+
|
|
1299
|
+
store._collection = _MockCollection()
|
|
1300
|
+
store.flush()
|
|
1301
|
+
assert calls == [True]
|
|
1302
|
+
|
|
1303
|
+
|
|
1304
|
+
def test_flush_is_noop_without_collection() -> None:
|
|
1305
|
+
store = _MOD.VelesDBDocumentStore(path="/tmp/hs", collection_name="t_flush_none")
|
|
1306
|
+
store.flush() # must not raise
|
|
1307
|
+
|
|
1308
|
+
|
|
1309
|
+
def test_get_db_reuses_existing_connection() -> None:
|
|
1310
|
+
"""_get_db() must return the cached ``self._db`` instead of opening a new
|
|
1311
|
+
``velesdb.Database`` when one is already set (same contract LangChain and
|
|
1312
|
+
LlamaIndex rely on for the CollectionAdminMixin)."""
|
|
1313
|
+
store = _MOD.VelesDBDocumentStore(path="/tmp/hs", collection_name="t_get_db")
|
|
1314
|
+
sentinel = object()
|
|
1315
|
+
store._db = sentinel
|
|
1316
|
+
assert store._get_db() is sentinel
|
|
@@ -18,6 +18,7 @@ from __future__ import annotations
|
|
|
18
18
|
|
|
19
19
|
import importlib
|
|
20
20
|
import sys
|
|
21
|
+
import time
|
|
21
22
|
from typing import List
|
|
22
23
|
|
|
23
24
|
import pytest
|
|
@@ -136,6 +137,23 @@ def _store(tmp_path) -> VelesDBDocumentStore:
|
|
|
136
137
|
)
|
|
137
138
|
|
|
138
139
|
|
|
140
|
+
def _wait_until(predicate, timeout_s: float = 2.0, interval_s: float = 0.02):
|
|
141
|
+
"""Poll *predicate* until it returns truthy or *timeout_s* elapses.
|
|
142
|
+
|
|
143
|
+
The real streaming ingestion channel flushes asynchronously on its own
|
|
144
|
+
background interval (engine default ~50ms), independent of
|
|
145
|
+
``DocumentStore.flush()``. Tests that exercise ``stream_insert`` /
|
|
146
|
+
``write_documents_streaming`` need to wait for that eventual visibility
|
|
147
|
+
instead of asserting immediately after the call returns.
|
|
148
|
+
"""
|
|
149
|
+
deadline = time.monotonic() + timeout_s
|
|
150
|
+
while time.monotonic() < deadline:
|
|
151
|
+
if predicate():
|
|
152
|
+
return True
|
|
153
|
+
time.sleep(interval_s)
|
|
154
|
+
return predicate()
|
|
155
|
+
|
|
156
|
+
|
|
139
157
|
def _docs() -> List[Document]:
|
|
140
158
|
return [
|
|
141
159
|
Document(
|
|
@@ -245,14 +263,95 @@ def test_duplicate_policy_skip_with_real_haystack(tmp_path) -> None:
|
|
|
245
263
|
|
|
246
264
|
|
|
247
265
|
# ---------------------------------------------------------------------------
|
|
248
|
-
#
|
|
266
|
+
# Streaming ingestion — real velesdb Collection.stream_insert (issue #1548)
|
|
267
|
+
#
|
|
268
|
+
# The real binding requires ``Collection.enable_streaming()`` before
|
|
269
|
+
# ``stream_insert`` will accept points (it raises "streaming is not
|
|
270
|
+
# configured on this collection" otherwise) — same caller-managed contract
|
|
271
|
+
# already documented for LangChain/LlamaIndex in ECOSYSTEM_PARITY.md.
|
|
272
|
+
# ---------------------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def test_stream_insert_round_trips_via_real_velesdb(tmp_path) -> None:
|
|
276
|
+
"""The real streaming channel is async (bounded queue, own background
|
|
277
|
+
flush interval, default ~50ms) — poll for eventual visibility instead of
|
|
278
|
+
asserting immediately after ``stream_insert`` returns."""
|
|
279
|
+
store = _store(tmp_path)
|
|
280
|
+
store._get_collection().enable_streaming()
|
|
281
|
+
|
|
282
|
+
count = store.stream_insert(_docs())
|
|
283
|
+
assert count == 3
|
|
284
|
+
|
|
285
|
+
assert _wait_until(lambda: store.count_documents() == 3)
|
|
286
|
+
assert {d.id for d in store.filter_documents()} == {
|
|
287
|
+
"doc-en-1", "doc-en-2", "doc-fr-1",
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def test_write_documents_streaming_round_trips_via_real_velesdb(tmp_path) -> None:
|
|
292
|
+
store = _store(tmp_path)
|
|
293
|
+
store._get_collection().enable_streaming()
|
|
294
|
+
|
|
295
|
+
written = store.write_documents_streaming(_docs(), batch_size=2)
|
|
296
|
+
assert written == 3
|
|
297
|
+
|
|
298
|
+
assert _wait_until(lambda: store.count_documents() == 3)
|
|
299
|
+
# count() and vector-searchability can land at slightly different times
|
|
300
|
+
# (HNSW indexing trails the raw point store) — poll the search itself.
|
|
301
|
+
results_holder: dict = {}
|
|
302
|
+
|
|
303
|
+
def _search_ready() -> bool:
|
|
304
|
+
found = store.embedding_retrieval(
|
|
305
|
+
query_embedding=[1.0, 0.0, 0.0, 0.0], top_k=1
|
|
306
|
+
)
|
|
307
|
+
if found:
|
|
308
|
+
results_holder["docs"] = found
|
|
309
|
+
return bool(found)
|
|
310
|
+
|
|
311
|
+
assert _wait_until(_search_ready)
|
|
312
|
+
assert results_holder["docs"][0].id == "doc-en-1"
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# ---------------------------------------------------------------------------
|
|
316
|
+
# CollectionAdminMixin — real velesdb Database.train_pq / analyze_collection
|
|
317
|
+
# (issue #1548)
|
|
318
|
+
# ---------------------------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def test_analyze_and_get_collection_stats_via_real_velesdb(tmp_path) -> None:
|
|
322
|
+
store = _store(tmp_path)
|
|
323
|
+
store.write_documents(_docs())
|
|
324
|
+
|
|
325
|
+
stats = store.analyze_collection()
|
|
326
|
+
assert stats["total_points"] == 3
|
|
327
|
+
|
|
328
|
+
cached = store.get_collection_stats()
|
|
329
|
+
assert cached is not None
|
|
330
|
+
assert cached["total_points"] == 3
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def test_is_metadata_only_false_for_vector_collection_via_real_velesdb(tmp_path) -> None:
|
|
334
|
+
store = _store(tmp_path)
|
|
335
|
+
store.write_documents(_docs())
|
|
336
|
+
assert store.is_metadata_only() is False
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
# ---------------------------------------------------------------------------
|
|
340
|
+
# Pipeline integration — exercises the @component decorator contract
|
|
249
341
|
# ---------------------------------------------------------------------------
|
|
250
342
|
|
|
251
343
|
|
|
252
344
|
@component
|
|
253
345
|
class VelesRetriever:
|
|
254
|
-
"""
|
|
255
|
-
|
|
346
|
+
"""A hand-rolled ``@component`` wrapper around ``embedding_retrieval``.
|
|
347
|
+
|
|
348
|
+
The README and ``examples/rag_pipeline.py`` now use the shipped
|
|
349
|
+
``VelesDBEmbeddingRetriever`` instead (see
|
|
350
|
+
``test_shipped_embedding_retriever_component_runs`` below); this class
|
|
351
|
+
stays only to exercise the generic Haystack 2.x ``@component`` contract
|
|
352
|
+
for callers who need custom retrieval logic beyond what the shipped
|
|
353
|
+
component offers. The decorator is REQUIRED — without it,
|
|
354
|
+
``Pipeline.add_component`` raises.
|
|
256
355
|
"""
|
|
257
356
|
|
|
258
357
|
def __init__(self, document_store: VelesDBDocumentStore, top_k: int = 5) -> None:
|
|
@@ -269,11 +368,10 @@ class VelesRetriever:
|
|
|
269
368
|
|
|
270
369
|
|
|
271
370
|
def test_pipeline_with_decorated_retriever(tmp_path) -> None:
|
|
272
|
-
"""A real Haystack Pipeline accepts
|
|
371
|
+
"""A real Haystack Pipeline accepts a custom decorated component and runs.
|
|
273
372
|
|
|
274
373
|
Without the @component decorator, ``add_component`` raises
|
|
275
|
-
``PipelineError`` — this test would fail at construction.
|
|
276
|
-
the canary for the rag_pipeline.py example fix.
|
|
374
|
+
``PipelineError`` — this test would fail at construction.
|
|
277
375
|
"""
|
|
278
376
|
store = _store(tmp_path)
|
|
279
377
|
store.write_documents(_docs())
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{haystack_velesdb-3.12.0 → haystack_velesdb-4.0.0}/src/haystack_velesdb.egg-info/SOURCES.txt
RENAMED
|
File without changes
|
|
File without changes
|
{haystack_velesdb-3.12.0 → haystack_velesdb-4.0.0}/src/haystack_velesdb.egg-info/requires.txt
RENAMED
|
File without changes
|
{haystack_velesdb-3.12.0 → haystack_velesdb-4.0.0}/src/haystack_velesdb.egg-info/top_level.txt
RENAMED
|
File without changes
|