File size: 4,606 Bytes
49f0cfb
 
 
 
 
 
 
 
 
 
 
 
 
dc1b199
 
 
49f0cfb
 
 
dc1b199
 
 
49f0cfb
dc1b199
 
 
 
49f0cfb
 
 
dc1b199
 
32c4506
 
49f0cfb
 
dc1b199
 
 
49f0cfb
 
 
 
 
dc1b199
 
49f0cfb
dc1b199
 
 
 
 
 
49f0cfb
dc1b199
 
b76f199
 
 
c893230
 
dc1b199
49f0cfb
dc1b199
49f0cfb
dc1b199
 
49f0cfb
dc1b199
49f0cfb
b76f199
 
c893230
 
 
 
 
dc1b199
 
 
 
 
 
 
 
 
 
 
 
49f0cfb
dc1b199
 
 
 
 
49f0cfb
dc1b199
 
 
 
 
 
 
 
3f6fdc5
732b14f
 
 
 
 
 
 
 
c893230
 
732b14f
 
 
 
 
 
 
 
 
 
 
c893230
 
732b14f
 
3f6fdc5
 
49f0cfb
3f6fdc5
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
"""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.
        """
        ...