"""Abstract base class for LangChain-backed vector store backends. In the LangChain experiment branch the vector store interface changes: - ``add_documents()`` replaces ``add(chunks, vectors)`` — the store handles embedding internally via the injected LangChain ``Embeddings`` object. - ``search()`` now takes a **text query** instead of a pre-computed vector — again the store embeds internally. This enables a much cleaner pipeline: file → loader → splitter → ``add_documents()`` → done query → ``search(text)`` → SearchResult list → reranker → LLM """ from abc import ABC, abstractmethod from langchain_core.documents import Document from app.models.schemas import SearchResult class VectorStore(ABC): """Interface for a LangChain-backed pooled vector store. All implementations must enforce ``tenant_id`` isolation: a search call for tenant A must **never** return chunks belonging to tenant B. The embedding step is delegated to the LangChain ``Embeddings`` object injected at construction time — callers never compute vectors manually. Example:: from app.vectorstore.factory import get_vectorstore vs = get_vectorstore() vs.add_documents(docs) results = vs.search("Victorian terrace", tenant_id="t-abc", k=10) """ @abstractmethod def add_documents(self, documents: list[Document]) -> None: """Embed and insert ``documents`` into the store. Each document must carry ``chunk_id``, ``doc_id``, and ``tenant_id`` fields in its ``metadata`` dict. Args: documents: LangChain :class:`Document` objects with metadata. """ ... @abstractmethod def search( self, query: str, tenant_id: str, k: int = 10, *, hierarchy_level: str | None = None, doc_id_in: frozenset[str] | None = None, purpose_in: frozenset[str] | None = None, exclude_purpose: frozenset[str] | None = None, ) -> list[SearchResult]: """Retrieve top-k chunks for ``tenant_id`` nearest to ``query``. The store embeds the query internally — no pre-computed vector needed. Args: query: Plain-text search query. tenant_id: Only return chunks belonging to this tenant. k: Maximum number of results. hierarchy_level: When set, only rows with this ``hierarchy_level`` metadata. doc_id_in: When set, only rows whose ``doc_id`` is in this set. purpose_in: When set, only rows whose ``document_purpose`` is in this set. Used to retrieve from the style corpus only. exclude_purpose: When set, drop rows whose ``document_purpose`` is in this set. Used to keep style-corpus chunks out of factual retrieval. Returns: List of :class:`~app.models.schemas.SearchResult` ordered by descending similarity score. """ ... @abstractmethod def delete_document(self, doc_id: str) -> None: """Remove all chunks belonging to ``doc_id`` from the store. Args: doc_id: Document identifier whose chunks should be deleted. """ ... @abstractmethod def count(self, tenant_id: str) -> int: """Return total chunk count for ``tenant_id``. Args: tenant_id: Tenant identifier to count. Returns: Integer chunk count. """ ... async def search_async( self, query: str, tenant_id: str, k: int = 10, *, hierarchy_level: str | None = None, doc_id_in: frozenset[str] | None = None, purpose_in: frozenset[str] | None = None, exclude_purpose: frozenset[str] | None = None, ) -> list[SearchResult]: """Async search; default runs sync ``search`` in a thread pool.""" from app.async_executor import run_sync_in_executor return await run_sync_in_executor( self.search, query, tenant_id, k=k, hierarchy_level=hierarchy_level, doc_id_in=doc_id_in, purpose_in=purpose_in, exclude_purpose=exclude_purpose, ) @abstractmethod def count_for_doc(self, doc_id: str) -> int: """Return chunk count for a specific document. Args: doc_id: Document identifier to count. Returns: Integer chunk count for that document only. """ ...