Lesson 0002 · Relevance & Scoring

BM25 scoring

Where _score actually comes from — three intuitions, one formula, two knobs.

~10 minWarm-up + quiz + labCheat sheet: here

Warm-up — recall from 0001

Before we start: which two bool clauses produce a _score? Answer from memory, then check. (Lesson 0001). — It's must and should (query context). Everything below is what those clauses compute.

In Lesson 0001 you learned whether a clause is scored. Now: how. Since Elasticsearch 5.0, the default similarity is BM25 (“Best Match 25”), which replaced classic TF/IDF. Connelly, Practical BM25 (Part 2) Don't memorize the formula cold — understand its three moving parts, and the formula reads itself.

Three intuitions

1 · Rare terms matter more — IDF

A match on elasticsearch tells you more than a match on the. Inverse Document Frequency weights each query term by how rare it is across the collection: the fewer documents contain a term, the higher its weight.

2 · Repetition helps — but with diminishing returns (TF saturation)

A document mentioning tuning five times is more about tuning than one mentioning it once — but not five times more. BM25 lets term frequency raise the score while saturating: each extra occurrence adds less than the last. This is the key thing BM25 fixed about raw TF/IDF, where frequency grew without limit.

3 · Short fields count more — length normalization

A term in a 4-word title is a stronger signal than the same term buried in a 900-word body. BM25 compares each field's length to the average length for that field and discounts longer-than-average fields.

The formula

Put the three together. For a query term t in document D:

score(t,D) = IDF(t)  ·  f(t,D) · (k1 + 1) f(t,D) + k1 · (1 − b + b · |D|avgdl)

Sum that over every query term to get the document's score. The pieces:

Read the saturation in the formula

As f(t,D) grows very large, the fraction approaches (k1 + 1) — a hard ceiling. So one term's contribution can never exceed IDF · (k1+1), no matter how many times it repeats. That ceiling is saturation.

The two knobs

k1
Controls TF saturation. Default 1.2. Higher → frequency keeps mattering longer before it saturates; k1 = 0 → term frequency ignored entirely (presence/absence only).
b
Controls length normalization. Default 0.75, range 0–1. b = 0 → field length ignored; b = 1 → full normalization (long fields penalized hardest).

Defaults and tuning guidance: Connelly, Practical BM25 (Part 3) · Similarity settings

Interview-grade nuances

Scores are per-shard

N and n in IDF are counted per shard, not cluster-wide. So the same document can score slightly differently depending on which shard it lands on — very visible with few documents. Force globally-consistent stats with the search type dfs_query_then_fetch (computes distributed term stats first). Practical BM25 (Part 1)

_score is not a percentage

It has no upper bound and no absolute meaning. Scores are only comparable within one query against one index — never treat a raw _score as a confidence or compare it across different queries.

Ties back to 0001

All of this runs only in query context. A filter clause, or a constant_score, computes none of it — that's exactly why filters are cheaper.

Check yourself

From memory — effortful recall is the point. Feedback is immediate.

In BM25, a term appearing in very few documents receives:

IDF rewards rarity: fewer documents containing the term → larger ln(1 + (N−n+0.5)/(n+0.5)).

The k1 parameter controls:

k1 sets how fast repeated terms saturate. Length normalization is b; IDF has no knob.

Setting b = 0 means:

With b = 0 the |D|/avgdl term drops out, so field length no longer affects the score.

Doubling a term's frequency in a document:

Saturation: each extra occurrence adds less, approaching the ceiling IDF·(k1+1).

Two shards can score the same match differently because:

IDF depends on N and n, counted per shard. TF is per-document; boost is query-defined. Use dfs_query_then_fetch for global stats.

Hands-on: watch BM25 explain itself

The _explain API prints the exact BM25 arithmetic for one document. Reuse the lab_articles index from Lesson 0001 (re-run its seed if you deleted it).

1 · Explain a single-occurrence match

GET /lab_articles/_explain/3
{ "query": { "match": { "title": "tuning" } } }   // doc 3: "postgres tuning"

In the response, find the idf and tf sub-explanations. You'll see the literal inputs: n (docs with "tuning"), N (total docs), freq, k1=1.2, b=0.75, and the field's avgdl.

2 · Compare a double-occurrence match — see saturation

GET /lab_articles/_explain/2
{ "query": { "match": { "title": "tuning" } } }   // doc 2: "...tuning tuning" (freq=2)
What you should observe

Doc 2's tf component is higher than doc 3's, but not double — that's saturation in the raw numbers. Its idf is identical to doc 3's (same term, same collection). And doc 2's field is longer, so length normalization pushes back a little. Three intuitions, one number.

3 · Turn a knob and predict

PUT /lab_bm25_b0
{ "settings": { "index": { "similarity": {
  "default": { "type": "BM25", "b": 0 }            // length normalization OFF
}}}}

Re-index the same docs into lab_bm25_b0 and compare a query's scores against lab_articles. Predict first: which docs should rise when long-field penalty is removed? Then DELETE /lab_articles /lab_bm25_b0 to clean up, and bring any surprising numbers to your teacher.

Primary source — read this next

Shane Connelly — Practical BM25, Part 2: The BM25 Algorithm and its Variables. The clearest plain-English derivation of this exact formula. Parts 1 and 3 cover shards and tuning b/k1 respectively.