Spaces:
Running
Running
| 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 | |
| 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 | |
| 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 | |
| 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 | |