File size: 9,887 Bytes
32c4506
dc1b199
32c4506
 
 
dc1b199
32c4506
 
 
49f0cfb
32c4506
 
dc1b199
 
 
32c4506
dc1b199
faa8fb3
dc1b199
faa8fb3
49f0cfb
 
dc1b199
 
49f0cfb
dc1b199
 
 
 
49f0cfb
 
dc1b199
 
49f0cfb
dc1b199
49f0cfb
 
dc1b199
 
 
49f0cfb
 
 
dc1b199
 
49f0cfb
 
dc1b199
 
faa8fb3
32c4506
dc1b199
 
 
49f0cfb
 
 
 
faa8fb3
49f0cfb
 
 
 
 
 
 
 
dc1b199
49f0cfb
dc1b199
 
49f0cfb
 
 
dc1b199
49f0cfb
 
dc1b199
 
49f0cfb
dc1b199
49f0cfb
dc1b199
 
32c4506
 
faa8fb3
32c4506
 
dc1b199
32c4506
49f0cfb
dc1b199
 
 
49f0cfb
dc1b199
 
b76f199
 
 
dc1b199
b76f199
dc1b199
49f0cfb
 
dc1b199
 
49f0cfb
 
dc1b199
b76f199
 
dc1b199
 
 
 
b76f199
49f0cfb
732b14f
 
 
 
32c4506
 
 
faa8fb3
732b14f
 
32c4506
 
49f0cfb
 
 
dc1b199
 
faa8fb3
49f0cfb
 
dc1b199
b76f199
 
 
 
 
5fca0ca
 
 
 
 
faa8fb3
 
b76f199
 
 
 
 
 
0b42403
b76f199
 
 
 
 
 
 
 
dc1b199
 
49f0cfb
b76f199
49f0cfb
 
faa8fb3
49f0cfb
b76f199
 
 
 
 
 
 
 
 
 
0b42403
dc1b199
 
 
 
 
 
 
732b14f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dc1b199
49f0cfb
dc1b199
49f0cfb
 
dc1b199
 
49f0cfb
dc1b199
32c4506
 
 
 
faa8fb3
32c4506
faa8fb3
32c4506
 
 
 
 
dc1b199
 
49f0cfb
dc1b199
 
 
 
 
 
 
32c4506
 
 
faa8fb3
 
3f6fdc5
 
 
 
 
 
 
 
49f0cfb
3f6fdc5
32c4506
 
 
faa8fb3
 
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
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
"""LangChain-backed FAISS vector store (default backend).

Uses ``langchain_community.vectorstores.FAISS`` — open source, runs locally,
no separate vector database process. Embeddings and persistence use the
configured LangChain embedder and ``settings.faiss_index_path``.

Tenant isolation: FAISS has no server-side metadata filters; we
over-fetch and post-filter by ``tenant_id`` (same pattern as the legacy
custom FAISS wrapper).

Concurrent writes are serialized with a lock so parallel ingest workers do
not corrupt the in-memory index or on-disk files.
"""

import logging
import threading
from pathlib import Path
from typing import Any, cast

from langchain_community.vectorstores.faiss import FAISS
from langchain_core.documents import Document
from langchain_core.embeddings import Embeddings

from app.config import settings
from app.models.schemas import SearchResult
from app.vectorstore.base import VectorStore

logger = logging.getLogger(__name__)

_FETCH_MULTIPLIER = 20  # over-fetch factor to compensate for post-tenant filtering


