Spaces:
Sleeping
Sleeping
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"))
|