Skip to content
Select theme

Ranking with BM25

Step 6 of 12, and the ranker changes. Step 5 left haystack with a scorer that counted mentions raw, and a ranking to match: the long style guide sitting above the page that is actually about borrowing. This step replaces that scorer with BM25 — the ranking function Lucene and Elasticsearch ship — and the same query puts the right document on top.

The starting state is tag search-ch05, the end of step 5: postings that carry term frequencies, documents that carry lengths, and a TF-IDF scorer whose length bias you can watch happen. The end state of this step is tagged search-ch06. The corpus, the index file and the tokeniser are unchanged — the whole step is the scorer.

BM25 scores one term against one document as a product of two factors. The first is the term’s weight, the inverse document frequency from step 5:

idf = ln(N / df)

The second factor is the term-frequency component, and it is the whole of this step:

score = idf * tf * (k1 + 1) / (tf + k1 * (1 - b + b * dl / avgdl))

tf is how often the term appears in the document, dl is the document’s length in terms, and avgdl is the average length across the collection — the only new number the index has to produce. A query with several terms sums one contribution per term; haystack asks about one term at a time.

Two constants, k1 and b, are the only numbers in the formula that are not counted from the corpus. Both have fixed values in Lucene, and both are copied here: 1.2 and 0.75.

tf_score is the new factor on its own, with k1 and b as parameters so the constants can be swept:

tf k1=0 k1=1.2 k1=2.0
1 1.000 1.000 1.000
2 1.000 1.375 1.500
4 1.000 1.692 2.000
8 1.000 1.913 2.400
16 1.000 2.047 2.667
64 1.000 2.160 2.909
ceiling at k1=1.2: 2.2

The numerator grows with tf, and so does the denominator — faster. That asymmetry is the saturation: the ratio approaches k1 + 1 and stops, which at Lucene’s k1 is 2.2. Four mentions are worth 1.692 of a possible 2.2; going from sixteen to sixty-four adds 0.113. A page that says “borrow” sixty-four times scores 2.160, barely above four mentions and a fraction of the 64 a raw count would have given it. Keyword stuffing hits a ceiling.

k1 = 0 is the degenerate case worth knowing: the denominator loses its tf and every match scores exactly 1, however often the term repeats. That is binary matching, present or absent, and it is what an engine wants when it only cares whether a term is there at all.

What k1 doesn’t touch is rarity. A term in every document still weighs nothing, from step 5’s idf; saturation only shapes what repetition inside one document is worth.

The second constant operates on the two documents step 5 ended on: the borrow-checker page, 12 terms long with four mentions, and the style guide, 57 terms long with five. Both come from the corpus’s real postings, and the average document length is 122 / 6 = 20.33 terms:

b borrow-checker style-guide
tf=4 len=12 tf=5 len=57
0 0.3085 0.3235
0.25 0.3160 0.2975
0.5 0.3239 0.2754
0.75 0.3321 0.2564
1 0.3408 0.2398

Same two documents, same k1, b swept from 0 to 1. At b = 0 the length term collapses to 1 and length is ignored: the style guide still wins, 0.3235 against 0.3085. That is step 5’s ranking with saturation bolted on — the bias survives k1 untouched, because k1 never looks at dl.

Somewhere between b = 0 and b = 0.25 the order flips, and from there the short document only extends its lead. At Lucene’s 0.75 the scores are 0.3321 against 0.2564. At b = 1 the whole k1 scales with dl / avgdl and the gap is at its widest: 0.3408 against 0.2398.

b = 1 is not the best setting even though it fixes this corpus. Full normalisation punishes a long document that genuinely repeats a term — a reference page that mentions “borrow” in every section is long because it is about borrowing. The defaults are empirical rather than derived: 1.2 and 0.75 were tuned on TREC collections and have held up through three decades of attempts to do better.

The scorer is now a method on the index rather than a free function, because it needs the document lengths and their average alongside the postings. It reads the two numbers off the index — average_length() is the mean of the document lengths, 20.33 terms on this corpus — multiplies step 5’s idf by the term-frequency component, and sorts with the same total_cmp tie rule as before. The whole method is in the file at the end of this page.

Run the demo and the two queries come back ranked by BM25:

average document length: 20.33 terms
query "borrow", ranked by BM25:
0.3321 docs/borrow-checker.md tf=4 len=12
0.2564 docs/style-guide.md tf=5 len=57
0.2139 docs/lifetimes.md tf=1 len=13
0.2139 docs/iterators.md tf=1 len=13
0.2042 docs/ownership.md tf=1 len=15
query "owner", ranked by BM25:
1.6309 docs/ownership.md tf=2 len=15
1.3199 docs/borrow-checker.md tf=1 len=12

The style guide drops to second. The three single-mention documents now sort by length — lifetimes and iterators, 13 terms each, above ownership at 15 — which is the length normalisation doing visibly honest work. The “owner” query is the control: ownership.md mentions it twice and still wins, because there the more frequent document is also the more relevant one. BM25 discounts length, not frequency.

The index file is still format version 2. Step 5 wrote term frequencies and document lengths to disk in advance — they were in the file before the ranker that needs them — so there is no version bump and no migration, and the round trip tests are untouched.

The idf is step 5’s classic ln(N / df). Lucene’s BM25Similarity uses a different one, ln(1 + (N - df + 0.5) / (df + 0.5)), smoothed so that it stays positive, and the two disagree about a term in every document: Lucene gives it a small positive weight, this engine gives it zero and the term drops out of the ranking. Since one term’s idf is a constant multiplier on all of its scores, the choice cannot reorder anything this step produces; it starts to matter when a query sums several terms.

