File size: 18,357 Bytes
32c4506
dc1b199
32c4506
 
 
dc1b199
32c4506
 
 
49f0cfb
32c4506
 
dc1b199
 
 
c893230
 
32c4506
c893230
dc1b199
faa8fb3
dc1b199
faa8fb3
49f0cfb
 
dc1b199
 
49f0cfb
dc1b199
 
 
 
49f0cfb
c893230
 
49f0cfb
dc1b199
 
49f0cfb
dc1b199
49f0cfb
 
dc1b199
 
 
49f0cfb
 
 
dc1b199
 
49f0cfb
 
dc1b199
 
faa8fb3
32c4506
c893230
dc1b199
 
 
49f0cfb
 
 
 
faa8fb3
49f0cfb
 
 
 
c893230
 
 
 
49f0cfb
c893230
 
49f0cfb
dc1b199
49f0cfb
dc1b199
c893230
 
 
 
 
 
 
 
 
 
 
 
 
 
dc1b199
c893230
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dc1b199
c893230
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49f0cfb
dc1b199
 
49f0cfb
c893230
dc1b199
49f0cfb
dc1b199
 
c893230
32c4506
c893230
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dc1b199
c893230
 
 
32c4506
dc1b199
 
 
49f0cfb
dc1b199
 
b76f199
 
 
c893230
 
dc1b199
b76f199
dc1b199
49f0cfb
 
dc1b199
 
49f0cfb
 
dc1b199
b76f199
 
c893230
 
 
 
 
 
dc1b199
 
 
 
49f0cfb
c893230
732b14f
 
 
 
32c4506
 
 
c893230
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49f0cfb
 
 
dc1b199
 
faa8fb3
49f0cfb
 
dc1b199
b76f199
 
 
 
 
5fca0ca
 
 
 
 
c893230
 
 
 
 
 
 
 
 
 
 
faa8fb3
 
b76f199
 
 
 
 
 
0b42403
b76f199
 
 
 
 
 
 
 
dc1b199
 
49f0cfb
b76f199
49f0cfb
 
faa8fb3
49f0cfb
b76f199
 
 
 
 
 
 
 
 
 
c893230
0b42403
dc1b199
 
 
 
 
 
 
c893230
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
732b14f
 
 
 
 
 
 
 
c893230
 
732b14f
 
 
 
 
 
 
 
 
 
 
 
 
 
c893230
 
732b14f
 
 
dc1b199
49f0cfb
dc1b199
49f0cfb
 
dc1b199
 
49f0cfb
dc1b199
32c4506
 
 
 
faa8fb3
32c4506
faa8fb3
32c4506
 
 
 
 
dc1b199
 
49f0cfb
dc1b199
 
 
 
 
 
 
c893230
32c4506
 
 
faa8fb3
 
3f6fdc5
 
 
 
 
 
 
 
49f0cfb
3f6fdc5
c893230
32c4506
 
 
faa8fb3
 
