RAGForge / src /ragforge /retrieval.py
ArchitSharma's picture
Upgrade RAGForge to v1.9 adaptive scale and release readiness
f0307a2
Raw
History Blame Contribute Delete
14.9 kB
from __future__ import annotations
import re
import threading
from collections import defaultdict
from collections.abc import Iterable
import numpy as np
from fastembed import TextEmbedding
from fastembed.rerank.cross_encoder import TextCrossEncoder
from qdrant_client import QdrantClient, models
from rank_bm25 import BM25Okapi
from .config import get_settings
from .schemas import Chunk, SearchHit
class ModelRegistry:
_lock = threading.Lock()
_embedding: TextEmbedding | None = None
_reranker: TextCrossEncoder | None = None
@classmethod
def embedding(cls) -> TextEmbedding:
if cls._embedding is None:
with cls._lock:
if cls._embedding is None:
cls._embedding = TextEmbedding(model_name=get_settings().embedding_model)
return cls._embedding
@classmethod
def reranker(cls) -> TextCrossEncoder:
if cls._reranker is None:
with cls._lock:
if cls._reranker is None:
cls._reranker = TextCrossEncoder(model_name=get_settings().reranker_model)
return cls._reranker
def _tokens(text: str) -> list[str]:
return re.findall(r"[A-Za-z0-9_]+", text.lower())
def _norm01(score: float) -> float:
# Cosine scores from BGE are usually positive for plausible text matches;
# clip rather than pretend a reranker logit is a calibrated probability.
return max(0.0, min(1.0, float(score)))
class HybridRetriever:
"""Dense + BM25 hybrid retriever with optional source-scoped search.
Qdrant remains the primary unfiltered vector store. We additionally keep the
normalized embedding matrix in memory so hierarchical retrieval can search
only the source(s) selected by the source-level index without rebuilding a
vector collection per document.
"""
def __init__(self, collection: str = "chunks"):
self.client = QdrantClient(":memory:")
self.collection = collection
self.chunks: list[Chunk] = []
self.chunk_by_id: dict[str, Chunk] = {}
self.bm25: BM25Okapi | None = None
self._ready = False
self._vectors: np.ndarray | None = None
self._source_indices: dict[str, np.ndarray] = {}
def index(self, chunks: list[Chunk]) -> None:
self.chunks = chunks
self.chunk_by_id = {c.id: c for c in chunks}
self.bm25 = BM25Okapi([_tokens(c.text) for c in chunks]) if chunks else None
self._source_indices = {}
if not chunks:
self._ready = False
self._vectors = None
return
embedding = ModelRegistry.embedding()
vectors = np.asarray(list(embedding.passage_embed([c.text for c in chunks])), dtype=np.float32)
norms = np.linalg.norm(vectors, axis=1, keepdims=True) + 1e-9
self._vectors = vectors / norms
size = int(vectors.shape[1])
for source in {c.source for c in chunks}:
self._source_indices[source] = np.asarray(
[i for i, c in enumerate(chunks) if c.source == source], dtype=np.int32
)
if self.client.collection_exists(self.collection):
self.client.delete_collection(self.collection)
self.client.create_collection(
collection_name=self.collection,
vectors_config={"dense": models.VectorParams(size=size, distance=models.Distance.COSINE)},
)
points = [
models.PointStruct(
id=idx,
vector={"dense": vector.tolist()},
payload={"chunk_id": chunk.id, "source": chunk.source},
)
for idx, (chunk, vector) in enumerate(zip(chunks, vectors))
]
self.client.upload_points(collection_name=self.collection, points=points)
self._ready = True
def index_precomputed(self, chunks: list[Chunk], vectors: np.ndarray) -> None:
"""Index chunks using caller-supplied vectors.
This is primarily used by deterministic scale-stress evaluation, where
existing corpus vectors are cloned for synthetic distractor copies. It
avoids re-embedding hundreds of repeated chunks while exercising the
real Qdrant + BM25 retrieval path.
"""
if len(chunks) != int(getattr(vectors, "shape", [0])[0]):
raise ValueError("chunks and vectors must have the same length")
self.chunks = list(chunks)
self.chunk_by_id = {c.id: c for c in chunks}
self.bm25 = BM25Okapi([_tokens(c.text) for c in chunks]) if chunks else None
self._source_indices = {}
if not chunks:
self._ready = False
self._vectors = None
return
matrix = np.asarray(vectors, dtype=np.float32)
norms = np.linalg.norm(matrix, axis=1, keepdims=True) + 1e-9
self._vectors = matrix / norms
size = int(self._vectors.shape[1])
for source in {c.source for c in chunks}:
self._source_indices[source] = np.asarray(
[i for i, c in enumerate(chunks) if c.source == source], dtype=np.int32
)
if self.client.collection_exists(self.collection):
self.client.delete_collection(self.collection)
self.client.create_collection(
collection_name=self.collection,
vectors_config={"dense": models.VectorParams(size=size, distance=models.Distance.COSINE)},
)
points = [
models.PointStruct(
id=idx,
vector={"dense": vector.tolist()},
payload={"chunk_id": chunk.id, "source": chunk.source},
)
for idx, (chunk, vector) in enumerate(zip(chunks, self._vectors))
]
self.client.upload_points(collection_name=self.collection, points=points)
self._ready = True
def search(
self,
query: str,
top_k: int = 6,
use_reranker: bool = True,
allowed_sources: Iterable[str] | None = None,
) -> list[SearchHit]:
if not self._ready or not self.chunks:
return []
allowed = set(allowed_sources or []) or None
settings = get_settings()
dense = self._dense(query, settings.top_k_dense, allowed)
sparse = self._sparse(query, settings.top_k_sparse, allowed)
fused = self._rrf(dense, sparse)
candidates = fused[: max(top_k * 3, 12)]
if use_reranker and candidates:
self._rerank(query, candidates)
return candidates[:top_k]
def source_balanced_search(
self,
query: str,
top_k: int,
sources: Iterable[str] | None = None,
per_source: int = 1,
use_reranker: bool = True,
) -> list[SearchHit]:
"""Return query-relevant evidence while preventing a long source from monopolizing top-k.
This is task-driven diversity, not a query-string rule. It is used for
overview/cross-document plans where breadth across distinct sources is
part of evidence sufficiency.
"""
if not self._ready or self._vectors is None:
return []
allowed = list(sources or sorted(self._source_indices))
if not allowed:
return []
query_vec = self._query_vector(query)
sparse_scores = self._all_sparse_scores(query)
sparse_max = float(np.max(sparse_scores)) if sparse_scores.size and np.max(sparse_scores) > 0 else 1.0
selected: list[SearchHit] = []
for source in allowed:
idxs = self._source_indices.get(source)
if idxs is None or not len(idxs):
continue
dense_scores = self._vectors[idxs] @ query_vec
local_sparse = sparse_scores[idxs] / sparse_max if sparse_scores.size else np.zeros(len(idxs))
# Dense is the more reliable signal for broad synthesis; BM25 gives
# exact terminology a useful but bounded boost.
combined = 0.75 * np.clip(dense_scores, 0.0, 1.0) + 0.25 * np.clip(local_sparse, 0.0, 1.0)
order = np.argsort(combined)[::-1][: max(1, per_source)]
for local_idx in order:
absolute_idx = int(idxs[int(local_idx)])
chunk = self.chunks[absolute_idx]
selected.append(
SearchHit(
chunk=chunk,
score=float(combined[int(local_idx)]),
dense_score=_norm01(float(dense_scores[int(local_idx)])),
sparse_score=_norm01(float(local_sparse[int(local_idx)])),
)
)
selected.sort(key=lambda h: h.score, reverse=True)
# Preserve at least one candidate per source before allowing a second
# chunk from the same source.
diversified = self._source_round_robin(selected, top_k)
if use_reranker and diversified:
self._rerank(query, diversified, preserve_source_diversity=True)
return diversified[:top_k]
def _query_vector(self, query: str) -> np.ndarray:
vector = np.asarray(list(ModelRegistry.embedding().query_embed([query]))[0], dtype=np.float32)
return vector / (np.linalg.norm(vector) + 1e-9)
def _dense(self, query: str, k: int, allowed_sources: set[str] | None = None) -> list[SearchHit]:
if allowed_sources:
if self._vectors is None:
return []
query_vec = self._query_vector(query)
arrays = [self._source_indices[s] for s in allowed_sources if s in self._source_indices]
idxs = np.concatenate(arrays).astype(np.int32, copy=False) if arrays else np.asarray([], dtype=np.int32)
if not len(idxs):
return []
scores = self._vectors[idxs] @ query_vec
order = np.argsort(scores)[::-1][: min(k, len(idxs))]
hits: list[SearchHit] = []
for local_idx in order:
idx = int(idxs[int(local_idx)])
chunk = self.chunks[idx]
score = _norm01(float(scores[int(local_idx)]))
if float(chunk.metadata.get("injection_score", 0)) >= 0.5:
score *= 0.35
hits.append(SearchHit(chunk=chunk, score=score, dense_score=score))
return hits
emb = self._query_vector(query)
result = self.client.query_points(
collection_name=self.collection,
using="dense",
query=emb.tolist(),
with_payload=True,
limit=min(k, len(self.chunks)),
)
hits: list[SearchHit] = []
for point in result.points:
chunk = self.chunk_by_id.get(point.payload.get("chunk_id"))
if not chunk:
continue
score = _norm01(float(point.score))
if float(chunk.metadata.get("injection_score", 0)) >= 0.5:
score *= 0.35
hits.append(SearchHit(chunk=chunk, score=score, dense_score=score))
return hits
def _all_sparse_scores(self, query: str) -> np.ndarray:
if not self.bm25:
return np.zeros(len(self.chunks), dtype=float)
return np.asarray(self.bm25.get_scores(_tokens(query)), dtype=float)
def _sparse(self, query: str, k: int, allowed_sources: set[str] | None = None) -> list[SearchHit]:
scores = self._all_sparse_scores(query)
if not len(scores):
return []
if allowed_sources:
valid = np.asarray([c.source in allowed_sources for c in self.chunks], dtype=bool)
scores = np.where(valid, scores, -np.inf)
finite = np.isfinite(scores)
if not finite.any():
return []
idxs = np.argsort(scores)[::-1][: min(k, int(finite.sum()))]
positive = [int(i) for i in idxs if np.isfinite(scores[int(i)]) and scores[int(i)] > 0]
if not positive:
return []
max_score = max(float(scores[i]) for i in positive) or 1.0
hits: list[SearchHit] = []
for idx in positive:
norm = float(scores[idx]) / max_score
chunk = self.chunks[idx]
if float(chunk.metadata.get("injection_score", 0)) >= 0.5:
norm *= 0.35
hits.append(SearchHit(chunk=chunk, score=norm, sparse_score=norm))
return hits
def _rerank(self, query: str, hits: list[SearchHit], preserve_source_diversity: bool = False) -> None:
try:
scores = list(ModelRegistry.reranker().rerank(query, [hit.chunk.text for hit in hits]))
for hit, score in zip(hits, scores):
hit.rerank_score = float(score)
hits.sort(key=lambda h: h.rerank_score if h.rerank_score is not None else -999.0, reverse=True)
if preserve_source_diversity:
hits[:] = self._source_round_robin(hits, len(hits))
except Exception:
return
@staticmethod
def _source_round_robin(hits: list[SearchHit], top_k: int) -> list[SearchHit]:
if not hits:
return []
by_source: dict[str, list[SearchHit]] = defaultdict(list)
source_order: list[str] = []
for hit in hits:
if hit.chunk.source not in by_source:
source_order.append(hit.chunk.source)
by_source[hit.chunk.source].append(hit)
out: list[SearchHit] = []
round_idx = 0
while len(out) < top_k:
added = False
for source in source_order:
bucket = by_source[source]
if round_idx < len(bucket):
out.append(bucket[round_idx])
added = True
if len(out) >= top_k:
break
if not added:
break
round_idx += 1
return out
def _rrf(self, dense: list[SearchHit], sparse: list[SearchHit], k: int = 60) -> list[SearchHit]:
scores: dict[str, float] = defaultdict(float)
records: dict[str, SearchHit] = {}
for ranking in (dense, sparse):
for rank, hit in enumerate(ranking, start=1):
scores[hit.chunk.id] += 1.0 / (k + rank)
if hit.chunk.id not in records:
records[hit.chunk.id] = hit
else:
records[hit.chunk.id].dense_score = records[hit.chunk.id].dense_score or hit.dense_score
records[hit.chunk.id].sparse_score = records[hit.chunk.id].sparse_score or hit.sparse_score
ordered = sorted(scores.items(), key=lambda kv: kv[1], reverse=True)
max_rrf = ordered[0][1] if ordered else 1.0
out: list[SearchHit] = []
for chunk_id, score in ordered:
hit = records[chunk_id]
hit.score = score / max_rrf
out.append(hit)
return out