File size: 11,654 Bytes
44d1e6b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0e052c4
44d1e6b
 
 
14e0359
 
 
44d1e6b
 
14e0359
 
 
 
44d1e6b
 
 
0e052c4
14e0359
0e052c4
 
14e0359
 
 
0e052c4
 
 
f48c6c5
44d1e6b
 
 
 
 
 
 
 
 
 
 
 
 
e6f2d55
 
 
 
 
 
 
 
 
 
 
 
 
44d1e6b
 
e6f2d55
 
 
 
 
 
 
 
44d1e6b
 
 
 
e6f2d55
 
44d1e6b
 
 
 
 
 
 
 
 
 
 
e6f2d55
 
 
 
 
 
44d1e6b
 
 
 
 
 
 
def50fa
 
e6f2d55
 
 
def50fa
e6f2d55
 
44d1e6b
 
e6f2d55
44d1e6b
 
 
 
 
 
 
 
 
 
 
 
e6f2d55
def50fa
44d1e6b
 
 
 
 
 
 
0e052c4
 
 
 
 
 
 
 
 
 
 
 
 
 
6fd543d
 
 
 
 
 
0e052c4
 
 
 
 
 
 
 
4d9ecfd
 
f48c6c5
4d9ecfd
 
 
 
 
 
 
 
 
 
 
 
 
0e052c4
 
 
 
6fd543d
 
 
 
 
 
0e052c4
f48c6c5
0e052c4
 
4d9ecfd
0e052c4
4d9ecfd
0e052c4
 
 
 
 
4d9ecfd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44d1e6b
 
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
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
"""
Stage 4 β€” embed (BGE-M3) + store (Qdrant), and Stage 5 β€” hybrid retrieval + rerank.

WHY BGE-M3: it emits a DENSE vector (semantic meaning: "variance of a sum" ~ "Var(X+Y)")
AND a SPARSE lexical vector (exact token/symbol overlap: matches "$\\sigma^2$", "binomial")
from a single model. Dense alone misses exact symbol matches; sparse alone misses
paraphrase. Fusing both (hybrid) is why this beats plain cosine on technical queries.

WHY parent/child here: we embed small retrieval units (children, or childless parents)
for precise matching, but store each unit's PARENT text in the payload so generation
gets the whole problem+solution, not a fragment.
"""
from __future__ import annotations

import os

# torch and FlagEmbedding both ship an OpenMP runtime (libiomp5md.dll); on Windows the
# duplicate triggers a native crash (0xC0000005). This makes the load robust.
os.environ.setdefault("KMP_DUPLICATE_LIB_OK", "TRUE")
os.environ.setdefault("HF_HUB_DISABLE_SYMLINKS_WARNING", "1")

# IMPORT ORDER MATTERS: torch + FlagEmbedding native libs must load BEFORE qdrant_client,
# or the second one to load segfaults (0xC0000005) on Windows. Proven by isolation test.
import torch  # noqa: F401
import FlagEmbedding  # noqa: F401

import json
from pathlib import Path

from qdrant_client import QdrantClient, models

DENSE_DIM = 1024
COLLECTION = "probability"
_model = None
_reranker = None


def get_model():
    """Lazy-load BGE-M3. fp16 only when a GPU is visible; on CPU use fp32.
    The web server hides the GPU (CUDA_VISIBLE_DEVICES="") so the full 8GB is free for
    Ollama's 7B generation β€” retrieval on CPU is light (~1-3s)."""
    global _model
    if _model is None:
        cuda = torch.cuda.device_count() > 0
        # pass an explicit single device on CPU β€” auto-detect divides by GPU count (0 -> crash)
        _model = FlagEmbedding.BGEM3FlagModel(
            "BAAI/bge-m3", use_fp16=cuda, devices=None if cuda else "cpu")
    return _model


def get_reranker():
    """Lazy-load the bge-reranker-v2-m3 cross-encoder (fp16 on GPU, fp32 on CPU)."""
    global _reranker
    if _reranker is None:
        cuda = torch.cuda.device_count() > 0
        _reranker = FlagEmbedding.FlagReranker(
            "BAAI/bge-reranker-v2-m3", use_fp16=cuda, devices=None if cuda else "cpu")
    return _reranker


