feat(domain): add VectorStore port and SearchResult value object
Closes #69
This commit is contained in:
parent
b968ea230e
commit
a111a5009f
5 changed files with 206 additions and 0 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
...
|
||||
|
|
|
|||
|
|
@ -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 ----------------------------------------------------
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
113
document-parser/tests/test_vector_store_port.py
Normal file
113
document-parser/tests/test_vector_store_port.py
Normal 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)
|
||||
Loading…
Reference in a new issue