Spaces:
Running
Running
Devdan Schretlen
Claude Opus 5 (1M context)
Expand the calendar corpus to every faculty, and rewrite the UI copy
fcdba1d Download src/retrieve.py from DevSchretl/UBCHelper: direct link, hf CLI and curl.
- Browser
- Download file 8.38 kB
-
https://huggingface.co/spaces/DevSchretl/UBCHelper/resolve/main/src/retrieve.py
- Command line
-
hf download hf://spaces/DevSchretl/UBCHelper/src/retrieve.py
-
curl -L -o retrieve.py https://huggingface.co/spaces/DevSchretl/UBCHelper/resolve/main/src/retrieve.py
8.38 kB
| """ | |
| 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 | |
| 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']}") | |