AI Knowledge Tree

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.

⏱ 28 min

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?

TF(t,d)=count of term t in document d∣d∣.\text{TF}(t, d) = \frac{\text{count of term } t \text{ in document } d}{|d|}.

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:

IDF(t)=log⁡N1+df(t),\text{IDF}(t) = \log \frac{N}{1 + \text{df}(t)},

where NN is the number of documents and df(t)\text{df}(t) is the number of documents containing tt. The +1+1 avoids division by zero for unseen terms.

Derivation from information theory. A term that appears in fraction p=df(t)/Np = \text{df}(t)/N of documents has self-information log⁡(1/p)=log⁡(N/df(t))\log(1/p) = \log(N/\text{df}(t)): rare terms carry more information per occurrence. IDF is this self-information.

A magnitude scale on a million-document corpus (N=106N = 10^6) shows the weighting at work, using log⁡(N/(1+df))\log(N/(1+\text{df})):

  • A term in every document ("the", df=N\text{df} = N): log⁡(N/N)≈0\log(N/N) \approx 0. No discriminative signal, so IDF zeroes it out.
  • A term in half the corpus (df=5×105\text{df} = 5 \times 10^5): log⁡2≈0.69\log 2 \approx 0.69.
  • A term in 1% (df=104\text{df} = 10^4): log⁡100≈4.6\log 100 \approx 4.6.
  • A near-unique term (df=100\text{df} = 100): log⁡(104)≈9.2\log(10^4) \approx 9.2.

So the gap between a stopword and a rare technical term is a factor of roughly 9.2/0.69≈139.2 / 0.69 \approx 13 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 tt in document dd is:

TF-IDF(t,d)=TF(t,d)⋅IDF(t).\text{TF-IDF}(t, d) = \text{TF}(t, d) \cdot \text{IDF}(t).

Document relevance for query qq: score(q,d)=∑t∈qTF-IDF(t,d)\text{score}(q, d) = \sum_{t \in q} \text{TF-IDF}(t, d).

BM25

TF-IDF has two known failure modes:

  1. 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.
  2. No length normalization. Longer documents accumulate higher raw TF for every term.

BM25 fixes both[2]:

BM25(t,d)=IDF(t)⋅tf(t,d)⋅(k1+1)tf(t,d)+k1(1−b+b⋅∣d∣avgdl),\text{BM25}(t, d) = \text{IDF}(t) \cdot \frac{\text{tf}(t,d) \cdot (k_1 + 1)}{\text{tf}(t,d) + k_1 \left(1 - b + b \cdot \frac{|d|}{\text{avgdl}}\right)},

where:

  • tf(t,d)\text{tf}(t, d) is the raw term frequency (count) in document dd.
  • ∣d∣|d| is the document length in tokens; avgdl\text{avgdl} is the average document length.
  • k1∈[1.2,2.0]k_1 \in [1.2, 2.0] controls TF saturation: as tf→∞\text{tf} \to \infty, the score approaches IDF(t)⋅(k1+1)\text{IDF}(t) \cdot (k_1 + 1), a finite ceiling.
  • b∈[0,1]b \in [0, 1] controls length normalization: b=0b=0 disables it, b=1b=1 fully normalizes.

Typical defaults: k1=1.5k_1 = 1.5, b=0.75b = 0.75.

TF saturation. The term tf⋅(k1+1)tf+k1(… )\frac{\text{tf} \cdot (k_1+1)}{\text{tf} + k_1(\dots)} is a concave, saturating function of tf. At tf=0 it equals 0; as tf →∞\to \infty it approaches k1+1k_1 + 1. With k1=1.5k_1 = 1.5, the ceiling is 2.5 regardless of how many times the term appears.

Inverted index

Efficient retrieval precomputes an inverted index: for each term tt, store a postings list of (d,tf)(d, \text{tf}) pairs for all documents containing tt.

At query time:

  1. Tokenize and normalize the query.
  2. Look up each query term in the index to retrieve its postings list.
  3. Merge the lists (union for OR, intersection for AND) and score each document.
python · numpy
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]}")
BM25 saturates term frequency: the first occurrence of a query term counts a lot, each extra one less and less, unlike raw TF, which grows without bound. The k1 knob sets the saturation rate.
BM25 saturates term frequency: the first occurrence of a query term counts a lot, each extra one less and less, unlike raw TF, which grows without bound. The k1 knob sets the saturation rate.

Exercises

Exercise 1. A corpus has N=1000N = 1000 documents. Term "neural" appears in df=10\text{df} = 10 of them; term "the" appears in all 10001000. Using the lesson's IDF definition IDF(t)=log⁡N1+df(t)\text{IDF}(t) = \log\frac{N}{1 + \text{df}(t)} (natural log), compute both IDF weights and the ratio between them.

Solution

Step 1. For "neural":

IDF(neural)=log⁡10001+10=log⁡100011=log⁡(90.9)≈4.51.\text{IDF}(\text{neural}) = \log \frac{1000}{1 + 10} = \log \frac{1000}{11} = \log(90.9) \approx 4.51.

Step 2. For "the":

IDF(the)=log⁡10001+1000=log⁡10001001=log⁡(0.999)≈−0.001≈0.\text{IDF}(\text{the}) = \log \frac{1000}{1 + 1000} = \log \frac{1000}{1001} = \log(0.999) \approx -0.001 \approx 0.

Step 3. The rare term carries weight ≈4.51\approx 4.51; the stopword is driven to essentially 00. The ratio is roughly 4.51/0.0014.51 / 0.001, 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 tf(k1+1)tf+k1(1−b+b⋅∣d∣/avgdl)\frac{\text{tf}(k_1 + 1)}{\text{tf} + k_1(1 - b + b \cdot |d|/\text{avgdl})}. For a document of average length (∣d∣=avgdl|d| = \text{avgdl}) with k1=1.5k_1 = 1.5, b=0.75b = 0.75, compute the TF factor at tf=1\text{tf} = 1 and at tf=10\text{tf} = 10, and the saturation ceiling as tf→∞\text{tf} \to \infty.

Solution

Step 1. With ∣d∣=avgdl|d| = \text{avgdl}, the length term is 1−b+b⋅1=11 - b + b \cdot 1 = 1, so the denominator simplifies to tf+k1=tf+1.5\text{tf} + k_1 = \text{tf} + 1.5 and the numerator to tf⋅2.5\text{tf} \cdot 2.5.

Step 2. At tf=1\text{tf} = 1:

1⋅2.51+1.5=2.52.5=1.0.\frac{1 \cdot 2.5}{1 + 1.5} = \frac{2.5}{2.5} = 1.0.

Step 3. At tf=10\text{tf} = 10:

10⋅2.510+1.5=2511.5≈2.17.\frac{10 \cdot 2.5}{10 + 1.5} = \frac{25}{11.5} \approx 2.17.

Step 4. As tf→∞\text{tf} \to \infty, the ratio tf⋅2.5tf+1.5→2.5=k1+1\frac{\text{tf} \cdot 2.5}{\text{tf} + 1.5} \to 2.5 = k_1 + 1. So going from 1 to 10 occurrences raised the factor only from 1.01.0 to about 2.172.17, and it can never exceed 2.52.5: 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
python · numpy
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 tf=0\text{tf} = 0, 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. [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. [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↩