Spaces:
Running
Running
File size: 8,380 Bytes
494a4bf fcdba1d 494a4bf 024efc3 494a4bf 024efc3 494a4bf 024efc3 494a4bf 024efc3 494a4bf 024efc3 494a4bf 024efc3 494a4bf 024efc3 494a4bf 024efc3 494a4bf 024efc3 494a4bf | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 | """
Retrieve β given a question, return the most relevant calendar excerpts.
This is the "R" in RAG. Phase 2 turns it into a two-stage funnel:
stage 1 β RECALL dense (vector) and sparse (BM25) search each nominate ~50
candidates; Reciprocal Rank Fusion merges the two rankings.
stage 2 β PRECISION a cross-encoder re-scores the fused shortlist jointly with
the query; the final top-k come out the top.
Which stages run is a config knob (UBCAL_RETRIEVAL_MODE), so the eval can compare:
dense Phase-1 pure vector search (the baseline)
hybrid stage 1 only (dense + BM25, RRF-fused)
hybrid_rerank both stages (the Phase-2 default)
Why RRF for fusion? Cosine similarities and BM25 scores live on totally different
scales, so averaging them is meaningless. RRF ignores the scores entirely and combines
*ranks* β a document's fused score is sum(1 / (RRF_K + rank)) across the lists it
appears in β sidestepping the calibration problem.
Exposing the doc `id` on every result is deliberate: the Phase 3 evaluation harness
matches retrieved ids against "gold" chunk ids to compute hit@k / recall@k.
"""
from __future__ import annotations
import json
import sys
from dataclasses import dataclass
import numpy as np
from . import bm25, config, embed, trace
@dataclass
class Result:
"""One retrieved excerpt (chunk record) plus its retrieval score.
What `score` means depends on the mode that produced it: cosine similarity (dense),
RRF fused score (hybrid), or a cross-encoder logit (hybrid_rerank). Higher is always
better within one result list, but values are not comparable across modes.
"""
id: int
score: float
recipe: dict # the full metadata record from the index
# The index is loaded once and cached for the process lifetime.
_embeddings: np.ndarray | None = None
_metadata: list[dict] | None = None
_texts: list[str] | None = None
def _load_index() -> tuple[np.ndarray, list[dict]]:
global _embeddings, _metadata
if _embeddings is None or _metadata is None:
if not config.EMBEDDINGS_PATH.exists() or not config.METADATA_PATH.exists():
sys.exit(
"No index found. Build it first with: python -m src.ingest"
)
_embeddings = np.load(config.EMBEDDINGS_PATH)
with open(config.METADATA_PATH, "r", encoding="utf-8") as f:
_metadata = json.load(f)
return _embeddings, _metadata
def _corpus_texts() -> list[str]:
"""The raw document texts, id-aligned with the index (what BM25 searches over)."""
global _texts
if _texts is None:
_, metadata = _load_index()
_texts = [r["text"] for r in metadata]
return _texts
def _dense_search(query: str, k: int) -> list[tuple[int, float]]:
"""Embed the query and rank every chunk by cosine similarity; top-k, best first."""
embeddings, metadata = _load_index()
# Embed the query (shape (1, dim)) and reduce to a 1-D vector.
query_vec = embed.embed_texts([query])[0]
# Cosine similarity == dot product, since every vector is unit-length.
# scores[i] is the similarity between the query and chunk i.
scores = embeddings @ query_vec
# Indices of the top_k highest scores. argpartition finds them without fully
# sorting the whole array; we then sort just those k by score, descending.
k = min(k, len(metadata))
top_idx = np.argpartition(scores, -k)[-k:]
top_idx = top_idx[np.argsort(scores[top_idx])[::-1]]
return [(int(i), float(scores[i])) for i in top_idx]
def _rrf_fuse(rankings: list[list[int]]) -> list[tuple[int, float]]:
"""Merge ranked doc-id lists with Reciprocal Rank Fusion; best fused score first."""
fused: dict[int, float] = {}
for ranking in rankings:
for rank, doc_id in enumerate(ranking, start=1):
fused[doc_id] = fused.get(doc_id, 0.0) + 1.0 / (config.RRF_K + rank)
return sorted(fused.items(), key=lambda pair: pair[1], reverse=True)
def _as_results(ranked: list[tuple[int, float]], limit: int) -> list[Result]:
_, metadata = _load_index()
return [Result(id=i, score=s, recipe=metadata[i]) for i, s in ranked[:limit]]
def _candidates(ranked: list[tuple[int, float]]) -> list[dict]:
"""Render a ranked (id, score) list for the trace event stream.
Just enough to show a candidate column in a UI β the full record follows on the final
results, so there is no point carrying 20 chunk bodies per stage.
"""
_, metadata = _load_index()
return [
{"id": i, "score": round(float(s), 6), "title": metadata[i]["title"]}
for i, s in ranked
]
def warmup() -> int:
"""Load the index and build the BM25 postings up front; returns the chunk count.
Both are lazily cached module globals, so without this the *first* query of a process
pays the 22 MB .npy load plus the in-memory BM25 build. Fine for the CLI, bad for the
first visitor to a web demo.
"""
_, metadata = _load_index()
bm25.search("warmup", 1, _corpus_texts())
return len(metadata)
def retrieve(query: str, top_k: int | None = None, mode: str | None = None) -> list[Result]:
"""Return the top_k excerpts most relevant to `query`, best first.
`mode` defaults to config.RETRIEVAL_MODE (see the module docstring for the three
modes). The signature and Result shape are stable across modes β the eval harness
and CLI call this one function regardless of pipeline.
"""
top_k = top_k or config.TOP_K
mode = mode or config.RETRIEVAL_MODE
if mode not in ("dense", "hybrid", "hybrid_rerank"):
sys.exit(
f"Unknown retrieval mode {mode!r} β expected dense, hybrid, or hybrid_rerank."
)
# The query is echoed so a consumer can attribute this whole block to the right hop of
# a multi-hop agent run without having to infer boundaries from the surrounding steps.
trace.event("retrieval_start", query=query, mode=mode, top_k=top_k)
trace.detail("retrieve mode", mode)
trace.detail("embed query (OpenAI API)", repr(query))
if mode == "dense":
dense_hits = _dense_search(query, top_k)
trace.event("candidates", stage="dense", items=_candidates(dense_hits))
out = _as_results(dense_hits, top_k)
trace.results(out)
trace.event("retrieval_final", query=query, items=[trace.describe(r) for r in out])
return out
# Stage 1 β recall: each retriever nominates candidates, RRF merges the rankings.
dense_hits = _dense_search(query, config.DENSE_K)
sparse_hits = bm25.search(query, config.SPARSE_K, _corpus_texts())
dense_ids = [i for i, _ in dense_hits]
sparse_ids = [i for i, _ in sparse_hits]
fused = _rrf_fuse([dense_ids, sparse_ids])
trace.event("candidates", stage="dense", items=_candidates(dense_hits))
trace.event("candidates", stage="bm25", items=_candidates(sparse_hits))
trace.event("candidates", stage="rrf", items=_candidates(fused))
trace.detail("stage 1 recall", f"dense={len(dense_ids)} + bm25={len(sparse_ids)} "
f"-> RRF-fused to {len(fused)} unique")
if mode == "hybrid":
out = _as_results(fused, top_k)
trace.results(out)
trace.event("retrieval_final", query=query, items=[trace.describe(r) for r in out])
return out
# Stage 2 β precision: the cross-encoder re-scores the fused shortlist.
# Imported here so dense/hybrid modes never pay the multi-second torch import.
from . import rerank
shortlist = _as_results(fused, config.RERANK_CANDIDATES)
trace.event("shortlist", ids=[r.id for r in shortlist])
trace.detail("stage 2 precision", f"rerank {len(shortlist)} candidates (Cohere API) "
f"-> top {top_k}")
out = rerank.rerank(query, shortlist, top_k)
trace.results(out)
trace.event("retrieval_final", query=query, items=[trace.describe(r) for r in out])
return out
if __name__ == "__main__":
# Quick manual check: python -m src.retrieve "prerequisites for CPSC 210"
# (set UBCAL_RETRIEVAL_MODE to compare modes)
question = " ".join(sys.argv[1:]) or "prerequisites for CPSC 210"
for r in retrieve(question):
print(f"[{r.score:.3f}] (id={r.id}) {r.recipe['title']}")
|