c893230
 
 
 
 
 
 
 
 
 
 
 
 
 
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
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
"""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 shutil
import tempfile
import threading
import time
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
# When a tenant has few chunks in a large shared index, global top-k never includes them.
_TENANT_SCOPED_SEARCH_MAX = 2500


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._index_disk_mtime: float = 0.0
        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,
                )
                self._ensure_index_compatible()
                if self._store is not None:
                    self._index_disk_mtime = index_file.stat().st_mtime
                    logger.info("Loaded LangChain FAISS index from %s", self._index_path)
            except Exception as exc:
                logger.warning("Could not load FAISS index: %s", exc)
                self._quarantine_corrupt_files(str(exc))
                self._store = None
        else:
            logger.info("No FAISS index found at %s — will create on first add", self._index_path)

    def _quarantine_corrupt_files(self, reason: str) -> None:
        """Move unreadable index files aside so a fresh index can be built."""
        stamp = time.strftime("%Y%m%d-%H%M%S")
        for name in ("index.faiss", "index.pkl"):
            path = self._index_path / name
            if not path.exists():
                continue
            dest = self._index_path / f"{name}.corrupt.{stamp}"
            try:
                path.rename(dest)
                logger.warning("Quarantined corrupt FAISS file %s → %s (%s)", path, dest, reason)
            except OSError as move_exc:
                logger.warning("Could not quarantine %s: %s", path, move_exc)

    def _save(self) -> None:
        """Persist the current FAISS index atomically (temp dir → rename)."""
        if self._store is None:
            return
        self._index_path.mkdir(parents=True, exist_ok=True)
        tmp_dir = Path(tempfile.mkdtemp(prefix="faiss_save_", dir=self._index_path.parent))
        try:
            self._store.save_local(str(tmp_dir))
            for name in ("index.faiss", "index.pkl"):
                src = tmp_dir / name
                if not src.is_file():
                    continue
                dst = self._index_path / name
                tmp_dst = dst.with_suffix(dst.suffix + ".tmp")
                shutil.copy2(src, tmp_dst)
                tmp_dst.replace(dst)
        finally:
            shutil.rmtree(tmp_dir, ignore_errors=True)
        index_file = self._index_path / "index.faiss"
        if index_file.is_file():
            self._index_disk_mtime = index_file.stat().st_mtime

    def _reload_if_disk_newer(self) -> None:
        """Reload index when another process has written newer files to disk."""
        index_file = self._index_path / "index.faiss"
        if not index_file.is_file():
            return
        disk_mtime = index_file.stat().st_mtime
        if disk_mtime <= self._index_disk_mtime:
            return
        with self._lock:
            if not index_file.is_file():
                return
            disk_mtime = index_file.stat().st_mtime
            if disk_mtime <= self._index_disk_mtime:
                return
            logger.info("FAISS index on disk is newer than in-memory copy — reloading")
            self._load()

    def total_vectors(self) -> int:
        """Return FAISS ``ntotal`` (0 when no index is loaded)."""
        with self._lock:
            if self._store is None:
                return 0
            return int(self._store.index.ntotal)

    @property
    def index_loaded(self) -> bool:
        """True when an on-disk index was loaded or documents have been added."""
        return self._store is not None

    def _embedding_dim(self) -> int:
        return len(self._embedding.embed_query("dimension probe"))

    def _index_dim(self) -> int | None:
        if self._store is None:
            return None
        return int(self._store.index.d)

    def _reset_persisted_index(self, reason: str) -> None:
        """Drop in-memory and on-disk index (e.g. embedding model / dimension change)."""
        logger.warning(
            "Resetting FAISS index at %s: %s. Re-upload tenant documents to rebuild the index.",
            self._index_path,
            reason,
        )
        self._store = None
        for name in ("index.faiss", "index.pkl"):
            path = self._index_path / name
            if path.exists():
                path.unlink()

    def _ensure_index_compatible(self) -> None:
        """Invalidate a persisted index built with a different embedding dimension."""
        if self._store is None:
            return
        idx_d = self._index_dim()
        emb_d = self._embedding_dim()
        if idx_d is not None and idx_d != emb_d:
            self._reset_persisted_index(
                f"index dimension {idx_d} != current embeddings {emb_d}",
            )

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

        Args:
            documents: LangChain Documents with full metadata.
            persist: When false, skip the atomic disk write (call :meth:`flush` later).
        """
        if not documents:
            return

        self._reload_if_disk_newer()
        with self._lock:
            self._ensure_index_compatible()
            try:
                if self._store is None:
                    self._store = FAISS.from_documents(documents, self._embedding)
                else:
                    self._store.add_documents(documents)
            except AssertionError as exc:
                idx_d = self._index_dim()
                emb_d = self._embedding_dim()
                raise RuntimeError(
                    f"FAISS add failed (index dim={idx_d}, embeddings dim={emb_d}): {exc!r}"
                ) from exc

            if persist:
                self._save()
        logger.debug("Added %d documents to LangChain FAISS (persist=%s)", len(documents), persist)

    def flush(self) -> None:
        """Persist the in-memory index to disk (no-op when empty)."""
        with self._lock:
            self._save()

    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]:
        """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.
            purpose_in: If set, only return chunks whose ``document_purpose``
                is in this set (e.g. ``{"style_corpus"}`` for style-only
                retrieval). Missing metadata is treated as ``report_source``.
            exclude_purpose: If set, drop chunks whose ``document_purpose`` is
                in this set (e.g. ``{"style_corpus"}`` to keep facts from past
                reports out of new reports' factual retrieval).

        Returns:
            Filtered list of :class:`~app.models.schemas.SearchResult`.
        """
        try:
            self._reload_if_disk_newer()
            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 []
                tenant_n = self.count(tenant_id)
                fetch_k = max(k * _FETCH_MULTIPLIER, k * 4)
                if tenant_n > 0 and tenant_n <= _TENANT_SCOPED_SEARCH_MAX:
                    pairs = self._similarity_pairs_for_tenant(
                        query_vector,
                        tenant_id,
                        fetch_k=fetch_k,
                    )
                else:
                    if tenant_n > 0:
                        fetch_k = min(
                            self.total_vectors(),
                            max(fetch_k, tenant_n * 30),
                        )
                    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
            # ``document_purpose`` discriminator: ``style_corpus`` chunks
            # (the user's past reports) must never appear as factual
            # evidence for a different property. KB chunks are exempt from
            # the purpose filter since they are template/boilerplate, not
            # tenant-private past reports.
            doc_purpose = str(meta.get("document_purpose") or "report_source")
            if not meta.get("kb"):
                if purpose_in is not None and doc_purpose not in purpose_in:
                    continue
                if exclude_purpose is not None and doc_purpose in exclude_purpose:
                    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,
                    document_purpose=doc_purpose,
                    chunk_role=str(chunk_role) if chunk_role is not None else None,
                )
            )
            if len(results) >= k:
                break

        return results

    def _similarity_pairs_for_tenant(
        self,
        query_vector: list[float],
        tenant_id: str,
        *,
        fetch_k: int,
    ) -> list[tuple[Document, float]]:
        """Score only this tenant's vectors (avoids KB/global chunks crowding top-k)."""
        import numpy as np

        store = self._store
        if store is None:
            return []

        q = np.asarray(query_vector, dtype=np.float32)
        id_to_idx = {doc_id: int(idx) for idx, doc_id in store.index_to_docstore_id.items()}
        scored: list[tuple[Document, float]] = []
        for docstore_id, doc in store.docstore._dict.items():
            if doc.metadata.get("tenant_id") != tenant_id:
                continue
            faiss_idx = id_to_idx.get(docstore_id)
            if faiss_idx is None:
                continue
            vec = np.asarray(store.index.reconstruct(faiss_idx), dtype=np.float32)
            scored.append((doc, float(np.linalg.norm(q - vec))))

        scored.sort(key=lambda item: item[1])
        return scored[:fetch_k]

    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]:
        """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,
                purpose_in=purpose_in,
                exclude_purpose=exclude_purpose,
            ),
        )

    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.
        """
        self._reload_if_disk_newer()
        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.
        """
        self._reload_if_disk_newer()
        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])

    def indexed_doc_ids(self) -> set[str]:
        """Set of ``doc_id`` values present in the index (one scan)."""
        self._reload_if_disk_newer()
        with self._lock:
            if self._store is None:
                return set()
            ds = cast(Any, self._store.docstore)._dict
            out: set[str] = set()
            for doc in ds.values():
                did = doc.metadata.get("doc_id")
                if did:
                    out.add(str(did))
            return out