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']}")