RICS / app /vectorstore /faiss_wrapper.py
StormShadow308's picture
Speed up generation and ingestion; add live report progress on /status.
c893230
Raw
History Blame Contribute Delete
18.4 kB
"""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