feat(domain): add VectorStore port and SearchResult value object

Closes #69
This commit is contained in:
Pier-Jean Malandrino 2026-04-10 20:40:17 +02:00
parent b968ea230e
commit a111a5009f
5 changed files with 206 additions and 0 deletions

View file

@ -12,6 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
- Docker Compose dev stack (`docker-compose.dev.yml`) with OpenSearch, Dashboards, hot-reload backend and Vite frontend
- Soft-delete chunks: delete button with confirmation dialog, chunks hidden from UI but preserved in data
- Vector index metadata schema: `IndexedChunk` domain model, OpenSearch mapping builder, configurable embedding dimension
- `VectorStore` port (Protocol): `ensure_index`, `index_chunks`, `search_similar`, `get_chunks`, `delete_document`
### Fixed

View file

@ -16,6 +16,7 @@ if TYPE_CHECKING:
ConversionOptions,
ConversionResult,
)
from domain.vector_schema import IndexedChunk, SearchResult
class DocumentConverter(Protocol):
@ -79,3 +80,52 @@ class AnalysisRepository(Protocol):
async def delete(self, job_id: str) -> bool: ...
async def delete_by_document(self, document_id: str) -> int: ...
class VectorStore(Protocol):
"""Port for vector storage and retrieval.
Implementations (OpenSearch, pgvector, Qdrant, etc.) must satisfy this
contract. The port uses domain types from vector_schema no infrastructure
details leak into the domain.
"""
async def ensure_index(self, index_name: str, mapping: dict) -> None:
"""Create the index if it does not exist. No-op if it already exists."""
...
async def index_chunks(self, index_name: str, chunks: list[IndexedChunk]) -> int:
"""Bulk-index a list of chunks. Returns the number of successfully indexed chunks."""
...
async def search_similar(
self,
index_name: str,
embedding: list[float],
*,
k: int = 10,
doc_id: str | None = None,
) -> list[SearchResult]:
"""Find the k nearest chunks by embedding similarity.
Args:
index_name: Target index.
embedding: Query vector.
k: Number of results to return.
doc_id: If provided, restrict search to chunks from this document.
"""
...
async def get_chunks(
self,
index_name: str,
doc_id: str,
*,
limit: int = 1000,
) -> list[SearchResult]:
"""Retrieve all indexed chunks for a given document, ordered by chunk_index."""
...
async def delete_document(self, index_name: str, doc_id: str) -> int:
"""Delete all chunks for a document from the index. Returns count deleted."""
...

View file

@ -104,6 +104,17 @@ class IndexedChunk:
return result
# -- Search result -------------------------------------------------------------
@dataclass(frozen=True)
class SearchResult:
"""A chunk returned from a vector store query."""
chunk: IndexedChunk
score: float # similarity score (higher = more similar)
# -- Index mapping template ----------------------------------------------------

View file

@ -11,6 +11,7 @@ from domain.vector_schema import (
ChunkOrigin,
DocItemRef,
IndexedChunk,
SearchResult,
build_index_mapping,
)
@ -189,6 +190,36 @@ class TestBuildIndexMapping:
assert props[field_name]["type"] == "integer", f"{field_name} should be integer"
class TestSearchResult:
def test_construction(self):
chunk = IndexedChunk(
doc_id="doc-1",
filename="test.pdf",
content="Hello",
embedding=[0.1] * 384,
chunk_index=0,
chunk_type="text",
page_number=1,
)
result = SearchResult(chunk=chunk, score=0.95)
assert result.chunk.content == "Hello"
assert result.score == 0.95
def test_frozen(self):
chunk = IndexedChunk(
doc_id="d",
filename="f",
content="c",
embedding=[],
chunk_index=0,
chunk_type="text",
page_number=1,
)
result = SearchResult(chunk=chunk, score=0.5)
with pytest.raises(AttributeError):
result.score = 0.9 # type: ignore[misc]
class TestConstants:
def test_default_embedding_dimension(self):
assert DEFAULT_EMBEDDING_DIMENSION == 384

View file

@ -0,0 +1,113 @@
"""Tests for VectorStore port — verify the protocol contract is implementable."""
from __future__ import annotations
import pytest
from domain.ports import VectorStore
from domain.vector_schema import IndexedChunk, SearchResult
class FakeVectorStore:
"""Minimal concrete implementation to verify the protocol is implementable."""
async def ensure_index(self, index_name: str, mapping: dict) -> None:
pass
async def index_chunks(self, index_name: str, chunks: list[IndexedChunk]) -> int:
return len(chunks)
async def search_similar(
self,
index_name: str,
embedding: list[float],
*,
k: int = 10,
doc_id: str | None = None,
) -> list[SearchResult]:
return []
async def get_chunks(
self,
index_name: str,
doc_id: str,
*,
limit: int = 1000,
) -> list[SearchResult]:
return []
async def delete_document(self, index_name: str, doc_id: str) -> int:
return 0
class TestVectorStorePort:
def test_fake_satisfies_protocol(self):
"""A class implementing all methods is accepted as a VectorStore."""
store: VectorStore = FakeVectorStore()
assert store is not None
@pytest.mark.asyncio
async def test_ensure_index(self):
store = FakeVectorStore()
await store.ensure_index("test-index", {"mappings": {}})
@pytest.mark.asyncio
async def test_index_chunks(self):
store = FakeVectorStore()
chunk = IndexedChunk(
doc_id="d1",
filename="test.pdf",
content="Hello",
embedding=[0.1] * 384,
chunk_index=0,
chunk_type="text",
page_number=1,
)
count = await store.index_chunks("test-index", [chunk])
assert count == 1
@pytest.mark.asyncio
async def test_search_similar(self):
store = FakeVectorStore()
results = await store.search_similar("test-index", [0.1] * 384, k=5)
assert results == []
@pytest.mark.asyncio
async def test_search_similar_with_doc_filter(self):
store = FakeVectorStore()
results = await store.search_similar("test-index", [0.1] * 384, k=5, doc_id="d1")
assert results == []
@pytest.mark.asyncio
async def test_get_chunks(self):
store = FakeVectorStore()
results = await store.get_chunks("test-index", "d1")
assert results == []
@pytest.mark.asyncio
async def test_get_chunks_with_limit(self):
store = FakeVectorStore()
results = await store.get_chunks("test-index", "d1", limit=50)
assert results == []
@pytest.mark.asyncio
async def test_delete_document(self):
store = FakeVectorStore()
count = await store.delete_document("test-index", "d1")
assert count == 0
def test_protocol_methods_list(self):
"""Verify the protocol exposes the expected methods."""
expected = {
"ensure_index",
"index_chunks",
"search_similar",
"get_chunks",
"delete_document",
}
protocol_methods = {
name
for name in dir(VectorStore)
if not name.startswith("_") and callable(getattr(VectorStore, name, None))
}
assert expected.issubset(protocol_methods)