feat(vector): add VectorStore ABC and VectorMatch dataclass
This commit is contained in:
parent
ac45067ae7
commit
fe51914902
4 changed files with 141 additions and 0 deletions
3
circuitforge_core/vector/__init__.py
Normal file
3
circuitforge_core/vector/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
from .base import VectorMatch, VectorStore
|
||||||
|
|
||||||
|
__all__ = ["VectorMatch", "VectorStore"]
|
||||||
44
circuitforge_core/vector/base.py
Normal file
44
circuitforge_core/vector/base.py
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
"""
|
||||||
|
circuitforge_core.vector.base — VectorStore ABC and shared types.
|
||||||
|
|
||||||
|
Concrete implementations: LocalSQLiteVecStore (local), QdrantStore (cloud Paid tier).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class VectorMatch:
|
||||||
|
"""A single result from a vector similarity search."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
score: float # lower is better (cosine / L2 distance)
|
||||||
|
metadata: dict = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class VectorStore(ABC):
|
||||||
|
"""Abstract interface for vector storage backends."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def upsert(self, id: str, vector: list[float], metadata: dict) -> None:
|
||||||
|
"""Insert or replace a vector and its metadata."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def query(
|
||||||
|
self,
|
||||||
|
vector: list[float],
|
||||||
|
top_k: int = 10,
|
||||||
|
filter_metadata: dict | None = None,
|
||||||
|
) -> list[VectorMatch]:
|
||||||
|
"""Return the top_k nearest vectors. Optional metadata filter applied post-search."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def delete(self, id: str) -> None:
|
||||||
|
"""Remove a single vector by string ID."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def delete_where(self, filter_metadata: dict) -> int:
|
||||||
|
"""Remove all vectors whose metadata matches all key-value pairs. Returns count."""
|
||||||
0
tests/test_vector/__init__.py
Normal file
0
tests/test_vector/__init__.py
Normal file
94
tests/test_vector/test_base.py
Normal file
94
tests/test_vector/test_base.py
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
# tests/test_vector/test_base.py
|
||||||
|
"""Tests for VectorStore ABC and VectorMatch."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from circuitforge_core.vector.base import VectorMatch, VectorStore
|
||||||
|
|
||||||
|
|
||||||
|
class _ConcreteStore(VectorStore):
|
||||||
|
"""Minimal in-memory implementation for testing the ABC contract."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._data: dict[str, tuple[list[float], dict]] = {}
|
||||||
|
|
||||||
|
def upsert(self, id: str, vector: list[float], metadata: dict) -> None:
|
||||||
|
self._data[id] = (vector, metadata)
|
||||||
|
|
||||||
|
def query(
|
||||||
|
self,
|
||||||
|
vector: list[float],
|
||||||
|
top_k: int = 10,
|
||||||
|
filter_metadata: dict | None = None,
|
||||||
|
) -> list[VectorMatch]:
|
||||||
|
results = [
|
||||||
|
VectorMatch(id=k, score=0.0, metadata=v[1]) for k, v in self._data.items()
|
||||||
|
]
|
||||||
|
if filter_metadata:
|
||||||
|
results = [
|
||||||
|
r
|
||||||
|
for r in results
|
||||||
|
if all(r.metadata.get(k) == val for k, val in filter_metadata.items())
|
||||||
|
]
|
||||||
|
return results[:top_k]
|
||||||
|
|
||||||
|
def delete(self, id: str) -> None:
|
||||||
|
self._data.pop(id, None)
|
||||||
|
|
||||||
|
def delete_where(self, filter_metadata: dict) -> int:
|
||||||
|
to_remove = [
|
||||||
|
k
|
||||||
|
for k, (_, meta) in self._data.items()
|
||||||
|
if all(meta.get(fk) == fv for fk, fv in filter_metadata.items())
|
||||||
|
]
|
||||||
|
for k in to_remove:
|
||||||
|
del self._data[k]
|
||||||
|
return len(to_remove)
|
||||||
|
|
||||||
|
|
||||||
|
def test_vector_match_is_frozen():
|
||||||
|
match = VectorMatch(id="a", score=0.1, metadata={})
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
match.score = 0.5 # type: ignore[misc]
|
||||||
|
|
||||||
|
|
||||||
|
def test_upsert_and_query():
|
||||||
|
store = _ConcreteStore()
|
||||||
|
store.upsert("chunk-1", [0.1, 0.2], {"doc_id": "book-a", "page": 1})
|
||||||
|
results = store.query([0.1, 0.2])
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0].id == "chunk-1"
|
||||||
|
assert results[0].metadata["page"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_filter_metadata():
|
||||||
|
store = _ConcreteStore()
|
||||||
|
store.upsert("c1", [0.1], {"doc_id": "book-a"})
|
||||||
|
store.upsert("c2", [0.2], {"doc_id": "book-b"})
|
||||||
|
results = store.query([0.1], filter_metadata={"doc_id": "book-a"})
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0].id == "c1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete():
|
||||||
|
store = _ConcreteStore()
|
||||||
|
store.upsert("x", [0.1], {})
|
||||||
|
store.delete("x")
|
||||||
|
assert store.query([0.1]) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_where():
|
||||||
|
store = _ConcreteStore()
|
||||||
|
store.upsert("c1", [0.1], {"doc_id": "book-a"})
|
||||||
|
store.upsert("c2", [0.2], {"doc_id": "book-a"})
|
||||||
|
store.upsert("c3", [0.3], {"doc_id": "book-b"})
|
||||||
|
count = store.delete_where({"doc_id": "book-a"})
|
||||||
|
assert count == 2
|
||||||
|
assert len(store.query([0.1])) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_cannot_instantiate_abc_directly():
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
VectorStore() # type: ignore[abstract]
|
||||||
Loading…
Reference in a new issue