RICS / backend /core /rag_store.py
StormShadow308's picture
Add demo documentation and Docker setup for v2 report generation system
aad7814
Raw
History Blame Contribute Delete
22.7 kB
"""Per-tenant, two-tier FAISS vector store.
Tiers:
* ``TIER_MASTER`` — operator master-template paragraphs. **Not** scrubbed at
ingest (the master is property-agnostic boilerplate); it carries the firm's
approved wording and is the structural authority.
* ``TIER_REFERENCE`` — past completed reports. **Always** scrubbed at ingest;
used only for style/terminology. A read-time scrub backstop guards against any
chunk that slipped through unscrubbed.
Each (tenant, tier) pair is an independent ``IndexFlatIP`` over L2-normalised
embeddings (so inner product == cosine similarity), persisted to disk alongside a
JSON metadata sidecar.
"""
from __future__ import annotations
import json
import logging
import threading
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
import numpy as np
from backend.config import settings
from backend.core import pii_scrubber
from backend.core.embeddings import Embedder, get_embedder
from backend.core.reference_filter import build_reference_allowlist, meta_matches_allowlist
from backend.utils import tenant_store
logger = logging.getLogger(__name__)
TIER_MASTER = "master"
TIER_REFERENCE = "reference"
@dataclass
class Chunk:
text: str
section_id: str = ""
doc_id: str = ""
tier: str = TIER_MASTER
is_scrubbed: bool = False
chunk_id: str = ""
source_filename: str = ""
paragraph_index: int = 0
@dataclass
class SearchHit:
text: str
section_id: str
doc_id: str
tier: str
score: float
is_scrubbed: bool
source_filename: str = ""
paragraph_index: int = 0
chunk_id: str = ""
def _search_hit_from_meta(
meta: dict,
*,
tier: str,
rank: float,
text: str,
) -> SearchHit:
return SearchHit(
text=text,
section_id=meta.get("section_id", ""),
doc_id=meta.get("doc_id", ""),
tier=tier,
score=rank,
is_scrubbed=bool(meta.get("is_scrubbed")),
source_filename=meta.get("source_filename", ""),
paragraph_index=int(meta.get("paragraph_index") or 0),
chunk_id=meta.get("chunk_id", ""),
)
@dataclass
class _TierIndex:
index: object # faiss.IndexFlatIP
meta: list[dict] = field(default_factory=list)
class RagStore:
"""Lazy, thread-safe per-tenant/tier FAISS store."""
def __init__(self, embedder: Embedder | None = None) -> None:
self._embedder = embedder or get_embedder()
self._cache: dict[tuple[str, str], _TierIndex] = {}
self._lock = threading.RLock()
# ── persistence ──────────────────────────────────────────────────────────
def _paths(self, tenant_id: str, tier: str) -> tuple[Path, Path]:
d = tenant_store.faiss_dir(tenant_id, tier)
return d / "index.faiss", d / "meta.json"
def _new_index(self):
import faiss
return faiss.IndexFlatIP(self._embedder.embed_dim)
def _load(self, tenant_id: str, tier: str) -> _TierIndex:
import faiss
idx_path, meta_path = self._paths(tenant_id, tier)
if idx_path.is_file() and meta_path.is_file():
try:
# io_flags=0: load into RAM (avoid mmap — Windows cannot replace mmap'd files).
index = faiss.read_index(str(idx_path), 0)
meta = json.loads(meta_path.read_text(encoding="utf-8"))
return _TierIndex(index=index, meta=meta)
except Exception as exc: # noqa: BLE001
logger.warning("Failed to load FAISS %s/%s (%s); rebuilding empty.",
tenant_id, tier, exc)
return _TierIndex(index=self._new_index(), meta=[])
def _persist(self, tenant_id: str, tier: str, ti: _TierIndex) -> None:
import faiss
import os
import tempfile
from backend.utils.runtime_paths import ensure_data_drive_runtime_dirs
ensure_data_drive_runtime_dirs()
idx_path, meta_path = self._paths(tenant_id, tier)
idx_path = idx_path.resolve()
meta_path = meta_path.resolve()
idx_path.parent.mkdir(parents=True, exist_ok=True)
payload = json.dumps(ti.meta, ensure_ascii=False, separators=(",", ":"))
scratch_root = settings.data_dir_path / "tmp" / "faiss_persist"
scratch_root.mkdir(parents=True, exist_ok=True)
def _atomic_write() -> None:
with tempfile.TemporaryDirectory(prefix="faiss_", dir=str(scratch_root)) as td:
tmp_dir = Path(td)
tmp_idx = tmp_dir / "index.faiss"
tmp_meta = tmp_dir / "meta.json"
faiss.write_index(ti.index, os.fspath(tmp_idx))
tmp_meta.write_text(payload, encoding="utf-8")
for final, tmp in ((idx_path, tmp_idx), (meta_path, tmp_meta)):
final_s = os.fspath(final)
tmp_s = os.fspath(tmp)
if os.path.exists(final_s):
os.remove(final_s)
os.replace(tmp_s, final_s)
try:
_atomic_write()
except OSError as exc:
logger.error(
"FAISS persist failed for %s/%s (%s); quarantining and retrying once.",
tenant_id,
tier,
exc,
)
for stale in idx_path.parent.glob("*.write.*"):
stale.unlink(missing_ok=True)
for final in (idx_path, meta_path):
if final.is_file():
bad = final.with_suffix(final.suffix + ".bad")
if bad.is_file():
bad.unlink(missing_ok=True)
os.replace(os.fspath(final), os.fspath(bad))
_atomic_write()
def _get(self, tenant_id: str, tier: str) -> _TierIndex:
key = (tenant_id, tier)
with self._lock:
if key not in self._cache:
self._cache[key] = self._load(tenant_id, tier)
return self._cache[key]
# ── ingest ─────────────────────────────────────────────────────────────--
def ingest_document(
self,
tenant_id: str,
doc_id: str,
chunks: list[Chunk],
*,
tier: str,
source_filename: str = "",
) -> int:
"""Embed and add chunks. REFERENCE-tier chunks are PII-scrubbed before storage."""
prepared: list[Chunk] = []
audit_entries: list[dict] = []
# One session per document so the same name/address/reference is masked
# to one stable token across every chunk of this file (referential
# integrity), while distinct values remain distinguishable.
scrub_session = (
pii_scrubber.ScrubSession() if tier == TIER_REFERENCE else None
)
for c in chunks:
text = c.text or ""
scrubbed = c.is_scrubbed
chunk_audit: dict[str, int] = {}
if tier == TIER_REFERENCE and not scrubbed:
result = pii_scrubber.scrub_reference_for_ingest(
text, session=scrub_session
)
if result is None:
logger.warning(
"Skipping REFERENCE chunk from %s (PII could not be fully redacted)",
doc_id,
)
continue
text = result.text
chunk_audit = dict(result.audit)
scrubbed = True
if not text.strip():
continue
if chunk_audit:
audit_entries.append({
"redactions": [
{"type": k, "count": v} for k, v in sorted(chunk_audit.items())
],
"is_scrubbed": True,
})
prepared.append(
Chunk(
text=text,
section_id=c.section_id,
doc_id=c.doc_id or doc_id,
tier=tier,
is_scrubbed=scrubbed,
chunk_id=c.chunk_id,
source_filename=c.source_filename or source_filename,
paragraph_index=c.paragraph_index,
)
)
if not prepared:
return 0
if tier == TIER_REFERENCE and audit_entries:
self._append_scrub_audit(
tenant_id,
doc_id=doc_id,
chunks_processed=len(audit_entries),
chunk_audits=audit_entries,
)
vecs = self._embedder.embed_documents([c.text for c in prepared])
arr = np.asarray(vecs, dtype="float32")
with self._lock:
ti = self._get(tenant_id, tier)
ti.index.add(arr)
base = len(ti.meta)
for i, c in enumerate(prepared):
cid = c.chunk_id or f"{doc_id}:{base + i}"
src = c.source_filename or source_filename or doc_id.split(":", 1)[-1]
ti.meta.append({
"text": c.text,
"paragraph_text": c.text,
"section_id": c.section_id,
"doc_id": c.doc_id or doc_id,
"tier": tier,
"is_scrubbed": c.is_scrubbed,
"chunk_id": cid,
"source_filename": src,
"paragraph_index": c.paragraph_index or 0,
})
self._persist(tenant_id, tier, ti)
logger.info("Ingested %d chunks into %s/%s (doc=%s)",
len(prepared), tenant_id, tier, doc_id)
return len(prepared)
@staticmethod
def _append_scrub_audit(
tenant_id: str,
*,
doc_id: str,
chunks_processed: int,
chunk_audits: list[dict],
) -> None:
"""Persist scrub audit metadata (types and counts only, never originals)."""
record = {
"document": doc_id,
"timestamp": datetime.now(timezone.utc).isoformat(),
"chunks_processed": chunks_processed,
"chunks": chunk_audits,
}
path = tenant_store.scrub_audit_path(tenant_id)
with path.open("a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
def clear_tier(self, tenant_id: str, tier: str) -> None:
"""Drop all vectors for a (tenant, tier) — used by admin override."""
with self._lock:
ti = _TierIndex(index=self._new_index(), meta=[])
self._cache[(tenant_id, tier)] = ti
self._persist(tenant_id, tier, ti)
def evict_tier_cache(self, tenant_id: str, tier: str) -> None:
"""Drop in-memory index without persisting (used before disk purge)."""
with self._lock:
self._cache.pop((tenant_id, tier), None)
def remove_document(
self,
tenant_id: str,
tier: str,
*,
source_filename: str | None = None,
doc_id: str | None = None,
) -> int:
"""Drop all chunks for a reference upload and rebuild the FAISS index."""
if not source_filename and not doc_id:
return 0
with self._lock:
ti = self._get(tenant_id, tier)
kept: list[dict] = []
removed = 0
for row in ti.meta:
sf = str(row.get("source_filename") or "").strip()
did = str(row.get("doc_id") or "").strip()
match = False
if source_filename and sf == source_filename:
match = True
if doc_id and did == doc_id:
match = True
if source_filename and did == f"reference:{source_filename}":
match = True
if match:
removed += 1
else:
kept.append(row)
if removed == 0:
return 0
if not kept:
ti = _TierIndex(index=self._new_index(), meta=[])
else:
texts = [r["text"] for r in kept]
vecs = self._embedder.embed_documents(texts)
arr = np.asarray(vecs, dtype="float32")
index = self._new_index()
index.add(arr)
ti = _TierIndex(index=index, meta=kept)
self._cache[(tenant_id, tier)] = ti
self._persist(tenant_id, tier, ti)
logger.info(
"Removed %d chunk(s) from tenant=%s tier=%s (source=%s doc_id=%s)",
removed,
tenant_id,
tier,
source_filename,
doc_id,
)
return removed
def count(self, tenant_id: str, tier: str) -> int:
return len(self._get(tenant_id, tier).meta)
def list_source_filenames(self, tenant_id: str, tier: str) -> list[str]:
"""Unique source filenames ingested for a tier."""
seen: set[str] = set()
out: list[str] = []
for row in self._get(tenant_id, tier).meta:
name = str(row.get("source_filename") or row.get("doc_id") or "").strip()
if name.startswith("reference:"):
name = name.split(":", 1)[-1]
if name and name not in seen:
seen.add(name)
out.append(name)
return out
def sample_chunk_texts(self, tenant_id: str, tier: str, *, limit: int = 40) -> list[str]:
"""Return up to ``limit`` chunk texts from a tier (for style analysis)."""
texts: list[str] = []
for row in self._get(tenant_id, tier).meta:
text = str(row.get("text") or "").strip()
if text:
texts.append(text)
if len(texts) >= limit:
break
return texts
def fetch_section_chunks(
self,
tenant_id: str,
*,
tier: str,
section_id: str,
source_key: str | None = None,
allowed_doc_keys: frozenset[str] | None = None,
) -> list[SearchHit]:
"""Return EVERY stored chunk for a ``(tenant, tier, section_id)`` in document
order — a metadata scan, NOT a similarity search.
This backs section-complete baseline assembly: the full past-report section is
mapped, not only the top-K semantically nearest chunks. REFERENCE chunks pass
the same read-time scrub backstop as :meth:`search` (unscrubbed dropped, text
re-sanitised). When ``source_key`` is given, only chunks from that one document
(matched on ``source_filename`` or ``doc_id``) are returned, pinning assembly to
a single report. When ``allowed_doc_keys`` is set, the upload allowlist applies.
"""
want = (section_id or "").strip().upper()
if not want:
return []
ti = self._get(tenant_id, tier)
out: list[SearchHit] = []
for m in ti.meta:
if (m.get("section_id") or "").strip().upper() != want:
continue
if allowed_doc_keys is not None and not meta_matches_allowlist(m, allowed_doc_keys):
continue
if source_key is not None:
key = (m.get("source_filename") or m.get("doc_id") or "").strip()
if key != source_key:
continue
if tier == TIER_REFERENCE and not m.get("is_scrubbed"):
continue
text = m.get("text") or ""
if tier == TIER_REFERENCE:
text = pii_scrubber.sanitize_for_generation_context(text)
if not text.strip():
continue
# Uniform rank: ordering is by document position, not similarity.
out.append(_search_hit_from_meta(m, tier=tier, rank=1.0, text=text))
out.sort(key=lambda h: (h.paragraph_index or 0, h.chunk_id or ""))
return out
# ── search ─────────────────────────────────────────────────────────────--
def search(
self,
tenant_id: str,
query: str,
*,
tier: str | None = None,
section_id: str | None = None,
top_k: int = 5,
section_strict: bool = False,
allowed_doc_keys: frozenset[str] | None = None,
reference_document_ids: list[str] | None = None,
strict_uploaded_only: bool = False,
) -> list[SearchHit]:
"""Search one or both tiers, ranked by similarity.
When ``tier`` is None, MASTER hits are ranked ahead of REFERENCE hits at
equal relevance (master is authoritative). When ``section_id`` is given,
chunks tagged with that section are boosted.
When ``section_strict`` is True, only chunks whose ``section_id`` equals
``section_id`` are returned (used to pin mapping to the correct paragraph).
When ``allowed_doc_keys`` is set, only chunks whose ``doc_id`` or
``source_filename`` appears in the allowlist are returned.
When ``strict_uploaded_only`` is True and ``reference_document_ids`` is
non-empty, builds an allowlist via :func:`build_reference_allowlist` and
drops hits that fail :func:`meta_matches_allowlist` on ``doc_id`` or
``source_filename``.
"""
if allowed_doc_keys is None and strict_uploaded_only:
allowed_doc_keys = build_reference_allowlist(
tenant_id,
reference_document_ids,
strict_uploaded_only=True,
)
tiers = [tier] if tier else [TIER_MASTER, TIER_REFERENCE]
hits: list[SearchHit] = []
qvec = np.asarray([self._embedder.embed_query(query)], dtype="float32")
pool_mult = 20 if allowed_doc_keys else 3
for t in tiers:
ti = self._get(tenant_id, t)
n = len(ti.meta)
if n == 0:
continue
k = min(max(top_k * pool_mult, top_k), n)
scores, idxs = ti.index.search(qvec, k)
for score, i in zip(scores[0], idxs[0]):
if i < 0:
continue
m = ti.meta[i]
if allowed_doc_keys is not None and not meta_matches_allowlist(m, allowed_doc_keys):
continue
if section_strict and section_id and m.get("section_id") != section_id:
continue
# Reference backstop: never surface unscrubbed reference chunks.
if t == TIER_REFERENCE and not m.get("is_scrubbed"):
continue
text = m["text"]
if t == TIER_REFERENCE:
text = pii_scrubber.sanitize_for_generation_context(text)
if not text.strip():
continue
rank = float(score)
if t == TIER_MASTER:
rank += 0.05 # authoritative tier nudge
if section_id and m.get("section_id") == section_id:
rank += settings.retrieval_section_boost
hits.append(_search_hit_from_meta(m, tier=t, rank=rank, text=text))
hits.sort(key=lambda h: h.score, reverse=True)
return hits[:top_k]
def search_for_generation(
self,
tenant_id: str,
query: str,
*,
section_id: str | None = None,
top_k: int = 5,
section_strict: bool = False,
) -> list[SearchHit]:
"""Retrieve paragraphs for report mapping — MASTER boilerplate only.
Past completed reports (REFERENCE tier) are never passed to the mapping
step, so another property's sensitive data cannot enter the current report.
"""
hits = self.search(
tenant_id,
query,
tier=TIER_MASTER,
section_id=section_id,
top_k=top_k,
section_strict=section_strict,
)
safe: list[SearchHit] = []
for h in hits:
text = pii_scrubber.sanitize_for_generation_context(h.text)
if not text.strip():
continue
safe.append(
_search_hit_from_meta(
{
"section_id": h.section_id,
"doc_id": h.doc_id,
"is_scrubbed": h.is_scrubbed,
"source_filename": h.source_filename,
"paragraph_index": h.paragraph_index,
"chunk_id": h.chunk_id,
},
tier=h.tier,
rank=h.score,
text=text,
)
)
return safe
def search_for_reference_mapping(
self,
tenant_id: str,
query: str,
*,
section_id: str | None = None,
top_k: int = 5,
section_strict: bool = False,
allowed_doc_keys: frozenset[str] | None = None,
) -> list[SearchHit]:
"""Retrieve scrubbed past-report excerpts for minimum-AI mapping."""
hits = self.search(
tenant_id,
query,
tier=TIER_REFERENCE,
section_id=section_id,
top_k=top_k,
section_strict=section_strict,
allowed_doc_keys=allowed_doc_keys,
)
safe: list[SearchHit] = []
for h in hits:
text = pii_scrubber.sanitize_for_generation_context(h.text)
if not text.strip():
continue
safe.append(
_search_hit_from_meta(
{
"section_id": h.section_id,
"doc_id": h.doc_id,
"is_scrubbed": h.is_scrubbed,
"source_filename": h.source_filename,
"paragraph_index": h.paragraph_index,
"chunk_id": h.chunk_id,
},
tier=h.tier,
rank=h.score,
text=text,
)
)
return safe
_instance: RagStore | None = None
def get_rag_store() -> RagStore:
global _instance
if _instance is None:
_instance = RagStore()
return _instance
def reset_rag_store() -> None:
global _instance
_instance = None