Information retrieval: TF-IDF and BM25
Derive TF-IDF from information-theoretic first principles, then improve it with BM25's saturation and length normalization; implement a tiny inverted index.
Information retrieval (IR) is the problem of ranking documents by relevance to a query. Before dense neural retrieval (E6), sparse lexical methods dominated: rank documents by how well their term frequencies match the query, with rarer terms weighted more heavily[1].
Term frequency
The simplest relevance signal: how often does a query term appear in a document?
Problem: a document that mentions "neural" 100 times is probably not 100x more relevant than one that mentions it once. Raw frequency overweights long documents and saturates quickly.
Inverse document frequency
Rare terms are more informative than common ones. "The" appears in every document and carries no discriminative signal. The inverse document frequency downweights terms proportional to their corpus-wide frequency:
where is the number of documents and is the number of documents containing . The avoids division by zero for unseen terms.
Derivation from information theory. A term that appears in fraction of documents has self-information : rare terms carry more information per occurrence. IDF is this self-information.
A magnitude scale on a million-document corpus () shows the weighting at work, using :
- A term in every document ("the", ): . No discriminative signal, so IDF zeroes it out.
- A term in half the corpus (): .
- A term in 1% (): .
- A near-unique term (): .
So the gap between a stopword and a rare technical term is a factor of roughly in IDF weight, before TF even enters. The IDF spread is what makes a query's rare terms dominate its score.
TF-IDF
The TF-IDF weight of a term in document is:
Document relevance for query : .
BM25
TF-IDF has two known failure modes:
- No TF saturation. A document with 1000 occurrences of a term scores 1000x higher than one with 1 occurrence, even though relevance barely increases beyond a few repetitions.
- No length normalization. Longer documents accumulate higher raw TF for every term.
BM25 fixes both[2]:
where:
- is the raw term frequency (count) in document .
- is the document length in tokens; is the average document length.
- controls TF saturation: as , the score approaches , a finite ceiling.
- controls length normalization: disables it, fully normalizes.
Typical defaults: , .
TF saturation. The term is a concave, saturating function of tf. At tf=0 it equals 0; as tf it approaches . With , the ceiling is 2.5 regardless of how many times the term appears.
Inverted index
Efficient retrieval precomputes an inverted index: for each term , store a postings list of pairs for all documents containing .
At query time:
- Tokenize and normalize the query.
- Look up each query term in the index to retrieve its postings list.
- Merge the lists (union for OR, intersection for AND) and score each document.
import numpy as np
from collections import defaultdict, Counter
import math
docs = {
"d1": "the quick brown fox jumps over the lazy dog",
"d2": "the fox is quick and agile",
"d3": "lazy animals sleep all day and night",
"d4": "the dog chased the quick brown fox",
}
# Build inverted index
def tokenize(text):
return text.lower().split()
index = defaultdict(list) # term -> [(doc_id, tf)]
doc_lengths = {}
for doc_id, text in docs.items():
tokens = tokenize(text)
doc_lengths[doc_id] = len(tokens)
tf = Counter(tokens)
for term, count in tf.items():
index[term].append((doc_id, count))
N = len(docs)
avgdl = np.mean(list(doc_lengths.values()))
k1, b = 1.5, 0.75
def idf(term):
df = len(index.get(term, []))
return math.log((N - df + 0.5) / (df + 0.5) + 1)
def bm25_score(query, doc_id, doc_length, index):
score = 0.0
for term in tokenize(query):
postings = {d: tf for d, tf in index.get(term, [])}
tf = postings.get(doc_id, 0)
score += idf(term) * (tf * (k1 + 1)) / (tf + k1 * (1 - b + b * doc_length / avgdl))
return score
query = "quick fox"
scores = {doc_id: bm25_score(query, doc_id, doc_lengths[doc_id], index)
for doc_id in docs}
ranked = sorted(scores.items(), key=lambda x: -x[1])
for doc_id, score in ranked:
print(f" {doc_id} ({score:.3f}): {docs[doc_id]}")Exercises
Exercise 1. A corpus has documents. Term "neural" appears in of them; term "the" appears in all . Using the lesson's IDF definition (natural log), compute both IDF weights and the ratio between them.
Solution
Step 1. For "neural":
Step 2. For "the":
Step 3. The rare term carries weight ; the stopword is driven to essentially . The ratio is roughly , effectively unbounded: IDF all but deletes the stopword's contribution while keeping the discriminative term, which is the whole point of the weighting.
Exercise 2. BM25's TF factor is . For a document of average length () with , , compute the TF factor at and at , and the saturation ceiling as .
Solution
Step 1. With , the length term is , so the denominator simplifies to and the numerator to .
Step 2. At :
Step 3. At :
Step 4. As , the ratio . So going from 1 to 10 occurrences raised the factor only from to about , and it can never exceed : ten times the count buys roughly twice the score, not ten times, which is the saturation BM25 was built to impose.
Exercise 3 (code). Build a small inverted index over four documents and rank them for the query "quick fox" with BM25. Confirm that the documents containing both query terms outrank those containing only one, and that any document containing neither term scores exactly 0.
Solution
import numpy as np
from collections import defaultdict, Counter
import math
docs = {
"d1": "the quick brown fox jumps over the lazy dog",
"d2": "the fox is quick and agile",
"d3": "lazy animals sleep all day and night", # neither query term
"d4": "the dog chased the quick brown fox",
}
def tokenize(text):
return text.lower().split()
index = defaultdict(list)
doc_lengths = {}
for doc_id, text in docs.items():
tokens = tokenize(text)
doc_lengths[doc_id] = len(tokens)
for term, count in Counter(tokens).items():
index[term].append((doc_id, count))
N = len(docs)
avgdl = np.mean(list(doc_lengths.values()))
k1, b = 1.5, 0.75
def idf(term):
df = len(index.get(term, []))
return math.log((N - df + 0.5) / (df + 0.5) + 1)
def bm25_score(query, doc_id):
score = 0.0
for term in tokenize(query):
postings = {d: tf for d, tf in index.get(term, [])}
tf = postings.get(doc_id, 0)
L = doc_lengths[doc_id]
score += idf(term) * (tf * (k1 + 1)) / (tf + k1 * (1 - b + b * L / avgdl))
return score
scores = {d: bm25_score("quick fox", d) for d in docs}
# d3 has neither "quick" nor "fox": exact zero
assert scores["d3"] == 0.0
# d1, d2, d4 all contain both terms; each beats d3
for d in ["d1", "d2", "d4"]:
assert scores[d] > scores["d3"]
print({d: round(s, 3) for d, s in sorted(scores.items(), key=lambda x: -x[1])})The document with neither term scores exactly 0 because every query term has , zeroing its BM25 contribution; documents matching both terms accumulate two positive contributions and rank on top.
Sparse retrieval in the LLM era
BM25 remains the backbone of production search engines (Elasticsearch, OpenSearch). Modern retrieval-augmented generation (RAG) pipelines often combine BM25 (lexical, exact-match) with dense neural retrieval (semantic, embedding-based) using reciprocal rank fusion to blend their ranked lists. BM25 handles precise keyword matches (product SKUs, proper nouns) better than dense retrievers, making hybrid retrieval a practical default.
References
- [1]Manning, C. D., Raghavan, P., & Schütze, H. (2008). Introduction to Information Retrieval. Cambridge University Press. TF-IDF weighting, the vector space model, and inverted indexes.↩
- [2]Robertson, S., & Zaragoza, H. (2009). "The probabilistic relevance framework: BM25 and beyond". Foundations and Trends in Information Retrieval, 3(4), 333-389. The derivation and tuning of the BM25 saturation and length-normalization terms. doi:10.1561/1500000019↩