Spaces:
Running
Running
| """ | |
| Measure Recall@K of candidate pools β answers "what's the reranker's ceiling?" | |
| For each query, the cross-encoder can only re-rank documents that the upstream | |
| retriever put in the pool. So Recall@K of the pool is the upper bound on what | |
| any reranker can achieve at top-K. This script reports that ceiling for four | |
| pool strategies, at three pool sizes: | |
| - BM25 top-K | |
| - Dense top-K | |
| - RRF(BM25 top-K, Dense top-K) top-K (k=60, missing rank = K+1) | |
| - Set union (BM25 top-K βͺ Dense top-K) β absolute ceiling for any fusion | |
| The gap between RRF and set-union tells how much ranking quality matters above | |
| pool composition. The gap between RRF and the better of (BM25, Dense) tells | |
| whether fusion is pulling its weight at that K. | |
| Usage: | |
| uv run python scripts/cache_query_vectors.py # one-time, if not done | |
| uv run python scripts/recall_at_pool.py | |
| uv run python scripts/recall_at_pool.py --max-queries -1 # full corpus | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import os | |
| import pickle | |
| import random | |
| import sys | |
| sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "src")) | |
| import httpx | |
| from tqdm import tqdm | |
| from codesearch.config import ( | |
| EMBEDDING_MODEL, | |
| QDRANT_API_KEY, | |
| QDRANT_COLLECTION, | |
| QDRANT_URL, | |
| ) | |
| from codesearch.data import load_codesearch | |
| from codesearch.retrievers.bm25 import BM25Retriever | |
| from codesearch.retrievers.bm25_index import BM25Index | |
| from codesearch.retrievers.hybrid import rrf_fuse | |
| _SAMPLE_SEED = 42 | |
| _MAX_K = 100 | |
| _POOL_SIZES = (20, 50, 100) | |
| _SEARCH_BATCH = 50 | |
| CACHE_DIR = ".cache" | |
| BM25_CACHE_DIR = ".cache/bm25" | |
| def cache_path(model_name: str) -> str: | |
| safe = model_name.replace("/", "_") | |
| return os.path.join(CACHE_DIR, f"query_vectors_{safe}.pkl") | |
| def load_cached() -> tuple[list[dict], list[list[float]]]: | |
| path = cache_path(EMBEDDING_MODEL) | |
| if not os.path.exists(path): | |
| sys.exit( | |
| f"[error] Cache not found at {path}.\n" | |
| f" Run: uv run python scripts/cache_query_vectors.py" | |
| ) | |
| with open(path, "rb") as f: | |
| data = pickle.load(f) | |
| if data["model"] != EMBEDDING_MODEL: | |
| sys.exit( | |
| f"[error] Cache model mismatch. Re-run scripts/cache_query_vectors.py --recompute" | |
| ) | |
| return data["queries"], data["vectors"].tolist() | |
| def dense_search_batch(http: httpx.Client, vectors: list[list[float]], ef: int = 128) -> list[list[str]]: | |
| hits_all: list[list[str]] = [] | |
| n_batches = (len(vectors) + _SEARCH_BATCH - 1) // _SEARCH_BATCH | |
| for i in tqdm(range(0, len(vectors), _SEARCH_BATCH), total=n_batches, desc="Dense search"): | |
| chunk = vectors[i : i + _SEARCH_BATCH] | |
| payload = { | |
| "searches": [ | |
| { | |
| "query": qv, | |
| "limit": _MAX_K, | |
| "params": {"hnsw_ef": ef}, | |
| "with_payload": ["doc_id"], | |
| } | |
| for qv in chunk | |
| ] | |
| } | |
| r = http.post( | |
| f"/collections/{QDRANT_COLLECTION}/points/query/batch", | |
| json=payload, | |
| timeout=60.0, | |
| ) | |
| r.raise_for_status() | |
| for resp in r.json()["result"]: | |
| hits_all.append([p["payload"]["doc_id"] for p in resp["points"]]) | |
| return hits_all | |
| def _id_dicts(ids: list[str]) -> list[dict]: | |
| """Wrap doc-ids as minimal hit-dicts for rrf_fuse (which takes dicts).""" | |
| return [{"id": i} for i in ids] | |
| def recall(hit_lists: list[list[str]], relevant: list[str], k: int) -> float: | |
| """Fraction of queries whose relevant doc appears in the top-k of its hit list.""" | |
| n = len(hit_lists) | |
| if n == 0: | |
| return 0.0 | |
| return sum(1 for hits, rel in zip(hit_lists, relevant) if rel in hits[:k]) / n | |
| def main() -> None: | |
| parser = argparse.ArgumentParser( | |
| description="Recall@K of candidate pools (BM25, Dense, RRF, set-union ceiling)." | |
| ) | |
| parser.add_argument( | |
| "--max-queries", | |
| type=int, | |
| default=2000, | |
| help="Sample size (default: 2000; -1 = full eval set).", | |
| ) | |
| args = parser.parse_args() | |
| # [1/4] Cached queries + vectors | |
| print("[1/4] Loading cached query vectors...") | |
| queries, vectors = load_cached() | |
| print(f" Loaded {len(queries):,} queries.") | |
| if args.max_queries and args.max_queries > 0 and len(queries) > args.max_queries: | |
| random.seed(_SAMPLE_SEED) | |
| idx = random.sample(range(len(queries)), args.max_queries) | |
| queries = [queries[i] for i in idx] | |
| vectors = [vectors[i] for i in idx] | |
| print(f" Sampled {len(queries):,} queries (seed={_SAMPLE_SEED}).") | |
| relevant = [q["relevant_id"] for q in queries] | |
| # [2/4] BM25 β from cache if available, else build from scratch | |
| if BM25Index.exists(BM25_CACHE_DIR): | |
| print(f"[2/4] Loading cached BM25 index from {BM25_CACHE_DIR}...") | |
| bm25 = BM25Retriever.from_cache(BM25_CACHE_DIR) | |
| print(f" Loaded BM25 over {len(bm25.corpus):,} docs.") | |
| else: | |
| print("[2/4] No BM25 cache found β building from scratch (~2-3 min).") | |
| print(f" Tip: run scripts/cache_bm25.py to skip this next time.") | |
| corpus, _ = load_codesearch(n=-1) | |
| bm25 = BM25Retriever(corpus) | |
| # [3/4] BM25 search | |
| print(f"[3/4] Running BM25 on {len(queries):,} queries (top-{_MAX_K})...") | |
| bm25_results = bm25.retrieve_batch([q["query"] for q in queries], top_k=_MAX_K) | |
| bm25_hits = [[h["id"] for h in row] for row in bm25_results] | |
| # [4/4] Dense search via Qdrant REST | |
| print(f"[4/4] Running dense (Qdrant) on {len(queries):,} queries (top-{_MAX_K})...") | |
| http = httpx.Client(base_url=QDRANT_URL, headers={"api-key": QDRANT_API_KEY}) | |
| dense_hits = dense_search_batch(http, vectors) | |
| http.close() | |
| # Sanity check | |
| assert len(bm25_hits) == len(dense_hits) == len(relevant) | |
| # Compute recall at each pool size | |
| print() | |
| print(f"Pool Recall@K (n={len(queries):,} queries, seed={_SAMPLE_SEED})") | |
| print( | |
| f"{'K':>5} {'BM25':>8} {'Dense':>8} {'RRF':>8} {'Union':>8}" | |
| f" {'RRF gain':>9} {'ceiling gap':>11}" | |
| ) | |
| print(" " + "β" * 70) | |
| for k in _POOL_SIZES: | |
| r_bm25 = recall(bm25_hits, relevant, k) | |
| r_dense = recall(dense_hits, relevant, k) | |
| rrf_hits = [ | |
| [h["id"] for h in rrf_fuse(_id_dicts(b_ids[:k]), _id_dicts(d_ids[:k]), top_k=k)] | |
| for b_ids, d_ids in zip(bm25_hits, dense_hits) | |
| ] | |
| r_rrf = recall(rrf_hits, relevant, k) | |
| # Set-union ceiling: GT is in BM25 top-k OR Dense top-k | |
| r_union = sum( | |
| 1 | |
| for b_ids, d_ids, rel in zip(bm25_hits, dense_hits, relevant) | |
| if rel in b_ids[:k] or rel in d_ids[:k] | |
| ) / len(queries) | |
| better_single = max(r_bm25, r_dense) | |
| rrf_gain = r_rrf - better_single # vs. best single retriever | |
| ceiling_gap = r_union - r_rrf # what RRF leaves on the table | |
| print( | |
| f"{k:>5} {r_bm25:>8.4f} {r_dense:>8.4f} {r_rrf:>8.4f} {r_union:>8.4f}" | |
| f" {rrf_gain:>+9.4f} {ceiling_gap:>+11.4f}" | |
| ) | |
| print() | |
| print("Read:") | |
| print(" - 'RRF gain' = Recall(RRF@K) β Recall(best of BM25/Dense @K).") | |
| print(" Positive β fusion finds GT the better single list missed.") | |
| print(" - 'ceiling gap' = Recall(set-union@K) β Recall(RRF@K).") | |
| print(" Positive β GT is in the union but RRF didn't surface it.") | |
| print(" The reranker can recover this gap if the GT is anywhere") | |
| print(" in the candidate set it sees.") | |
| if __name__ == "__main__": | |
| main() | |