UBCHelper / src /bm25.py
Devdan Schretlen
Added RAGChef RAG engine and eval harness
494a4bf
Raw History Blame Contribute Delete
4.29 kB
"""
BM25 — hand-rolled sparse (keyword) retrieval, the other half of hybrid search.
Dense embeddings capture *meaning* but under-weight rare, high-signal tokens: a query
about "CPSC 320" lands near generic computer-science text because the code barely moves
the vector. BM25 is the opposite — it scores documents by exact term overlap, weighting
each term by how rare it is in the corpus (IDF), saturating repeated occurrences (k1), and
penalizing long documents (b). Rare course-code/program tokens are exactly where it
shines, which is why fusing it with dense retrieval helps.
This is the classic Okapi BM25 formula, built with numpy so the mechanism stays visible
(same spirit as the hand-rolled vector store):
score(q, d) = sum over query terms t of
idf(t) * tf(t,d) * (k1 + 1) / (tf(t,d) + k1 * (1 - b + b * |d| / avgdl))
idf(t) = ln(1 + (N - df(t) + 0.5) / (df(t) + 0.5))
Everything inside the sum depends only on the corpus, so each term's per-document
contribution is precomputed at build time; scoring a query is then just summing a few
postings arrays. The index is built in memory from the text the dense index already
stores (a few seconds even for tens of thousands of chunks, once per process) — no new artifact on disk and
no re-ingest needed.
"""
from __future__ import annotations
import math
import re
from collections import Counter
from dataclasses import dataclass
import numpy as np
# Standard Okapi parameters.
K1 = 1.5 # term-frequency saturation: repeats help, with diminishing returns
B = 0.75 # document-length normalization strength
_TOKEN_RE = re.compile(r"[a-z0-9]+")
def tokenize(text: str) -> list[str]:
"""Lowercase word/number tokens. Used for BOTH documents and queries — the sparse
analogue of "the same embedding model must embed the index and the query"."""
return _TOKEN_RE.findall(text.lower())
@dataclass
class _Index:
# term -> (doc indices, that term's precomputed idf*tf-saturation score per doc)
postings: dict[str, tuple[np.ndarray, np.ndarray]]
n_docs: int
def _build(texts: list[str]) -> _Index:
docs = [Counter(tokenize(t)) for t in texts]
doc_lens = np.array([sum(c.values()) for c in docs], dtype=np.float64)
avgdl = doc_lens.mean() or 1.0
n = len(texts)
# Document frequency: in how many docs does each term appear?
df: Counter[str] = Counter()
for counts in docs:
df.update(counts.keys())
# Per-document length penalty (the k1 * (1 - b + b*|d|/avgdl) denominator part).
norm = K1 * (1 - B + B * doc_lens / avgdl)
by_term: dict[str, tuple[list[int], list[float]]] = {t: ([], []) for t in df}
for i, counts in enumerate(docs):
for term, tf in counts.items():
indices, weights = by_term[term]
indices.append(i)
weights.append(tf * (K1 + 1) / (tf + norm[i]))
postings = {}
for term, (indices, weights) in by_term.items():
idf = math.log(1 + (n - df[term] + 0.5) / (df[term] + 0.5))
postings[term] = (
np.array(indices, dtype=np.int32),
idf * np.array(weights, dtype=np.float32),
)
return _Index(postings=postings, n_docs=n)
# Built once per process on first search, cached for the corpus it was built from.
_index: _Index | None = None
_source: list[str] | None = None
def _get_index(texts: list[str]) -> _Index:
global _index, _source
if _index is None or _source is not texts:
_index = _build(texts)
_source = texts
return _index
def search(query: str, k: int, texts: list[str]) -> list[tuple[int, float]]:
"""Return the top-k (doc index, BM25 score) pairs for `query`, best first.
Only documents sharing at least one term with the query are returned, so the list
can be shorter than k.
"""
index = _get_index(texts)
scores = np.zeros(index.n_docs, dtype=np.float32)
for term in tokenize(query):
entry = index.postings.get(term)
if entry is not None:
indices, weights = entry
scores[indices] += weights
k = min(k, index.n_docs)
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 if scores[i] > 0]