Lab QE2: BM25 and Scoring Internals
Background
The intensive gave you the BM25 formula and a worked numeric example.
This lab makes it real three ways. First you use _explain to decompose a live
document's score into its idf, tfNorm, dl/avgdl, k1, and b parts and read
them off the API. Second you change the similarity — tweak k1/b, then swap to
a different similarity entirely — and watch the score move in the direction the
math predicts. Third you write a standalone Lucene program that computes BM25
by hand and matches _explain to the last decimal, so the formula stops being a
black box. Finally you connect scoring to retrieval speed: Block-Max WAND makes
top-k faster without changing the top results, and totalHitsThreshold is where
the count (not the ranking) goes approximate.
Why This Matters for Contributors
Relevance bugs are the hardest class of search bug because "wrong" is subjective —
until you can decompose a score. A contributor who can run _explain, read every
node, and reproduce it offline can say with certainty "the score is correct, your
expectation is wrong" or "the idf is off because this shard has skewed stats."
That is the difference between a closed issue and a week of back-and-forth. And
anyone touching the scoring or skipping code (a new Similarity, a WANDScorer
change) must prove top-k is unchanged — this lab is that proof technique.
Prerequisites
-
A running OpenSearch 3.x (
docker run -p 9200:9200 -e discovery.type=single-node -e DISABLE_SECURITY_PLUGIN=true opensearchproject/opensearch:latest). -
curl,jq, Java 21+ (java -version). -
A Lucene
corejar for the standalone program: from anapache/lucenecheckout./gradlew :lucene:core:jar, or from any OpenSearch checkoutfind ~/.gradle -name 'lucene-core-*.jar' | head. - You've read the intensive BM25 section and done Lab QE1.
Note: keep
number_of_shards: 1. BM25idfuses per-shard statistics by default, so a single shard makes_explaindeterministic and easy to match by hand. With multiple shards you'd needdfs_query_then_fetch(see Search Execution) for global stats.
Step-by-Step Tasks
Step 1 — Build an index with controllable lengths
H='-H content-type:application/json'
curl -s -XDELETE localhost:9200/qe2 >/dev/null
curl -s -XPUT localhost:9200/qe2 $H -d '{
"settings": { "number_of_shards": 1, "number_of_replicas": 0 },
"mappings": { "properties": { "body": { "type": "text", "analyzer": "standard" } } }
}' | jq .
# Doc 1: "opensearch" appears 3x in a short field.
# Doc 2: "opensearch" once in a long field (more non-matching words -> longer dl).
# Docs 3..: filler that does NOT contain "opensearch" (pads N but not n).
curl -s -XPOST 'localhost:9200/qe2/_bulk?refresh=true' $H --data-binary '
{"index":{"_id":1}}
{"body":"opensearch opensearch opensearch fast"}
{"index":{"_id":2}}
{"body":"opensearch is a distributed search and analytics suite built on lucene with many features and modules"}
{"index":{"_id":3}}
{"body":"completely unrelated filler text about gardening"}
{"index":{"_id":4}}
{"body":"more filler about cooking and recipes"}
' | jq '.errors'
Step 2 — Decompose a score with _explain
curl -s 'localhost:9200/qe2/_explain/1?pretty' $H -d '{
"query": { "match": { "body": "opensearch" } }
}' | jq '.explanation'
The explanation is a tree. Find these nodes (descriptions vary slightly by version
— read the description strings):
_explain node | Symbol | What it is |
|---|---|---|
idf, computed as log(1 + (N - n + 0.5) / (n + 0.5)) | idf | rarity term; shows N (docCount) and n (docFreq) |
tf, computed as freq / (freq + k1 * (1 - b + b * dl / avgdl)) | tfNorm | saturation + length norm; shows freq, k1, b, dl, avgdl |
| the product node | score | boost * idf * tfNorm |
# Pull the raw inputs straight out of the explanation:
curl -s 'localhost:9200/qe2/_explain/1' $H -d '{"query":{"match":{"body":"opensearch"}}}' \
| jq '[.. | objects | select(has("description")) | {d:.description, v:.value}]'
Note that doc 1 (tf=3, short dl) scores higher than doc 2 (tf=1, long
dl) — both effects (more term frequency, shorter field) push the score up,
exactly as the formula says. Run _explain/2 and compare.
Step 3 — Change k1/b and watch the score move
k1 and b are index settings on the BM25 similarity. Reconfigure and re-explain.
(Changing similarity params requires closing/reopening the index.)
curl -s -XPOST localhost:9200/qe2/_close >/dev/null
curl -s -XPUT localhost:9200/qe2/_settings $H -d '{
"index": { "similarity": { "default": {
"type": "BM25", "k1": 0.5, "b": 0.0
}}}
}' | jq .
curl -s -XPOST localhost:9200/qe2/_open >/dev/null
sleep 1
curl -s 'localhost:9200/qe2/_explain/1' $H -d '{"query":{"match":{"body":"opensearch"}}}' \
| jq '.explanation.value'
Predict before you run:
b = 0.0removes length normalization entirely (1 - b + b*dl/avgdl = 1), so doc 1's length advantage over doc 2 disappears — onlytfseparates them.k1 = 0.5saturates faster, so doc 1'stf=3advantage shrinks versus the defaultk1=1.2.
Re-explain doc 2 and confirm docs 1 and 2 are now closer than in Step 2.
Step 4 — Swap to a different similarity
curl -s -XPOST localhost:9200/qe2/_close >/dev/null
curl -s -XPUT localhost:9200/qe2/_settings $H -d '{
"index": { "similarity": { "default": {
"type": "boolean"
}}}
}' | jq .
curl -s -XPOST localhost:9200/qe2/_open >/dev/null
sleep 1
curl -s 'localhost:9200/qe2/_search?pretty' $H -d '{"query":{"match":{"body":"opensearch"}}}' \
| jq '.hits.hits[] | {id:._id, score:._score}'
The boolean similarity ignores tf and idf entirely — every match gets the
query boost (1.0), so docs 1 and 2 tie. This is the cleanest demonstration
that BM25's score is the tf/idf/length math: remove it and ranking collapses to
membership. Restore BM25 before continuing:
curl -s -XPOST localhost:9200/qe2/_close >/dev/null
curl -s -XPUT localhost:9200/qe2/_settings $H -d '{
"index": { "similarity": { "default": { "type": "BM25", "k1": 1.2, "b": 0.75 } } } }' >/dev/null
curl -s -XPOST localhost:9200/qe2/_open >/dev/null
Step 5 — Compute BM25 by hand in standalone Lucene
This is the payoff: a tiny program that indexes the same docs, runs the same query, prints the score, and prints your hand-rolled BM25 from the raw stats — they must match.
// Bm25ByHand.java
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.document.*;
import org.apache.lucene.index.*;
import org.apache.lucene.search.*;
import org.apache.lucene.search.similarities.BM25Similarity;
import org.apache.lucene.store.*;
import org.apache.lucene.util.BytesRef;
public class Bm25ByHand {
static final float k1 = 1.2f, b = 0.75f;
public static void main(String[] args) throws Exception {
Directory dir = new ByteBuffersDirectory();
IndexWriterConfig cfg = new IndexWriterConfig(new StandardAnalyzer())
.setSimilarity(new BM25Similarity(k1, b));
try (IndexWriter w = new IndexWriter(dir, cfg)) {
add(w, "opensearch opensearch opensearch fast"); // doc 0
add(w, "opensearch is a distributed search and analytics suite built on lucene with many features and modules"); // 1
add(w, "completely unrelated filler text about gardening"); // 2
add(w, "more filler about cooking and recipes"); // 3
}
try (DirectoryReader r = DirectoryReader.open(dir)) {
IndexSearcher s = new IndexSearcher(r);
s.setSimilarity(new BM25Similarity(k1, b));
Term t = new Term("body", "opensearch");
Query q = new TermQuery(t);
// ---- stats we need for BM25 ----
int N = r.getDocCount("body"); // docs having the field
int n = r.docFreq(t); // docs containing the term
long sumDl = r.getSumTotalTermFreq("body"); // total tokens in field
double avgdl = (double) sumDl / N;
TopDocs top = s.search(q, 10);
System.out.printf("N=%d n=%d avgdl=%.4f%n", N, n, avgdl);
double idf = Math.log(1 + (N - n + 0.5) / (n + 0.5));
System.out.printf("idf = %.6f%n", idf);
for (ScoreDoc sd : top.scoreDocs) {
// per-doc tf and length
LeafReaderContext leaf = r.leaves().get(
ReaderUtil.subIndex(sd.doc, r.leaves()));
int localDoc = sd.doc - leaf.docBase;
PostingsEnum pe = leaf.reader().postings(t, PostingsEnum.FREQS);
pe.advance(localDoc);
int tf = pe.freq();
// field length (dl) = number of tokens in this doc's "body"
long dl = leaf.reader().getNormValues("body") != null
? decodeLen(leaf, localDoc) : tf;
double denom = tf + k1 * (1 - b + b * dl / avgdl);
double tfNorm = (tf * (k1 + 1)) / denom;
double byHand = idf * tfNorm;
System.out.printf(
"doc=%d lucene=%.6f byHand=%.6f (tf=%d dl=%d tfNorm=%.6f)%n",
sd.doc, sd.score, byHand, tf, dl, tfNorm);
}
}
}
// Lucene stores field length lossily in norms; decode the same way BM25 reads it.
static long decodeLen(LeafReaderContext leaf, int doc) throws Exception {
NumericDocValues norms = leaf.reader().getNormValues("body");
norms.advance(doc);
return SmallFloat.byte4ToInt((byte) norms.longValue()); // approx; see note
}
static void add(IndexWriter w, String body) throws Exception {
Document d = new Document();
FieldType ft = new FieldType(TextField.TYPE_NOT_STORED);
d.add(new Field("body", body, ft));
w.addDocument(d);
}
}
LUCENE=$(find ~/.gradle -name 'lucene-core-*.jar' | head -1)
ANALYZ=$(find ~/.gradle -name 'lucene-analysis-common-*.jar' | head -1)
CP="$LUCENE:$ANALYZ"
javac -cp "$CP" Bm25ByHand.java
java -cp ".:$CP" Bm25ByHand
Note: Lucene encodes field length (
dl) lossily into a single norm byte viaSmallFloat, so your hand-decodeddlis the same approximation BM25 uses — that's why they match. If yourbyHandis off by a hair, you decoded the norm with the wrong helper; grepBM25SimilarityforLENGTH_TABLE/decodeNormand mirror it exactly:grep -n "LENGTH_TABLE\|decodeNorm\|SmallFloat" lucene/core/src/java/org/apache/lucene/search/similarities/BM25Similarity.java
Step 6 — Top-k skipping keeps results identical; counts go approximate
Block-Max WAND speeds up top-k without changing the top-k. Demonstrate the two distinct effects.
# (a) The TOP results are stable regardless of totalHitsThreshold.
for THR in 1 10000; do
echo "track_total_hits=$THR"
curl -s "localhost:9200/qe2/_search?pretty" $H -d "{
\"track_total_hits\": $THR, \"size\": 2,
\"query\": { \"match\": { \"body\": \"opensearch\" } }
}" | jq '{total: .hits.total, ids: [.hits.hits[]._id], scores: [.hits.hits[]._score]}'
done
- With
track_total_hits: 1, OpenSearch stops counting once it has more than 1 match and reports{"value":1,"relation":"gte"}— the count is approximate. - The
idsandscoresof the returned top-2 are identical across both runs. That is the Block-Max WAND contract: the ranking is exact, only the total count can be early-terminated.
# (b) Profile shows the skipping machinery doing work:
curl -s 'localhost:9200/qe2/_search' $H -d '{
"profile": true, "size": 1, "track_total_hits": 10,
"query": { "match": { "body": "opensearch" } }
}' | jq '.profile.shards[0].searches[0].query[0].breakdown
| {set_min_competitive_score, advance, next_doc}'
A non-zero set_min_competitive_score is the collector pushing the rising
threshold θ down to the scorer — the trigger for skipping non-competitive
blocks. On this tiny corpus the savings are negligible; the mechanism is the
point. Tie back to the WAND/MaxScore/BMW table in the intensive.
Deliverables
-
The
_explain/1tree withidf,tfNorm,dl,avgdl,k1,bidentified, and the hand-computed product matching_explain'svalue. -
Before/after scores for the
k1=0.5,b=0.0change and thebooleansimilarity, each with a one-sentence prediction that came true. -
Bm25ByHand.javaoutput showinglucene == byHandfor every returned doc. -
The
track_total_hits1-vs-10000 comparison showing identical top-kidsandscoresbut a differenthits.total.relation.
Expected Output
# Bm25ByHand (default k1=1.2 b=0.75)
N=4 n=2 avgdl=...
idf = 0.470004
doc=0 lucene=0.601... byHand=0.601... (tf=3 dl=4 tfNorm=1.279...)
doc=1 lucene=0.215... byHand=0.215... (tf=1 dl=16 tfNorm=0.458...)
# Step 6a
track_total_hits=1
{ "total": {"value":1,"relation":"gte"}, "ids":["1","2"], "scores":[0.60,0.21] }
track_total_hits=10000
{ "total": {"value":2,"relation":"eq"}, "ids":["1","2"], "scores":[0.60,0.21] }
(Exact values depend on Lucene version and norm encoding — the equality
lucene == byHand and the stability of ids/scores are what matter.)
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
byHand differs in the 3rd decimal | wrong norm decode (dl) | mirror BM25Similarity's LENGTH_TABLE/decodeNorm exactly |
_settings rejects similarity change | index open | _close, change, _open (data-loss-free for settings) |
idf differs from your math | extra docs with the field padded N, or replicas/shards | number_of_shards:1, _refresh, recount docCount/docFreq |
boolean similarity still varies score | a boost somewhere in the query | strip boosts; expect a flat 1.0 |
| top-k changed between threshold runs | a different query body or per-shard idf | hold everything but track_total_hits constant; single shard |
getNormValues is null | field has no norms (e.g. norms:false) | use a default text field; norms are on by default |
Stretch Goals
- Match
_explaintoBm25ByHandexactly. Read the same doc's stats from both and assert equality to 6 decimals; if off, you've found the norm-decode subtlety — fix it and document it. - Per-field similarity. Map two fields with different
similarity(one BM25, oneboolean) and show a query scoring them differently in one_explain. - DFS the idf. Add a second shard, show scores drift, then add
dfs_query_then_fetchand show them converge — connect to Search Execution. - Force a visible BMW win. Index ~1M docs with one rare term + one common term
in a
shoulddisjunction,size:10, and comparetook/profilewithtrack_total_hits:truevs default — quantify the skipping.
Coding Exercises
You decomposed scores by eye and reproduced one in a throwaway program. These
exercises make the assertions permanent — JUnit/integration tests and a hardened
standalone — so a future scoring change can't silently move ranking. Locate
similarity classes with rg (rg -l "class BM25Similarity" ;
rg -l "SimilarityTests|class.*ScoringIT" server/).
-
(warm-up) Assert the BM25 ranking on a cluster. Write an
OpenSearchIntegTestCasethat indexes the fourqe2docs, runsmatch(body: opensearch), and asserts doc 1 (tf=3, short) scores strictly higher than doc 2 (tf=1, long). AassertThat(score1, greaterThan(score2))encodes the Step 2 observation as a regression test. -
(core) Turn
Bm25ByHandinto a graded test. Convert the Step 5 program into a JUnit test (no cluster) that, for every returned doc, assertsMath.abs(luceneScore - byHand) < 1e-5. Mirror Lucene's lossy norm decode exactly (rg -n "LENGTH_TABLE|decodeNorm|SmallFloat" $(rg -l "class BM25Similarity")) so the equality holds to 5–6 decimals. This is the "match_explainexactly" stretch goal, now graded and runnable. -
(core) Assert the direction of a
k1/bchange. Write a test that scores the corpus under default BM25 and underk1=0.5, b=0.0, and asserts doc 1 and doc 2 are closer together withb=0.0(length normalization removed) — aassertThat(gapAfter, lessThan(gapBefore)). This makes the Step 3 prediction an executable claim about the math, not a hand-wave. -
(core) A custom
Similaritysmoke test. Implement a trivialSimilaritysubclass (e.g. constant-score, or BM25 with a fixedtfcap) and write a test that wires it on a field and asserts the score behaves as designed — proving you can extend the scoring SPI, not just read it. Find the extension point withrg -n "class .*Similarity extends|SimilarityProvider" server/.../index/similarity/. -
(advanced) Advanced challenge — prove Block-Max WAND keeps top-k exact. Build an
OpenSearchIntegTestCase(or a standalone Lucene program) that indexes a larger corpus with one rare and one common term in ashoulddisjunction, runs the query withtrack_total_hits: 1and withtrack_total_hits: true, and asserts: (a) the returnedidsand_scores of the top-k are identical across both, while (b)hits.total.relationdiffers (gtevseq). Then captureprofileset_min_competitive_scoreto show the skipping fired. This is the exact proof technique anyWANDScorer/SimilarityPR must supply: ranking unchanged, only the count went approximate. Deliverable: one test asserting top-k stability under early termination, cross-linked to the WAND/MaxScore/BMW table in the intensive.
Issues to Practice On
Score-decomposition is the skill that closes relevance tickets. Practice on
opensearch-project/OpenSearch; the BM25/skipping internals also live in
apache/lucene, so check both.
| What to look for | How to list it |
|---|---|
| Relevance area | gh issue list --repo opensearch-project/OpenSearch --label "Search:Relevance" --state open |
| Good first issues | gh issue list --repo opensearch-project/OpenSearch --label "good first issue" --state open |
| Lucene scoring | gh issue list --repo apache/lucene --search "BM25 OR similarity OR WAND in:title" --state open |
Labels drift; confirm with gh label list --repo opensearch-project/OpenSearch
(look for Search:Relevance, Search).
Representative patterns. (1) "Scores differ across shards / look wrong." —
usually per-shard idf; reproduce with _explain, confirm N/n per shard, and
note whether dfs_query_then_fetch is needed. (2) "New similarity / scoring tweak
changed unrelated rankings." — reproduce with a fixed corpus, decompose with
_explain + a hand computation, and supply a top-k-stability test. Approach:
reproduce → locate via rg → fix → test → PR with CHANGELOG + DCO.
Planted-bug drill. In your Bm25ByHand (or a copied BM25Similarity test
subclass), drop the (k1 + 1) factor from the tfNorm numerator. Run your Step-5
equality test: byHand now diverges from Lucene — watch the assertion go red and
read how far off it is for tf=3 vs tf=1 (the error grows with tf). Revert,
then add an assertion comparing byHand to _explain's tfNorm node value
directly, so any future formula drift is caught at the tfNorm level, not just the
final product.
Etiquette: claim the issue first, reproduce before fixing, and every PR needs a test + CHANGELOG entry + DCO
Signed-off-by(git commit -s). See community interaction and the prepare-a-PR lab.
Validation / Self-check
-
You can read any
_explainBM25 tree and name every node (idf,tfNorm,dl,avgdl,k1,b, boost, product). -
You can predict the direction a score moves when you change
k1,b, or the similarity, and confirm it on the cluster. -
Your standalone program reproduces Lucene's score, and you can explain the
lossy norm encoding that makes
dlan approximation both sides share. -
You can state precisely what Block-Max WAND keeps exact (the top-k ranking)
and what
totalHitsThresholdmakes approximate (the hit count).