BM25 came out of the Okapi system at City University London. The function was published by Stephen Robertson, Steve Walker, Susan Jones, Micheline Hancock-Beaulieu and Mike Gatford in “Okapi at TREC-3” (1994), folding two earlier weighting functions, BM11 and BM15, into one. The theory behind it — why a saturating tf and a length correction fall out of a probabilistic model of relevance — is Robertson and Hugo Zaragoza’s “The Probabilistic Relevance Framework: BM25 and Beyond” (2009). Step 5 credited Karen Sparck Jones for the idf half of the formula; the tf and length halves are that paper’s.

One file: the two constants, tf_score, the rank method, average_length, and the tests. The block below is the file at tag search-ch06, byte for byte.

From the haystack directory:

Terminal window
cargo run
6 documents, 84 distinct terms
saved to haystack.idx: 2131 bytes
saved again: 2131 bytes, byte-identical: true
loaded back: 6 documents, 84 terms
postings for "borrow" — term frequency, then document length:
docs/ownership.md tf=1 len=15
docs/borrow-checker.md tf=4 len=12
docs/lifetimes.md tf=1 len=13
docs/iterators.md tf=1 len=13
docs/style-guide.md tf=5 len=57
average document length: 20.33 terms
query "borrow", ranked by BM25:
0.3321 docs/borrow-checker.md tf=4 len=12
0.2564 docs/style-guide.md tf=5 len=57
0.2139 docs/lifetimes.md tf=1 len=13
0.2139 docs/iterators.md tf=1 len=13
0.2042 docs/ownership.md tf=1 len=15
query "owner", ranked by BM25:
1.6309 docs/ownership.md tf=2 len=15
1.3199 docs/borrow-checker.md tf=1 len=12
a version 1 file: unsupported index version 1

The opening lines are the round trip from step 4, unchanged in shape. What is new is the average length and the two ranked queries.

The tests:

Terminal window
cargo test
running 40 tests
test tests::a_doubled_consonant_from_the_suffix_is_collapsed ... ok
test tests::a_missing_file_is_an_io_error ... ok
test tests::a_newer_version_is_refused ... ok
test tests::a_postings_list_that_is_not_ascending_is_refused ... ok
test tests::a_missing_term_is_empty_not_an_error ... ok
test tests::a_repeated_term_records_how_often_it_repeated ... ok
test tests::a_stem_needs_three_characters ... ok
test tests::a_term_that_is_not_utf8_is_refused ... ok
test tests::a_term_repeated_in_one_document_lists_it_once ... ok
test tests::a_term_in_two_documents_lists_both ... ok
test tests::a_version_1_file_is_refused ... ok
test tests::a_wrong_magic_is_refused ... ok
test tests::add_document_returns_the_id_it_gave ... ok
test tests::a_terms_frequency_survives_a_reload ... ok
test tests::a_saved_index_loads_back_identical ... ok
test tests::an_empty_index_has_no_average_length ... ok
test tests::a_frequent_term_in_a_relevant_document_still_wins ... ok
test tests::a_truncated_file_is_refused ... ok
test tests::average_length_is_the_mean_of_the_document_lengths ... ok
test tests::bm25_puts_the_highest_score_first ... ok
test tests::case_folding_is_unicode_aware ... ok
test tests::an_empty_index_round_trips ... ok
test tests::casing_and_punctuation_are_folded_away ... ok
test tests::distinct_terms_in_one_document_get_one_posting_each ... ok
test tests::document_length_counts_the_terms_the_tokeniser_kept ... ok
test tests::doubling_that_is_not_the_suffixs_fault_survives ... ok
test tests::equal_documents_keep_document_order ... ok
test tests::idf_falls_as_the_document_frequency_rises ... ok
test tests::idf_is_zero_for_a_term_in_every_document ... ok
test tests::ids_are_in_insertion_order ... ok
test tests::inflections_share_one_term ... ok
test tests::k1_of_zero_makes_frequency_irrelevant ... ok
test tests::bm25_lets_the_shorter_document_win ... ok
test tests::length_normalisation_off_leaves_length_alone ... ok
test tests::length_normalisation_on_favours_the_short_document ... ok
test tests::stop_words_are_not_indexed ... ok
test tests::term_frequency_saturates ... ok
test tests::the_dictionary_is_written_in_sorted_order ... ok
test tests::trailing_bytes_are_refused ... ok
test tests::saving_twice_writes_the_same_bytes ... ok
test result: ok. 40 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Forty tests: the thirty-one from the earlier steps, and nine around the new scorer — saturation, both extremes of b, the average, the tie order, and the corpus ranking pinned in both directions. The order is shuffled on every run, so nothing should be read into it.

This step’s state is tagged search-ch06 in the examples repository:

Terminal window
git clone https://github.com/hackingwithrust/examples
cd examples/search-engine
git checkout search-ch06

The tag contains Cargo.toml, the README and src/main.rs in full. If your build misbehaves, diff your files against the tag’s — the drifting line is usually findable that way. The track overview keeps the step list current.

Step 7, Phrase and boolean queries, gives every posting a position: “borrow checker” as a phrase becomes a different question from the two terms anywhere in the text, and AND, OR and NOT fall out of the same lists.