Introduction

Every time you type a query into a search engine, the engine must decide in milliseconds which of millions of documents is most relevant. The simplest idea — count how many times your query words appear in each document — turns out to be surprisingly wrong.

BM25 (Best Match 25) is the probabilistic ranking function that fixed those problems. Proposed by Stephen Robertson and colleagues at City University London around 1994 and refined through the TREC evaluation campaigns, it remained the gold-standard retrieval formula for two decades and is still the default scorer in Elasticsearch, Apache Lucene, and Apache Solr today.

Two ideas do most of the work:

  • Term frequency saturation. The first few times a query word appears in a document, it is strong evidence of relevance. The hundredth time adds almost nothing. BM25 squashes diminishing returns with a curve controlled by a parameter k1k_1 (typically 1.2 to 2.0).
  • Length normalization. A long document naturally contains more words, so it will mention your query term more often by chance. BM25 divides the raw count by the document length (relative to the corpus average), controlled by a parameter bb (typically 0.75).

These two corrections transform a naive word-count into a ranking function that matches human relevance judgments far better — and the math behind them traces back to a probabilistic model of how humans actually decide what is relevant.

Try It

The demo below ranks four short documents against your query using both raw term frequency (TF) and BM25. Type a word that appears frequently in some documents, then watch how the two methods disagree — BM25 is less impressed by sheer repetition and more careful about document length.

<!-- BM25 vs raw TF demo: rank four documents by query word(s) -->
<div class="controls">
  <input id="query" type="text" placeholder="Type a query word..." value="search" />
  <div class="sliders">
    <label>k<sub>1</sub> = <span id="k1val">1.2</span>
      <input id="k1" type="range" min="0" max="3" step="0.1" value="1.2" title="Term frequency saturation (higher = closer to raw TF)" />
    </label>
    <label>b = <span id="bval">0.75</span>
      <input id="b" type="range" min="0" max="1" step="0.05" value="0.75" title="Length normalization (0 = off, 1 = full)" />
    </label>
  </div>
