chore: import upstream snapshot with attribution
CI / Rust (ubuntu-latest) (push) Failing after 1s
CI / Python (ubuntu-latest) (push) Failing after 0s
CI / Rust (macos-14) (push) Has been cancelled
CI / Rust (windows-latest) (push) Has been cancelled
CI / Python (macos-14) (push) Has been cancelled
CI / Python (windows-latest) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:23:17 +08:00
commit 0d29a02500
129 changed files with 24744 additions and 0 deletions
@@ -0,0 +1,3 @@
from ._turbovec import IdMapIndex, TurboQuantIndex
__all__ = ["IdMapIndex", "TurboQuantIndex"]
+71
View File
@@ -0,0 +1,71 @@
"""Shared in-batch duplicate resolution for the framework integrations.
Each upstream library resolves a repeated id *within a single write* its own
way, and every turbovec wrapper must match its upstream to stay a true
drop-in:
- LangChain's ``InMemoryVectorStore`` overwrites on a repeated key → KEEP_LAST
- LlamaIndex rejects duplicate ``node_id`` in a batch → REJECT
- agno's LanceDb is append-only and keeps every row → KEEP_ALL
- Haystack exposes a runtime ``DuplicatePolicy`` (FAIL/SKIP/OVERWRITE).
Its resolution is *stateful* (it dedups against the existing store as well
as the batch, with deferred issue-#89 removal), so it does not reduce to
the pure in-batch function here and keeps its own logic; this enum still
documents the mapping (OVERWRITE→KEEP_LAST, SKIP→KEEP_FIRST, FAIL→REJECT).
The shared piece is the in-batch resolution only: given one key per item,
return the indices to keep. Each wrapper still owns its key extraction and
its cross-store upsert/removal.
"""
from __future__ import annotations
import enum
from typing import Hashable, List, Sequence
class DuplicatePolicy(enum.Enum):
"""How to resolve items that share a key within a single batch."""
KEEP_LAST = "keep_last"
"""One item per key; the last occurrence wins (dict-overwrite semantics)."""
KEEP_FIRST = "keep_first"
"""One item per key; the first occurrence wins."""
REJECT = "reject"
"""Raise ``ValueError`` if any key repeats; otherwise keep everything."""
KEEP_ALL = "keep_all"
"""No deduplication; items with duplicate keys all survive."""
def resolve_duplicates(
keys: Sequence[Hashable], policy: DuplicatePolicy
) -> List[int]:
"""Return, in ascending order, the batch indices to keep under ``policy``.
The returned indices index into ``keys`` (and any parallel arrays the
caller holds). For KEEP_ALL and REJECT the result is ``0..len(keys)``;
for KEEP_LAST/KEEP_FIRST it collapses to one index per distinct key.
Raises:
ValueError: under REJECT, if any key occurs more than once.
"""
if policy is DuplicatePolicy.KEEP_ALL:
return list(range(len(keys)))
if policy is DuplicatePolicy.REJECT:
seen: set = set()
for k in keys:
if k in seen:
raise ValueError(f"duplicate id in batch: {k!r}")
seen.add(k)
return list(range(len(keys)))
# KEEP_LAST / KEEP_FIRST collapse to one index per key.
chosen: dict = {}
for i, k in enumerate(keys):
if policy is DuplicatePolicy.KEEP_LAST or k not in chosen:
chosen[k] = i
return sorted(chosen.values())
__all__ = ["DuplicatePolicy", "resolve_duplicates"]
@@ -0,0 +1,55 @@
"""Shared persistence consistency checks for the framework integrations.
Each wrapper persists two artifacts: the binary ``.tvim`` index and a JSON
side-car holding the handle -> document/node/text payload maps. At query
time the wrapper resolves an index-returned u64 handle through that side-car
map. If the two files are out of sync — a partial copy, a stale backup, a
hand-edited or tampered side-car — an index handle won't resolve and the
wrapper would raise an opaque ``KeyError`` deep inside a query.
``check_persisted_handles`` turns that into a clean ``ValueError`` at load
time. ``IdMapIndex`` exposes only ``__len__`` and ``contains``; that's
sufficient: if the side-car's handle set and the index have equal size and
every side-car handle is present in the index, the two are a bijection (no
index handle can be missing from the side-car).
"""
from __future__ import annotations
from typing import Iterable
def check_persisted_handles(index, handles: Iterable[int], *, what: str = "entry") -> None:
"""Validate that the side-car's handle set matches the loaded index.
Args:
index: the loaded ``IdMapIndex`` (uses ``len`` and ``contains``).
handles: the u64 handles the side-car maps can resolve.
what: noun for error messages (e.g. "document", "node").
Raises:
ValueError: if the side-car has duplicate handles, a different count
than the index, or a handle the index doesn't contain.
"""
handle_list = [int(h) for h in handles]
n_index = len(index)
if len(set(handle_list)) != len(handle_list):
raise ValueError(
f"persisted store is corrupt: duplicate {what} handles in the side-car"
)
if len(handle_list) != n_index:
raise ValueError(
f"persisted store is inconsistent with its index: side-car has "
f"{len(handle_list)} {what} handle(s) but the index holds {n_index}. "
f"The .tvim index and its JSON side-car are out of sync."
)
for h in handle_list:
if not index.contains(h):
raise ValueError(
f"persisted store is inconsistent with its index: {what} handle "
f"{h} is not present in the index. The .tvim index and its JSON "
f"side-car are out of sync."
)
__all__ = ["check_persisted_handles"]
+788
View File
@@ -0,0 +1,788 @@
"""Agno VectorDb backed by turbovec's quantized index.
Install with: ``pip install turbovec[agno]``.
Implements Agno's ``VectorDb`` interface and matches the public surface
of ``agno.vectordb.lancedb.LanceDb`` (the closest in-tree single-machine
backend) so this can be swapped in wherever ``LanceDb`` is used.
"""
from __future__ import annotations
import json
from hashlib import md5
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Set, Union
import numpy as np
from ._turbovec import IdMapIndex
try:
from agno.knowledge.document import Document
from agno.knowledge.embedder import Embedder
from agno.knowledge.reranker.base import Reranker
from agno.vectordb.base import VectorDb
from agno.vectordb.distance import Distance
from agno.vectordb.search import SearchType
except ImportError as exc:
raise ImportError(
"agno is required to use turbovec.agno. "
"Install with: pip install turbovec[agno]"
) from exc
_INDEX_FILENAME = "index.tvim"
_STORE_FILENAME = "docstore.json"
# Bump when docstore.json shape changes; loader refuses unknown versions.
_DOCSTORE_SCHEMA_VERSION = 1
class TurboQuantVectorDb(VectorDb):
"""Agno VectorDb backed by a :class:`IdMapIndex`.
Vectors are quantized to 2-4 bits per dimension. The public surface
mirrors ``agno.vectordb.lancedb.LanceDb`` so this is a drop-in
replacement wherever a single-machine LanceDb is used. Search-time
filtering is resolved to an allowlist *before* scoring (kernel-level)
rather than via post-filtering, so selective filters return up to
``limit`` results from the filtered set instead of fewer.
Example::
from agno.knowledge.embedder.openai import OpenAIEmbedder
from turbovec.agno import TurboQuantVectorDb
vector_db = TurboQuantVectorDb(embedder=OpenAIEmbedder())
vector_db.create()
# ... use as a normal Agno VectorDb ...
"""
def __init__(
self,
*,
id: Optional[str] = None,
name: Optional[str] = None,
description: Optional[str] = None,
similarity_threshold: Optional[float] = None,
embedder: Optional[Embedder] = None,
bit_width: int = 4,
search_type: SearchType = SearchType.vector,
distance: Distance = Distance.cosine,
reranker: Optional[Reranker] = None,
path: Optional[str] = None,
) -> None:
"""
:param embedder: Required. Agno embedder used to encode documents
and queries. ``embedder.dimensions`` must be set — it's the
sole source of truth for the underlying quantized index's
dimensionality.
:param bit_width: Quantization width (2 or 4).
:param search_type: Only :class:`SearchType.vector` is supported;
other values raise :class:`ValueError`. (Keyword/hybrid search
would require an external BM25/lexical index.)
:param distance: Only :class:`Distance.cosine` is supported.
turbovec stores unit-normalized vectors, so the kernel's raw
score is cosine similarity directly.
:param reranker: Optional Agno reranker applied to the result set
after vector retrieval.
:param path: Optional directory for save/load persistence. When
given to the constructor, :meth:`create` loads existing data
from this path if present.
"""
super().__init__(
id=id,
name=name,
description=description,
similarity_threshold=similarity_threshold,
)
if embedder is None:
raise ValueError(
"`embedder` is required; turbovec needs the embedder's "
"`dimensions` to size the underlying index."
)
if embedder.dimensions is None:
raise ValueError("Embedder.dimensions must be set.")
if bit_width not in (2, 4):
raise ValueError(f"bit_width must be 2 or 4, got {bit_width}")
if search_type != SearchType.vector:
raise ValueError(
f"TurboQuantVectorDb only supports search_type=SearchType.vector; "
f"got {search_type}. Use LanceDb / Chroma / etc. for keyword "
f"or hybrid search."
)
if distance != Distance.cosine:
raise ValueError(
f"TurboQuantVectorDb only supports distance=Distance.cosine; "
f"got {distance}. turbovec stores unit-normalized vectors."
)
self.embedder: Embedder = embedder
self.dimensions: int = embedder.dimensions
self.bit_width = bit_width
self.search_type = search_type
self.distance = distance
self.reranker = reranker
self.path: Optional[str] = path
# Lazy: the underlying IdMapIndex is created by `create()`, not
# in __init__. This matches LanceDb's `exists()` contract: a
# freshly-constructed store doesn't "exist" until `create()` is
# called, and `drop()` returns it to that state.
self._index: Optional[IdMapIndex] = None
# str doc_id -> set of u64 handles. One-to-many: agno's derived
# doc_id is NOT unique (two documents with identical content, or a
# repeated explicit doc.id within a batch, derive the same id), and
# LanceDb keeps every such row. Mapping one doc_id to a single handle
# silently orphaned the earlier vectors — present in search and the
# index count but unreachable by id, so undeletable (issue #104).
self._str_to_u64: Dict[str, Set[int]] = {}
# u64 handle -> stored payload (mirrors LanceDb's "payload" shape)
self._u64_to_doc: Dict[int, Dict[str, Any]] = {}
# u64 handle assignment counter
self._next_u64: int = 0
# Auxiliary indexes for O(1) protocol queries
self._content_hashes: Set[str] = set()
self._name_to_ids: Dict[str, Set[str]] = {}
# ---- handle allocation ------------------------------------------------
def _issue_handle(self) -> int:
self._next_u64 += 1
return self._next_u64
# ---- VectorDb protocol: lifecycle ------------------------------------
def create(self) -> None:
"""Create the underlying index if it doesn't already exist.
Idempotent — calling on an already-created store is a no-op.
If ``path`` was set on the constructor and a previous save exists
under it, ``create()`` loads that save; otherwise it instantiates
a fresh empty index sized to ``embedder.dimensions``.
"""
if self._index is not None:
return
# Try loading from path first if one was set; fall through to a
# fresh index if the path doesn't contain a previous save.
if self.path is not None and Path(self.path).is_dir():
try:
self._load_from(Path(self.path))
return
except FileNotFoundError:
pass
self._index = IdMapIndex(self.dimensions, self.bit_width)
async def async_create(self) -> None:
self.create()
def drop(self) -> None:
"""Drop the underlying index. After this call ``exists()`` returns
``False`` until ``create()`` is called again — matches LanceDb's
contract where ``drop()`` removes the table entirely."""
self._index = None
self._str_to_u64.clear()
self._u64_to_doc.clear()
self._next_u64 = 0
self._content_hashes.clear()
self._name_to_ids.clear()
async def async_drop(self) -> None:
self.drop()
def exists(self) -> bool:
"""True iff the underlying index has been created via ``create()``
and not subsequently dropped. Matches LanceDb's
"table-exists-in-connection" semantic; *does not* mean
"has any documents" — call ``get_count()`` for that."""
return self._index is not None
async def async_exists(self) -> bool:
return self.exists()
def delete(self) -> bool:
"""Returns ``False``. The Agno protocol declares this abstract
method but LanceDb (the drop-in reference) unconditionally
returns False — actual destruction goes through ``drop()``."""
return False
def optimize(self) -> None:
"""No-op. The underlying quantized index doesn't have a
post-write optimization step. Matches LanceDb's ``optimize()``
which is also a no-op."""
return None
def get_count(self) -> int:
"""Number of documents currently stored."""
if self._index is None:
return 0
return len(self._index)
async def async_get_count(self) -> int:
return self.get_count()
# ---- VectorDb protocol: existence checks ------------------------------
def name_exists(self, name: str) -> bool:
if self._index is None:
return False
return name in self._name_to_ids
async def async_name_exists(self, name: str) -> bool:
# LanceDb raises NotImplementedError here; we have a trivial sync
# backing call, so we return the real answer. Intentional deviation.
return self.name_exists(name)
def id_exists(self, id: str) -> bool:
if self._index is None:
return False
return id in self._str_to_u64
def content_hash_exists(self, content_hash: str) -> bool:
if self._index is None:
return False
return content_hash in self._content_hashes
# ---- VectorDb protocol: insert / upsert -------------------------------
@staticmethod
def _derive_doc_id(doc: Document, content_hash: str, cleaned_content: str) -> str:
"""Match LanceDb's id-derivation contract so the same doc with the
same content_hash produces the same stable doc_id across stores."""
base_id = doc.id or md5(cleaned_content.encode()).hexdigest()
return md5(f"{base_id}_{content_hash}".encode()).hexdigest()
def _embed_missing(self, documents: List[Document]) -> None:
"""Populate embeddings on any documents that don't have one. Uses
the embedder's batch path when available."""
to_embed = [
doc
for doc in documents
if doc.embedding is None
or (isinstance(doc.embedding, list) and len(doc.embedding) == 0)
]
if not to_embed:
return
if (
getattr(self.embedder, "enable_batch", False)
and hasattr(self.embedder, "get_embeddings_batch_and_usage")
):
contents = [doc.content for doc in to_embed]
embeddings, usages = self.embedder.get_embeddings_batch_and_usage(contents)
for j, doc in enumerate(to_embed):
if j < len(embeddings):
doc.embedding = embeddings[j]
doc.usage = usages[j] if j < len(usages) else None
else:
for doc in to_embed:
doc.embed(embedder=self.embedder)
async def _embed_missing_async(self, documents: List[Document]) -> None:
to_embed = [
doc
for doc in documents
if doc.embedding is None
or (isinstance(doc.embedding, list) and len(doc.embedding) == 0)
]
if not to_embed:
return
if (
getattr(self.embedder, "enable_batch", False)
and hasattr(self.embedder, "async_get_embeddings_batch_and_usage")
):
contents = [doc.content for doc in to_embed]
embeddings, usages = await self.embedder.async_get_embeddings_batch_and_usage(
contents
)
for j, doc in enumerate(to_embed):
if j < len(embeddings):
doc.embedding = embeddings[j]
doc.usage = usages[j] if j < len(usages) else None
else:
# Embedder has no async batch path — fall back to sync.
self._embed_missing(to_embed)
def insert(
self,
content_hash: str,
documents: List[Document],
filters: Optional[Dict[str, Any]] = None,
) -> None:
if not documents:
return
if self._index is None:
# Match LanceDb's "table not initialized" handling: do not
# silently auto-create. Callers must invoke create() first.
raise RuntimeError(
"TurboQuantVectorDb not initialized — call create() before insert()."
)
# Merge `filters` into each document's metadata (matches LanceDb).
if filters:
for doc in documents:
meta = dict(doc.meta_data) if doc.meta_data else {}
meta.update(filters)
doc.meta_data = meta
self._embed_missing(documents)
# Raise on any document that still lacks an embedding rather than
# silently dropping — silent drops mask data-pipeline bugs.
missing = [doc for doc in documents if not doc.embedding]
if missing:
ids = [doc.id or "<no id>" for doc in missing]
raise ValueError(
f"failed to embed {len(missing)} document(s): {ids}"
)
# Batch the entire `documents` list into a single add_with_ids call.
# Per-document inserts would invalidate the SIMD-blocked cache
# between every doc.
vectors = np.asarray([doc.embedding for doc in documents], dtype=np.float32)
if vectors.ndim != 2:
raise ValueError(
f"expected 2D embedding batch, got {vectors.ndim}D"
)
if vectors.shape[1] != self.dimensions:
raise ValueError(
f"embedding dim {vectors.shape[1]} does not match "
f"index dim {self.dimensions}"
)
if not vectors.flags["C_CONTIGUOUS"]:
vectors = np.ascontiguousarray(vectors)
handles = np.array(
[self._issue_handle() for _ in documents], dtype=np.uint64
)
self._index.add_with_ids(vectors, handles)
for doc, handle in zip(documents, handles):
cleaned = doc.content.replace("\x00", "") if doc.content else ""
doc_id = self._derive_doc_id(doc, content_hash, cleaned)
h = int(handle)
self._str_to_u64.setdefault(doc_id, set()).add(h)
self._u64_to_doc[h] = {
"id": doc_id,
"name": doc.name,
"content": cleaned,
"meta_data": dict(doc.meta_data) if doc.meta_data else {},
"usage": doc.usage,
"content_id": doc.content_id,
"content_hash": content_hash,
}
self._content_hashes.add(content_hash)
if doc.name:
self._name_to_ids.setdefault(doc.name, set()).add(doc_id)
async def async_insert(
self,
content_hash: str,
documents: List[Document],
filters: Optional[Dict[str, Any]] = None,
) -> None:
if not documents:
return
await self._embed_missing_async(documents)
# Now every doc should have an embedding; insert delegates to sync.
self.insert(content_hash, documents, filters)
def upsert_available(self) -> bool:
return True
def upsert(
self,
content_hash: str,
documents: List[Document],
filters: Optional[Dict[str, Any]] = None,
) -> None:
# Match LanceDb's semantic: replace all documents previously
# stored under this content_hash with the incoming batch. Not
# "replace by derived doc_id" — that's a different contract.
#
# Capture the existing generation's handles, run the insert, and
# only then drop the old vectors — so a failed insert (dim
# mismatch, non-finite embeddings) never destroys the data being
# replaced (issue #89). We delete by captured handle rather than
# re-querying by content_hash, because insert() re-derives ids
# under the SAME content_hash and would otherwise clobber the
# just-inserted rows.
old_handles = self._handles_for_content_hash(content_hash)
self.insert(content_hash, documents, filters)
for handle in old_handles:
self._remove_handle(handle)
async def async_upsert(
self,
content_hash: str,
documents: List[Document],
filters: Optional[Dict[str, Any]] = None,
) -> None:
old_handles = self._handles_for_content_hash(content_hash)
await self.async_insert(content_hash, documents, filters)
for handle in old_handles:
self._remove_handle(handle)
def _handles_for_content_hash(self, content_hash: str) -> List[int]:
"""Internal handles of every document currently stored under this
content_hash. Used by upsert to defer removal of the previous
generation until the replacement add has succeeded (issue #89)."""
return [
handle
for handle, data in self._u64_to_doc.items()
if data.get("content_hash") == content_hash
]
def _remove_handle(self, handle: int) -> None:
"""Remove a single vector by its internal handle, leaving other
handles intact — including ones that share this document's derived
id (two distinct documents can map to the same doc_id, matching
LanceDb). Cleans the id, name, and content_hash side-indexes only
where no surviving handle still needs them."""
if self._index is None:
return
data = self._u64_to_doc.pop(handle, None)
if data is None:
return
self._index.remove(handle)
doc_id = data.get("id")
# Drop just this handle from the id's handle set; remove the id
# entirely only once no handle remains under it.
if doc_id is not None:
handles = self._str_to_u64.get(doc_id)
if handles is not None:
handles.discard(handle)
if not handles:
del self._str_to_u64[doc_id]
# Drop the name->id link only if no surviving handle keeps that
# (name, id) pair. The derived doc_id excludes `name`, so two docs
# with different names can share an id — matching on id alone would
# leave a stale name entry when the last handle for this name goes.
name = data.get("name")
if name and name in self._name_to_ids:
if not any(
d.get("id") == doc_id and d.get("name") == name
for d in self._u64_to_doc.values()
):
self._name_to_ids[name].discard(doc_id)
if not self._name_to_ids[name]:
del self._name_to_ids[name]
# Drop the content_hash only if no surviving doc carries it.
ch = data.get("content_hash")
if ch and not any(
d.get("content_hash") == ch for d in self._u64_to_doc.values()
):
self._content_hashes.discard(ch)
# ---- VectorDb protocol: search ----------------------------------------
def _resolve_filter_to_handles(
self, filters: Optional[Union[Dict[str, Any], List[Any]]]
) -> Optional[List[int]]:
"""Convert a dict filter into the list of internal u64 handles
whose document's ``meta_data`` matches every key/value pair (AND).
Returns ``None`` when no filter was supplied — caller should run
an unfiltered search. Returns ``[]`` to mean "no matches".
Matches LanceDb's dict-filter semantics (exact equality, AND of
keys). ``FilterExpr``-style list filters are not yet supported
in LanceDb itself, so we silently ignore them here too with a
debug log.
"""
if filters is None:
return None
if isinstance(filters, list):
# LanceDb logs a warning and ignores. Mirror that — the
# alternative is to error and break callers that pass an
# accidental list.
return None
if not isinstance(filters, dict) or not filters:
return None
items = list(filters.items())
return [
handle
for handle, data in self._u64_to_doc.items()
if all((data.get("meta_data") or {}).get(k) == v for k, v in items)
]
def _scaled_similarity(self, raw: float) -> float:
"""Map cosine similarity in ``[-1, 1]`` to ``[0, 1]``. Clamped to
absorb the small overshoot caused by quantization noise."""
return max(0.0, min(1.0, (raw + 1.0) / 2.0))
def _build_results(
self, scores: np.ndarray, handles: np.ndarray
) -> List[Document]:
results: List[Document] = []
threshold = self.similarity_threshold
for raw_score, handle in zip(scores[0], handles[0]):
doc_data = self._u64_to_doc.get(int(handle))
if doc_data is None:
continue
similarity = self._scaled_similarity(float(raw_score))
if threshold is not None and similarity < threshold:
continue
results.append(
Document(
id=doc_data["id"],
name=doc_data.get("name"),
content=doc_data.get("content", ""),
meta_data=dict(doc_data.get("meta_data") or {}),
usage=doc_data.get("usage"),
content_id=doc_data.get("content_id"),
# Match LanceDb._build_search_results: thread the
# store's embedder through so downstream code can call
# `doc.embed()` / `doc.async_embed()` on a retrieved
# hit without explicitly passing the embedder back in.
# Without this, `doc.embed()` raises
# "No embedder provided".
embedder=self.embedder,
)
)
return results
def search(
self,
query: str,
limit: int = 5,
filters: Optional[Union[Dict[str, Any], List[Any]]] = None,
) -> List[Document]:
# An empty query string usually indicates an upstream bug
# (uninitialised variable, failed prompt construction). LanceDb
# short-circuits this to [] rather than searching with a hash-
# derived embedding of "", which would return arbitrary garbage.
if not query:
return []
if self._index is None or len(self._index) == 0:
return []
query_embedding = self.embedder.get_embedding(query)
if query_embedding is None:
return []
qvec = np.asarray(query_embedding, dtype=np.float32)
if qvec.ndim == 1:
qvec = qvec[None, :]
if not qvec.flags["C_CONTIGUOUS"]:
qvec = np.ascontiguousarray(qvec)
allowed_handles = self._resolve_filter_to_handles(filters)
if allowed_handles is None:
# Unfiltered.
k = min(limit, len(self._index))
scores, handles = self._index.search(qvec, k)
else:
if not allowed_handles:
return []
allowlist = np.asarray(allowed_handles, dtype=np.uint64)
scores, handles = self._index.search(qvec, limit, allowlist=allowlist)
results = self._build_results(scores, handles)
if self.reranker is not None and results:
results = self.reranker.rerank(query=query, documents=results)
return results
async def async_search(
self,
query: str,
limit: int = 5,
filters: Optional[Union[Dict[str, Any], List[Any]]] = None,
) -> List[Document]:
if not query:
return []
if self._index is None or len(self._index) == 0:
return []
if hasattr(self.embedder, "async_get_embedding"):
query_embedding = await self.embedder.async_get_embedding(query)
else:
query_embedding = self.embedder.get_embedding(query)
if query_embedding is None:
return []
qvec = np.asarray(query_embedding, dtype=np.float32)
if qvec.ndim == 1:
qvec = qvec[None, :]
if not qvec.flags["C_CONTIGUOUS"]:
qvec = np.ascontiguousarray(qvec)
allowed_handles = self._resolve_filter_to_handles(filters)
if allowed_handles is None:
k = min(limit, len(self._index))
scores, handles = self._index.search(qvec, k)
else:
if not allowed_handles:
return []
allowlist = np.asarray(allowed_handles, dtype=np.uint64)
scores, handles = self._index.search(qvec, limit, allowlist=allowlist)
results = self._build_results(scores, handles)
if self.reranker is not None and results:
results = self.reranker.rerank(query=query, documents=results)
return results
def get_supported_search_types(self) -> List[SearchType]:
# Only vector. Keyword and hybrid would require an external BM25
# / lexical index that turbovec doesn't ship. Return shape
# mirrors LanceDb: a list of SearchType enum members (not their
# `.value` strings).
return [SearchType.vector]
# ---- VectorDb protocol: delete ----------------------------------------
def delete_by_id(self, id: str) -> bool:
if self._index is None:
return False
handles = self._str_to_u64.get(id)
if not handles:
return False
# Remove every vector sharing this id — a non-unique derived doc_id
# can map to several handles. _remove_handle maintains the id, name,
# and content_hash side-indexes per handle.
for handle in list(handles):
self._remove_handle(handle)
return True
def delete_by_name(self, name: str) -> bool:
if self._index is None:
return False
# Remove exactly the handles whose stored name matches. Delegating to
# delete_by_id would key on the derived doc_id, which excludes `name`,
# so it would also delete a differently-named doc that happens to
# share the id. LanceDb deletes rows matching the predicate directly.
handles = [h for h, d in self._u64_to_doc.items() if d.get("name") == name]
for handle in handles:
self._remove_handle(handle)
return bool(handles)
def delete_by_metadata(self, metadata: Dict[str, Any]) -> bool:
if self._index is None:
return False
items = list(metadata.items())
# Remove the matching handles directly (see delete_by_name): the
# derived doc_id ignores metadata, so delete_by_id would over-delete
# distinct docs that collide on the id.
handles = [
h
for h, data in self._u64_to_doc.items()
if all((data.get("meta_data") or {}).get(k) == v for k, v in items)
]
for handle in handles:
self._remove_handle(handle)
return bool(handles)
def delete_by_content_id(self, content_id: str) -> bool:
if self._index is None:
return False
# Remove the matching handles directly (see delete_by_name): the
# derived doc_id ignores content_id, so delete_by_id would over-delete
# distinct docs that collide on the id.
handles = [
h
for h, data in self._u64_to_doc.items()
if data.get("content_id") == content_id
]
for handle in handles:
self._remove_handle(handle)
return bool(handles)
def update_metadata(self, content_id: str, metadata: Dict[str, Any]) -> None:
"""Merge ``metadata`` into both ``meta_data`` and the ``filters``
payload field of every document whose ``content_id`` matches.
Mirrors LanceDb's update_metadata semantic which writes to both
fields (used by callers that pass filter-style restrictions at
retrieval time)."""
if self._index is None:
return
for data in self._u64_to_doc.values():
if data.get("content_id") == content_id:
meta = dict(data.get("meta_data") or {})
meta.update(metadata)
data["meta_data"] = meta
filters = data.get("filters")
if isinstance(filters, dict):
filters = dict(filters)
filters.update(metadata)
data["filters"] = filters
else:
data["filters"] = dict(metadata)
# ---- Persistence (JSON side-car) --------------------------------------
def save(self, folder_path: Optional[str] = None) -> None:
"""Persist the quantized index plus a JSON side-car to disk. Pass
``folder_path`` to override the constructor's ``path=``.
Writes two files under ``folder_path``:
- ``index.tvim`` — the :class:`IdMapIndex` payload.
- ``docstore.json`` — JSON-encoded document text, metadata, and
id maps. Side-car carries a ``schema_version`` field; loaders
reject unknown versions rather than silently misinterpreting
bytes.
"""
path = folder_path if folder_path is not None else self.path
if path is None:
raise ValueError(
"No path to save to. Pass `folder_path=` here or set "
"`path=` on the constructor."
)
if self._index is None:
raise RuntimeError(
"TurboQuantVectorDb has no index to save — call create() first."
)
folder = Path(path)
folder.mkdir(parents=True, exist_ok=True)
self._index.write(str(folder / _INDEX_FILENAME))
payload = {
"schema_version": _DOCSTORE_SCHEMA_VERSION,
# Round-trip int handles via list-of-pairs (JSON keys must be
# strings, but our handles are ints).
"u64_to_doc": [[h, d] for h, d in self._u64_to_doc.items()],
"next_u64": self._next_u64,
"bit_width": self.bit_width,
"dimensions": self.dimensions,
}
with open(folder / _STORE_FILENAME, "w") as f:
json.dump(payload, f)
def _load_from(self, folder: Path) -> None:
side_car = folder / _STORE_FILENAME
index_file = folder / _INDEX_FILENAME
if not side_car.exists() or not index_file.exists():
raise FileNotFoundError(
f"missing one of {_STORE_FILENAME}/{_INDEX_FILENAME} under {folder}"
)
with open(side_car) as f:
state = json.load(f)
version = state.get("schema_version", 0)
if version != _DOCSTORE_SCHEMA_VERSION:
raise ValueError(
f"{_STORE_FILENAME} has schema_version {version}; this "
f"turbovec expects {_DOCSTORE_SCHEMA_VERSION}"
)
if state.get("dimensions") != self.dimensions:
raise ValueError(
f"persisted dimensions={state.get('dimensions')} does not "
f"match this store's embedder dimensions={self.dimensions}"
)
self._index = IdMapIndex.load(str(index_file))
self._u64_to_doc = {int(h): d for h, d in state["u64_to_doc"]}
self._next_u64 = int(state["next_u64"])
# Rebuild reverse indexes from the loaded payload. doc_id is
# non-unique, so accumulate handles into a set per id rather than a
# dict comprehension (which would drop all but the last handle and
# re-orphan the very vectors issue #104 fixed).
self._str_to_u64 = {}
for handle, data in self._u64_to_doc.items():
self._str_to_u64.setdefault(data["id"], set()).add(handle)
self._content_hashes = set()
self._name_to_ids = {}
for data in self._u64_to_doc.values():
ch = data.get("content_hash")
if ch:
self._content_hashes.add(ch)
name = data.get("name")
if name:
self._name_to_ids.setdefault(name, set()).add(data["id"])
__all__ = ["TurboQuantVectorDb"]
+747
View File
@@ -0,0 +1,747 @@
"""Haystack DocumentStore backed by turbovec's quantized index.
Install with: ``pip install turbovec[haystack]``.
Implements the Haystack 2.x ``DocumentStore`` protocol and mirrors most
of ``InMemoryDocumentStore``'s public surface (write/filter/delete,
``embedding_retrieval``, ``save_to_disk``/``load_from_disk``, pipeline
``to_dict``/``from_dict``). BM25 (sparse-text) retrieval is not
implemented — wire an ``InMemoryBM25Retriever`` against a separate
store if you need keyword search alongside vector search. The
quantized index discards full-precision embeddings after compression —
callers that rely on ``Document.embedding`` after retrieval will see
``None``.
"""
from __future__ import annotations
import asyncio
import json
import math
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from typing import Any, Dict, Iterable, List, Literal, Optional, Tuple
import numpy as np
from ._persist import check_persisted_handles
from ._turbovec import IdMapIndex
try:
from haystack import Document
from haystack.dataclasses import ByteStream
from haystack.dataclasses.sparse_embedding import SparseEmbedding
from haystack.document_stores.errors import DuplicateDocumentError
from haystack.document_stores.types import DuplicatePolicy
from haystack.utils.filters import document_matches_filter
except ImportError as exc:
raise ImportError(
"haystack-ai is required to use turbovec.haystack. "
"Install with: pip install turbovec[haystack]"
) from exc
class TurboQuantDocumentStore:
"""Haystack DocumentStore backed by a :class:`~turbovec.IdMapIndex`.
Vectors are quantized to 24 bits per dimension. Full-precision
embeddings are dropped after quantization — callers requesting
``return_embedding=True`` on retrieval will see ``None`` on the
returned documents' ``embedding`` field regardless of the flag.
Example::
from turbovec.haystack import TurboQuantDocumentStore
from haystack import Document
store = TurboQuantDocumentStore(dim=1536, bit_width=4)
store.write_documents([
Document(content="...", embedding=[...], meta={"source": "a"}),
...
])
results = store.embedding_retrieval(query_embedding=[...], top_k=5)
"""
def __init__(
self,
dim: Optional[int] = None,
bit_width: int = 4,
*,
embedding_similarity_function: Literal["dot_product", "cosine"] = "cosine",
async_executor: Optional[ThreadPoolExecutor] = None,
return_embedding: bool = False,
) -> None:
"""
:param dim: Vector dimensionality. When omitted, the underlying
quantized index is created lazily by ``IdMapIndex`` itself on
the first ``write_documents`` call — matches the no-``dim``
ergonomics of ``InMemoryDocumentStore``.
:param bit_width: Quantization width per coordinate (2 or 4).
:param embedding_similarity_function: ``"cosine"`` (default) or
``"dot_product"``. Used to choose the ``scale_score`` formula
during retrieval. Defaults to ``"cosine"`` because turbovec
stores unit-normalized vectors.
:param async_executor: Optional executor for the ``*_async``
methods. If omitted, a single-threaded executor is created
and cleaned up on instance destruction.
:param return_embedding: Whether retrieval methods should leave
the ``embedding`` field populated on returned Documents.
turbovec never has the full-precision embedding available, so
this is always ``None`` either way; the flag is accepted for
API parity with ``InMemoryDocumentStore``.
"""
self._bit_width = bit_width
self.embedding_similarity_function = embedding_similarity_function
self.return_embedding = return_embedding
# IdMapIndex itself supports lazy construction — pass dim through
# and let it handle eager vs lazy. No per-store lazy wrapping.
self._index = IdMapIndex(dim, bit_width)
# Haystack doc_id (str) -> u64 handle
self._str_to_u64: Dict[str, int] = {}
# u64 handle -> stored doc data {id, content, meta}
self._u64_to_doc: Dict[int, Dict[str, Any]] = {}
# Counter for assigning u64 handles. Starts at 0; each new
# handle is `_next_u64 + 1`, then we bump. Plain int so pickle
# can round-trip it directly.
self._next_u64: int = 0
# Executor lifecycle mirrors InMemoryDocumentStore: own one when
# the caller didn't pass one in, and shut it down in __del__.
self._owns_executor = async_executor is None
self.executor = async_executor or ThreadPoolExecutor(
thread_name_prefix=f"async-turbovec-docstore-executor-{id(self)}",
max_workers=1,
)
def __del__(self) -> None:
if (
hasattr(self, "_owns_executor")
and self._owns_executor
and hasattr(self, "executor")
):
self.executor.shutdown(wait=True)
def shutdown(self) -> None:
"""Explicitly shut down the async executor if this store owns it."""
if self._owns_executor:
self.executor.shutdown(wait=True)
def _issue_handle(self) -> int:
self._next_u64 += 1
return self._next_u64
@property
def storage(self) -> Dict[str, Document]:
"""Map of ``doc_id -> Document`` for the currently stored documents.
Documents are reconstructed on every access; the
``embedding`` field is always ``None``.
"""
return {data["id"]: self._reconstruct(data) for data in self._u64_to_doc.values()}
# ---- DocumentStore protocol ---------------------------------------
def count_documents(self) -> int:
return len(self._str_to_u64)
def filter_documents(
self, filters: Optional[Dict[str, Any]] = None
) -> List[Document]:
if filters:
self._validate_filters(filters)
docs = [
self._reconstruct(data)
for data in self._u64_to_doc.values()
if document_matches_filter(filters=filters, document=self._reconstruct(data))
]
else:
docs = [self._reconstruct(data) for data in self._u64_to_doc.values()]
# `return_embedding` is informational here — we never have the
# full-precision embedding to begin with. Kept for parity.
return docs
def write_documents(
self,
documents: List[Document],
policy: DuplicatePolicy = DuplicatePolicy.NONE,
) -> int:
# Match InMemoryDocumentStore's input-shape validation rather
# than letting a bad input AttributeError on `.embedding`.
if (
not isinstance(documents, Iterable)
or isinstance(documents, str)
or any(not isinstance(doc, Document) for doc in documents)
):
raise ValueError("Please provide a list of Documents.")
if policy == DuplicatePolicy.NONE:
policy = DuplicatePolicy.FAIL
# First pass: validate and resolve duplicates according to policy.
# Duplicates are resolved against the batch-so-far as well as the
# existing store: InMemoryDocumentStore writes into its dict as it
# iterates, so a repeated id *within a single call* is resolved the
# same way a cross-call repeat would be. Without tracking the batch,
# every duplicate row still gets its own vector while _str_to_u64
# keeps only the last handle, orphaning the earlier vectors.
to_write: List[Document] = []
batch_pos: Dict[str, int] = {} # doc.id -> index into to_write
to_remove: List[str] = [] # existing ids to drop, deferred past add
written = len(documents)
for doc in documents:
if doc.embedding is None:
raise ValueError(
f"Document {doc.id!r} has no embedding. "
"TurboQuantDocumentStore only stores documents with precomputed "
"embeddings — run an embedder component before writing."
)
present = doc.id in self._str_to_u64 or doc.id in batch_pos
if policy != DuplicatePolicy.OVERWRITE and present:
if policy == DuplicatePolicy.FAIL:
raise DuplicateDocumentError(
f"ID '{doc.id}' already exists in the document store."
)
if policy == DuplicatePolicy.SKIP:
written -= 1
continue
if policy == DuplicatePolicy.OVERWRITE:
if doc.id in self._str_to_u64:
# Defer the removal until after the add succeeds so a
# failed validation/add never destroys existing data
# (issue #89).
to_remove.append(doc.id)
if doc.id in batch_pos:
# Last write wins: replace the earlier queued document
# in place rather than appending a second vector.
to_write[batch_pos[doc.id]] = doc
continue
batch_pos[doc.id] = len(to_write)
to_write.append(doc)
if not to_write:
return written
vectors = np.asarray(
[doc.embedding for doc in to_write], dtype=np.float32
)
if vectors.ndim != 2:
raise ValueError(
f"expected 2D embedding batch, got {vectors.ndim}D"
)
# IdMapIndex.add_with_ids handles both eager (dim must match) and
# lazy (locks dim on first call) cases. Surface its mismatch
# panic as a clean ValueError for parity with previous behaviour.
existing_dim = self._index.dim
if existing_dim is not None and vectors.shape[1] != existing_dim:
raise ValueError(
f"embedding dim {vectors.shape[1]} does not match store dim {existing_dim}"
)
if not vectors.flags["C_CONTIGUOUS"]:
vectors = np.ascontiguousarray(vectors)
handles = np.array(
[self._issue_handle() for _ in to_write], dtype=np.uint64
)
self._index.add_with_ids(vectors, handles)
# The add succeeded — now it's safe to drop the old vectors for any
# overwritten ids. Done before the mapping loop below so _remove_one
# resolves the old handle, not the one we're about to assign.
for doc_id in to_remove:
self._remove_one(doc_id)
for doc, handle in zip(to_write, handles):
h = int(handle)
self._str_to_u64[doc.id] = h
self._u64_to_doc[h] = {
"id": doc.id,
"content": doc.content,
"meta": dict(doc.meta),
"blob": doc.blob,
"sparse_embedding": doc.sparse_embedding,
}
return written
def delete_documents(self, document_ids: List[str]) -> None:
# Haystack's protocol says silently ignore missing ids.
for doc_id in document_ids:
self._remove_one(doc_id)
# ---- Utility methods (InMemoryDocumentStore parity) ---------------
def delete_all_documents(self) -> None:
"""Delete every document in the store."""
for doc_id in list(self._str_to_u64.keys()):
self._remove_one(doc_id)
def update_by_filter(
self, filters: Dict[str, Any], meta: Dict[str, Any]
) -> int:
"""Update metadata on every document matching ``filters``.
The new ``meta`` is merged into each matching document's existing
metadata. Embeddings are not touched — we never had them at full
precision anyway. Returns the number of documents updated.
"""
self._validate_filters(filters)
updated = 0
for data in self._u64_to_doc.values():
if document_matches_filter(filters=filters, document=self._reconstruct(data)):
data["meta"].update(meta)
updated += 1
return updated
def delete_by_filter(self, filters: Dict[str, Any]) -> int:
"""Delete every document matching ``filters``. Returns the count."""
self._validate_filters(filters)
matching_ids = [
data["id"]
for data in self._u64_to_doc.values()
if document_matches_filter(filters=filters, document=self._reconstruct(data))
]
for doc_id in matching_ids:
self._remove_one(doc_id)
return len(matching_ids)
def count_documents_by_filter(self, filters: Dict[str, Any]) -> int:
if filters:
self._validate_filters(filters)
return sum(
1
for data in self._u64_to_doc.values()
if document_matches_filter(filters=filters, document=self._reconstruct(data))
)
return self.count_documents()
def count_unique_metadata_by_filter(
self, filters: Dict[str, Any], metadata_fields: List[str]
) -> Dict[str, int]:
if filters:
self._validate_filters(filters)
docs_meta = [
data["meta"]
for data in self._u64_to_doc.values()
if document_matches_filter(filters=filters, document=self._reconstruct(data))
]
else:
docs_meta = [data["meta"] for data in self._u64_to_doc.values()]
result: Dict[str, int] = {}
for field in metadata_fields:
key = field.removeprefix("meta.") if field.startswith("meta.") else field
values = {meta.get(key) for meta in docs_meta if key in meta and meta[key] is not None}
result[key] = len(values)
return result
def get_metadata_fields_info(self) -> Dict[str, Dict[str, str]]:
type_map: Dict[str, str] = {}
for data in self._u64_to_doc.values():
for key, value in data["meta"].items():
if value is None:
continue
if isinstance(value, bool):
type_map[key] = "boolean"
elif isinstance(value, int):
type_map[key] = "int"
elif isinstance(value, float):
type_map[key] = "float"
else:
type_map[key] = "keyword"
return {k: {"type": v} for k, v in type_map.items()}
def get_metadata_field_min_max(self, metadata_field: str) -> Dict[str, Any]:
key = (
metadata_field.removeprefix("meta.")
if metadata_field.startswith("meta.")
else metadata_field
)
values = [
data["meta"][key]
for data in self._u64_to_doc.values()
if key in data["meta"]
and data["meta"][key] is not None
and isinstance(data["meta"][key], (int, float, str))
]
if not values:
return {"min": None, "max": None}
try:
return {"min": min(values), "max": max(values)}
except TypeError:
return {"min": None, "max": None}
def get_metadata_field_unique_values(
self, metadata_field: str, search_term: Optional[str] = None
) -> Tuple[List[str], int]:
key = (
metadata_field.removeprefix("meta.")
if metadata_field.startswith("meta.")
else metadata_field
)
if search_term:
docs_data = [
data
for data in self._u64_to_doc.values()
if data["content"] and search_term.lower() in data["content"].lower()
]
else:
docs_data = list(self._u64_to_doc.values())
values = sorted(
{
str(data["meta"][key])
for data in docs_data
if key in data["meta"] and data["meta"][key] is not None
},
key=str,
)
return values, len(values)
@staticmethod
def _validate_filters(filters: Optional[Dict[str, Any]]) -> None:
# Match InMemoryDocumentStore (document_store.py:504-509): a
# filter dict must have a top-level "operator" (simple comparison
# or logical) or "conditions" (compound). A bare "field" without
# an operator is malformed and the reference rejects it; we do too.
if (
filters
and "operator" not in filters
and "conditions" not in filters
):
raise ValueError(
"Invalid filter syntax. See https://docs.haystack.deepset.ai/docs/metadata-filtering for details."
)
# ---- Retrieval (not in core protocol but expected) ----------------
def embedding_retrieval(
self,
query_embedding: List[float],
filters: Optional[Dict[str, Any]] = None,
top_k: int = 10,
scale_score: bool = False,
return_embedding: Optional[bool] = None,
) -> List[Document]:
"""Return the ``top_k`` documents most similar to ``query_embedding``.
``return_embedding=None`` (default) honours the store-level
``return_embedding`` set in the constructor. turbovec never has
the full-precision embedding either way — the parameter is here
for API parity with ``InMemoryDocumentStore``.
``filters`` are resolved to an allowlist before scoring, so the
kernel never wastes work on non-matching documents and the result
count is always ``min(top_k, n_matches)`` rather than ``< top_k``
when the filter is selective.
"""
# `return_embedding` is accepted but we never have the full
# embedding to populate; left as-is for signature parity.
_ = return_embedding # noqa: F841
if self.count_documents() == 0:
return []
qvec = np.asarray(query_embedding, dtype=np.float32)
if qvec.ndim == 1:
qvec = qvec[None, :]
# By this point n_documents > 0, so the index has a committed dim.
expected_dim = self._index.dim
if qvec.shape[1] != expected_dim:
raise ValueError(
f"query_embedding dim {qvec.shape[1]} does not match store dim {expected_dim}"
)
if not qvec.flags["C_CONTIGUOUS"]:
qvec = np.ascontiguousarray(qvec)
if filters is None:
fetch_k = min(top_k, self.count_documents())
scores, handles = self._index.search(qvec, fetch_k)
else:
self._validate_filters(filters)
# Resolve filter → handle allowlist by walking the in-memory
# doc table once. This is the same O(N) cost as the old
# post-filter pass, just moved upfront so the kernel can score
# only matching vectors.
allowed_handles = [
handle
for handle, data in self._u64_to_doc.items()
if document_matches_filter(filters, self._reconstruct(data))
]
if not allowed_handles:
return []
allowlist = np.asarray(allowed_handles, dtype=np.uint64)
scores, handles = self._index.search(qvec, top_k, allowlist=allowlist)
out: List[Document] = []
for score, handle in zip(scores[0], handles[0]):
data = self._u64_to_doc[int(handle)]
out.append(self._reconstruct(data, score=float(score), scale_score=scale_score))
return out
# ---- Async variants ----------------------------------------------
async def count_documents_async(self) -> int:
return self.count_documents()
async def filter_documents_async(
self, filters: Optional[Dict[str, Any]] = None
) -> List[Document]:
return await asyncio.get_running_loop().run_in_executor(
self.executor, lambda: self.filter_documents(filters=filters)
)
async def write_documents_async(
self,
documents: List[Document],
policy: DuplicatePolicy = DuplicatePolicy.NONE,
) -> int:
return await asyncio.get_running_loop().run_in_executor(
self.executor, lambda: self.write_documents(documents=documents, policy=policy)
)
async def delete_documents_async(self, document_ids: List[str]) -> None:
await asyncio.get_running_loop().run_in_executor(
self.executor, lambda: self.delete_documents(document_ids=document_ids)
)
async def delete_all_documents_async(self) -> None:
await asyncio.get_running_loop().run_in_executor(
self.executor, self.delete_all_documents
)
async def update_by_filter_async(
self, filters: Dict[str, Any], meta: Dict[str, Any]
) -> int:
return await asyncio.get_running_loop().run_in_executor(
self.executor, lambda: self.update_by_filter(filters=filters, meta=meta)
)
async def count_documents_by_filter_async(self, filters: Dict[str, Any]) -> int:
return await asyncio.get_running_loop().run_in_executor(
self.executor, lambda: self.count_documents_by_filter(filters=filters)
)
async def count_unique_metadata_by_filter_async(
self, filters: Dict[str, Any], metadata_fields: List[str]
) -> Dict[str, int]:
return await asyncio.get_running_loop().run_in_executor(
self.executor,
lambda: self.count_unique_metadata_by_filter(
filters=filters, metadata_fields=metadata_fields
),
)
async def get_metadata_fields_info_async(self) -> Dict[str, Dict[str, str]]:
return await asyncio.get_running_loop().run_in_executor(
self.executor, self.get_metadata_fields_info
)
async def get_metadata_field_min_max_async(
self, metadata_field: str
) -> Dict[str, Any]:
return await asyncio.get_running_loop().run_in_executor(
self.executor,
lambda: self.get_metadata_field_min_max(metadata_field=metadata_field),
)
async def get_metadata_field_unique_values_async(
self, metadata_field: str, search_term: Optional[str] = None
) -> Tuple[List[str], int]:
return await asyncio.get_running_loop().run_in_executor(
self.executor,
lambda: self.get_metadata_field_unique_values(
metadata_field=metadata_field, search_term=search_term
),
)
async def embedding_retrieval_async(
self,
query_embedding: List[float],
filters: Optional[Dict[str, Any]] = None,
top_k: int = 10,
scale_score: bool = False,
return_embedding: Optional[bool] = None,
) -> List[Document]:
return await asyncio.get_running_loop().run_in_executor(
self.executor,
lambda: self.embedding_retrieval(
query_embedding=query_embedding,
filters=filters,
top_k=top_k,
scale_score=scale_score,
return_embedding=return_embedding,
),
)
# ---- Serialization (Pipeline to_dict / from_dict) -----------------
def to_dict(self) -> Dict[str, Any]:
return {
"type": f"{self.__class__.__module__}.{self.__class__.__name__}",
"init_parameters": {
# `_index.dim` is None on a lazy uncommitted store and an
# int once an add has locked the dim — both round-trip cleanly.
"dim": self._index.dim,
"bit_width": self._bit_width,
"embedding_similarity_function": self.embedding_similarity_function,
"return_embedding": self.return_embedding,
},
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "TurboQuantDocumentStore":
params = data.get("init_parameters", {})
return cls(**params)
# ---- Persistence -------------------------------------------------
# Side-car schema. Bump when the on-disk shape changes; loader
# accepts the current version plus any older versions whose missing
# fields we know how to reconstruct (currently v1, written before
# blob / sparse_embedding round-trip was added — both default to None
# on load).
_DOCSTORE_SCHEMA_VERSION = 2
_DOCSTORE_SCHEMA_COMPAT = (1, 2)
def save_to_disk(self, folder_path: str | Path) -> None:
"""Persist the quantized index plus the Haystack side-car to disk.
Writes into ``folder_path``:
- ``index.tvim`` — the :class:`IdMapIndex` payload. On a lazy
store that has never seen a write the file encodes the
uncommitted state via a ``dim=0`` sentinel.
- ``docstore.json`` — the str-id ↔ Document mapping and store
init parameters, JSON-encoded. Document metadata must be
JSON-serializable (the same constraint as
``InMemoryDocumentStore.save_to_disk``).
"""
folder = Path(folder_path)
folder.mkdir(parents=True, exist_ok=True)
self._index.write(str(folder / "index.tvim"))
# Keys in `_u64_to_doc` are ints (u64 handles); JSON object keys
# must be strings. Serialize as a list of [handle, data] pairs
# so we don't lose type fidelity on the round-trip.
payload = {
"schema_version": self._DOCSTORE_SCHEMA_VERSION,
"u64_to_doc": [
[h, self._serialize_doc_data(d)] for h, d in self._u64_to_doc.items()
],
"next_u64": self._next_u64,
"bit_width": self._bit_width,
"embedding_similarity_function": self.embedding_similarity_function,
"return_embedding": self.return_embedding,
}
with open(folder / "docstore.json", "w") as f:
json.dump(payload, f)
@classmethod
def load_from_disk(
cls,
folder_path: str | Path,
) -> "TurboQuantDocumentStore":
"""Reload a store from a folder previously written by
:meth:`save_to_disk`. Safe to call on any path — the side-car is
plain JSON, never pickle, so there's no deserialization-of-code
risk."""
folder = Path(folder_path)
with open(folder / "docstore.json") as f:
state = json.load(f)
version = state.get("schema_version", 0)
if version not in cls._DOCSTORE_SCHEMA_COMPAT:
raise ValueError(
f"docstore.json has schema version {version}; "
f"this turbovec accepts versions {list(cls._DOCSTORE_SCHEMA_COMPAT)}"
)
store = cls(
bit_width=state["bit_width"],
embedding_similarity_function=state.get(
"embedding_similarity_function", "cosine"
),
return_embedding=state.get("return_embedding", False),
)
# Reload the index — it carries dim internally (None for lazy
# uncommitted, int otherwise).
store._index = IdMapIndex.load(str(folder / "index.tvim"))
# Reconstruct {int handle: doc data} from the list-of-pairs form.
# `_deserialize_doc_data` is shape-tolerant: v1 entries lack the
# `blob` / `sparse_embedding` keys and come back with both set to
# None, which matches their original on-write state.
store._u64_to_doc = {
int(h): cls._deserialize_doc_data(d) for h, d in state["u64_to_doc"]
}
store._next_u64 = state["next_u64"]
# Rebuild str_to_u64 from the reloaded doc table.
store._str_to_u64 = {
data["id"]: handle for handle, data in store._u64_to_doc.items()
}
check_persisted_handles(store._index, store._u64_to_doc.keys(), what="document")
return store
# ---- Internals ----------------------------------------------------
def _remove_one(self, doc_id: str) -> bool:
handle = self._str_to_u64.pop(doc_id, None)
if handle is None:
return False
del self._u64_to_doc[handle]
self._index.remove(handle)
return True
def _reconstruct(
self,
data: Dict[str, Any],
score: Optional[float] = None,
scale_score: bool = False,
) -> Document:
if score is not None and scale_score:
# Match Haystack's InMemoryDocumentStore._compute_query_embedding_similarity_scores
# (document_store.py:818-822): different formula per similarity
# function. turbovec uses unit-normalized vectors so the cosine
# branch is the natural default.
if self.embedding_similarity_function == "dot_product":
score = 1.0 / (1.0 + math.exp(-score / 100.0))
elif self.embedding_similarity_function == "cosine":
# Clamp to the exact cosine range before rescaling. CauchySchwarz
# bounds the true cosine in [-1, 1], but the LUT scoring kernel's
# float-precision noise can land slightly outside that range on
# near-identical document/query pairs (e.g. a self-query under the
# length-renormalized estimator produces ~1.00016). Clamping
# preserves the [0, 1] contract for ``scale_score=True`` consumers.
score = (max(-1.0, min(1.0, score)) + 1.0) / 2.0
return Document(
id=data["id"],
content=data["content"],
meta=dict(data["meta"]),
blob=data.get("blob"),
sparse_embedding=data.get("sparse_embedding"),
score=score,
)
@staticmethod
def _serialize_doc_data(data: Dict[str, Any]) -> Dict[str, Any]:
# blob is a ByteStream; sparse_embedding is a SparseEmbedding.
# Both have a JSON-safe to_dict() form.
blob = data.get("blob")
sparse = data.get("sparse_embedding")
return {
"id": data["id"],
"content": data["content"],
"meta": data["meta"],
"blob": blob.to_dict() if blob is not None else None,
"sparse_embedding": sparse.to_dict() if sparse is not None else None,
}
@staticmethod
def _deserialize_doc_data(d: Dict[str, Any]) -> Dict[str, Any]:
blob = d.get("blob")
sparse = d.get("sparse_embedding")
return {
"id": d["id"],
"content": d["content"],
"meta": d["meta"],
"blob": ByteStream.from_dict(blob) if blob is not None else None,
"sparse_embedding": (
SparseEmbedding.from_dict(sparse) if sparse is not None else None
),
}
__all__ = ["TurboQuantDocumentStore"]
@@ -0,0 +1,558 @@
"""LangChain VectorStore backed by turbovec's quantized index.
Install with: ``pip install turbovec[langchain]``.
The public surface mirrors langchain_core's in-tree ``InMemoryVectorStore``
so this store can be swapped in wherever the in-memory store is used.
"""
from __future__ import annotations
import json
import uuid
from pathlib import Path
from typing import Any, Callable, Iterable, Sequence
import numpy as np
from ._dedup import DuplicatePolicy, resolve_duplicates
from ._persist import check_persisted_handles
from ._turbovec import IdMapIndex
try:
from langchain_core.documents import Document
from langchain_core.embeddings import Embeddings
from langchain_core.vectorstores import VectorStore
except ImportError as exc:
raise ImportError(
"langchain-core is required to use turbovec.langchain. "
"Install with: pip install turbovec[langchain]"
) from exc
_INDEX_FILENAME = "index.tvim"
_STORE_FILENAME = "docstore.json"
# Bump when the docstore.json shape changes; loader refuses to deserialize
# unknown versions.
_DOCSTORE_SCHEMA_VERSION = 1
class TurboQuantVectorStore(VectorStore):
"""LangChain VectorStore backed by a :class:`IdMapIndex`.
Vectors are quantized to 24 bits per dimension. A side-car dictionary
holds the original text and metadata keyed by document id. Deletion
is supported in O(1) per id via the underlying :class:`IdMapIndex`.
"""
def __init__(
self,
embedding: Embeddings,
index: IdMapIndex | None = None,
*,
bit_width: int = 4,
docs: dict[str, tuple[str, dict[str, Any]]] | None = None,
str_to_u64: dict[str, int] | None = None,
next_u64: int = 0,
) -> None:
"""
:param embedding: LangChain ``Embeddings`` instance used to encode
documents and queries.
:param index: Optional pre-built :class:`IdMapIndex`. When omitted,
a lazy ``IdMapIndex`` is created — it commits to a dim on the
first add and lets us match the no-arg constructor pattern of
langchain_core's ``InMemoryVectorStore``.
:param bit_width: Quantization width (2 or 4) used when the index
is created from scratch. Ignored if ``index`` is supplied.
"""
self._embedding = embedding
# IdMapIndex itself supports lazy construction now — no per-store
# lazy wrapping needed. When `index` is None we create a lazy
# IdMapIndex(dim=None, bit_width) and let it handle the rest.
self._index = index if index is not None else IdMapIndex(bit_width=bit_width)
self._docs: dict[str, tuple[str, dict[str, Any]]] = docs if docs is not None else {}
self._str_to_u64: dict[str, int] = str_to_u64 if str_to_u64 is not None else {}
# Reverse map (u64 handle → str id) kept in sync so search results
# can translate handles back to LangChain document ids.
self._u64_to_str: dict[int, str] = {
handle: sid for sid, handle in self._str_to_u64.items()
}
self._next_u64: int = next_u64
def _issue_handle(self) -> int:
self._next_u64 += 1
return self._next_u64
@property
def embeddings(self) -> Embeddings:
return self._embedding
# ---- Relevance score normalization --------------------------------
def _select_relevance_score_fn(self) -> Callable[[float], float]:
# turbovec returns the raw inner product of unit-normalized vectors —
# ideally cosine similarity in [-1, 1]. Quantization noise can
# push that very slightly outside the bounds, so clamp after
# mapping to LangChain's [0, 1] relevance scale via (sim + 1) / 2.
return lambda sim: max(0.0, min(1.0, (sim + 1.0) / 2.0))
# ---- Write path ---------------------------------------------------
def add_texts(
self,
texts: Iterable[str],
metadatas: list[dict] | None = None,
ids: list[str] | None = None,
**_: Any,
) -> list[str]:
texts_list = list(texts)
if not texts_list:
return []
if metadatas is None:
metadatas = [{} for _ in texts_list]
if ids is None:
ids = [str(uuid.uuid4()) for _ in texts_list]
if len(metadatas) != len(texts_list) or len(ids) != len(texts_list):
raise ValueError("texts, metadatas, and ids must all have the same length")
vectors = np.asarray(self._embedding.embed_documents(texts_list), dtype=np.float32)
return self._store_texts_and_vectors(texts_list, vectors, metadatas, ids)
async def aadd_texts(
self,
texts: Iterable[str],
metadatas: list[dict] | None = None,
ids: list[str] | None = None,
**_: Any,
) -> list[str]:
texts_list = list(texts)
if not texts_list:
return []
if metadatas is None:
metadatas = [{} for _ in texts_list]
if ids is None:
ids = [str(uuid.uuid4()) for _ in texts_list]
if len(metadatas) != len(texts_list) or len(ids) != len(texts_list):
raise ValueError("texts, metadatas, and ids must all have the same length")
vectors = np.asarray(
await self._embedding.aembed_documents(texts_list), dtype=np.float32
)
return self._store_texts_and_vectors(texts_list, vectors, metadatas, ids)
def add_documents(
self,
documents: list[Document],
ids: list[str] | None = None,
**kwargs: Any,
) -> list[str]:
# Override the base class default which drops the entire `ids` array
# if any Document has a None id. The reference InMemoryVectorStore
# falls back per-document so partial ids are honoured.
texts = [doc.page_content for doc in documents]
metadatas = [doc.metadata for doc in documents]
if ids is None:
ids = [doc.id or str(uuid.uuid4()) for doc in documents]
return self.add_texts(texts=texts, metadatas=metadatas, ids=ids, **kwargs)
async def aadd_documents(
self,
documents: list[Document],
ids: list[str] | None = None,
**kwargs: Any,
) -> list[str]:
texts = [doc.page_content for doc in documents]
metadatas = [doc.metadata for doc in documents]
if ids is None:
ids = [doc.id or str(uuid.uuid4()) for doc in documents]
return await self.aadd_texts(
texts=texts, metadatas=metadatas, ids=ids, **kwargs
)
def _store_texts_and_vectors(
self,
texts_list: list[str],
vectors: np.ndarray,
metadatas: list[dict],
ids: list[str],
) -> list[str]:
if vectors.ndim != 2:
raise ValueError(f"expected 2D embedding batch, got {vectors.ndim}D")
# Dedup intra-batch duplicate ids, keeping the last occurrence —
# matches InMemoryVectorStore, whose dict store silently overwrites
# on a repeated id. Without this every row is added to the index but
# _str_to_u64 keeps only the last handle per id, orphaning the
# earlier vectors. The returned id list still mirrors the input
# (one entry per input text), as the reference does.
result_ids = ids
keep = resolve_duplicates(ids, DuplicatePolicy.KEEP_LAST)
if len(keep) != len(ids):
ids = [ids[i] for i in keep]
texts_list = [texts_list[i] for i in keep]
metadatas = [metadatas[i] for i in keep]
vectors = vectors[keep]
# Validate before mutating any existing data. IdMapIndex.add_with_ids
# handles both eager (dim must match) and lazy (locks dim on first
# call) cases. Pre-check the eager case so we surface a clean
# ValueError rather than a Rust panic.
existing_dim = self._index.dim
if existing_dim is not None and vectors.shape[1] != existing_dim:
raise ValueError(
f"embedding dimension {vectors.shape[1]} does not match index dim {existing_dim}"
)
if not vectors.flags["C_CONTIGUOUS"]:
vectors = np.ascontiguousarray(vectors)
handles = np.array(
[self._issue_handle() for _ in texts_list], dtype=np.uint64
)
# Add first; if encoding rejects the batch (e.g. non-finite values)
# this raises before any existing data is touched. Only once the add
# has succeeded do we remove the old vectors for colliding ids, so a
# failed upsert never destroys existing data (issue #89). Handles are
# freshly issued, so the old and new vectors coexist until the delete.
self._index.add_with_ids(vectors, handles)
# Upsert: any id that already existed is removed so the re-added
# vector wins. Matches LangChain user expectation that `add_texts`
# with an existing id updates in place.
duplicates = [i for i in ids if i in self._str_to_u64]
if duplicates:
self.delete(duplicates)
for id_, text, meta, handle in zip(ids, texts_list, metadatas, handles):
h = int(handle)
self._str_to_u64[id_] = h
self._u64_to_str[h] = id_
self._docs[id_] = (text, dict(meta))
return result_ids
# ---- Read path (similarity search) --------------------------------
def similarity_search(
self,
query: str,
k: int = 4,
filter: dict[str, Any] | Callable[[Document], bool] | None = None,
**_: Any,
) -> list[Document]:
return [
doc
for doc, _score in self.similarity_search_with_score(query, k=k, filter=filter)
]
async def asimilarity_search(
self,
query: str,
k: int = 4,
filter: dict[str, Any] | Callable[[Document], bool] | None = None,
**_: Any,
) -> list[Document]:
return [
doc
for doc, _score in await self.asimilarity_search_with_score(
query, k=k, filter=filter
)
]
def similarity_search_with_score(
self,
query: str,
k: int = 4,
filter: dict[str, Any] | Callable[[Document], bool] | None = None,
**_: Any,
) -> list[tuple[Document, float]]:
qvec = np.asarray(self._embedding.embed_query(query), dtype=np.float32)
return self._search_vector(qvec, k, filter=filter)
async def asimilarity_search_with_score(
self,
query: str,
k: int = 4,
filter: dict[str, Any] | Callable[[Document], bool] | None = None,
**_: Any,
) -> list[tuple[Document, float]]:
qvec = np.asarray(
await self._embedding.aembed_query(query), dtype=np.float32
)
return self._search_vector(qvec, k, filter=filter)
def similarity_search_by_vector(
self,
embedding: list[float],
k: int = 4,
filter: dict[str, Any] | Callable[[Document], bool] | None = None,
**_: Any,
) -> list[Document]:
qvec = np.asarray(embedding, dtype=np.float32)
return [doc for doc, _score in self._search_vector(qvec, k, filter=filter)]
async def asimilarity_search_by_vector(
self,
embedding: list[float],
k: int = 4,
filter: dict[str, Any] | Callable[[Document], bool] | None = None,
**_: Any,
) -> list[Document]:
# The search itself is sync (no embedding step). Delegate.
return self.similarity_search_by_vector(embedding, k=k, filter=filter)
def _search_vector(
self,
qvec: np.ndarray,
k: int,
filter: dict[str, Any] | Callable[[Document], bool] | None = None,
) -> list[tuple[Document, float]]:
if qvec.ndim == 1:
qvec = qvec[None, :]
if not qvec.flags["C_CONTIGUOUS"]:
qvec = np.ascontiguousarray(qvec)
# IdMapIndex handles the lazy-uncommitted case internally (returns
# empty search results). A len-zero check covers both that and
# the eager-but-empty case.
if len(self._index) == 0:
return []
if filter is None:
search_k = min(k, len(self._index))
scores, handles = self._index.search(qvec, search_k)
else:
predicate = self._compile_filter(filter)
allowed_handles = [
self._str_to_u64[sid]
for sid, (text, meta) in self._docs.items()
if predicate(Document(id=sid, page_content=text, metadata=dict(meta)))
]
if not allowed_handles:
return []
allowlist = np.asarray(allowed_handles, dtype=np.uint64)
scores, handles = self._index.search(qvec, k, allowlist=allowlist)
results: list[tuple[Document, float]] = []
for score, handle in zip(scores[0], handles[0]):
sid = self._u64_to_str[int(handle)]
text, meta = self._docs[sid]
results.append(
(Document(id=sid, page_content=text, metadata=dict(meta)), float(score))
)
return results
@staticmethod
def _compile_filter(
filter: dict[str, Any] | Callable[[Document], bool],
) -> Callable[[Document], bool]:
# Match the in-tree InMemoryVectorStore convention: callable filters
# receive a Document, not a metadata dict
# (langchain_core/vectorstores/in_memory.py).
if callable(filter):
return filter
if isinstance(filter, dict):
items = list(filter.items())
return lambda doc: all(doc.metadata.get(k) == v for k, v in items)
raise TypeError(
"filter must be a dict of metadata key/value pairs or a callable "
f"taking a Document, got {type(filter).__name__}"
)
# ---- Max marginal relevance ---------------------------------------
#
# MMR requires the full-precision vector of every candidate to compute
# pairwise diversity scores. turbovec discards full vectors after
# quantization (that's the point), so we can't faithfully implement
# MMR. Raise loudly with a useful message rather than silently fall
# back to the base class's bare NotImplementedError.
_MMR_MSG = (
"TurboQuantVectorStore does not support max-marginal-relevance "
"search because the underlying quantized index discards "
"full-precision vectors after compression. MMR requires the "
"original embedding for every candidate to compute pairwise "
"diversity. Use `similarity_search` / `similarity_search_with_score` "
"instead, or maintain a parallel store with full-precision "
"embeddings if you need MMR specifically."
)
def max_marginal_relevance_search(
self,
query: str,
k: int = 4,
fetch_k: int = 20,
lambda_mult: float = 0.5,
**kwargs: Any,
) -> list[Document]:
raise NotImplementedError(self._MMR_MSG)
def max_marginal_relevance_search_by_vector(
self,
embedding: list[float],
k: int = 4,
fetch_k: int = 20,
lambda_mult: float = 0.5,
*,
filter: Callable[[Document], bool] | None = None,
**kwargs: Any,
) -> list[Document]:
raise NotImplementedError(self._MMR_MSG)
async def amax_marginal_relevance_search(
self,
query: str,
k: int = 4,
fetch_k: int = 20,
lambda_mult: float = 0.5,
**kwargs: Any,
) -> list[Document]:
raise NotImplementedError(self._MMR_MSG)
# ---- Get / delete -------------------------------------------------
def get_by_ids(self, ids: Sequence[str], /) -> list[Document]:
"""Return Documents for the given ids. Missing ids are silently skipped
(matches the InMemoryVectorStore reference)."""
out: list[Document] = []
for sid in ids:
if sid in self._docs:
text, meta = self._docs[sid]
out.append(Document(id=sid, page_content=text, metadata=dict(meta)))
return out
async def aget_by_ids(self, ids: Sequence[str], /) -> list[Document]:
return self.get_by_ids(ids)
def delete(self, ids: list[str] | None = None, **_: Any) -> None:
"""Remove documents by id. Missing ids are silently skipped — matches
the InMemoryVectorStore reference (which also accepts ``ids=None``
as a no-op)."""
if not ids:
return
for sid in ids:
handle = self._str_to_u64.pop(sid, None)
if handle is None:
continue
self._u64_to_str.pop(handle, None)
self._docs.pop(sid, None)
self._index.remove(handle)
async def adelete(self, ids: list[str] | None = None, **_: Any) -> None:
self.delete(ids)
# ---- Construction helpers -----------------------------------------
@classmethod
def from_texts(
cls,
texts: list[str],
embedding: Embeddings,
metadatas: list[dict] | None = None,
*,
bit_width: int = 4,
ids: list[str] | None = None,
**_: Any,
) -> "TurboQuantVectorStore":
# The underlying index is created lazily on the first `add_texts`
# call, picking up `dim` from the first batch of embeddings — same
# no-`dim` ergonomics as InMemoryVectorStore.
store = cls(embedding=embedding, bit_width=bit_width)
if texts:
store.add_texts(texts, metadatas=metadatas, ids=ids)
return store
@classmethod
async def afrom_texts(
cls,
texts: list[str],
embedding: Embeddings,
metadatas: list[dict] | None = None,
*,
bit_width: int = 4,
ids: list[str] | None = None,
**_: Any,
) -> "TurboQuantVectorStore":
store = cls(embedding=embedding, bit_width=bit_width)
if texts:
await store.aadd_texts(texts, metadatas=metadatas, ids=ids)
return store
# ---- Persistence --------------------------------------------------
#
# Method names match the InMemoryVectorStore reference (`dump`/`load`),
# but the on-disk layout is a folder containing the binary index file
# plus a JSON side-car (we can't embed the binary Rust index in a
# single JSON file the way the reference does with its raw-vector
# store).
def dump(self, folder_path: str | Path) -> None:
"""Persist the quantized index plus the side-car to disk.
``folder_path`` is a directory; turbovec writes ``index.tvim``
and ``docstore.json`` inside it. Document metadata must be
JSON-serializable (same constraint as ``InMemoryVectorStore``).
A lazy uncommitted index encodes its state via the index file's
own ``dim = 0`` sentinel; no special-case handling needed here.
"""
folder = Path(folder_path)
folder.mkdir(parents=True, exist_ok=True)
self._index.write(str(folder / _INDEX_FILENAME))
# `_docs` stores tuples `(text, metadata)` — JSON would drop the
# tuple-ness on round-trip, so serialize each entry as an explicit
# `{"text": ..., "metadata": ...}` dict.
docs_payload = {
sid: {"text": text, "metadata": meta}
for sid, (text, meta) in self._docs.items()
}
payload = {
"schema_version": _DOCSTORE_SCHEMA_VERSION,
"docs": docs_payload,
"str_to_u64": self._str_to_u64,
"next_u64": self._next_u64,
# Pull bit_width off the live index — same value whether
# the index was constructed eagerly or lazily.
"bit_width": self._index.bit_width,
}
with open(folder / _STORE_FILENAME, "w") as f:
json.dump(payload, f)
@classmethod
def load(
cls,
folder_path: str | Path,
embedding: Embeddings,
) -> "TurboQuantVectorStore":
"""Reload a store from a folder previously written by :meth:`dump`.
Safe to call on any path — the side-car is plain JSON, never
pickle, so there's no deserialization-of-code risk."""
folder = Path(folder_path)
with open(folder / _STORE_FILENAME) as f:
state = json.load(f)
version = state.get("schema_version", 0)
if version != _DOCSTORE_SCHEMA_VERSION:
raise ValueError(
f"docstore.json has schema version {version}; "
f"this turbovec expects version {_DOCSTORE_SCHEMA_VERSION}"
)
# IdMapIndex.load handles the dim=0 (lazy-uncommitted) sentinel
# internally and reconstructs the index in the right state.
index = IdMapIndex.load(str(folder / _INDEX_FILENAME))
# Rehydrate `_docs` from the explicit `{"text", "metadata"}` form
# back into the internal tuple representation.
docs = {
sid: (entry["text"], entry["metadata"])
for sid, entry in state["docs"].items()
}
# JSON object keys are strings; the str_to_u64 values are already
# ints in the payload, just need to confirm.
str_to_u64 = {sid: int(h) for sid, h in state["str_to_u64"].items()}
check_persisted_handles(index, str_to_u64.values(), what="document")
return cls(
embedding=embedding,
index=index,
bit_width=state.get("bit_width", 4),
docs=docs,
str_to_u64=str_to_u64,
next_u64=int(state["next_u64"]),
)
__all__ = ["TurboQuantVectorStore"]
@@ -0,0 +1,678 @@
"""LlamaIndex VectorStore backed by turbovec's quantized index.
Install with: ``pip install turbovec[llama-index]``.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Any, List, Optional, Sequence
import numpy as np
from ._dedup import DuplicatePolicy, resolve_duplicates
from ._persist import check_persisted_handles
from ._turbovec import IdMapIndex
try:
from llama_index.core.bridge.pydantic import PrivateAttr
from llama_index.core.schema import (
BaseNode,
NodeRelationship,
RelatedNodeInfo,
TextNode,
)
from llama_index.core.vector_stores.types import (
BasePydanticVectorStore,
FilterCondition,
FilterOperator,
MetadataFilter,
MetadataFilters,
VectorStoreQuery,
VectorStoreQueryMode,
VectorStoreQueryResult,
)
from llama_index.core.vector_stores.utils import (
metadata_dict_to_node,
node_to_metadata_dict,
)
except ImportError as exc:
raise ImportError(
"llama-index-core is required to use turbovec.llama_index. "
"Install with: pip install turbovec[llama-index]"
) from exc
# Persistence layout: a single user-facing ``persist_path`` (the file the
# framework asks us to write) is split into two files by extension —
# ``{base}.tvim`` for the binary IdMapIndex and ``{base}.nodes.json`` for
# the node side-car. Both live next to each other so the layout fits the
# directory-of-namespaced-files pattern used by StorageContext.
_INDEX_EXT = ".tvim"
_STORE_EXT = ".nodes.json"
# Filename template used by SimpleVectorStore for namespace lookup —
# mirrored here so `from_persist_dir` works the same way.
_NAMESPACE_SEP = "__"
_DEFAULT_PERSIST_FNAME = "vector_store.json"
_DEFAULT_VECTOR_STORE = "default"
# Bump when the nodes.json shape changes; loader accepts the current
# version plus any older versions whose missing fields we know how to
# reconstruct (currently v1, written before full-node round-trip was
# added — v1 entries are reconstructed as bare TextNodes with only
# text + metadata + SOURCE relationship, matching the original
# lossy behaviour rather than failing to load).
_NODES_SCHEMA_VERSION = 2
_NODES_SCHEMA_COMPAT = (1, 2)
def _split_persist_base(persist_path: str | Path) -> Path:
"""Strip the framework-provided extension off `persist_path` so the
binary index and JSON side-car can sit next to each other under a
shared base. We then append our own extensions in persist / load."""
p = Path(persist_path)
# Use the path without its suffix so both .tvim and .nodes.json share
# a base. If the input has no suffix (e.g. a bare folder-like name),
# use it as-is.
return p.with_suffix("") if p.suffix else p
class TurboQuantVectorStore(BasePydanticVectorStore):
"""LlamaIndex VectorStore backed by a :class:`IdMapIndex`.
Vectors are quantized to 24 bits per dimension. A side-car dictionary
holds node text and metadata keyed by ``node_id``. Supports ``delete``
(by ``ref_doc_id``, removing every node with that ref) and
``delete_nodes`` (by ``node_id``) — both O(1) per node.
"""
stores_text: bool = True
is_embedding_query: bool = True
flat_metadata: bool = False
_index: Any = PrivateAttr()
_nodes: dict[str, dict[str, Any]] = PrivateAttr()
_node_id_to_u64: dict[str, int] = PrivateAttr()
_u64_to_node_id: dict[int, str] = PrivateAttr()
_next_u64: int = PrivateAttr()
def __init__(self, index: IdMapIndex | None = None, *, bit_width: int = 4, **kwargs: Any) -> None:
"""Construct the vector store.
:param index: Optional pre-built :class:`IdMapIndex`. When omitted,
a lazy ``IdMapIndex`` is created — it commits to a dim on the
first add and lets callers use the no-arg construction pattern
common to LlamaIndex's other vector stores (e.g. via
``StorageContext.from_defaults(vector_store=TurboQuantVectorStore())``).
:param bit_width: Quantization width used when constructing the
lazy index. Ignored if ``index`` is supplied.
"""
super().__init__(**kwargs)
# IdMapIndex itself supports lazy construction now — no per-store
# lazy wrapping needed.
self._index = index if index is not None else IdMapIndex(bit_width=bit_width)
self._nodes = {}
self._node_id_to_u64 = {}
self._u64_to_node_id = {}
self._next_u64 = 0
def _issue_handle(self) -> int:
self._next_u64 += 1
return self._next_u64
@classmethod
def class_name(cls) -> str:
return "TurboQuantVectorStore"
@classmethod
def from_params(cls, dim: int | None = None, bit_width: int = 4) -> "TurboQuantVectorStore":
"""Build a store with a known ``dim`` (eager) or lazy when ``dim``
is omitted."""
return cls(index=IdMapIndex(dim, bit_width))
@property
def client(self) -> IdMapIndex:
return self._index
def add(self, nodes: list[BaseNode], **_: Any) -> list[str]:
if not nodes:
return []
# Reject intra-batch duplicates loudly. Letting them through would
# leave the index with N vectors but only the last node_id mapped
# back to one of them — the earlier handles become orphans that
# `query` later resolves through the duplicate node_id, returning
# the second node's payload attached to the first node's vector.
# Caller's job to deduplicate before calling add.
node_ids = [n.node_id for n in nodes]
try:
resolve_duplicates(node_ids, DuplicatePolicy.REJECT)
except ValueError:
seen: set[str] = set()
dup = next(nid for nid in node_ids if nid in seen or seen.add(nid))
raise ValueError(
f"duplicate node_id {dup!r} appears multiple times "
"in the input batch; deduplicate before calling add()"
) from None
embeddings = [node.get_embedding() for node in nodes]
vectors = np.asarray(embeddings, dtype=np.float32)
if vectors.ndim != 2:
raise ValueError(
f"expected 2D embedding batch, got {vectors.ndim}D"
)
# IdMapIndex.add_with_ids handles eager (dim must match) and lazy
# (locks dim on first add) — pre-check the eager case so we
# surface a clean ValueError rather than a Rust panic.
existing_dim = self._index.dim
if existing_dim is not None and vectors.shape[1] != existing_dim:
raise ValueError(
f"node embedding dim {vectors.shape[1]} does not match index dim {existing_dim}"
)
if not vectors.flags["C_CONTIGUOUS"]:
vectors = np.ascontiguousarray(vectors)
handles = np.array([self._issue_handle() for _ in nodes], dtype=np.uint64)
# Add first; if validation above or encoding here (e.g. non-finite
# values) rejects the batch, it raises before any existing data is
# touched. Only after the add succeeds do we remove the old entries
# for colliding node_ids, so a failed upsert never destroys existing
# data (issue #89). Handles are freshly issued, so the old and new
# vectors coexist until the delete.
self._index.add_with_ids(vectors, handles)
# Upsert-like: if a node_id is already present in the STORE, remove
# the old entry so the new embedding wins.
duplicates = [n.node_id for n in nodes if n.node_id in self._node_id_to_u64]
for node_id in duplicates:
self._remove_node_by_id(node_id)
ids: list[str] = []
for node, handle in zip(nodes, handles):
h = int(handle)
nid = node.node_id
self._node_id_to_u64[nid] = h
self._u64_to_node_id[h] = nid
# `metadata` and `ref_doc_id` are kept at top level for fast
# filter / doc-id lookup (queries hit these on every hit;
# parsing _node_content per hit would be wasteful). `node_dict`
# is the framework's canonical metadata representation
# (`_node_content` + `_node_type` + original metadata keys),
# which `metadata_dict_to_node` reconstructs into a full
# BaseNode — preserving relationships (PREVIOUS / NEXT /
# PARENT / CHILD), excluded_*_metadata_keys, template fields,
# start/end_char_idx and mimetype on retrieval. The narrow
# `{text, metadata, ref_doc_id}` schema we used to keep lost
# all of those silently.
self._nodes[nid] = {
"metadata": dict(node.metadata),
"ref_doc_id": node.ref_doc_id,
"node_dict": node_to_metadata_dict(
node, remove_text=False, flat_metadata=False
),
}
ids.append(nid)
return ids
def delete(self, ref_doc_id: str, **_: Any) -> None:
"""Delete every node whose ``ref_doc_id`` matches."""
matching = [
nid for nid, data in self._nodes.items() if data.get("ref_doc_id") == ref_doc_id
]
for nid in matching:
self._remove_node_by_id(nid)
def delete_nodes(
self,
node_ids: Optional[List[str]] = None,
filters: Optional[MetadataFilters] = None,
**_: Any,
) -> None:
"""Delete every node matching ``node_ids`` and/or ``filters``. Both
constraints intersect when supplied. Missing node_ids are ignored.
Matches the signature and semantics of ``SimpleVectorStore.delete_nodes``.
"""
if not node_ids and filters is None:
return
candidates = list(self._nodes.items())
if node_ids is not None:
node_id_set = set(node_ids)
candidates = [(nid, data) for nid, data in candidates if nid in node_id_set]
if filters is not None:
candidates = [
(nid, data)
for nid, data in candidates
if self._filters_match(data["metadata"], filters)
]
for nid, _data in candidates:
self._remove_node_by_id(nid)
def clear(self) -> None:
"""Drop every node from the store and reset to a fresh lazy index.
The new index keeps the same ``bit_width`` so subsequent adds
commit a new ``dim`` lazily.
"""
bw = self._index.bit_width
self._index = IdMapIndex(bit_width=bw)
self._nodes = {}
self._node_id_to_u64 = {}
self._u64_to_node_id = {}
self._next_u64 = 0
def get(self, text_id: str) -> List[float]:
"""LlamaIndex's protocol expects this to return the full-precision
embedding for a given node id. turbovec discards full-precision
embeddings after quantization, so we raise loudly with an
explanation rather than return a lossy reconstruction or zeroes.
"""
raise NotImplementedError(
"TurboQuantVectorStore.get(text_id) cannot return the original "
"embedding because turbovec quantizes vectors to 2-4 bits per "
"dimension and discards full precision after encoding. Keep a "
"parallel docstore if you need the raw embedding."
)
def get_nodes(
self,
node_ids: Optional[List[str]] = None,
filters: Optional[MetadataFilters] = None,
) -> List[BaseNode]:
"""Return the nodes matching ``node_ids`` and/or ``filters``. Both
constraints intersect when supplied; missing node_ids are
silently skipped.
Unlike ``SimpleVectorStore`` (which raises NotImplementedError
here because it doesn't store nodes), turbovec keeps node text
and metadata in a side-car so this can return populated
``TextNode`` objects directly.
"""
candidates = list(self._nodes.items())
if node_ids is not None:
node_id_set = set(node_ids)
candidates = [(nid, data) for nid, data in candidates if nid in node_id_set]
if filters is not None:
candidates = [
(nid, data)
for nid, data in candidates
if self._filters_match(data["metadata"], filters)
]
return [self._reconstruct_node(nid, data) for nid, data in candidates]
@staticmethod
def _reconstruct_node(nid: str, data: dict[str, Any]) -> BaseNode:
# v2 entries carry `node_dict` — round-trip via the framework's
# own helper so we get the full BaseNode subclass back
# (TextNode / IndexNode / ImageNode) with every field populated.
if "node_dict" in data:
return metadata_dict_to_node(data["node_dict"])
# v1 fallback: stores that were persisted before the full-node
# round-trip landed only have {text, metadata, ref_doc_id}.
# Reconstruct the minimum-fidelity TextNode they used to produce
# so old on-disk stores keep loading without manual migration.
node = TextNode(
id_=nid,
text=data["text"],
metadata=dict(data["metadata"]),
)
if data.get("ref_doc_id") is not None:
node.relationships[NodeRelationship.SOURCE] = RelatedNodeInfo(
node_id=data["ref_doc_id"]
)
return node
def _remove_node_by_id(self, node_id: str) -> bool:
handle = self._node_id_to_u64.pop(node_id, None)
if handle is None:
return False
self._u64_to_node_id.pop(handle, None)
self._nodes.pop(node_id, None)
self._index.remove(handle)
return True
def _resolve_allowed_handles(
self,
filters: MetadataFilters | None,
node_ids: list[str] | None,
doc_ids: list[str] | None,
) -> list[int]:
"""Resolve ``query.filters``, ``query.node_ids`` and ``query.doc_ids``
to the list of internal u64 handles that satisfy the filter. Empty
list means no node matches.
Semantics (matching the SimpleVectorStore reference where applicable):
- ``node_ids``: filter by node_id (set membership).
- ``doc_ids``: filter by ``ref_doc_id`` only (source document id).
- ``filters``: apply metadata filters.
All three intersect when more than one is supplied.
"""
candidates = self._nodes.items()
if node_ids:
node_id_set = set(node_ids)
candidates = [(nid, data) for nid, data in candidates if nid in node_id_set]
if doc_ids:
doc_id_set = set(doc_ids)
candidates = [
(nid, data)
for nid, data in candidates
if data.get("ref_doc_id") in doc_id_set
]
if filters is None:
return [self._node_id_to_u64[nid] for nid, _ in candidates]
return [
self._node_id_to_u64[nid]
for nid, data in candidates
if self._filters_match(data["metadata"], filters)
]
@classmethod
def _filters_match(
cls, metadata: dict[str, Any], filters: MetadataFilters
) -> bool:
condition = getattr(filters, "condition", None) or FilterCondition.AND
results: list[bool] = []
for f in filters.filters:
if isinstance(f, MetadataFilters):
results.append(cls._filters_match(metadata, f))
else:
results.append(cls._single_filter_match(metadata, f))
if condition == FilterCondition.AND:
return all(results) if results else True
if condition == FilterCondition.OR:
return any(results) if results else True
if condition == FilterCondition.NOT:
# Reference semantics (`build_metadata_filter_fn`,
# `utils.py:187-189`): NOT matches when none of the inner
# filters match. Empty inner list trivially satisfies NOT.
return not any(results)
raise NotImplementedError(
f"filter condition {condition!r} not supported by TurboQuantVectorStore"
)
@staticmethod
def _single_filter_match(metadata: dict[str, Any], f: MetadataFilter) -> bool:
# Semantics mirror SimpleVectorStore's _build_metadata_filter_fn
# (llama_index/core/vector_stores/simple.py) so that filtered
# results agree with the in-tree reference store.
op = f.operator
target = f.value
value = metadata.get(f.key)
# IS_EMPTY is the only operator that treats a missing key as a hit.
if op == FilterOperator.IS_EMPTY:
return value is None or value == "" or value == []
# Every other operator returns False when the key is absent — this
# matches the reference implementation (notably NE returns False on
# missing, not True).
if value is None:
return False
if op == FilterOperator.EQ:
return value == target
if op == FilterOperator.NE:
return value != target
if op == FilterOperator.GT:
return value > target
if op == FilterOperator.LT:
return value < target
if op == FilterOperator.GTE:
return value >= target
if op == FilterOperator.LTE:
return value <= target
if op == FilterOperator.IN:
return value in target
if op == FilterOperator.NIN:
return value not in target
if op == FilterOperator.CONTAINS:
return target in value
if op == FilterOperator.TEXT_MATCH:
# Reference (`utils.py:138-144`): case-SENSITIVE substring,
# both sides must be strings. Previous turbovec impl
# lowercased both sides — a silent semantic divergence that
# caused our results to disagree with SimpleVectorStore on
# mixed-case keys.
if isinstance(target, str) and isinstance(value, str):
return target in value
raise TypeError(
"Both metadata value and filter value must be strings "
"for the TEXT_MATCH operator"
)
if op == FilterOperator.TEXT_MATCH_INSENSITIVE:
if isinstance(target, str) and isinstance(value, str):
return target.lower() in value.lower()
raise TypeError(
"Both metadata value and filter value must be strings "
"for the TEXT_MATCH_INSENSITIVE operator"
)
if op == FilterOperator.ALL:
# Reference (`utils.py:152-153`): every element of `target`
# must be present in the metadata value (which is typically
# a list — tag-set matching).
return all(t in value for t in target)
if op == FilterOperator.ANY:
return any(t in value for t in target)
raise NotImplementedError(
f"filter operator {op!r} not supported by TurboQuantVectorStore"
)
def query(self, query: VectorStoreQuery, **_: Any) -> VectorStoreQueryResult:
# MMR / SVM / LINEAR_REGRESSION / HYBRID etc. all need access to
# full-precision vectors (for pairwise diversity, learned scoring,
# or sparse-dense fusion). turbovec discards full precision after
# quantization, so any non-DEFAULT mode is unsupportable here.
# Raise loudly instead of silently treating it as DEFAULT, which
# the previous impl did and which let callers think they were
# getting e.g. MMR diversity when they were not.
if query.mode != VectorStoreQueryMode.DEFAULT:
raise NotImplementedError(
f"TurboQuantVectorStore does not support query mode "
f"{query.mode!r}. Only VectorStoreQueryMode.DEFAULT is "
"supported — MMR / SVM / hybrid modes need access to "
"full-precision vectors which turbovec discards after "
"quantization. Maintain a parallel store with full vectors "
"if you need a non-default scoring mode."
)
if query.query_embedding is None:
raise ValueError(
"TurboQuantVectorStore requires a pre-computed query_embedding "
"(is_embedding_query=True)."
)
qvec = np.asarray(query.query_embedding, dtype=np.float32)
if qvec.ndim == 1:
qvec = qvec[None, :]
if not qvec.flags["C_CONTIGUOUS"]:
qvec = np.ascontiguousarray(qvec)
if len(self._index) == 0:
return VectorStoreQueryResult(nodes=[], similarities=[], ids=[])
has_filters = (
query.filters is not None
or bool(query.node_ids)
or bool(query.doc_ids)
)
if not has_filters:
k = min(query.similarity_top_k, len(self._index))
scores, handles = self._index.search(qvec, k)
else:
allowed_handles = self._resolve_allowed_handles(
query.filters, query.node_ids, query.doc_ids
)
if not allowed_handles:
return VectorStoreQueryResult(nodes=[], similarities=[], ids=[])
allowlist = np.asarray(allowed_handles, dtype=np.uint64)
scores, handles = self._index.search(
qvec, query.similarity_top_k, allowlist=allowlist
)
result_nodes: list[TextNode] = []
similarities: list[float] = []
ids: list[str] = []
for score, handle in zip(scores[0], handles[0]):
nid = self._u64_to_node_id[int(handle)]
data = self._nodes[nid]
result_nodes.append(self._reconstruct_node(nid, data))
similarities.append(float(score))
ids.append(nid)
return VectorStoreQueryResult(nodes=result_nodes, similarities=similarities, ids=ids)
# ---- Async overrides --------------------------------------------------
#
# The base class provides default async impls that delegate to sync via
# `return self.<sync>(...)`. We override them explicitly so the signature
# is visible on the class and an autodoc tool / IDE doesn't make
# callers chase the abstract base class for the documentation.
async def async_add(
self, nodes: Sequence[BaseNode], **kwargs: Any
) -> List[str]:
return self.add(list(nodes), **kwargs)
async def adelete(self, ref_doc_id: str, **kwargs: Any) -> None:
self.delete(ref_doc_id, **kwargs)
async def adelete_nodes(
self,
node_ids: Optional[List[str]] = None,
filters: Optional[MetadataFilters] = None,
**kwargs: Any,
) -> None:
self.delete_nodes(node_ids=node_ids, filters=filters, **kwargs)
async def aclear(self) -> None:
self.clear()
async def aquery(
self, query: VectorStoreQuery, **kwargs: Any
) -> VectorStoreQueryResult:
return self.query(query, **kwargs)
async def aget_nodes(
self,
node_ids: Optional[List[str]] = None,
filters: Optional[MetadataFilters] = None,
) -> List[BaseNode]:
return self.get_nodes(node_ids=node_ids, filters=filters)
# ---- Config serialization ---------------------------------------------
def to_dict(self, **_: Any) -> dict[str, Any]:
"""Serialize the store's *configuration* (not its data) so a
fresh instance can be reconstructed via ``from_dict``. Mirrors
the contract of ``SimpleVectorStore.to_dict`` — config-only;
node data round-trips through ``persist`` / ``from_persist_path``.
"""
return {
"bit_width": self._index.bit_width,
"dim": self._index.dim, # may be None (lazy uncommitted)
}
@classmethod
def from_dict(cls, data: dict[str, Any], **_: Any) -> "TurboQuantVectorStore":
"""Construct an empty store from a config dict produced by
``to_dict``. To restore data, use ``from_persist_path``."""
dim = data.get("dim")
bit_width = data.get("bit_width", 4)
return cls(index=IdMapIndex(dim, bit_width))
def persist(self, persist_path: str, fs: Any = None) -> None:
"""Persist the store. ``persist_path`` is treated as a path *stem*:
the binary index goes to ``{stem}.tvim`` and the node side-car to
``{stem}.nodes.json``. Any extension on ``persist_path`` (e.g.
``.json`` from a StorageContext default) is replaced.
This matches the layout assumed by ``StorageContext.persist`` —
which calls us with ``persist_path = {persist_dir}/{namespace}__vector_store.json`` —
and lets multiple namespaced stores coexist in the same directory.
Node metadata must be JSON-serializable (same constraint as
``SimpleVectorStore``). ``fs`` (fsspec) is not yet supported;
pass a local path.
"""
if fs is not None:
raise NotImplementedError(
"fsspec filesystems are not supported yet; pass a local path."
)
base = _split_persist_base(persist_path)
base.parent.mkdir(parents=True, exist_ok=True)
self._index.write(str(base.with_suffix(_INDEX_EXT)))
payload = {
"schema_version": _NODES_SCHEMA_VERSION,
"nodes": self._nodes,
# JSON object keys must be strings; round-trip int keys via
# an explicit list of [node_id, handle] pairs to preserve
# type fidelity.
"node_id_to_u64": list(self._node_id_to_u64.items()),
"next_u64": self._next_u64,
}
with open(base.with_suffix(_STORE_EXT), "w") as f:
json.dump(payload, f)
@classmethod
def from_persist_path(
cls,
persist_path: str,
fs: Any = None,
) -> "TurboQuantVectorStore":
"""Load a previously-persisted store. ``persist_path`` is the same
path that was passed to :meth:`persist` (extension is ignored;
``{stem}.tvim`` and ``{stem}.nodes.json`` are read).
Safe to call on any path — the side-car is plain JSON, never
pickle, so there's no deserialization-of-code risk.
"""
if fs is not None:
raise NotImplementedError(
"fsspec filesystems are not supported yet; pass a local path."
)
base = _split_persist_base(persist_path)
index = IdMapIndex.load(str(base.with_suffix(_INDEX_EXT)))
with open(base.with_suffix(_STORE_EXT)) as f:
state = json.load(f)
version = state.get("schema_version", 0)
if version not in _NODES_SCHEMA_COMPAT:
raise ValueError(
f"{_STORE_EXT.lstrip('.')} has schema version {version}; "
f"this turbovec accepts versions {list(_NODES_SCHEMA_COMPAT)}"
)
store = cls(index=index)
# v1 entries lack `node_dict` and reconstruct as narrow TextNodes;
# v2 entries carry it and reconstruct with full BaseNode fidelity.
# `_reconstruct_node` dispatches on shape, so we just load the
# dict as-is.
store._nodes = state["nodes"]
# Reconstruct {node_id: int handle} from the list-of-pairs form.
store._node_id_to_u64 = {nid: int(h) for nid, h in state["node_id_to_u64"]}
store._u64_to_node_id = {h: nid for nid, h in store._node_id_to_u64.items()}
store._next_u64 = int(state["next_u64"])
check_persisted_handles(index, store._u64_to_node_id.keys(), what="node")
return store
@classmethod
def from_persist_dir(
cls,
persist_dir: str,
namespace: str = _DEFAULT_VECTOR_STORE,
fs: Any = None,
) -> "TurboQuantVectorStore":
"""Load a store from a ``StorageContext``-style persist directory.
Builds the namespaced filename
``{persist_dir}/{namespace}__vector_store.json`` and forwards to
:meth:`from_persist_path`. The ``.json`` suffix is conventional —
our actual on-disk files use ``.tvim`` and ``.nodes.json``
extensions derived from the same stem.
"""
persist_fname = f"{namespace}{_NAMESPACE_SEP}{_DEFAULT_PERSIST_FNAME}"
persist_path = os.path.join(persist_dir, persist_fname)
return cls.from_persist_path(persist_path, fs=fs)
__all__ = ["TurboQuantVectorStore"]