feat: implement sqlite-vec vector store backend (#240)

This commit is contained in:
luffy2208
2026-07-05 20:40:05 +05:30
parent 9094f1ed95
commit 11836023ee
8 changed files with 1156 additions and 8 deletions
+2 -1
View File
@@ -147,9 +147,10 @@ vectorstore-weaviate = ["weaviate-client>=4.0.0"]
vectorstore-pinecone = ["pinecone-client>=3.0.0"]
vectorstore-milvus = ["pymilvus>=2.0.0"]
vectorstore-pgvector = ["psycopg[binary,pool]>=3.0.0", "pgvector>=0.2.0"]
vectorstore-sqlite = ["sqlite-vec>=0.1.1"]
vectorstore-all = [
"semantica[vectorstore-qdrant,vectorstore-weaviate,vectorstore-pinecone,vectorstore-milvus,vectorstore-pgvector]"
"semantica[vectorstore-qdrant,vectorstore-weaviate,vectorstore-pinecone,vectorstore-milvus,vectorstore-pgvector,vectorstore-sqlite]"
]
# ---- Infra / Queues / Workers ----
+1
View File
@@ -80,6 +80,7 @@ SUPPORTED_VECTOR_STORES = [
"qdrant",
"milvus",
"chroma",
"sqlite",
]
# Supported Graph Databases
+25 -5
View File
@@ -138,12 +138,29 @@ from .hybrid_search import HybridSearch, MetadataFilter, SearchRanker
from .hybrid_similarity import HybridSimilarityCalculator
from .decision_embedding_pipeline import DecisionEmbeddingPipeline
from .decision_vector_methods import (
quick_decision, find_precedents, explain, similar_to, batch_decisions,
filter_decisions, get_decision_context, search_by_entities, get_decision_statistics,
update_similarity_weights, set_global_vector_store, get_global_vector_store,
quick_decision,
find_precedents,
explain,
similar_to,
batch_decisions,
filter_decisions,
get_decision_context,
search_by_entities,
get_decision_statistics,
update_similarity_weights,
set_global_vector_store,
get_global_vector_store,
# Aliases
record, precedents, explain_decision, similar, batch, filter, context,
by_entities, stats, weights
record,
precedents,
explain_decision,
similar,
batch,
filter,
context,
by_entities,
stats,
weights,
)
from .metadata_store import MetadataIndex, MetadataSchema, MetadataStore
from .methods import (
@@ -161,6 +178,7 @@ from .methods import (
from .milvus_store import MilvusStore, MilvusClient, MilvusCollection, MilvusSearch
from .namespace_manager import Namespace, NamespaceManager
from .pgvector_store import PgVectorStore
from .sqlite_vec_store import SQLiteVecStore
from .pinecone_store import PineconeStore, PineconeClient, PineconeIndex, PineconeSearch
from .qdrant_store import QdrantStore, QdrantClient, QdrantCollection, QdrantSearch
from .registry import MethodRegistry, method_registry
@@ -205,6 +223,8 @@ __all__ = [
"PineconeSearch",
# PgVector
"PgVectorStore",
# SQLite
"SQLiteVecStore",
# Hybrid search
"HybridSearch",
"MetadataFilter",
+1
View File
@@ -120,6 +120,7 @@ class VectorStoreConfig:
"VECTOR_STORE_QDRANT_URL": "qdrant_url",
"VECTOR_STORE_MILVUS_HOST": "milvus_host",
"VECTOR_STORE_MILVUS_PORT": "milvus_port",
"VECTOR_STORE_SQLITE_PATH": "sqlite_path",
}
for env_var, config_key in env_mappings.items():
+626
View File
@@ -0,0 +1,626 @@
"""
SQLite Vector Store Module using sqlite-vec
This module provides SQLite integration using the sqlite-vec extension for vector storage and
similarity search in the Semantica framework, supporting L2 and Cosine distance metrics,
dynamic JSON metadata filtering, and disk-backed persistence.
Key Features:
- Distance metrics (Cosine, L2/Euclidean)
- Fully persistent or in-memory SQLite storage
- Dynamic metadata filtering using SQLite's JSON extract functions
- Fully thread-safe operations via locks and WAL mode
- Strict validation of vector dimensions and table names
- Parity with PgVectorStore interface for seamless drop-in usage
Main Classes:
- SQLiteVecStore: Main SQLite vector store using sqlite-vec virtual tables
Example Usage:
>>> from semantica.vector_store import SQLiteVecStore
>>> store = SQLiteVecStore(
... db_path="vectors.db",
... table_name="vectors",
... dimension=768,
... distance_metric="cosine"
... )
>>> store.add(vectors, metadata, ids)
>>> results = store.search(query_vector, top_k=10)
>>> store.close()
Author: Semantica Contributors
License: MIT
"""
import json
import os
import re
import sqlite3
import threading
import uuid
from contextlib import contextmanager
from typing import Any, Dict, List, Optional, Union
from urllib.request import pathname2url
import numpy as np
from ..utils.exceptions import ProcessingError, ValidationError
from ..utils.logging import get_logger
# Optional sqlite-vec import
try:
import sqlite_vec
SQLITE_VEC_AVAILABLE = True
except (ImportError, OSError):
SQLITE_VEC_AVAILABLE = False
sqlite_vec = None
class SQLiteVecStore:
"""
SQLite vector store using the sqlite-vec extension for similarity search.
- Vector storage with vec0 virtual table
- Similarity search with L2 and Cosine metrics
- Thread-safe query and insertion execution
- Dynamic metadata extraction and filtering
"""
SUPPORTED_METRICS = {"cosine", "l2"}
def __init__(
self,
db_path: str,
table_name: str,
dimension: int,
distance_metric: str = "cosine",
read_only: bool = False,
**kwargs,
):
"""
Initialize SQLiteVecStore.
Args:
db_path: Path to SQLite database file, or ':memory:'
table_name: Name of the virtual table to store vectors
dimension: Vector dimension
distance_metric: Distance metric (cosine, l2)
read_only: Open database in read-only mode (requires existing database)
**kwargs: Additional option parameters
Raises:
ValidationError: If parameters are invalid
ProcessingError: If sqlite-vec or connection load extension fails
"""
self.logger = get_logger("sqlite_vec_store")
# Validate dependencies
if not SQLITE_VEC_AVAILABLE:
raise ProcessingError(
"sqlite-vec Python package is not available. "
"Install with: pip install sqlite-vec"
)
# Validate parameters
if distance_metric.lower() not in self.SUPPORTED_METRICS:
raise ValidationError(
f"Unsupported distance metric: {distance_metric}. "
f"Supported: {', '.join(self.SUPPORTED_METRICS)}"
)
if not self._is_safe_identifier(table_name):
raise ValidationError(
f"Invalid table name: {table_name!r}. "
"Table names must be alphanumeric with underscores/hyphens only and start with a letter/underscore."
)
self.db_path = db_path
self.table_name = table_name
self.dimension = dimension
self.distance_metric = distance_metric.lower()
self.read_only = read_only
self.config = kwargs
self.use_wal = kwargs.get("use_wal", False)
# Lock to ensure thread safety when sharing a single SQLite connection
self._lock = threading.Lock()
self._conn = None
# Connect to database
self._init_connection()
# Ensure table exists (if not read-only)
if not self.read_only:
self._ensure_table_exists()
self.logger.info(
f"Initialized SQLiteVecStore: db_path={db_path}, table={table_name}, "
f"dimension={dimension}, metric={distance_metric}, read_only={read_only}"
)
def _init_connection(self):
"""Initialize database connection and load sqlite-vec extension."""
try:
if self.read_only:
if self.db_path == ":memory:":
self._conn = sqlite3.connect(":memory:", check_same_thread=False)
else:
if not os.path.exists(self.db_path):
raise ProcessingError(
f"Database file does not exist for read-only mode: {self.db_path}"
)
abs_path = os.path.abspath(self.db_path)
url_path = pathname2url(abs_path)
self._conn = sqlite3.connect(
f"file:{url_path}?mode=ro", uri=True, check_same_thread=False
)
else:
self._conn = sqlite3.connect(self.db_path, check_same_thread=False)
# Enable and load extension
try:
self._conn.enable_load_extension(True)
sqlite_vec.load(self._conn)
self._conn.enable_load_extension(False)
except AttributeError as ae:
raise ProcessingError(
"SQLite load extension attribute is not available in this Python build. "
"Make sure you are using a Python build compiled with loadable extension support."
) from ae
except Exception as e:
raise ProcessingError(
f"Failed to load sqlite-vec extension: {e}"
) from e
# Set WAL journal mode for performance and concurrent readers, if configured
if self.use_wal and not self.read_only and self.db_path != ":memory:":
self._conn.execute("PRAGMA journal_mode=WAL")
except (ValidationError, ProcessingError):
if self._conn:
self._conn.close()
raise
except Exception as e:
if self._conn:
self._conn.close()
raise ProcessingError(f"Failed to establish SQLite connection: {e}") from e
@contextmanager
def _get_connection(self):
"""Get the active connection."""
if not self._conn:
raise ProcessingError("Database connection is closed.")
try:
yield self._conn
except (ValidationError, ProcessingError):
raise
except Exception as e:
raise ProcessingError("Database operation failed") from e
def _is_safe_identifier(self, key: str) -> bool:
"""
Validate that a string is safe to use as a SQL identifier.
Only allows alphanumeric characters, underscores, and hyphens.
"""
if not isinstance(key, str):
return False
if not key:
return False
return bool(re.match(r"^[a-zA-Z_][a-zA-Z0-9_-]*$", key))
def _ensure_table_exists(self):
"""Ensure the vector table exists."""
# Define vec0 virtual table
create_table_sql = f"""
CREATE VIRTUAL TABLE IF NOT EXISTS {self.table_name} USING vec0(
id TEXT PRIMARY KEY,
embedding float[{self.dimension}] distance_metric={self.distance_metric},
+metadata TEXT
)
"""
with self._lock, self._get_connection() as conn:
try:
conn.execute(create_table_sql)
conn.commit()
self.logger.debug(f"Virtual table {self.table_name} ensured")
except Exception as e:
conn.rollback()
raise ProcessingError("Failed to create vec0 virtual table") from e
def add(
self,
vectors: Union[List[np.ndarray], np.ndarray],
metadata: Optional[List[Dict[str, Any]]] = None,
ids: Optional[List[str]] = None,
) -> List[str]:
"""
Add vectors to the store.
Args:
vectors: List of vectors or numpy array
metadata: List of metadata dictionaries (one per vector)
ids: Optional list of IDs (auto-generated if not provided)
Returns:
List of vector IDs
Raises:
ValidationError: If input dimensions or lengths don't match
ProcessingError: If read-only mode is active or database operation fails
"""
if self.read_only:
raise ProcessingError("Cannot add vectors in read-only mode")
# Convert to list if numpy array
if isinstance(vectors, np.ndarray):
vectors = [vectors[i] for i in range(len(vectors))]
num_vectors = len(vectors)
# Validate dimensions
for i, vec in enumerate(vectors):
if len(vec) != self.dimension:
raise ValidationError(
f"Vector at index {i} has dimension {len(vec)}, "
f"expected {self.dimension}"
)
# Generate IDs if not provided
if ids is None:
ids = [str(uuid.uuid4()) for _ in range(num_vectors)]
elif len(ids) != num_vectors:
raise ValidationError(
f"IDs length ({len(ids)}) must match vectors length ({num_vectors})"
)
# Prepare metadata
if metadata is None:
metadata = [{} for _ in range(num_vectors)]
elif len(metadata) != num_vectors:
raise ValidationError(
f"Metadata length ({len(metadata)}) must match vectors length ({num_vectors})"
)
with self._lock, self._get_connection() as conn:
try:
cur = conn.cursor()
# Run deletes first to emulate INSERT OR REPLACE / UPSERT behavior
# Use batched deletes to avoid a massive performance bottleneck
if ids:
batch_size = 1000
for i in range(0, len(ids), batch_size):
batch_ids = ids[i : i + batch_size]
placeholders = ",".join(["?"] * len(batch_ids))
cur.execute(
f"DELETE FROM {self.table_name} WHERE id IN ({placeholders})",
batch_ids,
)
# Build data tuples for insert
data_tuples = [
(
vec_id,
sqlite_vec.serialize_float32(vec),
json.dumps(meta),
)
for vec_id, vec, meta in zip(ids, vectors, metadata)
]
# Bulk insert
cur.executemany(
f"INSERT INTO {self.table_name} (id, embedding, metadata) VALUES (?, ?, ?)",
data_tuples,
)
conn.commit()
cur.close()
self.logger.info(f"Added {num_vectors} vectors")
return ids
except (ValidationError, ProcessingError):
conn.rollback()
raise
except Exception as e:
conn.rollback()
raise ProcessingError("Failed to add vectors to database") from e
def search(
self,
query_vector: np.ndarray,
top_k: int = 10,
filter: Optional[Dict[str, Any]] = None,
) -> List[Dict[str, Any]]:
"""
Search for similar vectors.
Args:
query_vector: Query vector
top_k: Number of results to return
filter: Optional metadata filter (dict of key-value pairs)
Returns:
List of results with id, score (similarity), and metadata
Raises:
ValidationError: If query vector dimension doesn't match
ProcessingError: If database operation fails
"""
if len(query_vector) != self.dimension:
raise ValidationError(
f"Query vector has dimension {len(query_vector)}, expected {self.dimension}"
)
# Serialize query vector
query_serialized = sqlite_vec.serialize_float32(query_vector)
# Build query
filter_conditions = []
filter_params = []
if filter:
for key, value in filter.items():
if not self._is_safe_identifier(key):
raise ValidationError(
f"Invalid filter key: {key!r}. "
"Keys must be alphanumeric with underscores/hyphens only."
)
filter_conditions.append(f"json_extract(metadata, '$.{key}') = ?")
if isinstance(value, bool):
filter_params.append(1 if value else 0)
else:
filter_params.append(value)
where_clause = ""
if filter_conditions:
where_clause = " AND " + " AND ".join(filter_conditions)
search_sql = f"""
SELECT id, distance, metadata
FROM {self.table_name}
WHERE embedding MATCH ? AND k = ?{where_clause}
"""
params = [query_serialized, top_k] + filter_params
with self._lock, self._get_connection() as conn:
try:
cur = conn.cursor()
cur.execute(search_sql, params)
rows = cur.fetchall()
cur.close()
results = []
for row in rows:
vec_id, distance, meta_json = row
# Convert distance to similarity score
similarity = 1.0 / (1.0 + float(distance))
results.append(
{
"id": vec_id,
"score": similarity,
"metadata": json.loads(meta_json) if meta_json else {},
}
)
return results
except (ValidationError, ProcessingError):
raise
except Exception as e:
raise ProcessingError("Failed to search vectors") from e
def delete(self, ids: List[str]) -> bool:
"""
Delete vectors by ID.
Args:
ids: List of vector IDs to delete
Returns:
True if successful
Raises:
ProcessingError: If read-only mode is active or database operation fails
"""
if not ids:
return True
if self.read_only:
raise ProcessingError("Cannot delete vectors in read-only mode")
with self._lock, self._get_connection() as conn:
try:
cur = conn.cursor()
for vec_id in ids:
cur.execute(
f"DELETE FROM {self.table_name} WHERE id = ?", (vec_id,)
)
conn.commit()
cur.close()
self.logger.info(f"Deleted vectors: {len(ids)}")
return True
except Exception as e:
conn.rollback()
raise ProcessingError("Failed to delete vectors") from e
def update(
self,
ids: List[str],
vectors: Optional[Union[List[np.ndarray], np.ndarray]] = None,
metadata: Optional[List[Dict[str, Any]]] = None,
) -> bool:
"""
Update existing vectors.
Args:
ids: List of vector IDs to update
vectors: Optional new vectors
metadata: Optional new metadata
Returns:
True if successful
Raises:
ValidationError: If input dimensions or lengths don't match
ProcessingError: If read-only mode is active or database operation fails
"""
if not ids:
return True
if self.read_only:
raise ProcessingError("Cannot update vectors in read-only mode")
if vectors is None and metadata is None:
raise ValidationError(
"Either vectors or metadata must be provided for update"
)
if vectors is not None:
if isinstance(vectors, np.ndarray):
vectors = [vectors[i] for i in range(len(vectors))]
if len(vectors) != len(ids):
raise ValidationError("Vectors length must match IDs length")
for i, vec in enumerate(vectors):
if len(vec) != self.dimension:
raise ValidationError(
f"Vector at index {i} has dimension {len(vec)}, expected {self.dimension}"
)
if metadata is not None and len(metadata) != len(ids):
raise ValidationError("Metadata length must match IDs length")
with self._lock, self._get_connection() as conn:
try:
cur = conn.cursor()
for i, vec_id in enumerate(ids):
updates = []
params = []
if vectors is not None:
updates.append("embedding = ?")
params.append(sqlite_vec.serialize_float32(vectors[i]))
if metadata is not None:
updates.append("metadata = ?")
params.append(json.dumps(metadata[i]))
params.append(vec_id)
cur.execute(
f"UPDATE {self.table_name} SET {', '.join(updates)} WHERE id = ?",
params,
)
conn.commit()
cur.close()
self.logger.info(f"Updated {len(ids)} vectors")
return True
except ValidationError:
conn.rollback()
raise
except Exception as e:
conn.rollback()
raise ProcessingError("Failed to update vectors") from e
def get(self, ids: List[str]) -> List[Dict[str, Any]]:
"""
Get vectors by ID.
Args:
ids: List of vector IDs
Returns:
List of dictionaries with id, vector, and metadata
Raises:
ProcessingError: If database operation fails
"""
if not ids:
return []
with self._lock, self._get_connection() as conn:
try:
cur = conn.cursor()
results = []
for vec_id in ids:
cur.execute(
f"SELECT id, embedding, metadata FROM {self.table_name} WHERE id = ?",
(vec_id,),
)
row = cur.fetchone()
if row:
vid, embedding_blob, metadata_json = row
vec = (
np.frombuffer(embedding_blob, dtype=np.float32).copy()
if embedding_blob
else None
)
meta = json.loads(metadata_json) if metadata_json else {}
results.append(
{
"id": vid,
"vector": vec,
"metadata": meta,
}
)
cur.close()
return results
except Exception as e:
raise ProcessingError("Failed to get vectors") from e
def create_index(
self,
index_type: str = "hnsw",
params: Optional[Dict[str, Any]] = None,
) -> bool:
"""
Create an index on the vector column.
For SQLiteVecStore, vec0 virtual tables automatically index vectors,
so this is a no-op that always returns True for interface parity.
"""
self.logger.debug("create_index is a no-op for SQLiteVecStore")
return True
def get_stats(self) -> Dict[str, Any]:
"""
Get store statistics.
Returns:
Dictionary with vector_count, dimension, and distance_metric
"""
with self._lock, self._get_connection() as conn:
try:
cur = conn.cursor()
cur.execute(f"SELECT COUNT(*) FROM {self.table_name}")
count = cur.fetchone()[0]
cur.close()
return {
"vector_count": count,
"dimension": self.dimension,
"distance_metric": self.distance_metric,
}
except Exception as e:
raise ProcessingError("Failed to get store statistics") from e
def close(self):
"""Close the database connection."""
if hasattr(self, "_lock") and self._lock:
with self._lock:
if hasattr(self, "_conn") and self._conn:
try:
self._conn.close()
except Exception:
pass
self._conn = None
else:
if hasattr(self, "_conn") and self._conn:
try:
self._conn.close()
except Exception:
pass
self._conn = None
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
def __del__(self):
self.close()
+27 -2
View File
@@ -90,7 +90,7 @@ class VectorStore:
• Provides vector store operations
"""
SUPPORTED_BACKENDS = {"faiss", "weaviate", "qdrant", "milvus", "pinecone", "pgvector", "inmemory"}
SUPPORTED_BACKENDS = {"faiss", "weaviate", "qdrant", "milvus", "pinecone", "pgvector", "inmemory", "sqlite"}
def __init__(self, backend="faiss", config=None, max_workers: int = 6, **kwargs):
"""Initialize vector store."""
@@ -210,6 +210,31 @@ class VectorStore:
)
self.logger.info(f"Initialized MilvusStore backend")
elif self.backend == "sqlite":
from .sqlite_vec_store import SQLiteVecStore
db_path = self.config.get("db_path") or self.config.get("sqlite_path")
if not db_path:
raise ValueError(
"sqlite backend requires 'db_path' in config. "
"Example: VectorStore(backend='sqlite', config={'db_path': 'vectors.db'})"
)
table_name = self.config.get("table_name", "vectors")
dimension = self.config.get("dimension", 768)
distance_metric = self.config.get("distance_metric", "cosine")
read_only = self.config.get("read_only", False)
self._backend_store = SQLiteVecStore(
db_path=db_path,
table_name=table_name,
dimension=dimension,
distance_metric=distance_metric,
read_only=read_only,
**{k: v for k, v in self.config.items()
if k not in ['db_path', 'sqlite_path', 'table_name', 'dimension', 'distance_metric', 'read_only']}
)
self.logger.info(f"Initialized SQLite backend")
else:
# Fallback to in-memory for unknown backends
self.logger.warning(f"Backend '{self.backend}' not implemented, using in-memory")
@@ -218,7 +243,7 @@ class VectorStore:
except ImportError as e:
raise ImportError(f"Backend '{self.backend}' not available: {e}. Please install required dependencies.") from e
except Exception as e:
if "requires" in str(e) and "connection_string" in str(e):
if "requires" in str(e) and ("connection_string" in str(e) or "db_path" in str(e)):
# Re-raise validation errors for missing required parameters
raise
else:
@@ -1752,6 +1752,64 @@ method_registry.register("store", "normalized", custom_store_vectors)
vector_ids = store_vectors(vectors, metadata=metadata, method="normalized")
```
### SQLite Vector Store (sqlite-vec)
The SQLite vector store backend uses the `sqlite-vec` extension to provide a lightweight, embedded, yet fully persistent vector store. It is ideal for local development, small-to-medium datasets, and embedded applications where setting up a separate PostgreSQL/pgvector instance is not desired.
#### Installation
Install Semantica with SQLite vector store dependencies:
```bash
pip install semantica[vectorstore-sqlite]
```
#### Usage Example
```python
from semantica.vector_store import VectorStore
import numpy as np
# Initialize the vector store using the 'sqlite' backend
# The 'db_path' parameter points to the SQLite database file on disk.
# Use ':memory:' for a transient, in-memory store.
store = VectorStore(
backend="sqlite",
config={
"db_path": "my_vector_database.db",
"table_name": "documents",
"dimension": 128,
"distance_metric": "cosine" # Supported metrics: 'cosine', 'l2'
}
)
# Store vectors with metadata
vectors = [np.random.rand(128).astype(np.float32) for _ in range(5)]
metadata = [
{"category": "ai", "public": True},
{"category": "finance", "public": False},
{"category": "ai", "public": False},
{"category": "healthcare", "public": True},
{"category": "finance", "public": True}
]
ids = store.store_vectors(vectors, metadata=metadata)
# Search vectors with metadata filtering
query = np.random.rand(128).astype(np.float32)
results = store.search_vectors(
query,
k=2,
filter={"category": "ai"}
)
for r in results:
print(f"ID: {r['id']}, Score: {r['score']}, Metadata: {r['metadata']}")
# Close the database connection when done
# (Recommended on Windows to release file handles)
store._backend_store.close()
```
## Best Practices
1. **Vector Storage**:
+416
View File
@@ -0,0 +1,416 @@
"""
SQLite Vector Store Tests
This module provides comprehensive unit tests for the SQLiteVecStore implementation using sqlite-vec.
Tests are skipped if sqlite-vec is not available.
pytest tests/vector_store/test_sqlite_vec_store.py -v
"""
import os
import uuid
import numpy as np
import pytest
from typing import Generator
# Check dependencies
try:
from semantica.vector_store.sqlite_vec_store import SQLITE_VEC_AVAILABLE
except ImportError:
SQLITE_VEC_AVAILABLE = False
# Skip all tests in this file if sqlite-vec is not available
pytestmark = pytest.mark.skipif(
not SQLITE_VEC_AVAILABLE, reason="sqlite-vec not available"
)
@pytest.fixture
def unique_table_name() -> str:
"""Generate a unique table name for test isolation."""
return f"test_vectors_{uuid.uuid4().hex[:8]}"
@pytest.fixture
def db_file(tmp_path) -> str:
"""Create a temporary database file path."""
return str(tmp_path / "test_vectors.db")
@pytest.fixture
def store(db_file, unique_table_name) -> Generator:
"""Create a SQLiteVecStore instance for testing."""
from semantica.vector_store.sqlite_vec_store import SQLiteVecStore
store = SQLiteVecStore(
db_path=db_file,
table_name=unique_table_name,
dimension=128,
distance_metric="cosine",
)
yield store
# Teardown
store.close()
class TestSQLiteVecStoreInit:
"""Test SQLiteVecStore initialization."""
def test_init_success(self, store, db_file):
"""Test successful initialization."""
assert store.dimension == 128
assert store.distance_metric == "cosine"
assert store.table_name.startswith("test_vectors_")
assert os.path.exists(db_file) or store.db_path == ":memory:"
def test_init_unsupported_metric(self, db_file):
"""Test initialization with unsupported distance metric."""
from semantica.vector_store.sqlite_vec_store import SQLiteVecStore
from semantica.utils.exceptions import ValidationError
with pytest.raises(ValidationError, match="Unsupported distance metric"):
SQLiteVecStore(
db_path=db_file,
table_name="test",
dimension=128,
distance_metric="invalid_metric",
)
def test_init_table_creation(self, store):
"""Test that table is created on initialization."""
with store._get_connection() as conn:
cur = conn.cursor()
cur.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name=?",
(store.table_name,),
)
row = cur.fetchone()
cur.close()
assert row is not None
assert row[0] == store.table_name
class TestSQLiteVecStoreAdd:
"""Test vector addition operations."""
def test_add_single_vector(self, store):
"""Test adding a single vector."""
vector = np.random.rand(128).astype(np.float32)
metadata = {"source": "test", "index": 0}
ids = store.add([vector], [metadata], ids=["vec_0"])
assert ids == ["vec_0"]
# Retrieve and verify
res = store.get(["vec_0"])
assert len(res) == 1
assert res[0]["id"] == "vec_0"
assert np.allclose(res[0]["vector"], vector)
assert res[0]["metadata"] == metadata
def test_add_multiple_vectors(self, store):
"""Test adding multiple vectors."""
vectors = [np.random.rand(128).astype(np.float32) for _ in range(5)]
metadata = [{"index": i} for i in range(5)]
ids = store.add(vectors, metadata)
assert len(ids) == 5
assert all(isinstance(id_str, str) for id_str in ids)
def test_add_auto_generate_ids(self, store):
"""Test that IDs are auto-generated if not provided."""
vectors = [np.random.rand(128).astype(np.float32) for _ in range(3)]
ids = store.add(vectors)
assert len(ids) == 3
assert len(set(ids)) == 3 # All unique
def test_add_wrong_dimension(self, store):
"""Test adding vector with wrong dimension."""
from semantica.utils.exceptions import ValidationError
vector = np.random.rand(64).astype(np.float32) # Wrong dimension
with pytest.raises(ValidationError, match="dimension"):
store.add([vector])
def test_add_no_metadata(self, store):
"""Test adding vectors without metadata."""
vectors = [np.random.rand(128).astype(np.float32) for _ in range(2)]
ids = store.add(vectors)
assert len(ids) == 2
res = store.get(ids)
assert all(r["metadata"] == {} for r in res)
def test_add_batch_with_numpy_array(self, store):
"""Test adding vectors as numpy array."""
vectors = np.random.rand(5, 128).astype(np.float32)
ids = store.add(vectors)
assert len(ids) == 5
class TestSQLiteVecStoreSearch:
"""Test vector search operations."""
@pytest.fixture(autouse=True)
def setup_vectors(self, store):
"""Setup test vectors for search tests."""
vectors = []
for i in range(10):
vec = np.zeros(128, dtype=np.float32)
vec[i] = 1.0 # Each vector has peak at different position
vectors.append(vec)
metadata = [{"category": "A" if i < 5 else "B", "index": i} for i in range(10)]
store.add(vectors, metadata)
def test_search_basic(self, store):
"""Test basic similarity search."""
query = np.zeros(128, dtype=np.float32)
query[0] = 1.0 # Should match first vector perfectly
results = store.search(query, top_k=3)
assert len(results) == 3
assert all("id" in r for r in results)
assert all("score" in r for r in results)
assert all("metadata" in r for r in results)
assert results[0]["score"] == pytest.approx(1.0)
def test_search_top_k(self, store):
"""Test search with different top_k values."""
query = np.random.rand(128).astype(np.float32)
results_5 = store.search(query, top_k=5)
results_10 = store.search(query, top_k=10)
assert len(results_5) == 5
assert len(results_10) == 10
def test_search_with_filter(self, store):
"""Test search with metadata filter."""
query = np.zeros(128, dtype=np.float32)
query[0] = 1.0
results = store.search(query, top_k=10, filter={"category": "A"})
assert len(results) <= 5 # Only 5 vectors have category A
assert all(r["metadata"].get("category") == "A" for r in results)
def test_search_wrong_dimension(self, store):
"""Test search with wrong query dimension."""
from semantica.utils.exceptions import ValidationError
query = np.random.rand(64).astype(np.float32)
with pytest.raises(ValidationError, match="dimension"):
store.search(query, top_k=5)
def test_search_empty_store(self, db_file, unique_table_name):
"""Test search on empty store."""
from semantica.vector_store.sqlite_vec_store import SQLiteVecStore
empty_store = SQLiteVecStore(
db_path=db_file,
table_name=unique_table_name + "_empty",
dimension=128,
distance_metric="cosine",
)
query = np.random.rand(128).astype(np.float32)
results = empty_store.search(query, top_k=5)
assert len(results) == 0
empty_store.close()
class TestSQLiteVecStoreGet:
"""Test vector retrieval operations."""
def test_get_existing_vectors(self, store):
"""Test getting existing vectors."""
vectors = [np.random.rand(128).astype(np.float32) for _ in range(3)]
metadata = [{"index": i} for i in range(3)]
ids = store.add(vectors, metadata)
results = store.get(ids)
assert len(results) == 3
result_ids = {r["id"] for r in results}
assert result_ids == set(ids)
assert all(r["vector"] is not None for r in results)
for r in results:
assert r["metadata"]["index"] in [0, 1, 2]
def test_get_nonexistent_ids(self, store):
"""Test getting non-existent vector IDs."""
results = store.get(["nonexistent_1", "nonexistent_2"])
assert len(results) == 0
def test_get_empty_list(self, store):
"""Test getting with empty ID list."""
results = store.get([])
assert results == []
def test_get_partial_ids(self, store):
"""Test getting mix of existing and non-existing IDs."""
vectors = [np.random.rand(128).astype(np.float32)]
ids = store.add(vectors, [{"test": True}])
results = store.get(ids + ["nonexistent"])
assert len(results) == 1
assert results[0]["id"] == ids[0]
class TestSQLiteVecStoreUpdate:
"""Test vector update operations."""
def test_update_vectors(self, store):
"""Test updating vectors."""
vectors = [np.random.rand(128).astype(np.float32) for _ in range(2)]
metadata = [{"version": 1} for _ in range(2)]
ids = store.add(vectors, metadata)
new_vectors = [np.random.rand(128).astype(np.float32) for _ in range(2)]
new_metadata = [{"version": 2} for _ in range(2)]
success = store.update(ids, new_vectors, new_metadata)
assert success is True
results = store.get(ids)
assert len(results) == 2
assert all(r["metadata"]["version"] == 2 for r in results)
for r, new_v in zip(results, new_vectors):
# Find matching original by id to compare
assert np.allclose(r["vector"], new_v) or np.allclose(
results[1 - results.index(r)]["vector"], new_v
)
def test_update_metadata_only(self, store):
"""Test updating only metadata."""
vectors = [np.random.rand(128).astype(np.float32)]
ids = store.add(vectors, [{"tag": "original"}])
success = store.update(ids, metadata=[{"tag": "updated"}])
assert success is True
results = store.get(ids)
assert results[0]["metadata"]["tag"] == "updated"
def test_update_vectors_only(self, store):
"""Test updating only vectors."""
vectors = [np.random.rand(128).astype(np.float32)]
ids = store.add(vectors, [{"tag": "keep"}])
new_vector = np.random.rand(128).astype(np.float32)
success = store.update(ids, vectors=[new_vector])
assert success is True
results = store.get(ids)
assert np.allclose(results[0]["vector"], new_vector)
assert results[0]["metadata"]["tag"] == "keep"
class TestSQLiteVecStoreDelete:
"""Test vector deletion operations."""
def test_delete_vectors(self, store):
"""Test deleting vectors."""
vectors = [np.random.rand(128).astype(np.float32) for _ in range(3)]
ids = store.add(vectors)
# Delete two of them
success = store.delete(ids[:2])
assert success is True
# Check only the third one remains
res = store.get(ids)
assert len(res) == 1
assert res[0]["id"] == ids[2]
def test_delete_empty(self, store):
"""Test deleting empty list of IDs."""
assert store.delete([]) is True
class TestSQLiteVecStoreReadOnly:
"""Test read-only mode behavior."""
def test_read_only_mode(self, db_file, unique_table_name):
"""Test that read-only mode restricts writes but allows reads."""
from semantica.vector_store.sqlite_vec_store import SQLiteVecStore
from semantica.utils.exceptions import ProcessingError
# 1. Create and populate database first
store_write = SQLiteVecStore(
db_path=db_file,
table_name=unique_table_name,
dimension=4,
)
vec = np.array([1, 2, 3, 4], dtype=np.float32)
store_write.add([vec], ids=["v1"])
store_write.close()
# 2. Open in read-only mode
store_ro = SQLiteVecStore(
db_path=db_file,
table_name=unique_table_name,
dimension=4,
read_only=True,
)
# Read should succeed
results = store_ro.get(["v1"])
assert len(results) == 1
assert results[0]["id"] == "v1"
# Search should succeed
search_res = store_ro.search(np.array([1, 2, 3, 4], dtype=np.float32), top_k=1)
assert len(search_res) == 1
# Write should fail
with pytest.raises(ProcessingError, match="read-only"):
store_ro.add([vec], ids=["v2"])
# Update should fail
with pytest.raises(ProcessingError, match="read-only"):
store_ro.update(["v1"], vectors=[vec])
# Delete should fail
with pytest.raises(ProcessingError, match="read-only"):
store_ro.delete(["v1"])
store_ro.close()
class TestSQLiteVecStoreStats:
"""Test store statistics retrieval."""
def test_get_stats(self, store):
"""Test getting stats from store."""
stats = store.get_stats()
assert stats["vector_count"] == 0
assert stats["dimension"] == 128
assert stats["distance_metric"] == "cosine"
# Add vectors and check again
vectors = [np.random.rand(128).astype(np.float32) for _ in range(4)]
store.add(vectors)
stats = store.get_stats()
assert stats["vector_count"] == 4