mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-09-02 04:00:40 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b972ca683 | ||
|
|
24b0b4a208 | ||
|
|
c3bf29a298 | ||
|
|
34519e8911 | ||
|
|
1da779276a |
+114
-9
@@ -3714,19 +3714,61 @@ def store_stats(cli_ctx: CLIContext, backend: str, fmt: str, local_json: bool) -
|
|||||||
_run_with_error_handling(_action)
|
_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")
|
@store.command("migrate")
|
||||||
@click.option("--from", "from_backend", required=True)
|
@click.option("--from", "from_backend", required=True)
|
||||||
@click.option("--to", "to_backend", required=True)
|
@click.option("--to", "to_backend", required=True)
|
||||||
@click.option("--namespace", default=None)
|
@click.option("--namespace", default=None)
|
||||||
@click.option("--dry-run", "local_dry", is_flag=True, default=False)
|
@click.option("--dry-run", "local_dry", is_flag=True, default=False)
|
||||||
|
@click.option("--json", "local_json", is_flag=True, default=False)
|
||||||
@click.pass_obj
|
@click.pass_obj
|
||||||
def store_migrate(cli_ctx: CLIContext, from_backend: str, to_backend: str,
|
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.
|
"""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
|
\b
|
||||||
Example:
|
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)
|
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):
|
if _is_dry(cli_ctx, local_dry):
|
||||||
_dry(cli_ctx, "migrate", from_backend=from_backend, to_backend=to_backend)
|
_dry(cli_ctx, "migrate", from_backend=from_backend, to_backend=to_backend)
|
||||||
return
|
return
|
||||||
raise click.ClickException(
|
|
||||||
f"Direct backend migration ({from_backend} → {to_backend}) is not yet supported "
|
if from_backend not in _MIGRATE_SUPPORTED_BACKENDS or to_backend not in _MIGRATE_SUPPORTED_BACKENDS:
|
||||||
"by the vector store layer. To migrate, export your data first:\n"
|
raise click.ClickException(
|
||||||
" semantica export --format parquet --output dump.parquet\n"
|
f"Direct backend migration ({from_backend} → {to_backend}) is only supported "
|
||||||
f" semantica embed index dump.parquet --store {to_backend}"
|
f"between {', '.join(sorted(_MIGRATE_SUPPORTED_BACKENDS))}. To migrate involving "
|
||||||
+ (f" --namespace {namespace}" if namespace else "")
|
"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)
|
_run_with_error_handling(_action)
|
||||||
|
|
||||||
|
|||||||
@@ -66,12 +66,32 @@ class FAISSIndex:
|
|||||||
self.metadata: Dict[str, Dict[str, Any]] = {}
|
self.metadata: Dict[str, Dict[str, Any]] = {}
|
||||||
|
|
||||||
def add_vectors(self, vectors: np.ndarray, ids: Optional[List[str]] = None):
|
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:
|
if ids is None:
|
||||||
ids = [f"vec_{i}" for i in range(len(vectors))]
|
ids = [f"vec_{i}" for i in range(len(vectors))]
|
||||||
|
|
||||||
self.index.add(vectors.astype(np.float32))
|
new_rows = []
|
||||||
self.vector_ids.extend(ids)
|
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(
|
def search(
|
||||||
self, query_vectors: np.ndarray, k: int = 10
|
self, query_vectors: np.ndarray, k: int = 10
|
||||||
@@ -305,6 +325,12 @@ class FAISSStore:
|
|||||||
"""
|
"""
|
||||||
Add vectors to index.
|
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:
|
Args:
|
||||||
vectors: List of vectors or numpy array
|
vectors: List of vectors or numpy array
|
||||||
ids: Vector IDs
|
ids: Vector IDs
|
||||||
@@ -312,7 +338,8 @@ class FAISSStore:
|
|||||||
**options: Additional options
|
**options: Additional options
|
||||||
|
|
||||||
Returns:
|
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
|
num_vectors = len(vectors) if isinstance(vectors, (list, np.ndarray)) else 1
|
||||||
tracking_id = self.progress_tracker.start_tracking(
|
tracking_id = self.progress_tracker.start_tracking(
|
||||||
@@ -526,6 +553,30 @@ class FAISSStore:
|
|||||||
|
|
||||||
return results
|
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]:
|
def get_stats(self) -> Dict[str, Any]:
|
||||||
"""Get index statistics."""
|
"""Get index statistics."""
|
||||||
if self.index is None:
|
if self.index is None:
|
||||||
|
|||||||
@@ -656,6 +656,52 @@ class PgVectorStore:
|
|||||||
self.logger.warning(f"Failed to get metadata for {vector_id}: {e}")
|
self.logger.warning(f"Failed to get metadata for {vector_id}: {e}")
|
||||||
return None
|
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(
|
def filter_by_metadata(
|
||||||
self, filters: Dict[str, Any], limit: int = 10
|
self, filters: Dict[str, Any], limit: int = 10
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
|
|||||||
@@ -616,6 +616,49 @@ class SQLiteVecStore:
|
|||||||
self.logger.warning(f"Failed to get metadata for {vector_id}: {e}")
|
self.logger.warning(f"Failed to get metadata for {vector_id}: {e}")
|
||||||
return None
|
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(
|
def filter_by_metadata(
|
||||||
self, filters: Dict[str, Any], limit: int = 10
|
self, filters: Dict[str, Any], limit: int = 10
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
|
|||||||
@@ -824,6 +824,64 @@ class VectorStore:
|
|||||||
else:
|
else:
|
||||||
raise NotImplementedError(f"Backend store {type(self._backend_store).__name__} does not implement get_metadata")
|
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:
|
def count(self) -> int:
|
||||||
"""Return the number of vectors in the store, backend-agnostic.
|
"""Return the number of vectors in the store, backend-agnostic.
|
||||||
|
|
||||||
|
|||||||
@@ -1359,6 +1359,101 @@ class TestStore:
|
|||||||
result = runner.invoke(cli_module.main, ["store", "migrate", "--from", "faiss"])
|
result = runner.invoke(cli_module.main, ["store", "migrate", "--from", "faiss"])
|
||||||
assert result.exit_code != 0
|
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):
|
def test_flush_requires_confirm(self, runner):
|
||||||
result = runner.invoke(cli_module.main, ["store", "flush"])
|
result = runner.invoke(cli_module.main, ["store", "flush"])
|
||||||
assert result.exit_code != 0
|
assert result.exit_code != 0
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from unittest.mock import MagicMock
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
import pytest
|
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():
|
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")
|
result = index.get_vector("vec_target")
|
||||||
|
|
||||||
np.testing.assert_allclose(result, vectors[3], atol=1e-6)
|
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))
|
||||||
|
|||||||
@@ -467,6 +467,48 @@ class TestPgVectorStoreDelete:
|
|||||||
assert success is True
|
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:
|
class TestPgVectorStoreIndex:
|
||||||
"""Test index creation operations."""
|
"""Test index creation operations."""
|
||||||
|
|
||||||
|
|||||||
@@ -415,6 +415,48 @@ class TestSQLiteVecStoreStats:
|
|||||||
assert stats["vector_count"] == 4
|
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:
|
class TestSQLiteVecStoreFilterByMetadata:
|
||||||
"""Test filter_by_metadata, including list-valued metadata handling."""
|
"""Test filter_by_metadata, including list-valued metadata handling."""
|
||||||
|
|
||||||
|
|||||||
@@ -120,6 +120,78 @@ class VectorStoreCountTests(unittest.TestCase):
|
|||||||
self.assertIn("count()", msg)
|
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
|
# VectorManager tests — inmemory backend
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user