def embed(texts: list[str]) -> tuple[list[list[float]], list[dict]]:
    out = get_model().encode(
        texts, return_dense=True, return_sparse=True, return_colbert_vecs=False,
        batch_size=8, max_length=8192,
    )
    dense = [v.tolist() for v in out["dense_vecs"]]
    sparse = [{int(k): float(v) for k, v in lw.items()} for lw in out["lexical_weights"]]
    return dense, sparse


def _sparse_vec(d: dict[int, float]) -> models.SparseVector:
    return models.SparseVector(indices=list(d.keys()), values=list(d.values()))


def contextualize(chapter: str, section: str, text: str) -> str:
    """Prepend a one-line location header ("Chapter 10: Generating Functions > Examples")
    to a chunk before it is embedded and reranked. WHY: worked examples state results in
    notation, not words -- e.g. Example 10.4 computes the Poisson mgf as "g(t) = ..." but
    never writes the phrase "moment generating function", so a query using that phrase
    out-ranks it onto longer prose-theory chunks. The chapter title ("Generating
    Functions") supplies the missing vocabulary, letting the example surface for the query
    that it actually answers. This is lightweight contextual retrieval -- no LLM, computed
    once at index time, and it also makes the generation context self-labelling."""
    loc = " > ".join(p for p in (chapter, section) if p)
    return f"[{loc}]\n{text}" if loc else text


def retrieval_units(chunks: list[dict]) -> list[dict]:
    """Units to embed: children, plus parents that have no children. Each unit is
    annotated with the parent text it should return.

    Front/back matter (title page, Contents, Preface, Index, appendix data tables) carries
    an empty chapter label and is dropped here. WHY: the Index is one 7600-token chunk that
    lists every term in the book, so it out-ranks real content on keyword queries (it
    surfaced as the top hit for "Bayes' theorem", burying the actual Bayes' Formula section
    and causing a refusal). None of this matter is explanatory content, so excluding it
    strictly improves retrieval precision."""
    by_id = {c["id"]: c for c in chunks}
    parents_with_kids = {c["parent_id"] for c in chunks if c.get("parent_id")}
    units = []
    for c in chunks:
        if not c["chapter"]:
            continue  # front/back matter, not course content
        if c["is_parent"] and c["id"] in parents_with_kids:
            continue  # represented by its children
        parent = by_id.get(c.get("parent_id")) if c.get("parent_id") else c
        u = dict(c)
        u["parent_text"] = parent["text"]
        u["parent_id"] = parent["id"]
        units.append(u)
    return units


def build_collection(client: QdrantClient, name: str = COLLECTION) -> None:
    # Delete unconditionally rather than guarding on collection_exists(): points use
    # sequential int ids (0..N-1), so if a rebuild has FEWER units than before and the old
    # collection survives, upsert overwrites 0..N-1 and leaves the tail ids as stale points
    # (this once left the dropped book Index lingering and answerable). delete_collection is
    # a no-op tolerant call here.
    client.delete_collection(name)
    client.create_collection(
        name,
        vectors_config={"dense": models.VectorParams(size=DENSE_DIM, distance=models.Distance.COSINE)},
        sparse_vectors_config={"sparse": models.SparseVectorParams(modifier=models.Modifier.IDF)},
    )


def upsert_units(client: QdrantClient, units: list[dict], name: str = COLLECTION,
                 pages: dict[str, int] | None = None) -> int:
    # Embed the contextualized child text (location header + chunk). The reranker scores
    # against the contextualized PARENT text (stored below), so both retrieval stages see
    # the chapter/section vocabulary the worked examples lack.
    pages = pages or {}
    embed_texts = [contextualize(u["chapter"], u["section"], u["text"]) for u in units]
    dense, sparse = embed(embed_texts)
    points = []
    for i, u in enumerate(units):
        parent_text = contextualize(u["chapter"], u["section"], u["parent_text"])
        points.append(
            models.PointStruct(
                id=i,
                vector={"dense": dense[i], "sparse": _sparse_vec(sparse[i])},
                payload={
                    "chunk_id": u["id"],
                    "parent_id": u["parent_id"],
                    "type": u["type"],
                    "chapter": u["chapter"],
                    "section": u["section"],
                    "source": u["source"],
                    "text": u["text"],
                    "parent_text": parent_text,
                    "page": pages.get(u["parent_id"]),  # 1-indexed PDF page for the UI viewer
                },
            )
        )
    client.upsert(name, points)
    return len(points)


