lexguard-backend / app /services /retrieval.py
Dar4devil's picture
LexGuard backend
c34b339
Raw
History Blame Contribute Delete
4.91 kB
"""RAG benchmark store: ChromaDB (in-memory) + BGE-small embeddings.
Loads `app/benchmarks/seed_clauses.json` at warmup, embeds with sentence-transformers
BGE-small, and exposes `fetch_benchmark_matches()` returning BenchmarkMatch objects
that carry semantic similarity scores (0-1).
"""
from __future__ import annotations
import json
import logging
import threading
from pathlib import Path
from app.schemas import BenchmarkMatch, BenchmarkRef, ClauseType
logger = logging.getLogger(__name__)
_BENCHMARK_PATH = Path(__file__).parents[1] / "benchmarks" / "seed_clauses.json"
_BGE_MODEL = "BAAI/bge-small-en-v1.5"
_COLLECTION_NAME = "benchmark_clauses"
_warmup_lock = threading.Lock()
_warmed = False
_collection = None
_embedder = None
_by_type: dict[str, list[BenchmarkRef]] = {}
def _load_seed() -> list[BenchmarkRef]:
if not _BENCHMARK_PATH.exists():
logger.warning("seed clauses file not found at %s", _BENCHMARK_PATH)
return []
raw = json.loads(_BENCHMARK_PATH.read_text(encoding="utf-8"))
return [BenchmarkRef(**item) for item in raw]
def warmup() -> None:
global _warmed, _collection, _embedder, _by_type
with _warmup_lock:
if _warmed:
return
seed = _load_seed()
for ref in seed:
_by_type.setdefault(ref.clause_type, []).append(ref)
logger.info("Loaded %d benchmark clauses into type index", len(seed))
try:
import chromadb
from sentence_transformers import SentenceTransformer
except Exception as exc:
logger.warning(
"Chroma or sentence-transformers unavailable (%s); type-only retrieval active",
exc,
)
_warmed = True
return
try:
_embedder = SentenceTransformer(_BGE_MODEL)
client = chromadb.EphemeralClient()
try:
client.delete_collection(_COLLECTION_NAME)
except Exception:
pass
_collection = client.create_collection(_COLLECTION_NAME)
if seed:
ids = [ref.id for ref in seed]
docs = [ref.text for ref in seed]
metas = [
{"clause_type": ref.clause_type, "doc_type": ref.doc_type}
for ref in seed
]
embeddings = _embedder.encode(docs, normalize_embeddings=True).tolist()
_collection.add(ids=ids, documents=docs, embeddings=embeddings, metadatas=metas)
logger.info(
"ChromaDB vector store ready — %d clauses indexed with BGE-small embeddings",
len(seed),
)
except Exception as exc:
logger.exception("Chroma initialization failed (%s); falling back to type-only", exc)
_collection = None
_embedder = None
_warmed = True
def fetch_benchmark_matches(
clause_text: str, clause_type: ClauseType, k: int = 2
) -> list[BenchmarkMatch]:
"""Return up to k BenchmarkMatch objects ranked by semantic similarity."""
same_type = _by_type.get(clause_type, [])
if not same_type:
return []
if _collection is None or _embedder is None:
# Fallback: return type-only matches with zero similarity
return [BenchmarkMatch(ref=ref, similarity=0.0) for ref in same_type[:k]]
try:
query_vec = _embedder.encode([clause_text], normalize_embeddings=True).tolist()
n = min(max(k * 3, 6), len(same_type))
result = _collection.query(
query_embeddings=query_vec,
n_results=n,
where={"clause_type": clause_type},
)
hit_ids = result.get("ids", [[]])[0]
distances = result.get("distances", [[]])[0]
by_id: dict[str, BenchmarkRef] = {ref.id: ref for ref in same_type}
matches: list[BenchmarkMatch] = []
for hit_id, dist in zip(hit_ids, distances):
if hit_id in by_id:
# ChromaDB cosine distance: 0=identical, 2=opposite → similarity = 1 - dist/2
sim = max(0.0, min(1.0, 1.0 - dist / 2.0))
matches.append(BenchmarkMatch(ref=by_id[hit_id], similarity=sim))
return matches[:k] if matches else [BenchmarkMatch(ref=r, similarity=0.0) for r in same_type[:k]]
except Exception as exc:
logger.warning("semantic retrieval failed (%s); returning type-only", exc)
return [BenchmarkMatch(ref=ref, similarity=0.0) for ref in same_type[:k]]
# Legacy shim for any callers that still use the old signature
def fetch_benchmarks_by_text(clause_text: str, clause_type: ClauseType, k: int = 2):
return [m.ref for m in fetch_benchmark_matches(clause_text, clause_type, k)]
def fetch_benchmarks(clause_type: ClauseType, k: int = 2):
refs = _by_type.get(clause_type, [])
return refs[:k]