Spaces:
Runtime error
Runtime error
| """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) | |
| 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 | |