HardNLP

Implement BM25 Ranking Score

NLP · Linear Algebra · Data Processing

Hard

Problem

Compute one BM25 relevance score per tokenized document. Repeated query terms are counted once.

\operatorname{idf}(t) = \log\left(\frac{N-\operatorname{df}(t)+0.5}{\operatorname{df}(t)+0.5}+1\right)

\operatorname{score}(D,Q) = \sum_{t \in Q} \operatorname{idf}(t)\frac{\operatorname{tf}(t,D)(k_1+1)}{\operatorname{tf}(t,D)+k_1\left(1-b+b\frac{|D|}{\operatorname{avgdl}}\right)}

Here, N is the document count, \operatorname{df}(t) counts documents containing term t, \operatorname{tf}(t,D) counts the term in document D, |D| is document length, and \operatorname{avgdl} is average document length. Return a NumPy array whose entries follow the original document order.

Theory

BM25 (Best Matching 25) is a ranking function used in information retrieval to estimate the relevance of documents to a given search query. It evolved from probabilistic retrieval models in the 1990s and remains the backbone of modern search engines. Despite the rise of neural approaches, BM25 often serves as a strong baseline and is used in hybrid retrieval systems.


The Evolution from TF-IDF

BM25 builds upon TF-IDF concepts while addressing its limitations:

TF-IDF approach: \text{TF-IDF}(t, d) = \text{tf}(t, d) \times \text{idf}(t)

Limitations of TF-IDF:

BM25 solutions:


The Complete BM25 Formula

For a query Q containing terms q_1, q_2, ..., q_n and a document D:

\text{BM25}(D, Q) = \sum_{i=1}^{n} \text{IDF}(q_i) \cdot \frac{f(q_i, D) \cdot (k_1 + 1)}{f(q_i, D) + k_1 \cdot (1 - b + b \cdot \frac{|D|}{\text{avgdl}})}

Variables explained:


The IDF Component

Inverse Document Frequency penalizes common terms that appear in many documents:

\text{IDF}(q_i) = \ln\left(\frac{N - n(q_i) + 0.5}{n(q_i) + 0.5} + 1\right)

Variables:

Properties:


Understanding Parameter k_1 (Term Frequency Saturation)

The k_1 parameter controls how quickly additional term occurrences lose their impact:

Low k_1 (e.g., 0.5):

High k_1 (e.g., 2.0):

Boundary cases:

Typical value: 1.2 balances saturation with frequency sensitivity


Understanding Parameter b (Length Normalization)

The b parameter controls how document length affects scoring:

b = 0 (no normalization):

b = 1 (full normalization):

b = 0.75 (balanced):

The normalization term \frac{|D|}{\text{avgdl}} compares each document to the corpus average length.


Term Frequency Saturation Explained

The saturation function bounds the contribution of term frequency:

\text{saturation}(f) = \frac{f \cdot (k_1 + 1)}{f + k_1}

Behavior analysis (with k_1 = 1.5):

Key insight: The first few occurrences contribute significantly, but additional occurrences have diminishing returns. The maximum possible contribution is bounded by k_1 + 1.


Multi-Term Query Handling

BM25 scores for multi-term queries are computed additively:

\text{BM25}(D, Q) = \sum_{i=1}^{n} \text{BM25}_{q_i}(D)

Properties of additive scoring:


Worked Example

Corpus (3 documents):

Query: "cat"

Step 1 - Compute corpus statistics:

Step 2 - Calculate IDF("cat"): \ln\left(\frac{3 - 2 + 0.5}{2 + 0.5} + 1\right) = \ln\left(\frac{1.5}{2.5} + 1\right) = \ln(1.6) \approx 0.47

Step 3 - Calculate BM25 scores (k1=1.5, b=0.75):

For D1 (f=1, |D|=6):

For D2 (f=0): Score = 0 (term not present)

For D3 (f=1, |D|=1):

Ranking result: D3 (0.75) > D1 (0.47) > D2 (0)

D3 scores highest because it is shorter than average, receiving a length normalization boost.


BM25 Variants

BM25+: Adds a small constant delta to prevent negative scores: \text{BM25+} = \text{BM25} + \delta

BM25L: Addresses over-penalization of long but relevant documents

BM25F: Extends to structured documents with multiple fields (title, body, anchor text) using per-field weights: \text{BM25F} = \sum_{\text{fields}} w_f \cdot \text{BM25}_f


Implementation Considerations


Where BM25 Shows Up

Examples

Example 1

Input
query_tokens = ["machine", "learning"], docs = [["introduction", "to", "machine", "learning"], ["deep", "learning", "basics"], ["cooking", "pasta", "guide"]], k1 = 1.2, b = 0.75
Output
[1.341106, 0.490052, 0.0]
Explanation
The first document matches both query terms, the second matches one, and the third matches neither.

Example 2

Input
query_tokens = ["data"], docs = [["data", "data", "mining"], ["applied", "data", "science", "science", "science"], ["sports", "news"]], k1 = 1.2, b = 0.75
Output
[0.664957, 0.390192, 0.0]

Hints

  1. Use one Counter per document for term frequencies.
  2. Use Counter.update(set(document)) to count document frequencies.
  3. Build a NumPy vector of one term's frequency across all documents before applying the formula.

Requirements

Constraints

Starter Code

import math
from collections import Counter
import numpy as np

def bm25_score(query_tokens: list[str], docs: list[list[str]], k1: float = 1.2, b: float = 0.75) -> np.ndarray:
    """
    Returns a NumPy array with one score per document.
    """
    # Write code here
    pass

Test Cases

CaseMatches
Basic querypublic
Single term, multiple docspublic