def _dedupe_parents(hits: list[dict]) -> list[dict]:
    """Keep the best-scoring unit per parent so the LLM sees distinct problems."""
    seen: dict[str, dict] = {}
    for h in hits:
        pid = h["parent_id"]
        if pid not in seen:
            seen[pid] = h
    return list(seen.values())


def rerank(query: str, hits: list[dict], *, top_k: int) -> list[dict]:
    """Cross-encoder rerank: score (query, parent_text) pairs, return top_k."""
    if not hits:
        return []
    # batch_size=8: FlagEmbedding's compute_score runs a throwaway "adjust batch size"
    # forward pass over the FIRST batch before the real loop β€” with the default (128) that
    # first batch is the whole pool, so every rerank did 2x the GPU work. A small batch
    # caps the waste at 8 pairs. Scores are identical (measured: 51 pairs 2.0s -> 1.06s).
    scores = get_reranker().compute_score(
        [[query, h["parent_text"]] for h in hits], normalize=True, batch_size=8)
    if not isinstance(scores, list):
        scores = [scores]
    for h, s in zip(hits, scores):
        h["rerank_score"] = float(s)
    hits.sort(key=lambda h: h["rerank_score"], reverse=True)
    return hits[:top_k]


def _hybrid_candidates(client: QdrantClient, query: str, cand: int, name: str) -> list[dict]:
    """One dense+sparse RRF retrieval β†’ raw candidate payloads (pre-dedupe, pre-rerank)."""
    dense, sparse = embed([query])
    res = client.query_points(
        name,
        prefetch=[
            models.Prefetch(query=dense[0], using="dense", limit=cand),
            models.Prefetch(query=_sparse_vec(sparse[0]), using="sparse", limit=cand),
        ],
        query=models.FusionQuery(fusion=models.Fusion.RRF),
        limit=cand,
        with_payload=True,
    )
    return [{"score": p.score, **p.payload} for p in res.points]


def search(client: QdrantClient, query: str, *, top_k: int = 5, mode: str = "rerank",
           name: str = COLLECTION) -> list[dict]:
    """mode: 'dense' (vector-only), 'hybrid' (dense+sparse RRF), or 'rerank'
    (hybrid candidates re-scored by the cross-encoder)."""
    # Candidate pool feeds the cross-encoder, whose cost is linear (~40ms/pair on the
    # 4060, shared with Ollama) β€” the pool size IS the time-to-first-token. 80 candidates
    # cost ~5s/query; A/B on the 45-question golden set showed 24 is metric-identical
    # (Hit@1/3/5, MRR 0.881, same misses), so the old "big pool recovers vocabulary
    # mismatch" worry didn't survive measurement.
    cand = max(top_k * 4, 24)
    if mode == "dense":
        dense, _ = embed([query])
        res = client.query_points(name, query=dense[0], using="dense",
                                  limit=cand, with_payload=True)
        hits = _dedupe_parents([{"score": p.score, **p.payload} for p in res.points])
    else:
        hits = _dedupe_parents(_hybrid_candidates(client, query, cand, name))
    if mode == "rerank":
        return rerank(query, hits, top_k=top_k)
    return hits[:top_k]


def search_multi(client: QdrantClient, queries: list[str], *,
                 top_k: int = 5, cand: int = 40, name: str = COLLECTION) -> list[dict]:
    """Multi-query retrieval for comparison / multi-part questions. WHY: one query embedding
    can't sit near two distributions at once, so a comparison retrieves only one side.

    Each sub-query is retrieved AND reranked against ITSELF (a chunk about only the geometric
    distribution must be scored on "geometric variance", not on "compare binomial and
    geometric" β€” the latter buries single-topic chunks, which is exactly what killed the
    union-then-rerank approach). The per-query rankings are then round-robin merged, so every
    sub-query is guaranteed representation in the final top_k and both sides surface. LLM-free
    by design β€” the caller (generate.decompose) makes the sub-queries."""
    lanes = []
    for q in queries:
        hits = _dedupe_parents(_hybrid_candidates(client, q, cand, name))
        lanes.append(rerank(q, hits, top_k=top_k))  # scored against its OWN query
    merged, seen = [], set()
    for i in range(top_k):
        for lane in lanes:
            if i < len(lane) and lane[i]["parent_id"] not in seen:
                seen.add(lane[i]["parent_id"])
                merged.append(lane[i])
                if len(merged) >= top_k:
                    return merged
    return merged


def load_chunks(path: Path) -> list[dict]:
    return json.loads(Path(path).read_text(encoding="utf-8"))