class FAISSVectorStore(VectorStore):
    """VectorStore backed by LangChain FAISS with post-hoc tenant filtering.

    Args:
        embedding: LangChain-compatible embeddings instance.

    Example::

        vs = FAISSVectorStore(embedding=get_embedding_client())
        vs.add_documents(docs)
        results = vs.search("Victorian terrace", tenant_id="t-abc", k=5)
    """

    def __init__(self, embedding: Embeddings) -> None:
        self._embedding = embedding
        self._index_path = Path(settings.faiss_index_path)
        self._index_path.mkdir(parents=True, exist_ok=True)
        self._store: FAISS | None = None
        self._lock = threading.RLock()
        self._load()

    def _load(self) -> None:
        """Load a persisted LangChain FAISS index from disk if present."""
        index_file = self._index_path / "index.faiss"
        if index_file.exists():
            try:
                self._store = FAISS.load_local(
                    folder_path=str(self._index_path),
                    embeddings=self._embedding,
                    allow_dangerous_deserialization=True,
                )
                logger.info("Loaded LangChain FAISS index from %s", self._index_path)
            except Exception as exc:
                logger.warning("Could not load FAISS index: %s — starting fresh", exc)
                self._store = None
        else:
            logger.info("No FAISS index found at %s — will create on first add", self._index_path)

    def _save(self) -> None:
        """Persist the current FAISS index to disk."""
        if self._store is not None:
            self._store.save_local(str(self._index_path))

    def add_documents(self, documents: list[Document]) -> None:
        """Embed and insert ``documents`` into the FAISS index.

        Args:
            documents: LangChain Documents with full metadata.
        """
        if not documents:
            return

        with self._lock:
            if self._store is None:
                self._store = FAISS.from_documents(documents, self._embedding)
            else:
                self._store.add_documents(documents)

            self._save()
        logger.debug("Added %d documents to LangChain FAISS", len(documents))

    def search(
        self,
        query: str,
        tenant_id: str,
        k: int = 10,
        *,
        hierarchy_level: str | None = None,
        doc_id_in: frozenset[str] | None = None,
    ) -> list[SearchResult]:
        """Search FAISS and post-filter by ``tenant_id`` (and optional metadata filters).

        Over-fetches by ``_FETCH_MULTIPLIER`` to ensure enough tenant-matching
        results are available after filtering.

        Args:
            query: Plain-text search query.
            tenant_id: Only return chunks belonging to this tenant.
            k: Maximum number of results after filtering.
            hierarchy_level: Restrict to this ``hierarchy_level`` when set.
            doc_id_in: Restrict to these ``doc_id`` values when set.

        Returns:
            Filtered list of :class:`~app.models.schemas.SearchResult`.
        """
        fetch_k = max(k * _FETCH_MULTIPLIER, k * 4)
        try:
            if self._store is None:
                return []
            # Embed outside the index lock so concurrent reads do not serialize on embedding.
            query_vector = self._embedding.embed_query(query)
            with self._lock:
                if self._store is None:
                    return []
                # L2 distance from FAISS — not a 0–1 relevance score; map so higher = closer match.
                pairs = self._store.similarity_search_with_score_by_vector(
                    query_vector,
                    k=fetch_k,
                )
        except Exception as exc:
            logger.warning("FAISS search failed: %s", exc)
            return []

        results: list[SearchResult] = []
        for doc, distance in pairs:
            meta = doc.metadata
            if meta.get("tenant_id") != tenant_id:
                continue
            hl = str(meta.get("hierarchy_level") or "paragraph")
            if hierarchy_level is not None and hl != hierarchy_level:
                continue
            did = str(meta.get("doc_id", ""))
            if doc_id_in is not None and did not in doc_id_in:
                # Always allow knowledge-base (KB) documents through — these are
                # the firm's approved templates / training uploads that should be
                # retrievable regardless of which report is being generated.
                if not meta.get("kb"):
                    continue
            d = float(distance)
            sim = 1.0 / (1.0 + d)
            st = meta.get("section_title")
            sid = meta.get("section_id")
            pi = meta.get("paragraph_index")
            src = meta.get("source")
            kb = meta.get("kb")
            kb_path = meta.get("kb_path")
            chunk_role = meta.get("chunk_role")
            pidx: int | None
            if isinstance(pi, int):
                pidx = pi
            else:
                try:
                    pidx = int(pi) if pi is not None else None
                except (TypeError, ValueError):
                    pidx = None
            results.append(
                SearchResult(
                    chunk_id=str(meta.get("chunk_id", "")),
                    doc_id=did,
                    tenant_id=str(meta.get("tenant_id", "")),
                    text=doc.page_content,
                    score=sim,
                    section_type=str(meta.get("section_type", "paragraph")),
                    hierarchy_level=hl,
                    section_title=str(st) if st is not None else None,
                    section_id=str(sid) if sid is not None else None,
                    paragraph_index=pidx,
                    parent_chunk_id=str(meta.get("parent_chunk_id"))
                    if meta.get("parent_chunk_id")
                    else None,
                    source=str(src) if src is not None else None,
                    kb=bool(kb) if kb is not None else None,
                    kb_path=str(kb_path) if kb_path is not None else None,
                    chunk_role=str(chunk_role) if chunk_role is not None else None,
                )
            )
            if len(results) >= k:
                break

        return results

    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,
    ) -> list[SearchResult]:
        """Non-blocking search via thread pool (embedding no longer under global lock)."""
        from functools import partial

        from app.async_executor import run_sync_in_executor

        return await run_sync_in_executor(
            partial(
                self.search,
                query,
                tenant_id,
                k=k,
                hierarchy_level=hierarchy_level,
                doc_id_in=doc_id_in,
            ),
        )

    def delete_document(self, doc_id: str) -> None:
        """Remove all chunks for ``doc_id``.

        LangChain FAISS supports deletion by document ID if the index was
        built with ``docstore`` support (the default).

        Args:
            doc_id: Document identifier to remove.
        """
        with self._lock:
            if self._store is None:
                return

            ds = cast(Any, self._store.docstore)._dict
            ids_to_delete = [
                doc_id_key for doc_id_key, doc in ds.items() if doc.metadata.get("doc_id") == doc_id
            ]
            if ids_to_delete:
                self._store.delete(ids_to_delete)
                self._save()
                logger.info("Deleted %d chunks for doc_id=%s from FAISS", len(ids_to_delete), doc_id)

    def count(self, tenant_id: str) -> int:
        """Count live chunks for ``tenant_id``.

        Args:
            tenant_id: Tenant identifier.

        Returns:
            Integer chunk count.
        """
        with self._lock:
            if self._store is None:
                return 0
            ds = cast(Any, self._store.docstore)._dict
            return len([d for d in ds.values() if d.metadata.get("tenant_id") == tenant_id])

    def count_for_doc(self, doc_id: str) -> int:
        """Count live chunks for a specific document.

        Args:
            doc_id: Document identifier.

        Returns:
            Integer chunk count for that document.
        """
        with self._lock:
            if self._store is None:
                return 0
            ds = cast(Any, self._store.docstore)._dict
            return len([d for d in ds.values() if d.metadata.get("doc_id") == doc_id])