</div>
<div id="results" class="results"></div>
<p class="legend">Documents sorted by BM25 score (dark bar). Grey bar = raw TF. Try 'search' or 'algorithm'.</p>
/* Base layout styles */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; padding: .5rem; }
.controls { display: flex; flex-direction: column; gap: .5rem; margin-bottom: .8rem; }
input[type="text"] { font: 15px system-ui; padding: .4rem .7rem; border: 1px solid #cdd; border-radius: 8px; width: 100%; }
.sliders { display: flex; gap: 1rem; flex-wrap: wrap; }
label { font-size: .85rem; color: #555; display: flex; flex-direction: column; gap: .2rem; }
input[type="range"] { width: 130px; }
/* Result card styles */
.results { display: flex; flex-direction: column; gap: .55rem; }
.doc-card { border-radius: 10px; padding: .55rem .8rem; border: 1px solid #dde; background: #f8fafc; }
.doc-title { font-weight: 700; font-size: .9rem; margin-bottom: .25rem; }
.bar-row { display: flex; align-items: center; gap: .5rem; font-size: .8rem; margin: .18rem 0; }
.bar-label { width: 46px; color: #666; flex-shrink: 0; }
.bar-track { flex: 1; height: 10px; background: #e4e8f0; border-radius: 5px; overflow: hidden; }
.bar-fill { height: 100%; border-radius: 5px; transition: width .3s; }
.bar-tf   { background: #94a3b8; }
.bar-bm25 { background: #1d3557; }
.bar-val { width: 40px; text-align: right; color: #444; }
.legend { font-size: .78rem; color: #777; margin-top: .6rem; line-height: 1.5; }
/* BM25 scoring implementation */
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));

/* Small corpus: doc A is short+focused, B is medium, C is long+repetitive, D is a non-match */
const CORPUS = [
  { id: 'A', title: 'A – Focused article',
    text: 'search algorithm search ranking search' },
  { id: 'B', title: 'B – Medium article',
    text: 'a search engine uses a ranking algorithm to order results the search algorithm assigns each document a score based on term frequency' },
  { id: 'C', title: 'C – Long repetitive doc',
    text: 'search is a fundamental task in computing when you search for information you initiate a search query the search engine processes the search request and returns results search is used everywhere from web search to database search to search in file systems and search in emails search results depend on the search index which stores search terms and document references so that search can be performed quickly and each search can be ranked' },
  { id: 'D', title: 'D – Off-topic article',
    text: 'the quick brown fox jumps over the lazy dog an irrelevant sentence with no query words at all' }
];

/* Split text into lowercase tokens */
function tokenize(text) {
  return text.toLowerCase().split(/\s+/).filter(Boolean);
}

/* Raw term frequency: count occurrences of term in token list */
function rawTF(tokens, term) {
  return tokens.filter(t => t === term).length;
}

/* Robertson-Sparck Jones IDF variant used in BM25 */
function idf(N, df) {
  return Math.log((N - df + 0.5) / (df + 0.5) + 1);
}

/* BM25 score for a document given query terms, k1, b, and average document length */
function scoreBM25(tokens, query, k1, b, avgdl) {
  const dl = tokens.length;
  const N = CORPUS.length;
  let score = 0;
  for (const term of query) {
    const df = CORPUS.filter(d => tokenize(d.text).includes(term)).length;
    const tf = rawTF(tokens, term);
    const numerator = tf * (k1 + 1);
    const denominator = tf + k1 * (1 - b + b * dl / avgdl);
    score += idf(N, df) * (numerator / denominator);
  }
  return score;
}

/* Raw TF score: sum of counts per query term (no saturation, no length norm) */
function scoreTF(tokens, query) {
  return query.reduce((s, t) => s + rawTF(tokens, t), 0);
}

function render() {
  const qRaw = document.getElementById('query').value.trim().toLowerCase();
  const query = qRaw.split(/\s+/).filter(Boolean);
  const k1 = parseFloat(document.getElementById('k1').value);
  const b  = parseFloat(document.getElementById('b').value);
  document.getElementById('k1val').textContent = k1.toFixed(1);
  document.getElementById('bval').textContent  = b.toFixed(2);

  const tokenized = CORPUS.map(d => ({ ...d, tokens: tokenize(d.text) }));
  const avgdl = tokenized.reduce((s, d) => s + d.tokens.length, 0) / tokenized.length;

  const scored = tokenized.map(d => ({
    ...d,
    tf:   query.length ? scoreTF(d.tokens, query) : 0,
    bm25: query.length ? scoreBM25(d.tokens, query, k1, b, avgdl) : 0
  }));

  /* Sort by BM25 descending for display */
  const byBM25 = [...scored].sort((a, b2) => b2.bm25 - a.bm25);

  const maxTF   = Math.max(...scored.map(d => d.tf),   0.001);
  const maxBM25 = Math.max(...scored.map(d => d.bm25), 0.001);

  const container = document.getElementById('results');
  container.innerHTML = '';
  for (const d of byBM25) {
    const card = document.createElement('div');
    card.className = 'doc-card';
    const tfPct   = (d.tf   / maxTF)   * 100;
    const bm25Pct = (d.bm25 / maxBM25) * 100;
    card.innerHTML =
      `<div class="doc-title">${d.title}</div>` +
      `<div class="bar-row"><span class="bar-label">TF</span>` +
      `<div class="bar-track"><div class="bar-fill bar-tf" style="width:${tfPct.toFixed(1)}%"></div></div>` +
      `<span class="bar-val">${d.tf.toFixed(1)}</span></div>` +
      `<div class="bar-row"><span class="bar-label">BM25</span>` +
      `<div class="bar-track"><div class="bar-fill bar-bm25" style="width:${bm25Pct.toFixed(1)}%"></div></div>` +
      `<span class="bar-val">${d.bm25.toFixed(2)}</span></div>`;
    container.appendChild(card);
  }
}

document.getElementById('query').addEventListener('input', render);
document.getElementById('k1').addEventListener('input', render);
document.getElementById('b').addEventListener('input', render);
render();
Result
Console

Notice that a very long document stuffed with your query word rises to the top under raw TF but gets pulled back down by BM25's length penalty. Drag the k1k_1 slider to see saturation kick in: at k1=0k_1 = 0 every matching document gets the same TF score regardless of count; at high k1k_1 BM25 behaves more like raw TF. Set b=0b = 0 to disable length normalization entirely.

The Real Complexity

How BM25 actually scores a document for a multi-word query q={q1,…,qn}q = \{q_1, \dots, q_n\} is a sum over each query term:

BM25(D,q)=∑i=1nIDF(qi)⋅f(qi,D)⋅(k1+1)f(qi,D)+k1⋅(1−b+b⋅∣D∣avgdl)\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 \left(1 - b + b \cdot \frac{|D|}{\text{avgdl}}\right)}

where f(qi,D)f(q_i, D) is the raw frequency of term qiq_i in document DD, ∣D∣|D| is document length in words, avgdl is the average document length across the corpus, and IDF(qi)=ln⁡ ⁣(N−ni+0.5ni+0.5+1)\text{IDF}(q_i) = \ln\!\left(\frac{N - n_i + 0.5}{n_i + 0.5} + 1\right) down-weights terms that appear in many documents.

  • Per-query complexity is linear. Scoring one document against a qq-term query takes O(q)O(q) arithmetic operations. Ranking all NN documents takes O(N⋅q)O(N \cdot q) — fine for a few hundred results but impractical at web scale.
  • The inverted index makes it fast. In practice, retrieval engines store a posting list for each vocabulary term. A query only touches the (typically tiny) fraction of documents that actually contain at least one query term, so real throughput is far better than O(N⋅q)O(N \cdot q).
  • Tuning k1k_1 and bb. The defaults (k1=1.2k_1 = 1.2, b=0.75b = 0.75) work well across many text collections, but retrieval benchmarks show that tuning them to a specific corpus and query distribution can yield measurable gains. Some collections (e.g., very short tweets) prefer bb close to 0; others (legal documents) prefer higher bb.
  • BM25+ and BM25F. Variants address edge cases: BM25+ adds a floor to prevent zero scores for very rare terms; BM25F extends the formula to structured documents with multiple fields (title, body, anchor text), weighting each field separately.

BM25 occupies a sweet spot: it is grounded in a probabilistic model of relevance, it is simple enough to implement in an afternoon, and it scales to billions of documents when paired with an inverted index.

Where It Matters

BM25's simplicity and solid empirical performance have made it the default choice across a surprising range of systems:

  • Full-text search infrastructure. Elasticsearch, Apache Lucene, and Apache Solr all ship BM25 as the default scorer (Lucene switched from TF-IDF to BM25 in version 6.0 in 2016). Any application that uses these libraries — from e-commerce product search to log analytics — is already running BM25.
  • Academic information retrieval benchmarks. TREC, CLEF, and similar evaluation campaigns routinely use BM25 as the strong baseline that neural models must beat. Many papers still report BM25 as their strongest non-neural competitor.
  • Retrieval-Augmented Generation (RAG). Modern large-language-model pipelines often pair BM25 (fast, exact-match) with dense vector search (semantic similarity) in a hybrid retrieval step. BM25 anchors the exact-keyword leg of the hybrid.
  • Legal and biomedical search. Domains with precise terminology (case numbers, drug names, gene symbols) favor BM25's exact-match character over the fuzzier behavior of learned embeddings.
  • Question answering. Open-domain QA systems like DPR (Dense Passage Retrieval) were benchmarked against BM25 and initially struggled to beat it on keyword-heavy questions, which drove the research community toward hybrid approaches.

Understanding BM25 is a prerequisite for understanding modern retrieval — even the newest neural ranking models are evaluated relative to it, and many production systems still rely on it as their primary or fallback ranker. It connects naturally to Bayesian inference and the probabilistic relevance model that inspired it.

Conclusion

BM25 is a masterclass in applied probabilistic thinking. It starts from the observation that raw word-counting is naive — the tenth occurrence of "python" in a document tells you less than the first, and a book-length document mentioning a word once should not beat a short focused article — and fixes both problems with a single elegant formula.

Three decades on, BM25 is still the default scorer in the most widely deployed search infrastructure on the planet. Neural re-rankers have closed the gap in benchmark evaluations, but they almost always sit on top of a BM25 first-stage retriever rather than replacing it outright. The formula's combination of speed, interpretability, and solid empirical performance is simply hard to beat.

The next time a search engine surfaces exactly the document you were looking for, there is a good chance that a 30-year-old sum of logarithms — tweaked by two parameters named k1k_1 and bb — had the final say.

Share this article

Pick a channel — or use your device's native share sheet.

Comments

Loading comments...

https://www.kipuhub.com/en/article/bm25-ranking/Content licensed under CC BY-NC 4.0.