ReMe/tests/unit/test_zvec_file_store.py
lichen2015 ad4f23e4dc
feat(file_store): add ZvecLocalFileStore backend (#410)
* feat(file_store): add ZvecLocalFileStore backend

- Implement ZvecLocalFileStore with native zvec collection for ANN search.
- Keep JSONL chunks as the source of truth; rebuild collection from chunks
  when sidecar digest/dimension/HNSW M mismatch is detected.
- Add dedicated unit tests in tests/unit/test_zvec_file_store.py.
- Parametrize existing file_store consistency tests to cover both
  LocalFileStore and ZvecLocalFileStore.
- Register the new backend in reme/components/file_store/__init__.py.

* fix(file_store): fix zvec collection sync and content validation, declare zvec dependency
2026-08-05 22:08:30 +08:00

614 lines
22 KiB
Python

"""Tests for ZvecLocalFileStore: native upsert/delete sync, persistence, and rebuild triggers."""
# pylint: disable=protected-access
import asyncio
import json
import os
import tempfile
import tomllib
import warnings
from pathlib import Path
import numpy as np
import pytest
from reme.components.file_store import ZvecLocalFileStore
from reme.schema import FileChunk, FileNode
class temp_chdir:
"""Temporarily chdir into a test workspace."""
def __init__(self, path):
self.path = path
self.old = None
def __enter__(self):
self.old = os.getcwd()
os.chdir(self.path)
return self
def __exit__(self, *exc):
os.chdir(self.old)
class FakeEmbeddingStore:
"""Small deterministic embedding provider used by file-store tests."""
dimensions = 2
max_batch_size = 10
def _embed(self, text: str) -> np.ndarray:
if "beta" in text or "fresh" in text:
return np.array([0.0, 1.0], dtype=np.float16)
return np.array([1.0, 0.0], dtype=np.float16)
async def health_check(self, _timeout: float = 2.0) -> bool:
"""Report the fake embedding service as healthy."""
return True
async def get_embedding(self, input_text: str, **_kwargs) -> np.ndarray:
"""Return a deterministic embedding for a single text."""
return self._embed(input_text)
async def get_node_embeddings(self, nodes: list[FileChunk], **_kwargs) -> list[FileChunk]:
"""Attach deterministic embeddings to file chunks."""
for chunk_node in nodes:
chunk_node.embedding = self._embed(chunk_node.text)
return nodes
def run(coro):
"""Run an async test body."""
return asyncio.run(coro)
def node(path: str) -> FileNode:
"""Build a minimal file node."""
return FileNode(path=path, st_mtime=1.0)
def chunk(chunk_id: str, path: str, text: str, **metadata) -> FileChunk:
"""Build a minimal file chunk."""
return FileChunk(id=chunk_id, path=path, text=text, start_line=1, end_line=1, metadata=metadata)
def embedded_chunk(chunk_id: str, path: str, text: str, embedding: list[float]) -> FileChunk:
"""Build a chunk carrying an explicit, caller-provided embedding."""
file_chunk = chunk(chunk_id, path, text)
file_chunk.embedding = np.asarray(embedding, dtype=np.float16)
return file_chunk
def _track_rebuilds(store: ZvecLocalFileStore) -> list[bool]:
"""Record calls to the collection rebuild path."""
rebuilds: list[bool] = []
original_rebuild = store._rebuild_collection
store._rebuild_collection = lambda: rebuilds.append(True) or original_rebuild()
return rebuilds
def _tamper_with_collection(store: ZvecLocalFileStore, mutate) -> None:
"""Mutate the persisted collection behind the store's back, then release the handle.
zvec holds an in-process lock on the collection directory, so the store must
already be closed and the local handle must be dropped before another store
reopens the same path.
"""
zvec = pytest.importorskip("zvec")
collection = zvec.open(path=str(store.zvec_path))
mutate(zvec, collection)
collection.flush()
del collection
def _new_zvec_store(name, **kwargs):
"""Construct a zvec store with embedding disabled at bind time."""
try:
store = ZvecLocalFileStore(name=name, embedding_store="", **kwargs)
except ImportError:
pytest.skip("zvec is not installed")
return store
async def _started_store(name, **kwargs) -> ZvecLocalFileStore:
"""Start a store with a fake embedding provider and a live collection."""
store = _new_zvec_store(name, **kwargs)
store.embedding_store = FakeEmbeddingStore()
await store.start()
if store._collection is None:
store._collection = store._create_collection()
return store
async def _seed_unembedded_chunk(store: ZvecLocalFileStore, chunk_id: str, path: str, text: str) -> None:
"""Attach one chunk without a vector, keeping the graph invariant intact."""
store.file_chunks[chunk_id] = chunk(chunk_id, path, text)
file_node = node(path)
file_node.chunk_ids = [chunk_id]
await store.file_graph.upsert_nodes([file_node])
# -- CRUD / search ------------------------------------------------------------
def test_zvec_upsert_and_vector_search_ranks_by_similarity():
"""Upserted chunks are searchable; results are ranked by cosine similarity."""
async def go():
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
store = await _started_store("t_zvec_basic")
await store.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
await store.upsert([(node("b.md"), [chunk("b", "b.md", "beta text")])])
results = await store.vector_search("alpha", 10, {})
assert [c.id for c in results] == ["a", "b"]
# Identical vector -> cosine distance 0 -> similarity score 1.
assert results[0].scores["vector"] == pytest.approx(1.0, abs=1e-5)
assert results[0].scores["score"] == results[0].scores["vector"]
await store.close()
run(go())
def test_zvec_same_id_text_change_updates_vector_in_place():
"""A same-id text change replaces the vector via native zvec upsert."""
async def go():
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
store = await _started_store("t_zvec_update")
await store.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
assert [c.id for c in await store.vector_search("alpha", 5, {})] == ["a"]
await store.upsert([(node("a.md"), [chunk("a", "a.md", "beta text")])])
assert store._indexed_ids == {"a"}
results = await store.vector_search("beta", 5, {})
assert [c.id for c in results] == ["a"]
assert results[0].scores["vector"] == pytest.approx(1.0, abs=1e-5)
await store.close()
run(go())
def test_zvec_explicit_embedding_change_updates_collection():
"""A same-id, same-text update with a new explicit embedding replaces the vector."""
async def go():
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
store = await _started_store("t_zvec_explicit_embedding")
await store.upsert([(node("a.md"), [embedded_chunk("same", "a.md", "same text", [1.0, 0.0])])])
await store.upsert([(node("a.md"), [embedded_chunk("same", "a.md", "same text", [0.0, 1.0])])])
# "beta" embeds to [0, 1]; the collection must hold the new vector.
results = await store.vector_search("beta", 5, {})
assert [c.id for c in results] == ["same"]
assert results[0].scores["vector"] == pytest.approx(1.0, abs=1e-5)
await store.close()
# The stale vector must not survive a restart either.
reopened = _new_zvec_store("t_zvec_explicit_embedding")
reopened.embedding_store = FakeEmbeddingStore()
rebuilds = _track_rebuilds(reopened)
await reopened.start()
assert not rebuilds # the collection was already in sync, no repair needed
results = await reopened.vector_search("beta", 5, {})
assert [c.id for c in results] == ["same"]
assert results[0].scores["vector"] == pytest.approx(1.0, abs=1e-5)
await reopened.close()
run(go())
def test_zvec_upsert_removes_stale_chunks_from_collection():
"""Re-upserting a path deletes vectors of chunks that no longer exist."""
async def go():
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
store = await _started_store("t_zvec_stale")
await store.upsert([(node("a.md"), [chunk("old", "a.md", "alpha old")])])
await store.upsert([(node("a.md"), [chunk("new", "a.md", "alpha new")])])
assert store._indexed_ids == {"new"}
assert [c.id for c in await store.vector_search("alpha", 10, {})] == ["new"]
await store.close()
run(go())
def test_zvec_delete_removes_vectors():
"""Deleting a path removes its chunk vectors from the collection."""
async def go():
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
store = await _started_store("t_zvec_delete")
await store.upsert(
[
(node("a.md"), [chunk("a", "a.md", "alpha text")]),
(node("b.md"), [chunk("b", "b.md", "beta text")]),
],
)
await store.delete("a.md")
assert store._indexed_ids == {"b"}
assert [c.id for c in await store.vector_search("alpha", 10, {})] == ["b"]
await store.close()
run(go())
def test_zvec_vector_search_applies_post_filter():
"""The shared search_filter semantics apply on top of ANN results."""
async def go():
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
store = await _started_store("t_zvec_filter")
await store.upsert(
[
(node("a.md"), [chunk("a", "a.md", "alpha one", kind="x")]),
(node("b.md"), [chunk("b", "b.md", "alpha two", kind="y")]),
],
)
assert [c.id for c in await store.vector_search("alpha", 10, {"path": "b.md"})] == ["b"]
assert [c.id for c in await store.vector_search("alpha", 10, {"kind": "x"})] == ["a"]
assert await store.vector_search("alpha", 10, {"path": "c.md"}) == []
await store.close()
run(go())
def test_zvec_vector_search_uses_current_query_api():
"""The ANN query path must not rely on zvec's deprecated VectorQuery alias."""
async def go():
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
store = await _started_store("t_zvec_query_api")
await store.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
with warnings.catch_warnings():
warnings.simplefilter("error", DeprecationWarning)
assert [c.id for c in await store.vector_search("alpha", 5, {})] == ["a"]
await store.close()
run(go())
def test_zvec_clear_resets_collection():
"""clear() drops the collection, sidecar, and indexed-id state."""
async def go():
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
store = await _started_store("t_zvec_clear")
await store.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
await store.dump()
assert store.zvec_sidecar_path.exists()
await store.clear()
assert store._indexed_ids == set()
assert not store.zvec_sidecar_path.exists()
assert await store.vector_search("alpha", 10, {}) == []
# The store stays usable after clear.
await store.upsert([(node("b.md"), [chunk("b", "b.md", "beta text")])])
assert [c.id for c in await store.vector_search("beta", 10, {})] == ["b"]
await store.close()
run(go())
def test_zvec_keyword_only_mode_keeps_keyword_search_working():
"""Without an embedding store, vector search is empty but keyword search works."""
async def go():
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
store = _new_zvec_store("t_zvec_keyword_only")
await store.start()
await store.upsert([(node("a.md"), [chunk("a", "a.md", "uniquezvecword only")])])
assert store._collection is None
assert await store.vector_search("uniquezvecword", 5, {}) == []
assert [c.id for c in await store.keyword_search("uniquezvecword", 5, {})] == ["a"]
await store.close()
run(go())
# -- embedding backfill ---------------------------------------------------------
def test_zvec_backfill_adds_incrementally():
"""Backfilled vectors are upserted into the live collection without a rebuild."""
async def go():
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
store = await _started_store("t_zvec_backfill")
await store.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
await _seed_unembedded_chunk(store, "c", "c.md", "alpha extra")
rebuilds = []
original_rebuild = store._rebuild_collection
store._rebuild_collection = lambda: rebuilds.append(True) or original_rebuild()
await store._backfill_missing_embeddings()
assert not rebuilds
assert store._indexed_ids == {"a", "c"}
assert {c.id for c in await store.vector_search("alpha", 10, {})} == {"a", "c"}
await store.close()
run(go())
# -- persistence ----------------------------------------------------------------
def test_zvec_persistence_round_trip_reopens_without_rebuild():
"""dump() + a fresh store reattaches the persisted collection via the sidecar."""
async def go():
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
seed = await _started_store("t_zvec_persist")
await seed.upsert(
[
(node("a.md"), [chunk("a", "a.md", "alpha text")]),
(node("b.md"), [chunk("b", "b.md", "beta text")]),
],
)
await seed.close()
store = _new_zvec_store("t_zvec_persist")
store.embedding_store = FakeEmbeddingStore()
rebuilds = []
original_rebuild = store._rebuild_collection
store._rebuild_collection = lambda: rebuilds.append(True) or original_rebuild()
await store.start()
assert not rebuilds
assert store._indexed_ids == {"a", "b"}
assert [c.id for c in await store.vector_search("alpha", 5, {})][0] == "a"
assert [c.id for c in await store.vector_search("beta", 5, {})][0] == "b"
await store.close()
run(go())
def test_zvec_digest_mismatch_triggers_rebuild():
"""A sidecar whose digest fell behind the chunk JSONL forces a clean rebuild."""
async def go():
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
seed = await _started_store("t_zvec_digest")
await seed.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
await seed.close()
sidecar = json.loads(seed.zvec_sidecar_path.read_text())
sidecar["digest"] = "0" * 64
seed.zvec_sidecar_path.write_text(json.dumps(sidecar))
store = _new_zvec_store("t_zvec_digest")
store.embedding_store = FakeEmbeddingStore()
rebuilds = []
original_rebuild = store._rebuild_collection
store._rebuild_collection = lambda: rebuilds.append(True) or original_rebuild()
await store.start()
assert rebuilds == [True]
assert store._indexed_ids == {"a"}
assert [c.id for c in await store.vector_search("alpha", 5, {})] == ["a"]
await store.close()
run(go())
def test_zvec_missing_sidecar_triggers_rebuild():
"""A collection directory without its sidecar cannot be trusted and is rebuilt."""
async def go():
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
seed = await _started_store("t_zvec_no_sidecar")
await seed.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
await seed.close()
seed.zvec_sidecar_path.unlink()
store = _new_zvec_store("t_zvec_no_sidecar")
store.embedding_store = FakeEmbeddingStore()
await store.start()
assert store._indexed_ids == {"a"}
assert [c.id for c in await store.vector_search("alpha", 5, {})] == ["a"]
await store.close()
run(go())
def test_zvec_hnsw_m_mismatch_triggers_rebuild():
"""A persisted collection built with a different HNSW M is rebuilt."""
async def go():
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
seed = await _started_store("t_zvec_m_mismatch", hnsw_m=16)
await seed.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
await seed.close()
store = _new_zvec_store("t_zvec_m_mismatch", hnsw_m=48)
store.embedding_store = FakeEmbeddingStore()
rebuilds = []
original_rebuild = store._rebuild_collection
store._rebuild_collection = lambda: rebuilds.append(True) or original_rebuild()
await store.start()
assert rebuilds == [True]
assert [c.id for c in await store.vector_search("alpha", 5, {})] == ["a"]
await store.close()
run(go())
def test_zvec_missing_collection_document_triggers_rebuild():
"""A collection that lost a document is rebuilt even though the sidecar matches."""
async def go():
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
seed = await _started_store("t_zvec_missing_doc")
await seed.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
await seed.close()
_tamper_with_collection(seed, lambda _zvec, collection: collection.delete(ids=["a"]))
# The sidecar still claims the chunk is indexed.
assert json.loads(seed.zvec_sidecar_path.read_text())["ids"] == ["a"]
store = _new_zvec_store("t_zvec_missing_doc")
store.embedding_store = FakeEmbeddingStore()
rebuilds = _track_rebuilds(store)
await store.start()
assert rebuilds == [True]
assert store._indexed_ids == {"a"}
assert store._collection.stats.doc_count == 1
assert [c.id for c in await store.vector_search("alpha", 5, {})] == ["a"]
await store.close()
run(go())
def test_zvec_unexpected_collection_document_triggers_rebuild():
"""A collection holding a document no chunk owns is rebuilt."""
async def go():
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
seed = await _started_store("t_zvec_extra_doc")
await seed.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
await seed.close()
_tamper_with_collection(
seed,
lambda zvec, collection: collection.upsert(
[zvec.Doc(id="ghost", vectors={"embedding": [0.0, 1.0]})],
),
)
store = _new_zvec_store("t_zvec_extra_doc")
store.embedding_store = FakeEmbeddingStore()
rebuilds = _track_rebuilds(store)
await store.start()
assert rebuilds == [True]
assert store._indexed_ids == {"a"}
assert store._collection.stats.doc_count == 1
assert [c.id for c in await store.vector_search("beta", 5, {})] == ["a"]
await store.close()
run(go())
def test_zvec_corrupted_collection_vector_triggers_rebuild():
"""An expected id whose stored vector was replaced is rebuilt from the chunks."""
async def go():
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
seed = await _started_store("t_zvec_bad_vector")
await seed.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
await seed.close()
_tamper_with_collection(
seed,
lambda zvec, collection: collection.upsert(
[zvec.Doc(id="a", vectors={"embedding": [0.0, 1.0]})],
),
)
store = _new_zvec_store("t_zvec_bad_vector")
store.embedding_store = FakeEmbeddingStore()
rebuilds = _track_rebuilds(store)
await store.start()
assert rebuilds == [True]
# The rebuilt collection carries the vector of the authoritative chunk.
results = await store.vector_search("alpha", 5, {})
assert [c.id for c in results] == ["a"]
assert results[0].scores["vector"] == pytest.approx(1.0, abs=1e-5)
await store.close()
run(go())
def test_zvec_stale_embedding_dimension_rebuilds_from_backfill():
"""Persisted vectors with a stale dimension are dropped and re-embedded."""
async def go():
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
seed = await _started_store("t_zvec_stale_dim")
await seed.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
await seed.close()
class WideEmbeddingStore(FakeEmbeddingStore):
"""Same provider with a different dimension."""
dimensions = 4
def _embed(self, text: str) -> np.ndarray:
base = super()._embed(text)
return np.concatenate([base, base]).astype(np.float16)
store = _new_zvec_store("t_zvec_stale_dim")
store.embedding_store = WideEmbeddingStore()
await store.start()
# Startup backfill re-embeds the stale chunk in the background.
await store._embedding_backfill_task
assert store._collection.schema.vectors[0].dimension == 4
assert store._indexed_ids == {"a"}
assert [c.id for c in await store.vector_search("alpha", 5, {})] == ["a"]
await store.close()
run(go())
# -- packaging ------------------------------------------------------------
def test_zvec_declared_as_installable_dependency():
"""The registered backend needs a supported installation path in pyproject."""
pyproject = Path(__file__).resolve().parents[2] / "pyproject.toml"
if not pyproject.exists(): # running against an installed package, not the repo
pytest.skip("pyproject.toml is not available")
optional = tomllib.loads(pyproject.read_text(encoding="utf-8"))["project"]["optional-dependencies"]
assert any(dep.replace(" ", "").startswith("zvec") for dep in optional["core"])
# -- maintenance ------------------------------------------------------------
def test_zvec_optimize_index_runs_native_optimize():
"""optimize_index() delegates to Collection.optimize() and keeps search intact."""
async def go():
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
store = await _started_store("t_zvec_optimize")
await store.upsert([(node("a.md"), [chunk("a", "a.md", "alpha text")])])
await store.optimize_index()
assert [c.id for c in await store.vector_search("alpha", 5, {})] == ["a"]
await store.close()
run(go())