Files
wehub-resource-sync 0d29a02500
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
chore: import upstream snapshot with attribution
2026-07-13 12:23:17 +08:00

789 lines
32 KiB
Python

"""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"]