From a111a5009fdc7c85f165d26a347ca88e10f1abcb Mon Sep 17 00:00:00 2001 From: Pier-Jean Malandrino Date: Fri, 10 Apr 2026 20:40:17 +0200 Subject: [PATCH] feat(domain): add VectorStore port and SearchResult value object Closes #69 --- CHANGELOG.md | 1 + document-parser/domain/ports.py | 50 ++++++++ document-parser/domain/vector_schema.py | 11 ++ document-parser/tests/test_vector_schema.py | 31 +++++ .../tests/test_vector_store_port.py | 113 ++++++++++++++++++ 5 files changed, 206 insertions(+) create mode 100644 document-parser/tests/test_vector_store_port.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bff0be..84674d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/document-parser/domain/ports.py b/document-parser/domain/ports.py index 4c56396..aa852cc 100644 --- a/document-parser/domain/ports.py +++ b/document-parser/domain/ports.py @@ -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.""" + ... diff --git a/document-parser/domain/vector_schema.py b/document-parser/domain/vector_schema.py index 7e52ba1..59512e6 100644 --- a/document-parser/domain/vector_schema.py +++ b/document-parser/domain/vector_schema.py @@ -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 ---------------------------------------------------- diff --git a/document-parser/tests/test_vector_schema.py b/document-parser/tests/test_vector_schema.py index d201280..960fcb4 100644 --- a/document-parser/tests/test_vector_schema.py +++ b/document-parser/tests/test_vector_schema.py @@ -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 diff --git a/document-parser/tests/test_vector_store_port.py b/document-parser/tests/test_vector_store_port.py new file mode 100644 index 0000000..eea70d7 --- /dev/null +++ b/document-parser/tests/test_vector_store_port.py @@ -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)