Compare commits

...
10 changed files with 655 additions and 14 deletions
+114 -9
View File
@@ -3714,19 +3714,61 @@ def store_stats(cli_ctx: CLIContext, backend: str, fmt: str, local_json: bool) -
_run_with_error_handling(_action)
_MIGRATE_SUPPORTED_BACKENDS = {"faiss", "sqlite", "pgvector"}
_MIGRATE_BATCH_SIZE = 500
def _migrate_backend_config(vs_cfg: Dict[str, Any], backend: str) -> Dict[str, Any]:
"""Resolve per-backend config out of the vector_store config section.
Supports both a per-backend nested shape (``vector_store.faiss.dimension``)
and the common flat single-backend shape (``vector_store.backend`` +
sibling keys), since either can appear depending on how many backends a
user has configured.
"""
nested = vs_cfg.get(backend)
if isinstance(nested, dict):
return dict(nested)
if vs_cfg.get("backend") == backend:
return {k: v for k, v in vs_cfg.items() if k != "backend"}
return {}
def _require_faiss_index_path(cfg: Dict[str, Any], role: str) -> str:
"""FAISS has no server to hold state between commands: a fresh FAISSStore
starts empty and nothing outside the process persists it, so migration
needs an explicit on-disk index to read from or write to."""
index_path = cfg.get("index_path")
if not index_path:
raise click.ClickException(
f"faiss as migration {role} requires 'index_path' in the vector_store "
f"config (vector_store.faiss.index_path or vector_store.index_path "
f"when faiss is the configured backend)."
)
return index_path
@store.command("migrate")
@click.option("--from", "from_backend", required=True)
@click.option("--to", "to_backend", required=True)
@click.option("--namespace", default=None)
@click.option("--dry-run", "local_dry", is_flag=True, default=False)
@click.option("--json", "local_json", is_flag=True, default=False)
@click.pass_obj
def store_migrate(cli_ctx: CLIContext, from_backend: str, to_backend: str,
namespace: Optional[str], local_dry: bool) -> None:
namespace: Optional[str], local_dry: bool, local_json: bool) -> None:
"""Migrate data between backends.
Direct migration is only wired up between faiss, sqlite, and pgvector -
these are the backends whose storage contract supports paging through
every stored vector. Migrating to or from qdrant, pinecone, milvus, or
weaviate still needs the export/reindex workaround below, since each of
those needs its own enumeration design (Qdrant scroll, Pinecone list,
etc.) that hasn't been built yet.
\b
Example:
semantica store migrate --from faiss --to qdrant --namespace production --dry-run
semantica store migrate --from faiss --to sqlite --namespace production --dry-run
"""
cli_ctx = _require_ctx(cli_ctx)
@@ -3734,13 +3776,76 @@ def store_migrate(cli_ctx: CLIContext, from_backend: str, to_backend: str,
if _is_dry(cli_ctx, local_dry):
_dry(cli_ctx, "migrate", from_backend=from_backend, to_backend=to_backend)
return
raise click.ClickException(
f"Direct backend migration ({from_backend}{to_backend}) is not yet supported "
"by the vector store layer. To migrate, export your data first:\n"
" semantica export --format parquet --output dump.parquet\n"
f" semantica embed index dump.parquet --store {to_backend}"
+ (f" --namespace {namespace}" if namespace else "")
)
if from_backend not in _MIGRATE_SUPPORTED_BACKENDS or to_backend not in _MIGRATE_SUPPORTED_BACKENDS:
raise click.ClickException(
f"Direct backend migration ({from_backend}{to_backend}) is only supported "
f"between {', '.join(sorted(_MIGRATE_SUPPORTED_BACKENDS))}. To migrate involving "
"another backend, export your data first:\n"
" semantica export --format parquet --output dump.parquet\n"
f" semantica embed index dump.parquet --store {to_backend}"
+ (f" --namespace {namespace}" if namespace else "")
)
from .vector_store import VectorStore
vs_cfg = cli_ctx.config.to_dict().get("vector_store", {}) or {}
source_cfg = _migrate_backend_config(vs_cfg, from_backend)
dest_cfg = _migrate_backend_config(vs_cfg, to_backend)
source_index_path = None
if from_backend == "faiss":
source_index_path = _require_faiss_index_path(source_cfg, "source")
dest_index_path = None
if to_backend == "faiss":
dest_index_path = _require_faiss_index_path(dest_cfg, "destination")
source = VectorStore(backend=from_backend, config=source_cfg)
if source_index_path:
source._backend_store.load_index(source_index_path)
source_dimension = getattr(source._backend_store, "dimension", None)
if source_dimension and "dimension" not in dest_cfg:
dest_cfg["dimension"] = source_dimension
dest = VectorStore(backend=to_backend, config=dest_cfg)
if dest_index_path and Path(dest_index_path).exists():
dest._backend_store.load_index(dest_index_path)
migrated = 0
vectors_batch: List[Any] = []
metadata_batch: List[Dict[str, Any]] = []
ids_batch: List[str] = []
def _flush() -> None:
nonlocal migrated
if not vectors_batch:
return
dest.store_vectors(list(vectors_batch), list(metadata_batch), ids=list(ids_batch))
migrated += len(vectors_batch)
vectors_batch.clear()
metadata_batch.clear()
ids_batch.clear()
for item in source.iter_vectors(batch_size=_MIGRATE_BATCH_SIZE):
meta = dict(item.get("metadata") or {})
if namespace and "namespace" not in meta:
meta["namespace"] = namespace
vectors_batch.append(item["vector"])
metadata_batch.append(meta)
ids_batch.append(item["id"])
if len(vectors_batch) >= _MIGRATE_BATCH_SIZE:
_flush()
_flush()
if dest_index_path and migrated:
dest._backend_store.save_index(dest_index_path)
result = {"from": from_backend, "to": to_backend, "migrated": migrated}
if _is_json(cli_ctx, local_json):
_jecho(result)
else:
_ok(cli_ctx, f"Migrated {migrated} vectors from {from_backend} to {to_backend}")
_run_with_error_handling(_action)
+55 -4
View File
@@ -66,12 +66,32 @@ class FAISSIndex:
self.metadata: Dict[str, Dict[str, Any]] = {}
def add_vectors(self, vectors: np.ndarray, ids: Optional[List[str]] = None):
"""Add vectors to index."""
"""
Add vectors to index.
Skips any id already present in vector_ids rather than appending a
second physical vector under the same id. FAISS indices here don't
support removing or replacing a single vector in place, so an
"update" isn't possible; without this check, re-running an add for
ids that already exist (e.g. retrying an interrupted migration)
would silently duplicate vectors under the same id on every retry.
"""
if ids is None:
ids = [f"vec_{i}" for i in range(len(vectors))]
self.index.add(vectors.astype(np.float32))
self.vector_ids.extend(ids)
new_rows = []
new_ids = []
existing = set(self.vector_ids)
for row, vec_id in zip(vectors, ids):
if vec_id in existing:
continue
new_rows.append(row)
new_ids.append(vec_id)
existing.add(vec_id)
if new_rows:
self.index.add(np.array(new_rows, dtype=np.float32))
self.vector_ids.extend(new_ids)
def search(
self, query_vectors: np.ndarray, k: int = 10
@@ -305,6 +325,12 @@ class FAISSStore:
"""
Add vectors to index.
Any id that already exists in the index is skipped rather than
stored as a second physical vector under the same id (see
FAISSIndex.add_vectors), so calling this again with ids from a
previous call is safe and doesn't accumulate duplicates. Metadata
for those ids is still updated.
Args:
vectors: List of vectors or numpy array
ids: Vector IDs
@@ -312,7 +338,8 @@ class FAISSStore:
**options: Additional options
Returns:
List of vector IDs
List of vector IDs (including ids that were already present
and therefore not re-added as new vectors)
"""
num_vectors = len(vectors) if isinstance(vectors, (list, np.ndarray)) else 1
tracking_id = self.progress_tracker.start_tracking(
@@ -526,6 +553,30 @@ class FAISSStore:
return results
def scan_vectors(self, offset: int = 0, limit: int = 100) -> List[Dict[str, Any]]:
"""
Page through stored vectors in insertion order.
Args:
offset: Number of vectors to skip
limit: Maximum number of vectors to return
Returns:
List of result dicts with 'id', 'metadata', and 'vector'
"""
if self.index is None or limit <= 0:
return []
ids_page = self.index.vector_ids[offset:offset + limit]
return [
{
"id": vector_id,
"metadata": self.get_metadata(vector_id) or {},
"vector": self.get_vector(vector_id),
}
for vector_id in ids_page
]
def get_stats(self) -> Dict[str, Any]:
"""Get index statistics."""
if self.index is None:
+46
View File
@@ -656,6 +656,52 @@ class PgVectorStore:
self.logger.warning(f"Failed to get metadata for {vector_id}: {e}")
return None
def scan_vectors(self, offset: int = 0, limit: int = 100) -> List[Dict[str, Any]]:
"""
Page through stored vectors ordered by id.
Args:
offset: Number of rows to skip
limit: Maximum number of rows to return
Returns:
List of result dicts with 'id', 'metadata', and 'vector'
"""
if not PSYCOPG3_AVAILABLE and not PSYCOPG2_AVAILABLE:
raise ProcessingError(
"Neither psycopg3 nor psycopg2 is available. "
"Install with: pip install psycopg[binary] or psycopg2-binary"
)
if limit <= 0:
return []
scan_sql = psycopg_sql.SQL("""
SELECT id, vector, metadata
FROM {}
ORDER BY id
LIMIT %s OFFSET %s
""").format(psycopg_sql.Identifier(self.table_name))
with self._get_connection() as conn:
try:
cur = conn.cursor()
cur.execute(scan_sql, (limit, offset))
rows = cur.fetchall()
cur.close()
results = []
for row in rows:
vec_id, vec, meta = row
results.append({
"id": vec_id,
"metadata": meta if isinstance(meta, dict) else json.loads(meta) if meta else {},
"vector": np.array(vec) if vec is not None else None,
})
return results
except Exception as e:
raise ProcessingError(f"Failed to scan vectors: {str(e)}") from e
def filter_by_metadata(
self, filters: Dict[str, Any], limit: int = 10
) -> List[Dict[str, Any]]:
@@ -616,6 +616,49 @@ class SQLiteVecStore:
self.logger.warning(f"Failed to get metadata for {vector_id}: {e}")
return None
def scan_vectors(self, offset: int = 0, limit: int = 100) -> List[Dict[str, Any]]:
"""
Page through stored vectors ordered by id.
Args:
offset: Number of rows to skip
limit: Maximum number of rows to return
Returns:
List of result dicts with 'id', 'metadata', and 'vector'
"""
if limit <= 0:
return []
query_sql = f"""
SELECT id, embedding, metadata
FROM {self.table_name}
ORDER BY id
LIMIT ? OFFSET ?
"""
with self._lock, self._get_connection() as conn:
try:
cur = conn.cursor()
cur.execute(query_sql, (limit, offset))
rows = cur.fetchall()
cur.close()
results = []
for row in rows:
vec_id, embedding_blob, meta_json = row
vec = None
if embedding_blob:
vec = np.frombuffer(embedding_blob, dtype=np.float32).copy()
results.append({
"id": vec_id,
"metadata": json.loads(meta_json) if meta_json else {},
"vector": vec,
})
return results
except Exception as e:
raise ProcessingError(f"Failed to scan vectors: {str(e)}") from e
def filter_by_metadata(
self, filters: Dict[str, Any], limit: int = 10
) -> List[Dict[str, Any]]:
+58
View File
@@ -824,6 +824,64 @@ class VectorStore:
else:
raise NotImplementedError(f"Backend store {type(self._backend_store).__name__} does not implement get_metadata")
def scan_vectors(self, offset: int = 0, limit: int = 100) -> List[Dict[str, Any]]:
"""
Page through stored vectors, backend-agnostic.
Follows the get_vector()/get_metadata() precedent (#843): the inmemory
backend pages its local dict directly, a persistent backend delegates
to a scan_vectors() on the wrapped store when available, and one that
cannot enumerate its contents raises NotImplementedError rather than
silently returning an empty page.
Args:
offset: Number of vectors to skip
limit: Maximum number of vectors to return
Returns:
List of result dicts with 'id', 'metadata', and 'vector'
"""
if limit <= 0:
return []
if self.backend == "inmemory":
ids_page = list(self.vectors.keys())[offset:offset + limit]
return [
{
"id": vec_id,
"metadata": self.metadata.get(vec_id, {}),
"vector": self.vectors.get(vec_id),
}
for vec_id in ids_page
]
elif self._backend_store and hasattr(self._backend_store, "scan_vectors"):
return self._backend_store.scan_vectors(offset=offset, limit=limit)
else:
raise NotImplementedError(
f"Backend store {type(self._backend_store).__name__} does not "
"implement scan_vectors(). Add a scan_vectors() method to the "
"backend store adapter to enable enumeration for this backend."
)
def iter_vectors(self, batch_size: int = 500):
"""
Iterate over every stored vector, one page at a time.
Args:
batch_size: Number of vectors to fetch per underlying scan_vectors() call
Yields:
Result dicts with 'id', 'metadata', and 'vector', in scan order
"""
offset = 0
while True:
page = self.scan_vectors(offset=offset, limit=batch_size)
if not page:
return
for item in page:
yield item
offset += len(page)
def count(self) -> int:
"""Return the number of vectors in the store, backend-agnostic.
+95
View File
@@ -1359,6 +1359,101 @@ class TestStore:
result = runner.invoke(cli_module.main, ["store", "migrate", "--from", "faiss"])
assert result.exit_code != 0
def test_migrate_refuses_unsupported_backend_pair(self, runner):
result = runner.invoke(cli_module.main, ["store", "migrate",
"--from", "faiss", "--to", "qdrant"])
assert result.exit_code != 0
assert "faiss, pgvector, sqlite" in result.output
def _fake_migrate_store_module(self, source_items, stored, dest_configs=None):
class _FakeBackendStore:
def __init__(self, dimension=None):
self.dimension = dimension
class _FakeStore:
def __init__(self, backend, config=None, **kw):
self.backend = backend
self._config = config or {}
dim = self._config.get("dimension")
self._backend_store = _FakeBackendStore(dimension=dim)
if dest_configs is not None:
dest_configs[backend] = dict(self._config)
def iter_vectors(self, batch_size=500):
if self.backend == "sqlite":
yield from source_items
return
return
yield # pragma: no cover - makes this a generator for other backends
def store_vectors(self, vectors, metadata, ids=None):
for vec_id, meta in zip(ids, metadata):
stored[vec_id] = meta
return _fake_module(VectorStore=_FakeStore)
def test_migrate_runs_between_supported_backends(self, runner, monkeypatch):
source_items = [
{"id": "a", "vector": [0.1, 0.2], "metadata": {"tag": "x"}},
{"id": "b", "vector": [0.3, 0.4], "metadata": {}},
]
stored = {}
fake_vs = self._fake_migrate_store_module(source_items, stored)
monkeypatch.setitem(__import__("sys").modules, "semantica.vector_store", fake_vs)
result = runner.invoke(cli_module.main, ["store", "migrate",
"--from", "sqlite", "--to", "pgvector",
"--namespace", "prod", "--json"])
_ok(result)
data = _json_output(result)
assert data == {"from": "sqlite", "to": "pgvector", "migrated": 2}
assert stored == {"a": {"tag": "x", "namespace": "prod"}, "b": {"namespace": "prod"}}
def test_migrate_reports_zero_for_empty_source(self, runner, monkeypatch):
stored = {}
fake_vs = self._fake_migrate_store_module([], stored)
monkeypatch.setitem(__import__("sys").modules, "semantica.vector_store", fake_vs)
result = runner.invoke(cli_module.main, ["store", "migrate",
"--from", "sqlite", "--to", "pgvector", "--json"])
_ok(result)
assert _json_output(result)["migrated"] == 0
assert stored == {}
def test_migrate_inherits_source_dimension_into_dest(self, runner, monkeypatch):
source_items = [{"id": "a", "vector": [0.1, 0.2, 0.3], "metadata": {}}]
stored = {}
dest_configs: dict = {}
fake_vs = self._fake_migrate_store_module(source_items, stored, dest_configs)
monkeypatch.setitem(__import__("sys").modules, "semantica.vector_store", fake_vs)
monkeypatch.setattr(
cli_module.Config, "to_dict",
lambda self: {"vector_store": {"sqlite": {"dimension": 3}, "pgvector": {}}},
)
result = runner.invoke(cli_module.main, ["store", "migrate",
"--from", "sqlite", "--to", "pgvector", "--json"])
_ok(result)
assert dest_configs["pgvector"].get("dimension") == 3
def test_migrate_faiss_source_requires_index_path(self, runner, monkeypatch):
fake_vs = _fake_module(VectorStore=lambda **kw: MagicMock())
monkeypatch.setitem(__import__("sys").modules, "semantica.vector_store", fake_vs)
result = runner.invoke(cli_module.main, ["store", "migrate",
"--from", "faiss", "--to", "sqlite"])
assert result.exit_code != 0
assert "index_path" in result.output
def test_migrate_faiss_dest_requires_index_path(self, runner, monkeypatch):
fake_vs = _fake_module(VectorStore=lambda **kw: MagicMock())
monkeypatch.setitem(__import__("sys").modules, "semantica.vector_store", fake_vs)
result = runner.invoke(cli_module.main, ["store", "migrate",
"--from", "sqlite", "--to", "faiss"])
assert result.exit_code != 0
assert "index_path" in result.output
def test_flush_requires_confirm(self, runner):
result = runner.invoke(cli_module.main, ["store", "flush"])
assert result.exit_code != 0
+88 -1
View File
@@ -3,7 +3,7 @@ from unittest.mock import MagicMock
import numpy as np
import pytest
from semantica.vector_store.faiss_store import FAISSIndex
from semantica.vector_store.faiss_store import FAISSIndex, FAISSStore
def test_get_vector_reconstructs_from_flat_l2_index():
@@ -120,3 +120,90 @@ def test_get_vector_reconstructs_from_real_ivfflat_index_without_prior_direct_ma
result = index.get_vector("vec_target")
np.testing.assert_allclose(result, vectors[3], atol=1e-6)
def _store_with_fake_index(ids, metadata_by_id=None):
backend_index = MagicMock()
backend_index.reconstruct.side_effect = lambda idx: [float(idx)] * 3
index = FAISSIndex(backend_index, dimension=3)
index.vector_ids = list(ids)
index.metadata = dict(metadata_by_id or {})
store = FAISSStore(dimension=3)
store.index = index
return store
def test_scan_vectors_returns_all_across_pages():
store = _store_with_fake_index(["a", "b", "c", "d", "e"])
seen_ids = []
offset = 0
while True:
page = store.scan_vectors(offset=offset, limit=2)
if not page:
break
seen_ids.extend(p["id"] for p in page)
offset += len(page)
assert seen_ids == ["a", "b", "c", "d", "e"]
def test_scan_vectors_includes_vector_and_metadata():
store = _store_with_fake_index(["a"], {"a": {"tag": "only"}})
page = store.scan_vectors(offset=0, limit=10)
assert len(page) == 1
assert page[0]["id"] == "a"
assert page[0]["metadata"] == {"tag": "only"}
np.testing.assert_array_equal(page[0]["vector"], np.array([0.0, 0.0, 0.0], dtype=np.float32))
def test_scan_vectors_no_index_returns_empty_list():
store = FAISSStore(dimension=3)
assert store.scan_vectors(offset=0, limit=10) == []
def test_scan_vectors_zero_limit_returns_empty_list():
store = _store_with_fake_index(["a"])
assert store.scan_vectors(offset=0, limit=0) == []
def test_scan_vectors_offset_past_end_returns_empty_list():
store = _store_with_fake_index(["a"])
assert store.scan_vectors(offset=100, limit=10) == []
def test_add_vectors_retry_with_same_ids_does_not_duplicate():
"""Re-running add_vectors with ids already in the index (e.g. retrying
an interrupted migration) must not create a second physical vector
under the same id."""
backend_index = MagicMock()
store = FAISSStore(dimension=3)
store.index = FAISSIndex(backend_index, dimension=3)
vectors = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]], dtype=np.float32)
ids = ["a", "b", "c", "d"]
store.add_vectors(vectors, ids=ids, metadata=[{"i": i} for i in range(4)])
assert store.count() == 4
store.add_vectors(vectors, ids=ids, metadata=[{"i": i} for i in range(4)])
assert store.count() == 4
assert store.index.vector_ids == ids
def test_add_vectors_retry_with_partial_overlap_only_adds_new_ids():
backend_index = MagicMock()
store = FAISSStore(dimension=3)
store.index = FAISSIndex(backend_index, dimension=3)
store.add_vectors(np.array([[1, 2, 3], [4, 5, 6]], dtype=np.float32), ids=["a", "b"])
store.add_vectors(np.array([[1, 2, 3], [7, 8, 9]], dtype=np.float32), ids=["a", "c"])
assert store.index.vector_ids == ["a", "b", "c"]
second_call_vectors = backend_index.add.call_args[0][0]
assert second_call_vectors.shape[0] == 1
np.testing.assert_array_equal(second_call_vectors[0], np.array([7, 8, 9], dtype=np.float32))
+42
View File
@@ -467,6 +467,48 @@ class TestPgVectorStoreDelete:
assert success is True
class TestPgVectorStoreScan:
"""Test scan_vectors pagination."""
def test_scan_returns_all_vectors_across_pages(self, store):
vectors = [np.random.rand(128).astype(np.float32) for _ in range(5)]
ids = store.add(vectors, [{"index": i} for i in range(5)])
seen_ids = []
offset = 0
while True:
page = store.scan_vectors(offset=offset, limit=2)
if not page:
break
seen_ids.extend(p["id"] for p in page)
offset += len(page)
assert set(seen_ids) == set(ids)
assert len(seen_ids) == 5
def test_scan_page_includes_vector_and_metadata(self, store):
vectors = [np.random.rand(128).astype(np.float32)]
ids = store.add(vectors, [{"tag": "only"}])
page = store.scan_vectors(offset=0, limit=10)
assert len(page) == 1
assert page[0]["id"] == ids[0]
assert page[0]["metadata"] == {"tag": "only"}
assert page[0]["vector"] is not None
def test_scan_empty_store_returns_empty_list(self, store):
assert store.scan_vectors(offset=0, limit=10) == []
def test_scan_zero_limit_returns_empty_list(self, store):
store.add([np.random.rand(128).astype(np.float32)])
assert store.scan_vectors(offset=0, limit=0) == []
def test_scan_offset_past_end_returns_empty_list(self, store):
store.add([np.random.rand(128).astype(np.float32)])
assert store.scan_vectors(offset=100, limit=10) == []
class TestPgVectorStoreIndex:
"""Test index creation operations."""
@@ -415,6 +415,48 @@ class TestSQLiteVecStoreStats:
assert stats["vector_count"] == 4
class TestSQLiteVecStoreScan:
"""Test scan_vectors pagination."""
def test_scan_returns_all_vectors_across_pages(self, store):
vectors = [np.random.rand(128).astype(np.float32) for _ in range(5)]
ids = store.add(vectors, [{"index": i} for i in range(5)])
seen_ids = []
offset = 0
while True:
page = store.scan_vectors(offset=offset, limit=2)
if not page:
break
seen_ids.extend(p["id"] for p in page)
offset += len(page)
assert set(seen_ids) == set(ids)
assert len(seen_ids) == 5
def test_scan_page_includes_vector_and_metadata(self, store):
vectors = [np.random.rand(128).astype(np.float32)]
ids = store.add(vectors, [{"tag": "only"}])
page = store.scan_vectors(offset=0, limit=10)
assert len(page) == 1
assert page[0]["id"] == ids[0]
assert page[0]["metadata"] == {"tag": "only"}
assert page[0]["vector"] is not None
def test_scan_empty_store_returns_empty_list(self, store):
assert store.scan_vectors(offset=0, limit=10) == []
def test_scan_zero_limit_returns_empty_list(self, store):
store.add([np.random.rand(128).astype(np.float32)])
assert store.scan_vectors(offset=0, limit=0) == []
def test_scan_offset_past_end_returns_empty_list(self, store):
store.add([np.random.rand(128).astype(np.float32)])
assert store.scan_vectors(offset=100, limit=10) == []
class TestSQLiteVecStoreFilterByMetadata:
"""Test filter_by_metadata, including list-valued metadata handling."""
@@ -120,6 +120,78 @@ class VectorStoreCountTests(unittest.TestCase):
self.assertIn("count()", msg)
# ---------------------------------------------------------------------------
# VectorStore.scan_vectors() / iter_vectors() dispatch tests
# ---------------------------------------------------------------------------
class _ScanningBackendStore:
"""Fake persistent backend store that supports scan_vectors()."""
def __init__(self, items):
self._items = items
def scan_vectors(self, offset=0, limit=100):
return self._items[offset:offset + limit]
class _NonScanningBackendStore:
"""Fake persistent backend store without any scan capability."""
class VectorStoreScanVectorsTests(unittest.TestCase):
"""VectorStore.scan_vectors() / iter_vectors() backend-agnostic accessors."""
def setUp(self):
self.vectors = [np.array([1.0, 0.0]), np.array([0.0, 1.0]), np.array([1.0, 1.0])]
self.metadata = [{"type": "a"}, {"type": "b"}, {"type": "c"}]
def test_scan_inmemory_pages_through_all_vectors(self):
store = VectorStore(backend="inmemory", dimension=2)
ids = store.store_vectors(self.vectors, self.metadata)
page1 = store.scan_vectors(offset=0, limit=2)
page2 = store.scan_vectors(offset=2, limit=2)
self.assertEqual([p["id"] for p in page1], ids[:2])
self.assertEqual([p["id"] for p in page2], ids[2:])
self.assertEqual(page2[0]["metadata"], {"type": "c"})
def test_scan_inmemory_empty_store(self):
store = VectorStore(backend="inmemory", dimension=2)
self.assertEqual(store.scan_vectors(offset=0, limit=10), [])
def test_scan_zero_limit_returns_empty_list(self):
store = VectorStore(backend="inmemory", dimension=2)
store.store_vectors(self.vectors, self.metadata)
self.assertEqual(store.scan_vectors(offset=0, limit=0), [])
def test_scan_delegates_to_backend_store(self):
items = [{"id": "a", "metadata": {}, "vector": None}]
store = VectorStore(backend="inmemory", dimension=2)
store.backend = "faiss"
store._backend_store = _ScanningBackendStore(items)
self.assertEqual(store.scan_vectors(offset=0, limit=10), items)
def test_scan_raises_not_implemented_without_backend_support(self):
store = VectorStore(backend="inmemory", dimension=2)
store.backend = "faiss"
store._backend_store = _NonScanningBackendStore()
with self.assertRaises(NotImplementedError):
store.scan_vectors(offset=0, limit=10)
def test_iter_vectors_walks_every_page(self):
store = VectorStore(backend="inmemory", dimension=2)
ids = store.store_vectors(self.vectors, self.metadata)
collected = list(store.iter_vectors(batch_size=2))
self.assertEqual([item["id"] for item in collected], ids)
def test_iter_vectors_empty_store_yields_nothing(self):
store = VectorStore(backend="inmemory", dimension=2)
self.assertEqual(list(store.iter_vectors(batch_size=2)), [])
# ---------------------------------------------------------------------------
# VectorManager tests — inmemory backend
# ---------------------------------------------------------------------------