Lesson 10 / 27
BM25: The Standard Keyword Ranker
Use the ranking function behind most search engines.
TF-IDF with saturation and length control
BM25 improves on TF-IDF with two ideas: term-frequency saturation (the tenth occurrence of a word adds much less than the second) and length normalisation (a match in a short document counts more than the same match in a very long one). Two parameters, usually k1 around 1.2 to 2 and b around 0.75, control these. BM25 is the default in Elasticsearch, OpenSearch and Lucene and is a strong baseline that often beats poorly chosen embeddings on exact-term queries. Always keep it available, even if you add vectors.
BM25 from scratch, run
I ran this plain-Python (standard library only) example. "expense approval director" ranks the expense policy first (2.534) and "lost laptop report" ranks the security policy first (3.024); the other documents score near zero.
DOCS = {
"leave": "Employees get 24 days of paid leave per year. Unused leave up to 5 days can be carried over to the next year.",
"remote": "Remote work is allowed up to 3 days per week with manager approval. Core hours are 11:00 to 16:00.",
"expense": "Expenses above 5000 rupees need approval from a director. Submit receipts within 30 days.",
"security": "Use a password manager and enable two factor authentication. Report lost laptops within 24 hours.",
"travel": "Flights must be booked at least 14 days in advance. Hotel cost is capped at 6000 rupees per night.",
}
import math, re
from collections import Counter
def tok(t): return re.findall(r"[a-z0-9]+", t.lower())
names = list(DOCS); docs = [tok(DOCS[n]) for n in names]
N = len(docs); avg = sum(len(d) for d in docs) / N
df = Counter(w for d in docs for w in set(d))
def bm25(q, d, k1=1.5, b=0.75):
tf = Counter(d); score = 0.0
for w in tok(q):
if w not in tf: continue
idf = math.log((N - df[w] + 0.5) / (df[w] + 0.5) + 1)
score += idf * tf[w] * (k1 + 1) / (tf[w] + k1 * (1 - b + b * len(d) / avg))
return score
for q in ("expense approval director", "lost laptop report"):
ranked = sorted(((round(bm25(q, d), 3), n) for n, d in zip(names, docs)), reverse=True)[:3]
print(q, "->", ranked)
Output:
expense approval director -> [(2.534, 'expense'), (0.823, 'remote'), (0.0, 'travel')] lost laptop report -> [(3.024, 'security'), (0.0, 'travel'), (0.0, 'remote')]
Keep BM25 for identifiers
Order numbers, error codes and names are often missed by embeddings. A keyword index catches them reliably.
Quick check: What does BM25 length normalisation do?
- Prevents long documents from winning just by containing more words
- Shortens the query
- Encrypts text
- Chooses the embedding model
Answer
Prevents long documents from winning just by containing more words — A match in a concise document is stronger evidence than the same match in a huge one.