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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: haystack-velesdb
3
- Version: 3.12.0
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, microsecond-latency vector database.
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 — wrap `embedding_retrieval`
106
- # in a thin Haystack component that forwards the call. Full working example in
107
- # `integrations/haystack/examples/rag_pipeline.py` (`_VelesRetriever`).
108
- from haystack import component
109
- from haystack.dataclasses import Document
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", VelesRetriever(document_store=store))
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
- | `filter_documents(filters)` | Scroll documents matching a VelesDB filter dict |
147
- | `embedding_retrieval(query_embedding, top_k, filters, scale_score)` | Vector similarity search |
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, microsecond-latency vector database.
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 — wrap `embedding_retrieval`
78
- # in a thin Haystack component that forwards the call. Full working example in
79
- # `integrations/haystack/examples/rag_pipeline.py` (`_VelesRetriever`).
80
- from haystack import component
81
- from haystack.dataclasses import Document
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", VelesRetriever(document_store=store))
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
- | `filter_documents(filters)` | Scroll documents matching a VelesDB filter dict |
119
- | `embedding_retrieval(query_embedding, top_k, filters, scale_score)` | Vector similarity search |
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "haystack-velesdb"
7
- version = "3.12.0"
7
+ version = "4.0.0"
8
8
  description = "Haystack 2.x DocumentStore for VelesDB: The Local AI Memory Database."
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -4,4 +4,4 @@ from haystack_velesdb.document_store import VelesDBDocumentStore
4
4
  from haystack_velesdb.retriever import VelesDBEmbeddingRetriever
5
5
 
6
6
  __all__ = ["VelesDBDocumentStore", "VelesDBEmbeddingRetriever"]
7
- __version__ = "3.12.0"
7
+ __version__ = "4.0.0"
@@ -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
- class VelesDBDocumentStore:
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
- def _get_collection(self) -> Any:
422
- """Return the VelesDB collection, opening or creating it as needed."""
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 = self._db.get_collection(self._collection_name)
484
+ col = db.get_collection(self._collection_name)
429
485
  except KeyError:
430
486
  pass
431
487
  if col is None:
432
- col = self._db.create_collection(
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
- if policy == DuplicatePolicy.FAIL:
526
- _enforce_fail_policy(col, int_id_map)
527
- survivors = documents
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.12.0
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, microsecond-latency vector database.
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 — wrap `embedding_retrieval`
106
- # in a thin Haystack component that forwards the call. Full working example in
107
- # `integrations/haystack/examples/rag_pipeline.py` (`_VelesRetriever`).
108
- from haystack import component
109
- from haystack.dataclasses import Document
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", VelesRetriever(document_store=store))
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
- | `filter_documents(filters)` | Scroll documents matching a VelesDB filter dict |
147
- | `embedding_retrieval(query_embedding, top_k, filters, scale_score)` | Vector similarity search |
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
- # Pipeline integration — exercises the @component decorator on VelesRetriever
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
- """Mirror of the canonical pattern from the README. The decorator is
255
- REQUIRED — without it, ``Pipeline.add_component`` raises.
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 the decorated VelesRetriever and runs.
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. Acts as
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())