If you have ever tuned search relevance in Elasticsearch or OpenSearch, you have tuned BM25 whether you knew it or not. It has been the default scoring function in Lucene since version 6, and the formula is short enough to read in one sitting. I think it is worth doing, because most of the "why did this document rank first?" mysteries disappear once you see what each term does.
Starting from TF-IDF
The older idea is TF-IDF. Score a document for a query term by multiplying two numbers: how many times the term appears in the document (term frequency), and how rare the term is across the whole collection (inverse document frequency). A word like "the" appears everywhere, so its IDF is near zero. A word like "memtable" appears in few documents, so a match carries real weight.
That works until you look at two failure cases. First, a document that says "redis" thirty times scores about ten times higher than one that says it three times, which is rarely what a reader wants. Second, a long document has more chances to contain any given word by accident, so length quietly inflates scores.
What BM25 changes
BM25 keeps the IDF half and rewrites the term frequency half:
tf × (k1 + 1) / (tf + k1 × (1 − b + b × len / avglen)) Two parameters do the work.
k1 sets how quickly repetition stops helping. With k1 = 1.2 and an average-length document, a term seen three times gets about 71% of the maximum possible contribution, and a term seen thirty times gets about 96%. The curve flattens. Set k1 to 0 and the term only counts as present or absent.
b controls length normalization. At b = 0 a long document is not penalized at all. At b = 1 its term frequencies are scaled fully by how much longer than average it is. The usual default is 0.75.
Seeing it on four sentences
Reading the formula is one thing, so I wrote a small calculator where you paste documents and a query and see both scores. Using these four lines and the query "redis caching":
1. redis is an in-memory data store used for caching 2. redis cluster shards keys across nodes, and redis sentinel handles failover 3. postgres is a relational database with strong consistency guarantees 4. caching strategies include write-through, write-behind, and cache-aside TF-IDF scores documents 1 and 2 identically, at 1.386. Document 2 never mentions caching, but it says "redis" twice, and raw counts make up the difference. BM25 with default settings scores document 1 at 1.386 and document 2 at 0.927, so the document matching both terms wins. Drop k1 to 0 and document 2 falls to 0.693. Push it to 5 and repetition counts for more again, and document 2 climbs to 1.128.
One quirk I did not expect: a term that appears in every document gets zero weight under textbook TF-IDF, but a small positive weight under Lucene's BM25 IDF, which is ln(1 + (N − n + 0.5) / (n + 0.5)). The log argument always stays above 1, so the weight never goes negative.
Things that trip people up
Scores are only comparable within one query. A BM25 score of 12 means nothing in isolation, so a fixed cutoff across different queries is a bad idea.
Statistics are per shard in a distributed engine. With a small index spread over several shards, IDF can differ from shard to shard and shuffle close rankings. Elasticsearch has a distributed-frequency search type for this, at the cost of an extra round trip.
Tune with evidence. The defaults of 1.2 and 0.75 hold up across many corpora. If you change them, do it against a labeled set of queries. For short fields such as titles, a lower b often works better, since length differences there say little about relevance.
BM25 matches literal terms only. "Car" will not find "automobile", which is why many production systems pair it with an embedding index and merge the results.
Where to go next
I wrote up the longer side-by-side, including a table of the differences, in a BM25 vs TF-IDF comparison on Nodique, and the calculator I used above is the BM25 Calculator, which runs entirely in the browser. Try breaking it: stuff a document with one repeated word, or add a long paragraph of filler, and watch how k1 and b respond.
