Notebook 06 · Sparse Retrieval
We already know TF-IDF. Today we find the one thing wrong with it, and the one idea that fixes it. That idea is BM25.
Before we start, the obvious question
Last class we saw embeddings place meaning into a few hundred dimensions, so "how do plants make food using light" lands right next to "photosynthesis converts sunlight into glucose" even though the two sentences share almost no words. That part is real and it works.
So here is the fair question to ask before we spend a whole notebook on something older than embeddings: if meaning already works, what is left to fix?
So, a 30 second recap
Score a word by how often it shows up in this document, and how rare it is across every document. Common words like "the" score low everywhere. Rare words like "ERR_2048" score high wherever they appear.
The problem
Two documents, both about dogs. Same query: "dog".
What BM25 changes
TF-IDF keeps climbing forever. BM25 rises fast at first, then flattens and hits a wall.
The first few mentions matter the most. Mention six, ten and fifty barely move the needle.
The same argument, in numbers
Same word, same document, just counted more times. Score shown relative to saying the word once.
| word appears this many times | 1 | 5 | 10 | 20 | 50 | 100 |
|---|---|---|---|---|---|---|
| TF-IDF says | 1× | 5× | 10× | 20× | 50× | 100× |
| BM25 says | 1× | 1.9× | 2.2× | 2.3× | 2.4× | 2.5× |
Under the hood
Back to our own corpus
ERR_CONN_TIMEOUT appears in exactly one of our sixteen documents. That gives it a huge IDF. It only needs to appear once for BM25 to be certain, so the saturation cap never even gets tested. Rare, exact tokens are BM25's home turf, and its score tells you exactly why it's confident. Dense also finds it here, but as a softer, harder to explain signal.
Being honest about the limit
Not one word is shared between the query and the document. BM25 only ever looks at literal tokens, so a perfect saturation formula still scores this a flat zero. Fixing raw counts was never going to fix vocabulary gaps, that is exactly where dense embeddings take over, and neither method alone covers both jobs.
A third option
BM25 counts words that are literally there. Dense turns a whole sentence into one blurry vector. SPLADE sits in between: it still builds a sparse vector, one slot per vocabulary word, exactly like BM25's index. The difference is who decides the weight in each slot.
Doc 0 only ever says "Error ERR_CONN_TIMEOUT ... database connection ... limit ... pool timeout." But the model is also allowed to switch on words that fit the meaning, even if that exact word was never typed.
Put it to the same test
Same six queries we've been using all class, one retriever added to the table. Look closely: SPLADE does not beat dense here, it ties it. Both hit all six.
| query | BM25 | Dense | SPLADE |
|---|---|---|---|
| exact code | HIT | HIT | HIT |
| synonym gap | miss | HIT | HIT |
| paraphrase | miss | HIT | HIT |
| exact SKU | HIT | HIT | HIT |
| lay → technical | miss | HIT | HIT |
| version number | miss | HIT | HIT |
Why this isn't just academic
This is not a minor tuning detail. It is the reason keyword search on any real product, from your codebase search to your email search, behaves the way it does.
Notebook 06 · today's plan