ProbabilityRAG / src /store.py
Piero7's picture
Apply ponytail audit: delete dead endpoints, scripts, params, and assets
f48c6c5
Raw
History Blame Contribute Delete
11.7 kB
"""
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"))