AAI Agentic Handbook
Problem 110 · Retrieval

BM25 Scoring from Scratch

MediumAI engineering
01Problem

Implement a `BM25` class over a list of documents with parameters `k1=1.5`, `b=0.75`. Tokenise by lowercasing and extracting `[a-z0-9]+`. Use the non-negative IDF `log(1 + (N - df + 0.5) / (df + 0.5))`. Provide `score(query, doc_index)` and `search(query, k)` returning `(doc_index, score)` pairs, highest first, ties by lower index, omitting zero scores.

02Key insight

BM25 is TF-IDF with two corrections that matter: term frequency saturates (k1), so the tenth mention adds little, and scores are normalised by document length relative to the average (b), so long documents do not win just by containing more words. The `1 +` inside the log keeps IDF positive for terms in more than half the corpus; the original formula goes negative and can make a matching document rank below a non-matching one. Lexical search still beats embeddings on exact identifiers, error codes and rare names, which is why hybrid retrieval keeps it.

03Solution

Try it first.

TimeIndex O(total tokens); query O(N * |q|) here, O(sum of posting lengths) with an inverted index
SpaceO(total tokens)
tests.py · 8 passing
tests.pypython20 lines
docs = [
    "the cat sat on the mat",
    "the dog sat on the log",
    "cat cat cat",
    "error code E1234 in payment service",
    "the cat sat on the mat while the dog slept on the long log by the door",
]
bm = BM25(docs)
assert bm.search("E1234", 3) == [(3, bm.score("E1234", 3))]
assert bm.idf["e1234"] > bm.idf["the"] > 0
assert bm.score("cat mat", 0) > bm.score("cat mat", 4)
assert bm.score("cat", 2) > bm.score("cat", 0)
assert bm.score("unicorn", 0) == 0 and bm.search("unicorn", 5) == []
flat = BM25(docs, b=0.0)
assert abs(flat.score("mat", 0) - flat.score("mat", 4)) < 1e-12
n, df, f, dl = 5, 2, 1, 6
avg = sum(bm.lens) / 5
expected = math.log(1 + (n - df + 0.5) / (df + 0.5)) * f * 2.5 / (f + 1.5 * (0.25 + 0.75 * dl / avg))
assert abs(bm.score("dog", 1) - expected) < 1e-12
assert [i for i, _ in bm.search("cat", 3)] == [2, 0, 4]
04Staff-level follow-up

Replace the per-document scan with an inverted index and WAND or block-max WAND so you skip documents that cannot enter the top-k. Then discuss tokenisation for code and non-English text, field boosts (title vs body), and why BM25 parameters tuned on one corpus rarely transfer.