Skip to main content

Best Match 25

This is a better version of dumb Keyword Search! Gives you ranking and fixes a lot of other problems.

It may be combined with Vector Search for something better (hybrid search). No synonyms, no meaning, no idea what “denies” means. It just counts words smarter.

Why “25”? It’s the 25th attempt in a series of “Best Match” scoring functions from the Okapi search system at City University London in the 1990s lol. Here’s the Wikipedia page.

People like this since it’s fast and explainable. But, again, it is dumb and has a problem with synonyms just like Keyword Search.

Why not Just Count yo?​

Consider the dumbest possible keyword search: score each document by how many times the query words show up in it. Cool. But there are some problems:

  1. Repetition wins.
    A note that says “allergy” 10 times beats one that says it once. But the 10th mention tells you way less than the 1st.
  2. All words are equal.
    ”allergy” shows up in half your notes. “penicillin” shows up in two. A match on the rare word should count for more.
  3. Long documents win.
    A 2,000-word discharge summary will contain something matching your query just by being long.

BM25 (and its extensions) attempt to fix these problems.

The Formula​

For a query QQ with words q1,…,qnq_1, \dots, q_n and a document DD:

BM25(D,Q)=∑i=1nIDF(qi)⏟rarity⋅f(qi,D)⋅(k1+1)f(qi,D)+k1(1−b+b⋅∣D∣avgdl)⏟saturating, length-adjusted count\text{BM25}(D, Q) = \sum_{i=1}^{n} \underbrace{\text{IDF}(q_i)}_{\text{rarity}} \cdot \underbrace{\frac{f(q_i, D) \cdot (k_1 + 1)}{f(q_i, D) + k_1 \left(1 - b + b \cdot \frac{|D|}{\text{avgdl}}\right)}}_{\text{saturating, length-adjusted count}}

where

  • f(qi,D)f(q_i, D) = how many times word qiq_i appears in DD (the term frequency)
  • ∣D∣|D| = length of DD in words; avgdl\text{avgdl} = average document length across the collection
  • k1k_1 = how fast the count saturates (usually 1.21.2)
  • bb = how much to punish long documents (usually 0.750.75; 00 = don’t care about length, 11 = full punishment)

Phew! How does it address the problems?

Fix 1: Rare Words Matter More (IDF)​

IDF(q)=ln⁡(N−n(q)+0.5n(q)+0.5+1)\text{IDF}(q) = \ln\left(\frac{N - n(q) + 0.5}{n(q) + 0.5} + 1\right)

NN = number of documents, n(q)n(q) = number of documents containing qq. A word in every document gets IDF close to 00. A word in one document gets a big IDF. The 0.50.5s are there so nothing blows up when n(q)=0n(q) = 0, and the +1+1 keeps it from going negative1.

Fix 2: Diminishing Returns (Saturation)​

Ignore length for a second (pretend ∣D∣=avgdl|D| = \text{avgdl}, so the denominator’s messy part is just k1k_1). With k1=1.2k_1 = 1.2, the count term is 2.2ff+1.2\frac{2.2 f}{f + 1.2}:

times word appears (ff)123101,000,000
raw count123101,000,000
BM25 count term1.001.3751.5711.964~2.2

++It can never go above k1+1k_1 + 1.++ The first mention is worth 1, the second adds 0.375, the third adds 0.2, and then you’re mostly done. Spamming a word gets you nowhere.

Fix 3: Length Normalization​

The 1−b+b⋅∣D∣avgdl1 - b + b \cdot \frac{|D|}{\text{avgdl}} bit is 11 for an average-length document, bigger for a long one, smaller for a short one. It sits in the denominator, so a match in a short document is worth more than the same match in a long one. Makes sense: “penicillin” in a 3-word note is probably the point of that note. In an 18-word note it’s one thing among many.

We’ll need an example…


Example​

Take 5 tiny notes. Query is “penicillin allergy”. (Real systems also drop stop words and stem. Just counting every word to keep it simple.)

NoteTextlength
D1Penicillin allergy: hives.3
D2Seasonal allergy to pollen. Allergy worse in spring. Allergy meds helping.11
D3Started amoxicillin for strep throat.5
D4Annual physical. No known allergy.5
D5Admitted with chest pain. Troponin negative. History of hypertension and diabetes. Allergy to penicillin noted. Discharged on metformin.18

N=5N = 5, avgdl=3+11+5+5+185=8.4\text{avgdl} = \frac{3 + 11 + 5 + 5 + 18}{5} = 8.4.

Dumb counting says: D2 (3 hits) > D1, D5 (2 hits each) > D4 (1 hit). The pollen note wins a penicillin search. Lol.

Step 1: IDF​

“penicillin” is in 2 notes (D1, D5). “allergy” is in 4 (D1, D2, D4, D5).

IDF(penicillin)=ln⁡(5−2+0.52+0.5+1)=ln⁡(2.4)≈0.875IDF(allergy)=ln⁡(5−4+0.54+0.5+1)=ln⁡(1.333)≈0.288\begin{aligned} \text{IDF}(\text{penicillin}) & = \ln\left(\frac{5 - 2 + 0.5}{2 + 0.5} + 1\right) = \ln(2.4) & \approx 0.875 \\ \text{IDF}(\text{allergy}) & = \ln\left(\frac{5 - 4 + 0.5}{4 + 0.5} + 1\right) = \ln(1.333) & \approx 0.288 \end{aligned}

A “penicillin” match is worth ~3× an “allergy” match.

Step 2: Length Factor​

K(D)=k1(1−b+b⋅∣D∣avgdl)=1.2(0.25+0.75⋅∣D∣8.4)K(D) = k_1\left(1 - b + b \cdot \frac{|D|}{\text{avgdl}}\right) = 1.2\left(0.25 + 0.75 \cdot \frac{|D|}{8.4}\right)

note∣D∣\lvert D \rvertK(D)K(D)
D130.621
D2111.479
D450.836
D5182.229

(D3 has no query words so it scores 00 regardless.)

Step 3: Count Terms and Final Score​

Count term =2.2ff+K(D)= \frac{2.2 f}{f + K(D)}.

noteff penicillinff allergycount term(s)score
D1112.21.621=1.357\frac{2.2}{1.621} = 1.3571.357(0.875+0.288)=1.5781.357(0.875 + 0.288) = \mathbf{1.578}
D5112.23.229=0.681\frac{2.2}{3.229} = 0.6810.681(0.875+0.288)=0.7930.681(0.875 + 0.288) = \mathbf{0.793}
D2036.64.479=1.474\frac{6.6}{4.479} = 1.4741.474×0.288=0.4241.474 \times 0.288 = \mathbf{0.424}
D4012.21.836=1.198\frac{2.2}{1.836} = 1.1981.198×0.288=0.3451.198 \times 0.288 = \mathbf{0.345}
D300—0\mathbf{0}

What happened:

  • D1 wins. Both words, and it’s short, so each match counts double what it does in D5.
  • D5 beats D2 even though D5 is the longest note. It has the rare word “penicillin”; D2 only has the common word “allergy”.
  • D2’s three “allergy”s only got it to 1.4741.474, barely more than D4’s single one (1.1981.198). Saturation.
  • D4 still scores. “No known allergy” matches “allergy”. BM25 has no clue what “no” means. Neither does any keyword search. That’s what the LLM in RAG is for.
  • D3 scores zero. Amoxicillin is a penicillin, but BM25 doesn’t know that. That’s what vector search (or query expansion) is for.

BM25 vs. TF-IDF​

TODO: Finish this.

Other Cool Stuff​

When you set b=0b = 0, length stops mattering. K(D)=k1K(D) = k_1 for every document, so D1 and D5 would get the exact same score.

As k1→0k_1 \to 0, count term becomes ff=1\frac{f}{f} = 1 for any f>0f > 0. Repetition is worthless; a word is either present or not (binary). CooL!

Here’s another super-cool thing: If a word appears in every single document, ln⁡(0.5N+0.5+1)≈0\ln\left(\frac{0.5}{N + 0.5} + 1\right) \approx 0 for big NN. Matching it barely moves the score, like a stop word.

Footnotes​

  1. The original Robertson–Spärck Jones IDF doesn’t have the +1+1, so a word in more than half the documents gets a negative weight. Lucene (and so Elasticsearch) added the +1+1. Libraries differ slightly here, which is why the same query can give different BM25 numbers in different tools. ↩