Intermediate Examples
Examples 29-56 cover BM25's own idf, saturation, and length-normalization mechanics; sweeping k1 and b to see them actually change (and even flip) a ranking; top-k selection with a size-k heap; the precision/recall/F1 family and precision@k; relevance judgments (qrels) and the rank-aware metrics built on them (Average Precision, MAP, nDCG); a typed analyzer pipeline model (char filters -> tokenizer -> token filters); a small boolean query DSL, parsed and executed; positional indexes and the phrase/proximity queries they enable; and a segment-merge model. Every code example is real, runnable, fully type-annotated, colocated under learning/code/ex-NN-<slug>/, actually executed for genuine output, and pyright-clean under # pyright: strict. Run each example with python3 <file>.py from its own learning/code/ex-NN-<slug>/ directory.
Example 29: BM25 IDF Term
ex-29 · exercises co-16
BM25's own idf, the Robertson-Sparck-Jones (RSJ) form, is log((N - df + 0.5) / (df + 0.5)) -- a different formula from the plain log(N/df) used by tf-idf. This example verifies the two disagree and that BM25's idf stays finite (though it goes negative) even for a term present in every document (co-16).
learning/code/ex-29-bm25-idf-term/bm25_idf_term.py
# pyright: strict
"""Example 29: BM25 IDF Term (co-16)."""
from __future__ import annotations # => hygiene: postpones annotation evaluation, interpreter-version-agnostic
import math # => stdlib math -- log/sqrt for idf, cosine, and skip-pointer spacing
def bm25_idf(term_df: int, n_docs: int) -> float: # => bM25's own RSJ idf: log((N - df + 0.5) / (df + 0.5))
"""BM25's own RSJ idf: log((N - df + 0.5) / (df + 0.5))."""
return math.log((n_docs - term_df + 0.5) / (term_df + 0.5)) # => co-16: Robertson & Zaragoza (2009) eq. 3.3
def plain_idf(term_df: int, n_docs: int) -> float: # => the Example 13 tf-idf idf, for comparison: log(N / df)
"""The Example 13 tf-idf idf, for comparison: log(N / df)."""
return math.log(n_docs / term_df) # => returns math.log(n_docs / term_df)
def main() -> None: # => defines main
n_docs: int = 10 # => a small corpus, N
common_df: int = 10 # => a term present in EVERY document
rare_df: int = 1 # => a term present in only one document
bm25_common: float = bm25_idf(common_df, n_docs) # => co-16: BM25's idf for the ubiquitous term
plain_common: float = plain_idf(common_df, n_docs) # => the plain tf-idf idf for the same term
print(f"term in ALL {n_docs} docs: bm25_idf={bm25_common:.4f} plain_idf={plain_common:.4f}") # => shows term in ALL
bm25_rare: float = bm25_idf(rare_df, n_docs) # => bm25 rare = bm25_idf(rare_df, n_docs)
plain_rare: float = plain_idf(rare_df, n_docs) # => plain rare = plain_idf(rare_df, n_docs)
print(f"term in only 1 doc: bm25_idf={bm25_rare:.4f} plain_idf={plain_rare:.4f}") # => shows term in only 1 doc: bm25_idf=
assert math.isfinite(bm25_common), "BM25's idf must stay FINITE even for a term in every document" # => BM25's idf must stay FINITE even for a term in every document
assert bm25_common < 0, "BM25's idf for a term in EVERY doc goes NEGATIVE, unlike plain idf's 0" # => BM25's idf for a term in EVERY doc goes NEGATIVE, unlike plain idf's 0
assert not math.isclose(bm25_common, plain_common), "BM25's idf and plain idf must be DIFFERENT formulas" # => BM25's idf and plain idf must be DIFFERENT formulas
print(f"MATCH: bm25_idf({common_df}/{n_docs})={bm25_common:.4f} is finite and negative, unlike plain_idf's {plain_common:.4f}") # => shows MATCH: bm25_idf(
if __name__ == "__main__": # => entry point -- runs only when this file executes directly, not on import
main() # => runs the example end to endRun: python3 bm25_idf_term.py
Output:
term in ALL 10 docs: bm25_idf=-3.0445 plain_idf=0.0000
term in only 1 doc: bm25_idf=1.8458 plain_idf=2.3026
MATCH: bm25_idf(10/10)=-3.0445 is finite and negative, unlike plain_idf's 0.0000Key takeaway: BM25's RSJ idf can go negative for very common terms -- a real, intentional consequence of the formula, not a bug to guard against.
Why It Matters: BM25's RSJ idf formula is not a course simplification -- it is the literal formula in Lucene's BM25Similarity class and the paper it is named after, and its ability to go negative for very common terms is a real, documented property production engineers need to know about rather than defend against as a bug. A negative idf term effectively penalizes a document for containing an extremely common word, which is intentional. This example is the first of BM25's three components this chapter assembles piece by piece.
Example 30: BM25 Score: One Term
ex-30 · exercises co-16
The full BM25 term weight combines RSJ idf, saturating tf, and length normalization in one formula: idf * (tf * (k1+1)) / (tf + k1 * B). This example checks the computed weight against the same formula written out independently as a hand computation (co-16).
learning/code/ex-30-bm25-score-one-term/bm25_score_one_term.py
# pyright: strict
"""Example 30: BM25 Score: One Term (co-16)."""
from __future__ import annotations # => hygiene: postpones annotation evaluation, interpreter-version-agnostic
import math # => stdlib math -- log/sqrt for idf, cosine, and skip-pointer spacing
def bm25_idf(term_df: int, n_docs: int) -> float: # => bM25's own RSJ idf: log((N - df + 0.5) / (df + 0.5)) -- distinct from plain log(N/df)
"""BM25's own RSJ idf: log((N - df + 0.5) / (df + 0.5)) -- distinct from plain log(N/df)."""
return math.log((n_docs - term_df + 0.5) / (term_df + 0.5)) # => returns math.log((n_docs - term_df + 0.5) / (term_df + 0.5))
def bm25_term_weight( # => defines bm25 term weight
tf: int, term_df: int, n_docs: int, dl: float, avgdl: float, *, k1: float = 1.2, b: float = 0.75 # => part of this step's computation, continued from the line above
) -> float: # => part of this step's computation, continued from the line above
"""One query term's BM25 contribution: RSJ idf * saturating tf * length normalization."""
idf: float = bm25_idf(term_df, n_docs) # => idf = bm25_idf(term_df, n_docs)
length_norm: float = (1 - b) + b * (dl / avgdl) # => co-18: B -- 1.0 at average length, >1 for long docs
return idf * (tf * (k1 + 1)) / (tf + k1 * length_norm) # => co-17: the k1-saturated, B-normalized term score
def bm25_score( # => defines bm25 score
query_terms: list[str], # => part of this step's computation, continued from the line above
doc_tf: dict[str, int], # => part of this step's computation, continued from the line above
df: dict[str, int], # => part of this step's computation, continued from the line above
n_docs: int, # => part of this step's computation, continued from the line above
dl: float, # => part of this step's computation, continued from the line above
avgdl: float, # => part of this step's computation, continued from the line above
*, # => part of this step's computation, continued from the line above
k1: float = 1.2, # => k1 = 1.2,
b: float = 0.75, # => b = 0.75,
) -> float: # => part of this step's computation, continued from the line above
"""Sum bm25_term_weight over every query term the document actually contains."""
total: float = 0.0 # => total = 0.0
for term in query_terms: # => iterates one item at a time
if term in doc_tf: # => true when term in doc_tf
total += bm25_term_weight(doc_tf[term], df.get(term, 0), n_docs, dl, avgdl, k1=k1, b=b) # => part of this step's computation, continued from the line above
return total # => returns total
def main() -> None: # => defines main
tf, term_df, n_docs = 3, 2, 5 # => this term occurs 3 times in the doc; 2 of 5 docs contain it
dl, avgdl = 10.0, 8.0 # => this doc has 10 tokens; the corpus average is 8
k1, b = 1.2, 0.75 # => co-19: the Lucene/Elasticsearch software defaults
weight: float = bm25_term_weight(tf, term_df, n_docs, dl, avgdl, k1=k1, b=b) # => co-16: the function under test
print(f"bm25_term_weight(tf={tf}, df={term_df}, N={n_docs}, dl={dl}, avgdl={avgdl}) = {weight:.6f}") # => shows bm25_term_weight(tf=
# Hand computation, written out independently (not calling bm25_term_weight):
hand_idf: float = math.log((n_docs - term_df + 0.5) / (term_df + 0.5)) # => hand idf = math.log((n_docs - term_df + 0.5) / (term_df + ...
hand_B: float = (1 - b) + b * (dl / avgdl) # => hand B = (1 - b) + b * (dl / avgdl)
hand_weight: float = hand_idf * (tf * (k1 + 1)) / (tf + k1 * hand_B) # => hand weight = hand_idf * (tf * (k1 + 1)) / (tf + k1 * hand_B)
print(f"hand computation: idf={hand_idf:.6f} B={hand_B:.6f} weight={hand_weight:.6f}") # => shows hand computation: idf=
assert math.isclose(weight, hand_weight, rel_tol=1e-9), "bm25_term_weight must match the independent hand computation" # => bm25_term_weight must match the independent hand computation
print(f"MATCH: bm25_term_weight's {weight:.6f} equals the hand computation's {hand_weight:.6f}") # => shows MATCH: bm25_term_weight's
if __name__ == "__main__": # => entry point -- runs only when this file executes directly, not on import
main() # => runs the example end to endRun: python3 bm25_score_one_term.py
Output:
bm25_term_weight(tf=3, df=2, N=5, dl=10.0, avgdl=8.0) = 0.501857
hand computation: idf=0.336472 B=1.187500 weight=0.501857
MATCH: bm25_term_weight's 0.501857 equals the hand computation's 0.501857Key takeaway: BM25's formula is dense but entirely mechanical -- three well-defined pieces (idf, saturation, length norm) multiplied and divided in a fixed order.
Why It Matters: BM25 is the default relevance-scoring algorithm in Lucene, Elasticsearch, and OpenSearch today, having replaced tf-idf as the industry-standard baseline over a decade ago, so this single-term formula is quite literally what runs inside a production search cluster's scorer. Checking the computed weight against an independent hand-written formula is the same cross-validation a search-relevance engineer runs before trusting a custom scoring change. Three well-defined pieces multiplying together is what makes BM25 tunable in a way ad-hoc scoring formulas are not.
Example 31: BM25 Saturation Curve
ex-31 · exercises co-17
Printing BM25's term score as tf climbs from 1 to 20 (with k1 = 1.2) traces out the saturation curve directly: each additional occurrence of the term adds less to the score than the one before it, unlike tf-idf's unbounded linear growth (co-17).
%% Color Palette: Blue #0173B2, Orange #DE8F05
xychart-beta
title "BM25 term score vs tf (k1=1.2, B=1)"
x-axis "tf" [1, 5, 10, 15, 20]
y-axis "score" 0 --> 2
line [0.60, 1.35, 1.62, 1.73, 1.79]
learning/code/ex-31-bm25-saturation-curve/bm25_saturation_curve.py
# pyright: strict
"""Example 31: BM25 Saturation Curve (co-17)."""
from __future__ import annotations # => hygiene: postpones annotation evaluation, interpreter-version-agnostic
import math # => stdlib math -- log/sqrt for idf, cosine, and skip-pointer spacing
def bm25_idf(term_df: int, n_docs: int) -> float: # => bM25's own RSJ idf: log((N - df + 0.5) / (df + 0.5)) -- distinct from plain log(N/df)
"""BM25's own RSJ idf: log((N - df + 0.5) / (df + 0.5)) -- distinct from plain log(N/df)."""
return math.log((n_docs - term_df + 0.5) / (term_df + 0.5)) # => returns math.log((n_docs - term_df + 0.5) / (term_df + 0.5))
def bm25_term_weight( # => defines bm25 term weight
tf: int, term_df: int, n_docs: int, dl: float, avgdl: float, *, k1: float = 1.2, b: float = 0.75 # => part of this step's computation, continued from the line above
) -> float: # => part of this step's computation, continued from the line above
"""One query term's BM25 contribution: RSJ idf * saturating tf * length normalization."""
idf: float = bm25_idf(term_df, n_docs) # => idf = bm25_idf(term_df, n_docs)
length_norm: float = (1 - b) + b * (dl / avgdl) # => co-18: B -- 1.0 at average length, >1 for long docs
return idf * (tf * (k1 + 1)) / (tf + k1 * length_norm) # => co-17: the k1-saturated, B-normalized term score
def bm25_score( # => defines bm25 score
query_terms: list[str], # => part of this step's computation, continued from the line above
doc_tf: dict[str, int], # => part of this step's computation, continued from the line above
df: dict[str, int], # => part of this step's computation, continued from the line above
n_docs: int, # => part of this step's computation, continued from the line above
dl: float, # => part of this step's computation, continued from the line above
avgdl: float, # => part of this step's computation, continued from the line above
*, # => part of this step's computation, continued from the line above
k1: float = 1.2, # => k1 = 1.2,
b: float = 0.75, # => b = 0.75,
) -> float: # => part of this step's computation, continued from the line above
"""Sum bm25_term_weight over every query term the document actually contains."""
total: float = 0.0 # => total = 0.0
for term in query_terms: # => iterates one item at a time
if term in doc_tf: # => true when term in doc_tf
total += bm25_term_weight(doc_tf[term], df.get(term, 0), n_docs, dl, avgdl, k1=k1, b=b) # => part of this step's computation, continued from the line above
return total # => returns total
def main() -> None: # => defines main
term_df, n_docs = 3, 10 # => a moderately common term
dl, avgdl = 8.0, 8.0 # => dl == avgdl -- length normalization is neutral (B == 1), isolating tf's effect
k1: float = 1.2 # => the Lucene/Elasticsearch default
scores: list[float] = [bm25_term_weight(tf, term_df, n_docs, dl, avgdl, k1=k1) for tf in range(1, 21)] # => co-17: score at each tf
deltas: list[float] = [scores[i] - scores[i - 1] for i in range(1, len(scores))] # => the marginal gain of EACH extra occurrence
for tf, score in zip(range(1, 21), scores): # => iterates one item at a time
marker: str = f" (+{scores[tf - 1] - scores[tf - 2]:.4f})" if tf > 1 else "" # => marker = f" (+{scores[tf - 1] - scores[tf - 2]:.4f})" if...
print(f"tf={tf:>2}: score={score:.4f}{marker}") # => shows tf=
for i in range(1, len(deltas)): # => every adjacent PAIR of marginal gains
assert deltas[i] <= deltas[i - 1] + 1e-9, f"marginal gain must be non-increasing (tf={i + 2})" # => marginal gain must be non-increasing (tf={i + 2})
assert deltas[0] > deltas[-1], "the FIRST extra occurrence must help strictly more than the LAST one shown" # => the FIRST extra occurrence must help strictly more than the LAST one shown
print(f"MATCH: marginal gain shrinks monotonically from {deltas[0]:.4f} (tf 1->2) to {deltas[-1]:.4f} (tf 19->20)") # => shows MATCH: marginal gain shrinks monotonically from
if __name__ == "__main__": # => entry point -- runs only when this file executes directly, not on import
main() # => runs the example end to endRun: python3 bm25_saturation_curve.py
Output:
tf= 1: score=0.7621
tf= 2: score=1.0479 (+0.2858)
tf= 3: score=1.1976 (+0.1497)
tf= 4: score=1.2898 (+0.0921)
tf= 5: score=1.3522 (+0.0624)
tf= 6: score=1.3973 (+0.0451)
tf= 7: score=1.4313 (+0.0341)
tf= 8: score=1.4580 (+0.0267)
tf= 9: score=1.4794 (+0.0214)
tf=10: score=1.4971 (+0.0176)
tf=11: score=1.5118 (+0.0147)
tf=12: score=1.5243 (+0.0125)
tf=13: score=1.5350 (+0.0107)
tf=14: score=1.5443 (+0.0093)
tf=15: score=1.5525 (+0.0082)
tf=16: score=1.5597 (+0.0072)
tf=17: score=1.5662 (+0.0064)
tf=18: score=1.5719 (+0.0058)
tf=19: score=1.5771 (+0.0052)
tf=20: score=1.5818 (+0.0047)
MATCH: marginal gain shrinks monotonically from 0.2858 (tf 1->2) to 0.0047 (tf 19->20)Key takeaway: Saturation is the defining shape of BM25 -- once a term has appeared a handful of times, repeating it further barely moves the score, blocking crude keyword-stuffing.
Why It Matters: The saturation curve keeps BM25 resistant to crude keyword-stuffing -- repeating a term twenty times earns barely more score than repeating it five times, exactly the anti-spam behavior a public-facing ranking function needs. Tf-idf, by contrast, keeps growing linearly with no ceiling, precisely the gap Example 32 shows side by side. Understanding this curve's shape is what lets an engineer reason about why BM25 resists gaming in a way a naive frequency-based score does not.
Example 32: TF-IDF vs BM25 Saturation
ex-32 · exercises co-17
Placed side by side, linear tf-idf keeps growing by a constant amount per extra occurrence while BM25's growth shrinks toward zero -- the exact contrast that motivates BM25 as tf-idf's successor (co-17).
learning/code/ex-32-tfidf-vs-bm25-saturation/tfidf_vs_bm25_saturation.py
# pyright: strict
"""Example 32: TF-IDF vs BM25 Saturation (co-17)."""
from __future__ import annotations # => hygiene: postpones annotation evaluation, interpreter-version-agnostic
import math # => stdlib math -- log/sqrt for idf, cosine, and skip-pointer spacing
def bm25_idf(term_df: int, n_docs: int) -> float: # => bM25's own RSJ idf: log((N - df + 0.5) / (df + 0.5)) -- distinct from plain log(N/df)
"""BM25's own RSJ idf: log((N - df + 0.5) / (df + 0.5)) -- distinct from plain log(N/df)."""
return math.log((n_docs - term_df + 0.5) / (term_df + 0.5)) # => returns math.log((n_docs - term_df + 0.5) / (term_df + 0.5))
def bm25_term_weight( # => defines bm25 term weight
tf: int, term_df: int, n_docs: int, dl: float, avgdl: float, *, k1: float = 1.2, b: float = 0.75 # => part of this step's computation, continued from the line above
) -> float: # => part of this step's computation, continued from the line above
"""One query term's BM25 contribution: RSJ idf * saturating tf * length normalization."""
idf: float = bm25_idf(term_df, n_docs) # => idf = bm25_idf(term_df, n_docs)
length_norm: float = (1 - b) + b * (dl / avgdl) # => co-18: B -- 1.0 at average length, >1 for long docs
return idf * (tf * (k1 + 1)) / (tf + k1 * length_norm) # => co-17: the k1-saturated, B-normalized term score
def bm25_score( # => defines bm25 score
query_terms: list[str], # => part of this step's computation, continued from the line above
doc_tf: dict[str, int], # => part of this step's computation, continued from the line above
df: dict[str, int], # => part of this step's computation, continued from the line above
n_docs: int, # => part of this step's computation, continued from the line above
dl: float, # => part of this step's computation, continued from the line above
avgdl: float, # => part of this step's computation, continued from the line above
*, # => part of this step's computation, continued from the line above
k1: float = 1.2, # => k1 = 1.2,
b: float = 0.75, # => b = 0.75,
) -> float: # => part of this step's computation, continued from the line above
"""Sum bm25_term_weight over every query term the document actually contains."""
total: float = 0.0 # => total = 0.0
for term in query_terms: # => iterates one item at a time
if term in doc_tf: # => true when term in doc_tf
total += bm25_term_weight(doc_tf[term], df.get(term, 0), n_docs, dl, avgdl, k1=k1, b=b) # => part of this step's computation, continued from the line above
return total # => returns total
def tfidf_score(tf: int, term_df: int, n_docs: int) -> float: # => plain tf-idf: tf * log(N / df) -- LINEAR in tf, no saturation
"""Plain tf-idf: tf * log(N / df) -- LINEAR in tf, no saturation."""
return tf * math.log(n_docs / term_df) # => returns tf * math.log(n_docs / term_df)
def main() -> None: # => defines main
term_df, n_docs = 3, 10 # => part of this step's computation, continued from the line above
dl, avgdl = 8.0, 8.0 # => neutral length normalization, isolating the tf effect
tfidf_scores: list[float] = [tfidf_score(tf, term_df, n_docs) for tf in (10, 20, 40, 80)] # => co-14: linear growth
bm25_scores: list[float] = [bm25_term_weight(tf, term_df, n_docs, dl, avgdl) for tf in (10, 20, 40, 80)] # => co-17: saturating growth
for tf, ts, bs in zip((10, 20, 40, 80), tfidf_scores, bm25_scores): # => iterates one item at a time
print(f"tf={tf:>3}: tf-idf={ts:8.4f} bm25={bs:6.4f}") # => shows tf=
tfidf_growth_10_to_80: float = tfidf_scores[-1] - tfidf_scores[0] # => tf-idf's growth over the SAME tf range
bm25_growth_10_to_80: float = bm25_scores[-1] - bm25_scores[0] # => BM25's growth over the SAME tf range
print(f"growth from tf=10 to tf=80: tf-idf grew by {tfidf_growth_10_to_80:.4f}, bm25 grew by {bm25_growth_10_to_80:.4f}") # => shows growth from tf=10 to tf=80: tf-idf grew by
assert tfidf_growth_10_to_80 > bm25_growth_10_to_80 * 5, "tf-idf's growth must vastly outpace BM25's over a wide tf range" # => tf-idf's growth must vastly outpace BM25's over a wide tf range
assert bm25_scores[-1] < bm25_scores[0] * 1.5, "BM25 must be near-flat by tf=80, having started saturating much earlier" # => BM25 must be near-flat by tf=80, having started saturating much earlier
print("MATCH: tf-idf keeps climbing near-linearly while BM25 has essentially flattened out over the same range") # => shows MATCH: tf-idf keeps climbing near-linearly while BM25 has essentially flattened out over the same range
if __name__ == "__main__": # => entry point -- runs only when this file executes directly, not on import
main() # => runs the example end to endRun: python3 tfidf_vs_bm25_saturation.py
Output:
tf= 10: tf-idf= 12.0397 bm25=1.4971
tf= 20: tf-idf= 24.0795 bm25=1.5818
tf= 40: tf-idf= 48.1589 bm25=1.6279
tf= 80: tf-idf= 96.3178 bm25=1.6519
growth from tf=10 to tf=80: tf-idf grew by 84.2781, bm25 grew by 0.1549
MATCH: tf-idf keeps climbing near-linearly while BM25 has essentially flattened out over the same rangeKey takeaway: tf-idf and BM25 agree on direction (more occurrences help) but disagree sharply on magnitude at high tf -- BM25's saturation is what keeps a single repeated term from dominating a score.
Why It Matters: Seeing linear tf-idf growth and BM25's saturating growth on the same plot is the exact argument that motivated BM25's adoption as the industry default: both signals agree more term occurrences help, but only BM25 refuses to let a single repeated term dominate a score without bound. This is not a historical footnote -- Lucene shipped BM25Similarity as its default scorer specifically because saturation produces better relevance in practice than unbounded tf-idf growth. This example is the direct, measurable evidence for why the industry moved.
Example 33: BM25 Length Normalization
ex-33 · exercises co-18
Two documents with the same term count but different lengths get different BM25 scores: B = (1-b) + b*(dl/avgdl) grows with document length, penalizing the longer document in the denominator (co-18).
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73
flowchart LR
A["doc length dl vs avgdl"]:::blue --> B["B = (1-b) + b*(dl/avgdl)"]:::orange
B --> C["B grows with document length"]:::teal
C --> D["longer doc's score penalized<br/>in the denominator"]:::teal
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
learning/code/ex-33-bm25-length-norm/bm25_length_norm.py
# pyright: strict
"""Example 33: BM25 Length Normalization (co-18)."""
from __future__ import annotations # => hygiene: postpones annotation evaluation, interpreter-version-agnostic
import math # => stdlib math -- log/sqrt for idf, cosine, and skip-pointer spacing
def bm25_idf(term_df: int, n_docs: int) -> float: # => bM25's own RSJ idf: log((N - df + 0.5) / (df + 0.5)) -- distinct from plain log(N/df)
"""BM25's own RSJ idf: log((N - df + 0.5) / (df + 0.5)) -- distinct from plain log(N/df)."""
return math.log((n_docs - term_df + 0.5) / (term_df + 0.5)) # => returns math.log((n_docs - term_df + 0.5) / (term_df + 0.5))
def bm25_term_weight( # => defines bm25 term weight
tf: int, term_df: int, n_docs: int, dl: float, avgdl: float, *, k1: float = 1.2, b: float = 0.75 # => part of this step's computation, continued from the line above
) -> float: # => part of this step's computation, continued from the line above
"""One query term's BM25 contribution: RSJ idf * saturating tf * length normalization."""
idf: float = bm25_idf(term_df, n_docs) # => idf = bm25_idf(term_df, n_docs)
length_norm: float = (1 - b) + b * (dl / avgdl) # => co-18: B -- 1.0 at average length, >1 for long docs
return idf * (tf * (k1 + 1)) / (tf + k1 * length_norm) # => co-17: the k1-saturated, B-normalized term score
def bm25_score( # => defines bm25 score
query_terms: list[str], # => part of this step's computation, continued from the line above
doc_tf: dict[str, int], # => part of this step's computation, continued from the line above
df: dict[str, int], # => part of this step's computation, continued from the line above
n_docs: int, # => part of this step's computation, continued from the line above
dl: float, # => part of this step's computation, continued from the line above
avgdl: float, # => part of this step's computation, continued from the line above
*, # => part of this step's computation, continued from the line above
k1: float = 1.2, # => k1 = 1.2,
b: float = 0.75, # => b = 0.75,
) -> float: # => part of this step's computation, continued from the line above
"""Sum bm25_term_weight over every query term the document actually contains."""
total: float = 0.0 # => total = 0.0
for term in query_terms: # => iterates one item at a time
if term in doc_tf: # => true when term in doc_tf
total += bm25_term_weight(doc_tf[term], df.get(term, 0), n_docs, dl, avgdl, k1=k1, b=b) # => part of this step's computation, continued from the line above
return total # => returns total
def main() -> None: # => defines main
term_df, n_docs = 3, 10 # => part of this step's computation, continued from the line above
avgdl: float = 20.0 # => the corpus average document length
tf: int = 4 # => the SAME term count in both documents below
short_doc_len: float = 10.0 # => half the average length
long_doc_len: float = 60.0 # => three times the average length
short_score: float = bm25_term_weight(tf, term_df, n_docs, short_doc_len, avgdl) # => co-18: short doc, same tf
long_score: float = bm25_term_weight(tf, term_df, n_docs, long_doc_len, avgdl) # => co-18: long doc, same tf
print(f"short doc (dl={short_doc_len}, tf={tf}): score={short_score:.4f}") # => shows short doc (dl=
print(f"long doc (dl={long_doc_len}, tf={tf}): score={long_score:.4f}") # => shows long doc (dl=
b: float = 0.75 # => the default b
short_B: float = (1 - b) + b * (short_doc_len / avgdl) # => short B = (1 - b) + b * (short_doc_len / avgdl)
long_B: float = (1 - b) + b * (long_doc_len / avgdl) # => long B = (1 - b) + b * (long_doc_len / avgdl)
print(f"B(short)={short_B:.4f} B(long)={long_B:.4f}") # => shows B(short)=
assert long_B > short_B, "the longer document must have a LARGER B (length-norm factor)" # => the longer document must have a LARGER B (length-norm factor)
assert short_score > long_score, "with EQUAL tf, the SHORTER document must score HIGHER" # => with EQUAL tf, the SHORTER document must score HIGHER
print(f"MATCH: with identical tf={tf}, the short doc (B={short_B:.4f}) outscores the long doc (B={long_B:.4f})") # => shows MATCH: with identical tf=
if __name__ == "__main__": # => entry point -- runs only when this file executes directly, not on import
main() # => runs the example end to endRun: python3 bm25_length_norm.py
Output:
short doc (dl=10.0, tf=4): score=1.4120
long doc (dl=60.0, tf=4): score=0.9581
B(short)=0.6250 B(long)=2.5000
MATCH: with identical tf=4, the short doc (B=0.6250) outscores the long doc (B=2.5000)Key takeaway: Length normalization is BM25's answer to a cheap exploit: without it, an artificially long document could rack up term occurrences and win purely on volume.
Why It Matters: Length normalization is BM25's defense against a cheap relevance exploit: without the B term penalizing documents longer than the corpus average, an artificially padded document could accumulate more raw term occurrences and outrank a genuinely more relevant, concise one. This is a real trade-off search-relevance engineers guard against -- Elasticsearch exposes b per field precisely so short titles and long bodies can be normalized differently. Two documents with identical term counts but different lengths must not score identically, and this is the term that enforces it.
Example 34: BM25 b Sweep
ex-34 · exercises co-18
Sweeping b across {0, 0.5, 0.75, 1} on a short, topically concentrated document versus a long, more diluted one shows the ranking genuinely flip: at b=0 (length normalization off) the long document's higher raw tf wins, but as b rises toward 1 the short document overtakes it (co-18).
learning/code/ex-34-bm25-b-sweep/bm25_b_sweep.py
# pyright: strict
"""Example 34: BM25 b Sweep (co-18)."""
from __future__ import annotations # => hygiene: postpones annotation evaluation, interpreter-version-agnostic
import math # => stdlib math -- log/sqrt for idf, cosine, and skip-pointer spacing
def bm25_idf(term_df: int, n_docs: int) -> float: # => bM25's own RSJ idf: log((N - df + 0.5) / (df + 0.5)) -- distinct from plain log(N/df)
"""BM25's own RSJ idf: log((N - df + 0.5) / (df + 0.5)) -- distinct from plain log(N/df)."""
return math.log((n_docs - term_df + 0.5) / (term_df + 0.5)) # => returns math.log((n_docs - term_df + 0.5) / (term_df + 0.5))
def bm25_term_weight( # => defines bm25 term weight
tf: int, term_df: int, n_docs: int, dl: float, avgdl: float, *, k1: float = 1.2, b: float = 0.75 # => part of this step's computation, continued from the line above
) -> float: # => part of this step's computation, continued from the line above
"""One query term's BM25 contribution: RSJ idf * saturating tf * length normalization."""
idf: float = bm25_idf(term_df, n_docs) # => idf = bm25_idf(term_df, n_docs)
length_norm: float = (1 - b) + b * (dl / avgdl) # => co-18: B -- 1.0 at average length, >1 for long docs
return idf * (tf * (k1 + 1)) / (tf + k1 * length_norm) # => co-17: the k1-saturated, B-normalized term score
def bm25_score( # => defines bm25 score
query_terms: list[str], # => part of this step's computation, continued from the line above
doc_tf: dict[str, int], # => part of this step's computation, continued from the line above
df: dict[str, int], # => part of this step's computation, continued from the line above
n_docs: int, # => part of this step's computation, continued from the line above
dl: float, # => part of this step's computation, continued from the line above
avgdl: float, # => part of this step's computation, continued from the line above
*, # => part of this step's computation, continued from the line above
k1: float = 1.2, # => k1 = 1.2,
b: float = 0.75, # => b = 0.75,
) -> float: # => part of this step's computation, continued from the line above
"""Sum bm25_term_weight over every query term the document actually contains."""
total: float = 0.0 # => total = 0.0
for term in query_terms: # => iterates one item at a time
if term in doc_tf: # => true when term in doc_tf
total += bm25_term_weight(doc_tf[term], df.get(term, 0), n_docs, dl, avgdl, k1=k1, b=b) # => part of this step's computation, continued from the line above
return total # => returns total
def main() -> None: # => defines main
term_df, n_docs = 3, 10 # => part of this step's computation, continued from the line above
avgdl: float = 20.0 # => the corpus average -- short doc is well below it, long doc well above
short_dl, short_tf = 5.0, 2 # => a SHORT, concentrated document: few words, term appears twice
long_dl, long_tf = 50.0, 4 # => a LONG, diluted document: many words, term appears more often in raw count
for b in (0.0, 0.5, 0.75, 1.0): # => co-18: sweeping the length-normalization strength
short_score: float = bm25_term_weight(short_tf, term_df, n_docs, short_dl, avgdl, b=b) # => short score = bm25_term_weight(short_tf, term_df, n_docs, sho...
long_score: float = bm25_term_weight(long_tf, term_df, n_docs, long_dl, avgdl, b=b) # => long score = bm25_term_weight(long_tf, term_df, n_docs, long...
winner: str = "short" if short_score > long_score else "long" # => winner = "short" if short_score > long_score else "long"
print(f"b={b:.2f}: short={short_score:.4f} long={long_score:.4f} winner={winner}") # => shows b=
score_at_b0_short: float = bm25_term_weight(short_tf, term_df, n_docs, short_dl, avgdl, b=0.0) # => score at b0 short = bm25_term_weight(short_tf, term_df, n_docs, sho...
score_at_b0_long: float = bm25_term_weight(long_tf, term_df, n_docs, long_dl, avgdl, b=0.0) # => score at b0 long = bm25_term_weight(long_tf, term_df, n_docs, long...
score_at_b1_short: float = bm25_term_weight(short_tf, term_df, n_docs, short_dl, avgdl, b=1.0) # => score at b1 short = bm25_term_weight(short_tf, term_df, n_docs, sho...
score_at_b1_long: float = bm25_term_weight(long_tf, term_df, n_docs, long_dl, avgdl, b=1.0) # => score at b1 long = bm25_term_weight(long_tf, term_df, n_docs, long...
assert score_at_b0_long > score_at_b0_short, "at b=0 (no length norm), the long doc's higher raw tf must win" # => at b=0 (no length norm), the long doc's higher raw tf must win
assert score_at_b1_short > score_at_b1_long, "at b=1 (full length norm), the short doc must overtake the long one" # => at b=1 (full length norm), the short doc must overtake the long one
print("MATCH: the ranking genuinely flips -- long doc wins at b=0, short doc wins at b=1") # => shows MATCH: the ranking genuinely flips -- long doc wins at b=0, short doc wins at b=1
if __name__ == "__main__": # => entry point -- runs only when this file executes directly, not on import
main() # => runs the example end to endRun: python3 bm25_b_sweep.py
Output:
b=0.00: short=1.0479 long=1.2898 winner=long
b=0.50: short=1.2194 long=1.0995 winner=short
b=0.75: short=1.3281 long=1.0239 winner=short
b=1.00: short=1.4580 long=0.9581 winner=short
MATCH: the ranking genuinely flips -- long doc wins at b=0, short doc wins at b=1Key takeaway: b is not a cosmetic tuning knob -- pushed from 0 to 1 it can reverse which of two documents ranks first, which is exactly why Elasticsearch exposes it per-field.
Why It Matters: Sweeping b from 0 to 1 and watching a ranking genuinely flip is exactly why Elasticsearch exposes b as a per-field, tunable parameter -- choosing b=0 for a title field versus b=0.75 for a body field is making precisely this trade-off. This is not a cosmetic knob: the demonstrated ranking reversal shows real production relevance decisions hinge on this single parameter. Treating b as a fixed default instead of tuning it per field is a common, avoidable relevance mistake.
Example 35: BM25 k1 Sweep
ex-35 · exercises co-17
Sweeping k1 changes where saturation kicks in: a small k1 saturates almost immediately, while a larger k1 lets more occurrences still add meaningful score before flattening out (co-17).
learning/code/ex-35-bm25-k1-sweep/bm25_k1_sweep.py
# pyright: strict
"""Example 35: BM25 k1 Sweep (co-17)."""
from __future__ import annotations # => hygiene: postpones annotation evaluation, interpreter-version-agnostic
import math # => stdlib math -- log/sqrt for idf, cosine, and skip-pointer spacing
def bm25_idf(term_df: int, n_docs: int) -> float: # => bM25's own RSJ idf: log((N - df + 0.5) / (df + 0.5)) -- distinct from plain log(N/df)
"""BM25's own RSJ idf: log((N - df + 0.5) / (df + 0.5)) -- distinct from plain log(N/df)."""
return math.log((n_docs - term_df + 0.5) / (term_df + 0.5)) # => returns math.log((n_docs - term_df + 0.5) / (term_df + 0.5))
def bm25_term_weight( # => defines bm25 term weight
tf: int, term_df: int, n_docs: int, dl: float, avgdl: float, *, k1: float = 1.2, b: float = 0.75 # => part of this step's computation, continued from the line above
) -> float: # => part of this step's computation, continued from the line above
"""One query term's BM25 contribution: RSJ idf * saturating tf * length normalization."""
idf: float = bm25_idf(term_df, n_docs) # => idf = bm25_idf(term_df, n_docs)
length_norm: float = (1 - b) + b * (dl / avgdl) # => co-18: B -- 1.0 at average length, >1 for long docs
return idf * (tf * (k1 + 1)) / (tf + k1 * length_norm) # => co-17: the k1-saturated, B-normalized term score
def bm25_score( # => defines bm25 score
query_terms: list[str], # => part of this step's computation, continued from the line above
doc_tf: dict[str, int], # => part of this step's computation, continued from the line above
df: dict[str, int], # => part of this step's computation, continued from the line above
n_docs: int, # => part of this step's computation, continued from the line above
dl: float, # => part of this step's computation, continued from the line above
avgdl: float, # => part of this step's computation, continued from the line above
*, # => part of this step's computation, continued from the line above
k1: float = 1.2, # => k1 = 1.2,
b: float = 0.75, # => b = 0.75,
) -> float: # => part of this step's computation, continued from the line above
"""Sum bm25_term_weight over every query term the document actually contains."""
total: float = 0.0 # => total = 0.0
for term in query_terms: # => iterates one item at a time
if term in doc_tf: # => true when term in doc_tf
total += bm25_term_weight(doc_tf[term], df.get(term, 0), n_docs, dl, avgdl, k1=k1, b=b) # => part of this step's computation, continued from the line above
return total # => returns total
def marginal_gain(term_df: int, n_docs: int, dl: float, avgdl: float, k1: float, tf_from: int, tf_to: int) -> float: # => the score gained by moving from tf_from to tf_to occurrences, at a fixed k1
"""The score gained by moving from tf_from to tf_to occurrences, at a fixed k1."""
return bm25_term_weight(tf_to, term_df, n_docs, dl, avgdl, k1=k1) - bm25_term_weight(tf_from, term_df, n_docs, dl, avgdl, k1=k1) # => returns bm25_term_weight(tf_to, term_df, n_docs, dl, avgdl, k1=k1...
def main() -> None: # => defines main
term_df, n_docs = 3, 10 # => part of this step's computation, continued from the line above
dl, avgdl = 8.0, 8.0 # => neutral length normalization
for k1 in (0.5, 1.2, 2.0, 5.0): # => co-17: small to large saturation constants
early_gain: float = marginal_gain(term_df, n_docs, dl, avgdl, k1, 1, 2) # => gain from the FIRST extra occurrence
late_gain: float = marginal_gain(term_df, n_docs, dl, avgdl, k1, 9, 10) # => gain from a MUCH later occurrence
print(f"k1={k1:.1f}: early gain (tf 1->2)={early_gain:.4f} late gain (tf 9->10)={late_gain:.4f}") # => shows k1=
low_k1_late_gain: float = marginal_gain(term_df, n_docs, dl, avgdl, 0.5, 9, 10) # => a SMALL k1's late-stage gain
high_k1_late_gain: float = marginal_gain(term_df, n_docs, dl, avgdl, 5.0, 9, 10) # => a LARGE k1's late-stage gain
assert high_k1_late_gain > low_k1_late_gain, "a LARGER k1 must still yield a bigger late-stage marginal gain (saturates later)" # => a LARGER k1 must still yield a bigger late-stage marginal gain (saturates later)
print(f"MATCH: at tf 9->10, k1=5.0's gain ({high_k1_late_gain:.4f}) exceeds k1=0.5's gain ({low_k1_late_gain:.4f}) -- k1 moves the saturation point") # => shows MATCH: at tf 9->10, k1=5.0's gain (
if __name__ == "__main__": # => entry point -- runs only when this file executes directly, not on import
main() # => runs the example end to endRun: python3 bm25_k1_sweep.py
Output:
k1=0.5: early gain (tf 1->2)=0.1524 late gain (tf 9->10)=0.0057
k1=1.2: early gain (tf 1->2)=0.2858 late gain (tf 9->10)=0.0176
k1=2.0: early gain (tf 1->2)=0.3811 late gain (tf 9->10)=0.0346
k1=5.0: early gain (tf 1->2)=0.5444 late gain (tf 9->10)=0.1089
MATCH: at tf 9->10, k1=5.0's gain (0.1089) exceeds k1=0.5's gain (0.0057) -- k1 moves the saturation pointKey takeaway: k1 sets how aggressively repeated occurrences get discounted -- a small k1 treats the second occurrence as almost worthless, a large k1 stays closer to linear tf-idf for longer.
Why It Matters: k1 decides how aggressively BM25 discounts repeated term occurrences, and Lucene, Elasticsearch, and the original paper all treat it as tunable for exactly this reason -- a small k1 saturates almost immediately, a larger one stays closer to tf-idf's linear growth for longer. Search-relevance engineers sweep k1 alongside b as a real tuning exercise when a corpus's term-frequency distribution does not match the defaults' assumptions. This example is that sweep made concrete rather than left as an abstract parameter description.
Example 36: BM25 Defaults
ex-36 · exercises co-19
Running BM25 with k1=1.2, b=0.75 -- the Lucene/Elasticsearch software defaults -- and checking them against Robertson & Zaragoza's own recommended experimental range (1.2 < k1 < 2, 0.5 < b < 0.8) shows k1=1.2 sits exactly on the paper's lower boundary, technically outside its open interval, while b=0.75 sits comfortably inside its own (co-19).
learning/code/ex-36-bm25-defaults/bm25_defaults.py
# pyright: strict
"""Example 36: BM25 Defaults (co-19)."""
from __future__ import annotations # => hygiene: postpones annotation evaluation, interpreter-version-agnostic
import math # => stdlib math -- log/sqrt for idf, cosine, and skip-pointer spacing
def bm25_idf(term_df: int, n_docs: int) -> float: # => bM25's own RSJ idf: log((N - df + 0.5) / (df + 0.5)) -- distinct from plain log(N/df)
"""BM25's own RSJ idf: log((N - df + 0.5) / (df + 0.5)) -- distinct from plain log(N/df)."""
return math.log((n_docs - term_df + 0.5) / (term_df + 0.5)) # => returns math.log((n_docs - term_df + 0.5) / (term_df + 0.5))
def bm25_term_weight( # => defines bm25 term weight
tf: int, term_df: int, n_docs: int, dl: float, avgdl: float, *, k1: float = 1.2, b: float = 0.75 # => part of this step's computation, continued from the line above
) -> float: # => part of this step's computation, continued from the line above
"""One query term's BM25 contribution: RSJ idf * saturating tf * length normalization."""
idf: float = bm25_idf(term_df, n_docs) # => idf = bm25_idf(term_df, n_docs)
length_norm: float = (1 - b) + b * (dl / avgdl) # => co-18: B -- 1.0 at average length, >1 for long docs
return idf * (tf * (k1 + 1)) / (tf + k1 * length_norm) # => co-17: the k1-saturated, B-normalized term score
def bm25_score( # => defines bm25 score
query_terms: list[str], # => part of this step's computation, continued from the line above
doc_tf: dict[str, int], # => part of this step's computation, continued from the line above
df: dict[str, int], # => part of this step's computation, continued from the line above
n_docs: int, # => part of this step's computation, continued from the line above
dl: float, # => part of this step's computation, continued from the line above
avgdl: float, # => part of this step's computation, continued from the line above
*, # => part of this step's computation, continued from the line above
k1: float = 1.2, # => k1 = 1.2,
b: float = 0.75, # => b = 0.75,
) -> float: # => part of this step's computation, continued from the line above
"""Sum bm25_term_weight over every query term the document actually contains."""
total: float = 0.0 # => total = 0.0
for term in query_terms: # => iterates one item at a time
if term in doc_tf: # => true when term in doc_tf
total += bm25_term_weight(doc_tf[term], df.get(term, 0), n_docs, dl, avgdl, k1=k1, b=b) # => part of this step's computation, continued from the line above
return total # => returns total
def main() -> None: # => defines main
default_k1: float = 1.2 # => co-19: the Lucene/Elasticsearch SOFTWARE default
default_b: float = 0.75 # => co-19: the Lucene/Elasticsearch SOFTWARE default
paper_k1_lower, paper_k1_upper = 1.2, 2.0 # => Robertson & Zaragoza (2009)'s own recommended OPEN interval
paper_b_lower, paper_b_upper = 0.5, 0.8 # => Robertson & Zaragoza (2009)'s own recommended OPEN interval
k1_in_papers_open_range: bool = paper_k1_lower < default_k1 < paper_k1_upper # => STRICT inequality, as the paper states it
b_in_papers_open_range: bool = paper_b_lower < default_b < paper_b_upper # => STRICT inequality, as the paper states it
print(f"default k1={default_k1}: inside paper's OPEN range (1.2, 2.0)? {k1_in_papers_open_range}") # => shows default k1=
print(f"default b={default_b}: inside paper's OPEN range (0.5, 0.8)? {b_in_papers_open_range}") # => shows default b=
term_df, n_docs, dl, avgdl, tf = 3, 10, 8.0, 8.0, 4 # => part of this step's computation, continued from the line above
score: float = bm25_term_weight(tf, term_df, n_docs, dl, avgdl, k1=default_k1, b=default_b) # => the software defaults, applied
print(f"score with software defaults (k1={default_k1}, b={default_b}): {score:.4f}") # => shows score with software defaults (k1=
assert not k1_in_papers_open_range, "k1=1.2 sits exactly ON the paper's lower boundary -- NOT strictly inside its open interval" # => k1=1.2 sits exactly ON the paper's lower boundary -- NOT strictly inside its open interval
assert b_in_papers_open_range, "b=0.75 DOES sit strictly inside the paper's own recommended (0.5, 0.8) range" # => b=0.75 DOES sit strictly inside the paper's own recommended (0.5, 0.8) range
print("MATCH: k1=1.2 is the boundary value (not strictly inside the paper's range), while b=0.75 is strictly inside it") # => shows MATCH: k1=1.2 is the boundary value (not strictly inside the paper's range), while b=0.75 is strictly inside it
if __name__ == "__main__": # => entry point -- runs only when this file executes directly, not on import
main() # => runs the example end to endRun: python3 bm25_defaults.py
Output:
default k1=1.2: inside paper's OPEN range (1.2, 2.0)? False
default b=0.75: inside paper's OPEN range (0.5, 0.8)? True
score with software defaults (k1=1.2, b=0.75): 1.2898
MATCH: k1=1.2 is the boundary value (not strictly inside the paper's range), while b=0.75 is strictly inside itKey takeaway: The Lucene/Elasticsearch software defaults are not identical to the paper's own recommended experimental range -- a real, citable distinction worth knowing before tuning either constant.
Why It Matters: Lucene and Elasticsearch's shipped defaults, k1=1.2 and b=0.75, are not identical to Robertson and Zaragoza's own recommended experimental range from the original paper, and knowing that gap is exactly the fact a search-relevance engineer needs before assuming the paper's numbers and the software's numbers are interchangeable. Software defaults reflect what worked well across many real corpora in practice, while the paper's range reflects specific experimental findings. This example is the citable, checkable comparison that prevents that common conflation.
Example 37: BM25 Full Ranker
ex-37 · exercises co-16
Ranking a small corpus for a multi-term query sums bm25_term_weight over every query term a document contains -- this example verifies the top result against a second, independently-written scoring pass over the same fixture (co-16).
learning/code/ex-37-bm25-full-ranker/bm25_full_ranker.py
# pyright: strict
"""Example 37: BM25 Full Ranker (co-16)."""
from __future__ import annotations # => hygiene: postpones annotation evaluation, interpreter-version-agnostic
import math # => stdlib math -- log/sqrt for idf, cosine, and skip-pointer spacing
def bm25_idf(term_df: int, n_docs: int) -> float: # => bM25's own RSJ idf: log((N - df + 0.5) / (df + 0.5)) -- distinct from plain log(N/df)
"""BM25's own RSJ idf: log((N - df + 0.5) / (df + 0.5)) -- distinct from plain log(N/df)."""
return math.log((n_docs - term_df + 0.5) / (term_df + 0.5)) # => returns math.log((n_docs - term_df + 0.5) / (term_df + 0.5))
def bm25_term_weight( # => defines bm25 term weight
tf: int, term_df: int, n_docs: int, dl: float, avgdl: float, *, k1: float = 1.2, b: float = 0.75 # => part of this step's computation, continued from the line above
) -> float: # => part of this step's computation, continued from the line above
"""One query term's BM25 contribution: RSJ idf * saturating tf * length normalization."""
idf: float = bm25_idf(term_df, n_docs) # => idf = bm25_idf(term_df, n_docs)
length_norm: float = (1 - b) + b * (dl / avgdl) # => co-18: B -- 1.0 at average length, >1 for long docs
return idf * (tf * (k1 + 1)) / (tf + k1 * length_norm) # => co-17: the k1-saturated, B-normalized term score
def bm25_score( # => defines bm25 score
query_terms: list[str], # => part of this step's computation, continued from the line above
doc_tf: dict[str, int], # => part of this step's computation, continued from the line above
df: dict[str, int], # => part of this step's computation, continued from the line above
n_docs: int, # => part of this step's computation, continued from the line above
dl: float, # => part of this step's computation, continued from the line above
avgdl: float, # => part of this step's computation, continued from the line above
*, # => part of this step's computation, continued from the line above
k1: float = 1.2, # => k1 = 1.2,
b: float = 0.75, # => b = 0.75,
) -> float: # => part of this step's computation, continued from the line above
"""Sum bm25_term_weight over every query term the document actually contains."""
total: float = 0.0 # => total = 0.0
for term in query_terms: # => iterates one item at a time
if term in doc_tf: # => true when term in doc_tf
total += bm25_term_weight(doc_tf[term], df.get(term, 0), n_docs, dl, avgdl, k1=k1, b=b) # => part of this step's computation, continued from the line above
return total # => returns total
def build_df(docs: dict[int, list[str]]) -> dict[str, int]: # => defines build df
df: dict[str, int] = {} # => starts empty, populated by the loop below
for tokens in docs.values(): # => iterates one item at a time
for term in set(tokens): # => iterates one item at a time
df[term] = df.get(term, 0) + 1 # => counter pattern: 0 on first sight, then increments
return df # => returns df
def rank_bm25(docs: dict[int, list[str]], query_terms: list[str]) -> list[tuple[int, float]]: # => defines rank bm25
n_docs: int = len(docs) # => this fixture's own size
df: dict[str, int] = build_df(docs) # => df = build_df(docs)
total_len: int = sum(len(tokens) for tokens in docs.values()) # => total len = sum(len(tokens) for tokens in docs.values())
avgdl: float = total_len / n_docs # => co-18: the corpus-wide average document length
scores: dict[int, float] = {} # => starts empty, populated by the loop below
for doc_id, tokens in docs.items(): # => iterates one item at a time
doc_tf: dict[str, int] = {} # => starts empty, populated by the loop below
for t in tokens: # => iterates one item at a time
doc_tf[t] = doc_tf.get(t, 0) + 1 # => counter pattern: 0 on first sight, then increments
scores[doc_id] = bm25_score(query_terms, doc_tf, df, n_docs, float(len(tokens)), avgdl) # => scores = bm25_score(query_terms, doc_tf, df, n_docs, flo...
return sorted(scores.items(), key=lambda kv: (-kv[1], kv[0])) # => returns sorted(scores.items(), key=lambda kv: (-kv[1], kv[0]))
def main() -> None: # => defines main
docs: dict[int, list[str]] = { # => docs = {
0: ["search", "engine", "index", "search", "engine"], # => strong match: BOTH terms, repeated
1: ["search", "results", "page"], # => weak match: only ONE term
2: ["cooking", "recipe", "book"], # => no match at all
3: ["weather", "forecast", "today"], # => filler, no match -- keeps df(search)/df(engine) LOW relative to N
4: ["news", "update", "daily"], # => filler, no match -- same reason: avoids a tiny-corpus idf sign flip
} # => opens/closes this multi-line literal
query_terms: list[str] = ["search", "engine"] # => query terms = ["search", "engine"]
ranking: list[tuple[int, float]] = rank_bm25(docs, query_terms) # => co-16: the full BM25 ranking
for doc_id, score in ranking: # => iterates one item at a time
print(f"doc {doc_id}: score={score:.4f}") # => shows doc
# An INDEPENDENT reference pass: recompute doc 0's score from scratch, a different way.
n_docs = len(docs) # => n docs = len(docs)
df_ref: dict[str, int] = build_df(docs) # => df ref = build_df(docs)
avgdl_ref: float = sum(len(t) for t in docs.values()) / n_docs # => avgdl ref = sum(len(t) for t in docs.values()) / n_docs
ref_score_doc0: float = 0.0 # => ref score doc0 = 0.0
for term in query_terms: # => iterates one item at a time
tf_doc0: int = docs[0].count(term) # => tf doc0 = docs[0].count(term)
if tf_doc0 > 0: # => true when tf_doc0 > 0
idf_ref: float = math.log((n_docs - df_ref[term] + 0.5) / (df_ref[term] + 0.5)) # => idf ref = math.log((n_docs - df_ref[term] + 0.5) / (df_re...
B_ref: float = (1 - 0.75) + 0.75 * (len(docs[0]) / avgdl_ref) # => B ref = (1 - 0.75) + 0.75 * (len(docs[0]) / avgdl_ref)
ref_score_doc0 += idf_ref * (tf_doc0 * 2.2) / (tf_doc0 + 1.2 * B_ref) # => part of this step's computation, continued from the line above
assert ranking[0][0] == 0, "doc 0 must rank first -- it matches both query terms, repeated" # => doc 0 must rank first -- it matches both query terms, repeated
assert ranking[1][0] == 1, "doc 1 must rank second -- it matches one query term, once" # => doc 1 must rank second -- it matches one query term, once
assert ranking[0][1] > ranking[1][1] > 0, "both matching docs must score strictly positive, doc 0 higher than doc 1" # => both matching docs must score strictly positive, doc 0 higher than doc 1
assert math.isclose(ranking[0][1], ref_score_doc0, rel_tol=1e-9), "doc 0's score must match the independent reference pass" # => doc 0's score must match the independent reference pass
print(f"MATCH: doc 0 ranks first ({ranking[0][1]:.4f}), matching the independent reference computation ({ref_score_doc0:.4f})") # => shows MATCH: doc 0 ranks first (
if __name__ == "__main__": # => entry point -- runs only when this file executes directly, not on import
main() # => runs the example end to endRun: python3 bm25_full_ranker.py
Output:
doc 0: score=1.7426
doc 1: score=0.3535
doc 2: score=0.0000
doc 3: score=0.0000
doc 4: score=0.0000
MATCH: doc 0 ranks first (1.7426), matching the independent reference computation (1.7426)Key takeaway: A full BM25 ranker is nothing more than summing the single-term formula over every query term a document shares with the query -- there is no additional machinery hiding in "full-text ranking."
Why It Matters: Summing the single-term BM25 formula over every query term a document shares with the query is literally what a production full-text search engine's scorer does for a multi-term query -- there is no separate "full-text ranking" algorithm hiding behind the marketing term. Verifying the result against an independently-written second implementation is the same cross-check a search-relevance team runs validating a new scoring library. This example collapses full-text search from a mysterious black box into a straightforward sum of already-understood pieces.
Example 38: Top-K Heap
ex-38 · exercises co-20
A size-k min-heap keeps only the k highest-scoring documents seen so far, discarding a new candidate immediately if it is worse than the heap's current minimum. This example verifies the heap-based top-k equals a full sort's top-k on the same data (co-20).
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73
flowchart TD
A["new (score, doc_id)"]:::blue --> B{"heap has < k items?"}:::orange
B -->|"yes"| C["push -- always keep it"]:::teal
B -->|"no"| D{"score beats heap min?"}:::orange
D -->|"yes"| E["replace the current minimum"]:::teal
D -->|"no"| F["discard -- not in the top k"]:::blue
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
learning/code/ex-38-topk-heap/topk_heap.py
# pyright: strict
"""Example 38: Top-K Heap (co-20)."""
from __future__ import annotations # => hygiene: postpones annotation evaluation, interpreter-version-agnostic
import heapq # => stdlib binary heap -- backs the size-k top-k selection
import random # => stdlib PRNG -- reproducible synthetic fixtures and trials
def topk_via_heap(scored: list[tuple[float, int]], k: int) -> list[tuple[float, int]]: # => return the k highest-scoring (score, doc_id) pairs using a size-k min-heap
"""Return the k highest-scoring (score, doc_id) pairs using a size-k min-heap."""
heap: list[tuple[float, int]] = [] # => co-20: a MIN-heap -- the smallest of the k kept sits at heap[0]
for score, doc_id in scored: # => iterates one item at a time
if len(heap) < k: # => true when len(heap) < k
heapq.heappush(heap, (score, doc_id)) # => co-20: heap not full yet -- always keep this candidate
elif score > heap[0][0]: # => co-20: only replace the WORST kept item if this candidate beats it
heapq.heapreplace(heap, (score, doc_id)) # => pop the min, push the new one, in one O(log k) step
return sorted(heap, key=lambda sd: (-sd[0], sd[1])) # => co-20: final k results, best score first
def topk_via_full_sort(scored: list[tuple[float, int]], k: int) -> list[tuple[float, int]]: # => reference implementation: sort everything, take the first k
"""Reference implementation: sort everything, take the first k."""
return sorted(scored, key=lambda sd: (-sd[0], sd[1]))[:k] # => returns sorted(scored, key=lambda sd: (-sd[0], sd[1]))[:k]
def main() -> None: # => defines main
rng = random.Random(7) # => fixed seed -- reproducible
scored: list[tuple[float, int]] = [(rng.uniform(0, 100), doc_id) for doc_id in range(1000)] # => 1000 (score, doc_id) pairs
heap_result: list[tuple[float, int]] = topk_via_heap(scored, k=5) # => co-20: the size-5 heap's own top-5
sort_result: list[tuple[float, int]] = topk_via_full_sort(scored, k=5) # => the reference: full sort, then slice
print(f"heap top-5: {[(round(s, 2), d) for s, d in heap_result]}") # => shows heap top-5
print(f"sort top-5: {[(round(s, 2), d) for s, d in sort_result]}") # => shows sort top-5
assert heap_result == sort_result, "the heap's top-5 must be IDENTICAL to the full sort's top-5" # => the heap's top-5 must be IDENTICAL to the full sort's top-5
print("MATCH: the size-5 min-heap returns exactly the same top-5 as a full sort over all 1000 scores") # => shows MATCH: the size-5 min-heap returns exactly the same top-5 as a full sort over all 1000 scores
if __name__ == "__main__": # => entry point -- runs only when this file executes directly, not on import
main() # => runs the example end to endRun: python3 topk_heap.py
Output:
heap top-5: [(99.88, 558), (99.75, 733), (99.66, 905), (99.65, 607), (99.31, 143)]
sort top-5: [(99.88, 558), (99.75, 733), (99.66, 905), (99.65, 607), (99.31, 143)]
MATCH: the size-5 min-heap returns exactly the same top-5 as a full sort over all 1000 scoresKey takeaway: A size-k heap never needs to hold more than k items at once -- it answers "what are the best k" without ever fully sorting the candidate pool.
Why It Matters: A size-k min-heap is the actual data structure production search engines use to avoid fully sorting every scored candidate document -- Lucene's top-score collector maintains exactly this bounded structure internally rather than materializing and sorting the whole result set. Every candidate is still examined once, but discarding it immediately once it is worse than the heap's current minimum keeps memory proportional to k rather than the corpus size, and shrinks the per-candidate update cost from log N to log k. This is the algorithm behind every "show top 10 results" search box, which never actually ranks the entire corpus.
Example 39: Top-K vs Full Sort Timing
ex-39 · exercises co-20
Measuring heap-based top-k against a full sort on a large scored corpus shows both the identical result and the heap's real speed advantage when k is small relative to the corpus size (co-20).
learning/code/ex-39-topk-vs-fullsort-timing/topk_vs_fullsort_timing.py
# pyright: strict
"""Example 39: Top-K vs Full Sort Timing (co-20)."""
from __future__ import annotations # => hygiene: postpones annotation evaluation, interpreter-version-agnostic
import heapq # => stdlib binary heap -- backs the size-k top-k selection
import random # => stdlib PRNG -- reproducible synthetic fixtures and trials
import time # => stdlib timer -- wall-clock measurement, not a benchmark micro-op
from typing import Callable # => from typing: Callable
def topk_via_heap(scored: list[tuple[float, int]], k: int) -> list[tuple[float, int]]: # => defines topk via heap
return heapq.nlargest(k, scored, key=lambda sd: sd[0]) # => co-20: heapq's own optimized top-k
def topk_via_full_sort(scored: list[tuple[float, int]], k: int) -> list[tuple[float, int]]: # => defines topk via full sort
return sorted(scored, key=lambda sd: -sd[0])[:k] # => returns sorted(scored, key=lambda sd: -sd[0])[:k]
def main() -> None: # => defines main
rng = random.Random(11) # => fixed seed -- reproducible
n: int = 500_000 # => a large scored corpus
k: int = 10 # => a SMALL k relative to n -- exactly where a heap should win
scored: list[tuple[float, int]] = [(rng.uniform(0, 100), doc_id) for doc_id in range(n)] # => scored = [(rng.uniform(0, 100), doc_id) for doc_id in ra...
heap_result: list[tuple[float, int]] = topk_via_heap(scored, k) # => correctness check first
sort_result: list[tuple[float, int]] = topk_via_full_sort(scored, k) # => sort result = topk_via_full_sort(scored, k)
assert heap_result == sort_result, "heap and full-sort top-k must be IDENTICAL before comparing speed" # => heap and full-sort top-k must be IDENTICAL before comparing speed
heap_time: float = min(_time_call(lambda: topk_via_heap(scored, k)) for _ in range(3)) # => best of 3 runs
sort_time: float = min(_time_call(lambda: topk_via_full_sort(scored, k)) for _ in range(3)) # => best of 3 runs
print(f"n={n}, k={k}: heap={heap_time * 1000:.2f}ms full_sort={sort_time * 1000:.2f}ms ratio={sort_time / heap_time:.1f}x") # => shows n=
assert heap_time < sort_time, "the heap-based top-k must be FASTER than a full sort at this n and k" # => the heap-based top-k must be FASTER than a full sort at this n and k
print(f"MATCH: identical top-{k} results, and the heap is {sort_time / heap_time:.1f}x faster than a full sort") # => shows MATCH: identical top
def _time_call(fn: Callable[[], object]) -> float: # => defines time call
start: float = time.perf_counter() # => start = time.perf_counter()
fn() # => part of this step's computation, continued from the line above
return time.perf_counter() - start # => returns time.perf_counter() - start
if __name__ == "__main__": # => entry point -- runs only when this file executes directly, not on import
main() # => runs the example end to endRun: python3 topk_vs_fullsort_timing.py
Output:
n=500000, k=10: heap=15.10ms full_sort=100.76ms ratio=6.7x
MATCH: identical top-10 results, and the heap is 6.7x faster than a full sortKey takeaway: O(N log k) beats O(N log N) exactly where search engines live: N is the corpus, k is a handful of results a human will actually look at.
Why It Matters: Measuring the heap's real speed advantage, not just asserting O(N log k) beats O(N log N), is the same benchmark a search-relevance engineer runs before shipping a top-k collection strategy to production. The advantage is largest exactly where search engines operate: N (the corpus) is large, and k (results a human will actually look at) is small, often 10 or 20. This example turns an asymptotic argument into a number you can watch on your own machine, exactly why Lucene chose the heap-based approach for its top-N collectors.
Example 40: Precision/Recall Compute
ex-40 · exercises co-21
Precision (|rel ∩ ret| / |ret|) and recall (|rel ∩ ret| / |rel|) are computed from a retrieved set and a relevant set, verified here against hand-tallied values on a small fixture (co-21).
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73
flowchart LR
A["retrieved set"]:::blue --> C["intersection<br/>relevant AND retrieved"]:::teal
B["relevant set"]:::orange --> C
C --> D["precision =<br/>intersection / retrieved"]:::blue
C --> E["recall =<br/>intersection / relevant"]:::orange
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
learning/code/ex-40-precision-recall-compute/precision_recall_compute.py
# pyright: strict
"""Example 40: Precision/Recall Compute (co-21)."""
from __future__ import annotations # => hygiene: postpones annotation evaluation, interpreter-version-agnostic
def precision(retrieved: set[int], relevant: set[int]) -> float: # => |relevant ∩ retrieved| / |retrieved| -- denominator is what you RETURNED
"""|relevant ∩ retrieved| / |retrieved| -- denominator is what you RETURNED."""
if not retrieved: # => true when not retrieved
return 0.0 # => returns 0.0
return len(relevant & retrieved) / len(retrieved) # => co-21: how much of what you returned was actually relevant
def recall(retrieved: set[int], relevant: set[int]) -> float: # => |relevant ∩ retrieved| / |relevant| -- denominator is what EXISTS to find
"""|relevant ∩ retrieved| / |relevant| -- denominator is what EXISTS to find."""
if not relevant: # => true when not relevant
return 0.0 # => returns 0.0
return len(relevant & retrieved) / len(relevant) # => co-21: how much of what exists did you actually find
def main() -> None: # => defines main
relevant: set[int] = {1, 2, 3, 4, 5} # => the 5 documents that are ACTUALLY relevant to this query
retrieved: set[int] = {1, 2, 6, 7} # => the 4 documents the system actually returned
p: float = precision(retrieved, relevant) # => co-21: 2 of the 4 retrieved were relevant
r: float = recall(retrieved, relevant) # => co-21: 2 of the 5 relevant were found
print(f"relevant: {sorted(relevant)}") # => shows relevant
print(f"retrieved: {sorted(retrieved)}") # => shows retrieved
print(f"precision: {p:.4f} recall: {r:.4f}") # => shows precision
hand_precision: float = 2 / 4 # => hand tally: {1,2} are the 2 relevant docs among the 4 retrieved
hand_recall: float = 2 / 5 # => hand tally: {1,2} are the 2 found docs among the 5 relevant
assert p == hand_precision, f"precision must equal the hand tally {hand_precision}" # => precision must equal the hand tally {hand_precision}
assert r == hand_recall, f"recall must equal the hand tally {hand_recall}" # => recall must equal the hand tally {hand_recall}
print(f"MATCH: precision={p} and recall={r} both equal their hand-tallied values") # => shows MATCH: precision=
if __name__ == "__main__": # => entry point -- runs only when this file executes directly, not on import
main() # => runs the example end to endRun: python3 precision_recall_compute.py
Output:
relevant: [1, 2, 3, 4, 5]
retrieved: [1, 2, 6, 7]
precision: 0.5000 recall: 0.4000
MATCH: precision=0.5 and recall=0.4 both equal their hand-tallied valuesKey takeaway: Precision and recall answer different questions from different denominators -- confusing the two is the single most common evaluation mistake in information retrieval.
Why It Matters: Precision and recall are the two foundational metrics every serious search-relevance evaluation in production reports, from Lucene's own benchmark suites to enterprise search vendors' quality dashboards, and confusing their denominators is the single most common evaluation mistake newcomers make. Precision asks "of what I returned, how much was right"; recall asks "of what was right, how much did I return" -- two different questions silently conflated when a team reports only one number. This example computes both from the same fixture, making the distinction impossible to blur.
Example 41: Precision/Recall Direction
ex-41 · exercises co-21
Constructing a deliberately high-precision/low-recall retrieval and its inverse high-recall/low-precision counterpart on the same relevant set makes the two metrics' opposite denominators concrete (co-21).
learning/code/ex-41-precision-recall-direction/precision_recall_direction.py
# pyright: strict
"""Example 41: Precision/Recall Direction (co-21)."""
from __future__ import annotations # => hygiene: postpones annotation evaluation, interpreter-version-agnostic
def precision(retrieved: set[int], relevant: set[int]) -> float: # => defines precision
return len(relevant & retrieved) / len(retrieved) if retrieved else 0.0 # => returns len(relevant & retrieved) / len(retrieved) if retrieved e...
def recall(retrieved: set[int], relevant: set[int]) -> float: # => defines recall
return len(relevant & retrieved) / len(relevant) if relevant else 0.0 # => returns len(relevant & retrieved) / len(relevant) if relevant els...
def main() -> None: # => defines main
relevant: set[int] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10} # => 10 documents are truly relevant
cautious_retrieval: set[int] = {1} # => co-21: retrieves ONLY 1 doc -- but it IS relevant
p_cautious: float = precision(cautious_retrieval, relevant) # => 1/1 -- perfect precision
r_cautious: float = recall(cautious_retrieval, relevant) # => 1/10 -- terrible recall
print(f"cautious (retrieves 1 doc): precision={p_cautious:.4f} recall={r_cautious:.4f}") # => shows cautious (retrieves 1 doc): precision=
broad_retrieval: set[int] = set(range(1, 101)) # => co-21: retrieves EVERYTHING from 1 to 100
p_broad: float = precision(broad_retrieval, relevant) # => 10/100 -- terrible precision
r_broad: float = recall(broad_retrieval, relevant) # => 10/10 -- perfect recall
print(f"broad (retrieves 100 docs): precision={p_broad:.4f} recall={r_broad:.4f}") # => shows broad (retrieves 100 docs): precision=
assert p_cautious == 1.0 and r_cautious < 0.2, "the cautious retrieval must have PERFECT precision, POOR recall" # => the cautious retrieval must have PERFECT precision, POOR recall
assert r_broad == 1.0 and p_broad < 0.2, "the broad retrieval must have PERFECT recall, POOR precision" # => the broad retrieval must have PERFECT recall, POOR precision
print("MATCH: retrieving 1 doc maximizes precision at recall's expense; retrieving everything does the reverse") # => shows MATCH: retrieving 1 doc maximizes precision at recall's expense; retrieving everything does the reverse
if __name__ == "__main__": # => entry point -- runs only when this file executes directly, not on import
main() # => runs the example end to endRun: python3 precision_recall_direction.py
Output:
cautious (retrieves 1 doc): precision=1.0000 recall=0.1000
broad (retrieves 100 docs): precision=0.1000 recall=1.0000
MATCH: retrieving 1 doc maximizes precision at recall's expense; retrieving everything does the reverseKey takeaway: Precision and recall trade against each other at the extremes -- retrieving almost nothing or almost everything each maximizes one metric by sacrificing the other entirely.
Why It Matters: Constructing a deliberately high-precision/low-recall system and its inverse is the same exercise a search-relevance team runs explaining why "return everything remotely relevant" and "only return what's certain" are both bad defaults for a production search box. Retrieving almost nothing maximizes precision by sacrificing recall, and the reverse is equally true -- exactly the tension every ranking cutoff decision, like a relevance-score threshold, has to navigate. This example makes that trade-off concrete instead of abstract.
Example 42: F1 Harmonic Mean
ex-42 · exercises co-21
F1 is the harmonic mean of precision and recall, 2*p*r/(p+r) -- unlike an arithmetic mean, it stays close to whichever of the two is smaller, punishing systems that game one metric at the other's expense (co-21).
learning/code/ex-42-f1-harmonic/f1_harmonic.py
# pyright: strict
"""Example 42: F1 Harmonic Mean (co-21)."""
from __future__ import annotations # => hygiene: postpones annotation evaluation, interpreter-version-agnostic
def f1_score(precision: float, recall: float) -> float: # => the harmonic mean of precision and recall
"""The harmonic mean of precision and recall."""
if precision + recall == 0: # => true when precision + recall == 0
return 0.0 # => returns 0.0
return 2 * precision * recall / (precision + recall) # => co-21: harmonic, NOT arithmetic, mean
def main() -> None: # => defines main
balanced_p, balanced_r = 0.8, 0.8 # => a BALANCED system
lopsided_p, lopsided_r = 1.0, 0.1 # => a LOPSIDED system -- perfect precision, terrible recall
f1_balanced: float = f1_score(balanced_p, balanced_r) # => f1 balanced = f1_score(balanced_p, balanced_r)
f1_lopsided: float = f1_score(lopsided_p, lopsided_r) # => f1 lopsided = f1_score(lopsided_p, lopsided_r)
arithmetic_mean_lopsided: float = (lopsided_p + lopsided_r) / 2 # => an arithmetic mean, for contrast
print(f"balanced (p={balanced_p}, r={balanced_r}): F1={f1_balanced:.4f}") # => shows balanced (p=
print(f"lopsided (p={lopsided_p}, r={lopsided_r}): F1={f1_lopsided:.4f} (arithmetic mean would be {arithmetic_mean_lopsided:.4f})") # => shows lopsided (p=
assert lopsided_r < f1_lopsided < arithmetic_mean_lopsided, "F1 must sit BETWEEN recall and the arithmetic mean, closer to the SMALLER value" # => F1 must sit BETWEEN recall and the arithmetic mean, closer to the SMALLER value
assert f1_lopsided < 0.2, "F1 must stay near the smaller of the two lopsided values, not near their average" # => F1 must stay near the smaller of the two lopsided values, not near their average
print(f"MATCH: F1={f1_lopsided:.4f} stays close to the smaller value (recall={lopsided_r}), far below the arithmetic mean {arithmetic_mean_lopsided:.4f}") # => shows MATCH: F1=
if __name__ == "__main__": # => entry point -- runs only when this file executes directly, not on import
main() # => runs the example end to endRun: python3 f1_harmonic.py
Output:
balanced (p=0.8, r=0.8): F1=0.8000
lopsided (p=1.0, r=0.1): F1=0.1818 (arithmetic mean would be 0.5500)
MATCH: F1=0.1818 stays close to the smaller value (recall=0.1), far below the arithmetic mean 0.5500Key takeaway: F1's harmonic mean is a deliberate design choice -- it refuses to reward a system that inflates one of precision or recall while abandoning the other.
Why It Matters: F1's harmonic mean is a deliberate design choice search-quality dashboards rely on specifically because an arithmetic mean can be gamed: a system returning everything (perfect recall, terrible precision) or almost nothing (perfect precision, terrible recall) would score misleadingly well under a simple average. Refusing to reward whichever metric is smaller is what makes F1 resistant to that gaming in a way a plain average is not. This is why F1, not a raw average, is the standard single-number relevance-quality metric production teams report.
Example 43: Precision at K
ex-43 · exercises co-22
Precision@k measures precision over only the top-k ranked results -- the metric that actually matches what a user experiences, since almost nobody scrolls past the first page (co-22).
learning/code/ex-43-precision-at-k/precision_at_k.py
# pyright: strict
"""Example 43: Precision at K (co-22)."""
from __future__ import annotations # => hygiene: postpones annotation evaluation, interpreter-version-agnostic
def precision_at_k(ranked_results: list[int], relevant: set[int], k: int) -> float: # => precision computed over only the top-k entries of a ranked result list
"""Precision computed over only the top-k entries of a ranked result list."""
top_k: list[int] = ranked_results[:k] # => co-22: only the FIRST k results count
hits: int = sum(1 for doc_id in top_k if doc_id in relevant) # => how many of the top k are relevant
return hits / k if k > 0 else 0.0 # => returns hits / k if k > 0 else 0.0
def main() -> None: # => defines main
relevant: set[int] = {1, 3, 5, 7, 9, 11, 13} # => 7 truly relevant documents, corpus-wide
ranked: list[int] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] # => the system's ranked output, best-first
p_at_5: float = precision_at_k(ranked, relevant, k=5) # => co-22: precision over just the top 5
p_at_10: float = precision_at_k(ranked, relevant, k=10) # => co-22: precision over the top 10
print(f"ranked (top 10): {ranked}") # => shows ranked (top 10)
print(f"precision@5: {p_at_5:.4f}") # => shows precision@5
print(f"precision@10: {p_at_10:.4f}") # => shows precision@10
hand_top5_hits: int = sum(1 for d in ranked[:5] if d in relevant) # => hand count: {1,3,5} in the top 5
hand_top10_hits: int = sum(1 for d in ranked[:10] if d in relevant) # => hand count: {1,3,5,7,9} in the top 10
assert p_at_5 == hand_top5_hits / 5, "precision@5 must match a hand count of relevant docs in the first 5" # => precision@5 must match a hand count of relevant docs in the first 5
assert p_at_10 == hand_top10_hits / 10, "precision@10 must match a hand count of relevant docs in the first 10" # => precision@10 must match a hand count of relevant docs in the first 10
print(f"MATCH: precision@5 ({p_at_5}) and precision@10 ({p_at_10}) both equal their hand-counted values") # => shows MATCH: precision@5 (
if __name__ == "__main__": # => entry point -- runs only when this file executes directly, not on import
main() # => runs the example end to endRun: python3 precision_at_k.py
Output:
ranked (top 10): [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
precision@5: 0.6000
precision@10: 0.5000
MATCH: precision@5 (0.6) and precision@10 (0.5) both equal their hand-counted valuesKey takeaway: Precision@k is precision applied honestly -- a search engine with 90% overall precision but a terrible top-5 is a search engine users experience as broken.
Why It Matters: Precision@k is the metric that actually matches how users experience a search engine, since almost nobody scrolls past the first page -- a system with 90% overall precision but a terrible top-5 is, from a user's perspective, simply broken, no matter what the full-corpus number says. This is exactly why Elasticsearch's own relevance-tuning guidance and standard IR benchmarks like TREC report precision@k rather than precision over the entire result set. This example is the honest version of the metric a production team should actually watch.
Example 44: Qrels Load
ex-44 · exercises co-23
A relevance-judgment set (qrels) is a labeled query -> {relevant doc-ids} mapping -- the ground truth every evaluation metric in this course is measured against (co-23).
learning/code/ex-44-qrels-load/qrels_load.py
# pyright: strict
"""Example 44: Qrels Load (co-23)."""
from __future__ import annotations # => hygiene: postpones annotation evaluation, interpreter-version-agnostic
def load_qrels(raw: list[tuple[str, int]]) -> dict[str, set[int]]: # => load a flat list of (query, relevant_doc_id) rows into query -> {relevant doc-ids}
"""Load a flat list of (query, relevant_doc_id) rows into query -> {relevant doc-ids}."""
qrels: dict[str, set[int]] = {} # => co-23: the ground-truth relevance judgments
for query, doc_id in raw: # => one labeled (query, doc) judgment at a time
qrels.setdefault(query, set()).add(doc_id) # => part of this step's computation, continued from the line above
return qrels # => returns qrels
def main() -> None: # => defines main
raw_judgments: list[tuple[str, int]] = [ # => flat rows, as a human annotator might produce them
("search engine", 1), # => part of this step's computation, continued from the line above
("search engine", 3), # => part of this step's computation, continued from the line above
("search engine", 7), # => part of this step's computation, continued from the line above
("ranking algorithm", 2), # => part of this step's computation, continued from the line above
("ranking algorithm", 4), # => part of this step's computation, continued from the line above
] # => opens/closes this multi-line literal
qrels: dict[str, set[int]] = load_qrels(raw_judgments) # => co-23: the loaded, query-grouped judgments
for query in sorted(qrels): # => iterates one item at a time
print(f"{query!r}: {sorted(qrels[query])}") # => prints this step's result
assert qrels["search engine"] == {1, 3, 7}, "'search engine' must map to exactly its 3 labeled relevant docs" # => 'search engine' must map to exactly its 3 labeled relevant docs
assert qrels["ranking algorithm"] == {2, 4}, "'ranking algorithm' must map to exactly its 2 labeled relevant docs" # => 'ranking algorithm' must map to exactly its 2 labeled relevant docs
assert len(qrels) == 2, "there must be exactly 2 distinct queries in this judgment set" # => there must be exactly 2 distinct queries in this judgment set
print(f"MATCH: each query maps to exactly its labeled relevant docs, {len(qrels)} distinct queries total") # => shows MATCH: each query maps to exactly its labeled relevant docs,
if __name__ == "__main__": # => entry point -- runs only when this file executes directly, not on import
main() # => runs the example end to endRun: python3 qrels_load.py
Output:
'ranking algorithm': [2, 4]
'search engine': [1, 3, 7]
MATCH: each query maps to exactly its labeled relevant docs, 2 distinct queries totalKey takeaway: Every evaluation metric in this course is only as trustworthy as its qrels -- a metric computed against no ground truth is not measuring anything.
Why It Matters: A qrels file is the actual artifact production search-relevance teams build and maintain, often via human judges or click-through proxies, before running any offline evaluation, because every metric downstream -- precision, recall, MAP, nDCG -- is only as trustworthy as the ground truth it is measured against. A metric computed with no qrels, or against stale ones, is not measuring anything meaningful, a real failure mode when relevance judgments go unmaintained as a corpus evolves. This example is the ground-truth format every evaluation in this course, and in production, depends on.
Example 45: Evaluate Two Configs
ex-45 · exercises co-22, co-23
Comparing precision@k for a tf-idf ranker against a BM25 ranker over the same qrels turns "which is better" from opinion into measurement -- verified against a hand tally of the reported winner (co-22, co-23).
learning/code/ex-45-evaluate-two-configs/evaluate_two_configs.py
# pyright: strict
"""Example 45: Evaluate Two Configs (co-22, co-23)."""
from __future__ import annotations # => hygiene: postpones annotation evaluation, interpreter-version-agnostic
import math # => stdlib math -- log/sqrt for idf, cosine, and skip-pointer spacing
def bm25_idf(term_df: int, n_docs: int) -> float: # => bM25's own RSJ idf: log((N - df + 0.5) / (df + 0.5)) -- distinct from plain log(N/df)
"""BM25's own RSJ idf: log((N - df + 0.5) / (df + 0.5)) -- distinct from plain log(N/df)."""
return math.log((n_docs - term_df + 0.5) / (term_df + 0.5)) # => returns math.log((n_docs - term_df + 0.5) / (term_df + 0.5))
def bm25_term_weight( # => defines bm25 term weight
tf: int, term_df: int, n_docs: int, dl: float, avgdl: float, *, k1: float = 1.2, b: float = 0.75 # => part of this step's computation, continued from the line above
) -> float: # => part of this step's computation, continued from the line above
"""One query term's BM25 contribution: RSJ idf * saturating tf * length normalization."""
idf: float = bm25_idf(term_df, n_docs) # => idf = bm25_idf(term_df, n_docs)
length_norm: float = (1 - b) + b * (dl / avgdl) # => co-18: B -- 1.0 at average length, >1 for long docs
return idf * (tf * (k1 + 1)) / (tf + k1 * length_norm) # => co-17: the k1-saturated, B-normalized term score
def bm25_score( # => defines bm25 score
query_terms: list[str], # => part of this step's computation, continued from the line above
doc_tf: dict[str, int], # => part of this step's computation, continued from the line above
df: dict[str, int], # => part of this step's computation, continued from the line above
n_docs: int, # => part of this step's computation, continued from the line above
dl: float, # => part of this step's computation, continued from the line above
avgdl: float, # => part of this step's computation, continued from the line above
*, # => part of this step's computation, continued from the line above
k1: float = 1.2, # => k1 = 1.2,
b: float = 0.75, # => b = 0.75,
) -> float: # => part of this step's computation, continued from the line above
"""Sum bm25_term_weight over every query term the document actually contains."""
total: float = 0.0 # => total = 0.0
for term in query_terms: # => iterates one item at a time
if term in doc_tf: # => true when term in doc_tf
total += bm25_term_weight(doc_tf[term], df.get(term, 0), n_docs, dl, avgdl, k1=k1, b=b) # => part of this step's computation, continued from the line above
return total # => returns total
def build_df(docs: dict[int, list[str]]) -> dict[str, int]: # => defines build df
df: dict[str, int] = {} # => starts empty, populated by the loop below
for tokens in docs.values(): # => iterates one item at a time
for term in set(tokens): # => iterates one item at a time
df[term] = df.get(term, 0) + 1 # => counter pattern: 0 on first sight, then increments
return df # => returns df
def rank_tfidf(docs: dict[int, list[str]], query_terms: list[str]) -> list[int]: # => defines rank tfidf
n_docs: int = len(docs) # => this fixture's own size
df: dict[str, int] = build_df(docs) # => df = build_df(docs)
scores: dict[int, float] = {} # => starts empty, populated by the loop below
for doc_id, tokens in docs.items(): # => iterates one item at a time
tf: dict[str, int] = {} # => starts empty, populated by the loop below
for t in tokens: # => iterates one item at a time
tf[t] = tf.get(t, 0) + 1 # => counter pattern: 0 on first sight, then increments
scores[doc_id] = sum(tf[t] * math.log(n_docs / df[t]) for t in query_terms if t in tf) # => scores = sum(tf[t] * math.log(n_docs / df[t]) for t in q...
return [doc_id for doc_id, _ in sorted(scores.items(), key=lambda kv: (-kv[1], kv[0]))] # => returns [doc_id for doc_id, _ in sorted(scores.items(), key=lambd...
def rank_bm25_docs(docs: dict[int, list[str]], query_terms: list[str]) -> list[int]: # => defines rank bm25 docs
n_docs: int = len(docs) # => this fixture's own size
df: dict[str, int] = build_df(docs) # => df = build_df(docs)
avgdl: float = sum(len(t) for t in docs.values()) / n_docs # => avgdl = sum(len(t) for t in docs.values()) / n_docs
scores: dict[int, float] = {} # => starts empty, populated by the loop below
for doc_id, tokens in docs.items(): # => iterates one item at a time
tf: dict[str, int] = {} # => starts empty, populated by the loop below
for t in tokens: # => iterates one item at a time
tf[t] = tf.get(t, 0) + 1 # => counter pattern: 0 on first sight, then increments
scores[doc_id] = bm25_score(query_terms, tf, df, n_docs, float(len(tokens)), avgdl) # => scores = bm25_score(query_terms, tf, df, n_docs, float(l...
return [doc_id for doc_id, _ in sorted(scores.items(), key=lambda kv: (-kv[1], kv[0]))] # => returns [doc_id for doc_id, _ in sorted(scores.items(), key=lambd...
def precision_at_k(ranked: list[int], relevant: set[int], k: int) -> float: # => defines precision at k
top_k: list[int] = ranked[:k] # => top k = ranked[:k]
return sum(1 for d in top_k if d in relevant) / k if k > 0 else 0.0 # => returns sum(1 for d in top_k if d in relevant) / k if k > 0 else 0.0
def main() -> None: # => defines main
docs: dict[int, list[str]] = { # => docs = {
0: ["search", "engine"] * 5 + ["content"] * 40, # => KEYWORD-STUFFED: 5x each term padded into a 50-token doc
1: ["search", "engine", "index", "ranking"], # => balanced, on-topic, each term ONCE
2: ["cooking", "recipe"], # => off-topic
3: ["weather", "forecast", "today"], # => filler, off-topic -- keeps df(search)/df(engine) LOW relative to N
4: ["news", "update", "daily"], # => filler, off-topic -- same reason: avoids a tiny-corpus idf sign flip
} # => opens/closes this multi-line literal
query_terms: list[str] = ["search", "engine"] # => query terms = ["search", "engine"]
qrels: dict[int, bool] = {0: False, 1: True, 2: False, 3: False, 4: False} # => co-23: doc 1 is the ONE truly relevant result
relevant: set[int] = {doc_id for doc_id, is_rel in qrels.items() if is_rel} # => relevant = {doc_id for doc_id, is_rel in qrels.items() if ...
tfidf_ranking: list[int] = rank_tfidf(docs, query_terms) # => co-22: tf-idf's ranked order
bm25_ranking: list[int] = rank_bm25_docs(docs, query_terms) # => co-22: BM25's ranked order
p1_tfidf: float = precision_at_k(tfidf_ranking, relevant, k=1) # => precision@1 for each config
p1_bm25: float = precision_at_k(bm25_ranking, relevant, k=1) # => p1 bm25 = precision_at_k(bm25_ranking, relevant, k=1)
print(f"tf-idf ranking: {tfidf_ranking} precision@1={p1_tfidf:.4f}") # => shows tf-idf ranking
print(f"bm25 ranking: {bm25_ranking} precision@1={p1_bm25:.4f}") # => shows bm25 ranking
assert tfidf_ranking[0] == 0, "tf-idf's unbounded tf lets the repetitive doc 0 rank first (a hand-tallied fact of this fixture)" # => tf-idf's unbounded tf lets the repetitive doc 0 rank first (a hand-tallied fact of this fixture)
assert bm25_ranking[0] == 1, "BM25's saturation lets the balanced, truly relevant doc 1 rank first instead" # => BM25's saturation lets the balanced, truly relevant doc 1 rank first instead
assert p1_bm25 > p1_tfidf, "BM25 must score HIGHER precision@1 than tf-idf on this fixture" # => BM25 must score HIGHER precision@1 than tf-idf on this fixture
print(f"MATCH: BM25's precision@1 ({p1_bm25}) beats tf-idf's ({p1_tfidf}) -- saturation avoided the keyword-stuffed doc") # => shows MATCH: BM25's precision@1 (
if __name__ == "__main__": # => entry point -- runs only when this file executes directly, not on import
main() # => runs the example end to endRun: python3 evaluate_two_configs.py
Output:
tf-idf ranking: [0, 1, 2, 3, 4] precision@1=0.0000
bm25 ranking: [1, 0, 2, 3, 4] precision@1=1.0000
MATCH: BM25's precision@1 (1.0) beats tf-idf's (0.0) -- saturation avoided the keyword-stuffed docKey takeaway: A measured precision@k difference, not an intuition, is what should decide between two ranking configurations -- this fixture shows a concrete case where BM25's saturation earns a real win.
Why It Matters: Comparing two ranking configurations on measured precision@k, rather than intuition or a demo query that happened to look good, is exactly the offline-evaluation discipline production search teams apply before shipping a scoring change -- an A/B test in miniature, run entirely offline against qrels. This example is the template: hold the corpus and queries fixed, swap only the ranking function, and let the measured numbers decide the winner. This is precisely the kind of evidence that should gate any relevance change before it reaches real users.
Example 46: Average Precision
ex-46 · exercises co-24
Average Precision (AP) for one query averages precision@k computed at every rank position where a relevant document appears -- rewarding relevant results that appear early, not just present anywhere in the list (co-24).
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Gray #808080
flowchart LR
A["ranked results"]:::blue --> B{"relevant at this rank?"}:::orange
B -->|"yes"| C["compute precision@this-rank<br/>add to running sum"]:::teal
B -->|"no"| D["skip -- no contribution"]:::gray
C --> E["AP = sum / relevant count"]:::teal
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef gray fill:#808080,stroke:#000000,color:#FFFFFF,stroke-width:2px
learning/code/ex-46-average-precision/average_precision.py
# pyright: strict
"""Example 46: Average Precision (co-24)."""
from __future__ import annotations # => hygiene: postpones annotation evaluation, interpreter-version-agnostic
def average_precision(ranked: list[int], relevant: set[int]) -> float: # => mean of precision@k, evaluated at every rank position where a relevant doc appears
"""Mean of precision@k, evaluated at every rank position where a relevant doc appears."""
if not relevant: # => true when not relevant
return 0.0 # => returns 0.0
precisions_at_hits: list[float] = [] # => co-24: one precision@k value per relevant hit, in rank order
hits_so_far: int = 0 # => a running counter, starting at zero
for k, doc_id in enumerate(ranked, start=1): # => co-24: k is the 1-based rank position
if doc_id in relevant: # => true when doc_id in relevant
hits_so_far += 1 # => advances hits_so_far
precisions_at_hits.append(hits_so_far / k) # => co-22: precision@k, evaluated ONLY at this hit
return sum(precisions_at_hits) / len(relevant) # => co-24: averaged over ALL relevant docs, not just the ones found
def main() -> None: # => defines main
relevant: set[int] = {2, 4, 6} # => 3 truly relevant documents
ranked: list[int] = [1, 2, 3, 4, 5, 6, 7] # => relevant docs land at ranks 2, 4, and 6
ap: float = average_precision(ranked, relevant) # => co-24: this query's own Average Precision
print(f"ranked: {ranked}") # => shows ranked
print(f"relevant: {sorted(relevant)} (found at ranks 2, 4, 6)") # => shows relevant
print(f"average precision: {ap:.4f}") # => shows average precision
hand_p_at_2: float = 1 / 2 # => rank 2: 1 hit out of 2 seen so far
hand_p_at_4: float = 2 / 4 # => rank 4: 2 hits out of 4 seen so far
hand_p_at_6: float = 3 / 6 # => rank 6: 3 hits out of 6 seen so far
hand_ap: float = (hand_p_at_2 + hand_p_at_4 + hand_p_at_6) / 3 # => mean over the 3 relevant docs
assert ap == hand_ap, f"AP must equal the hand computation {hand_ap}" # => AP must equal the hand computation {hand_ap}
print(f"MATCH: AP={ap} equals the hand computation ({hand_p_at_2}+{hand_p_at_4}+{hand_p_at_6})/3={hand_ap}") # => shows MATCH: AP=
if __name__ == "__main__": # => entry point -- runs only when this file executes directly, not on import
main() # => runs the example end to endRun: python3 average_precision.py
Output:
ranked: [1, 2, 3, 4, 5, 6, 7]
relevant: [2, 4, 6] (found at ranks 2, 4, 6)
average precision: 0.5000
MATCH: AP=0.5 equals the hand computation (0.5+0.5+0.5)/3=0.5Key takeaway: AP folds rank position directly into the score -- the same 3 relevant docs found at ranks 1-2-3 instead of 2-4-6 would score strictly higher, capturing something precision@k alone cannot.
Why It Matters: AP folding rank position directly into the score is why it, not raw precision@k, is the standard per-query metric in academic and industrial information-retrieval evaluation including TREC -- a relevant document surfaced at rank 1 is worth more to a user than the identical document buried at rank 20, and precision@k alone cannot express that. Rewarding relevant results appearing early captures exactly what makes a result list feel good or bad to use. This example is the metric that makes where a relevant result appears, not just whether, count toward quality.
Example 47: MAP: Multi-Query
ex-47 · exercises co-24
Mean Average Precision (MAP) is simply the mean of per-query AP across a whole query set -- a single number summarizing ranking quality over many queries at once (co-24).
learning/code/ex-47-map-multi-query/map_multi_query.py
# pyright: strict
"""Example 47: MAP: Multi-Query (co-24)."""
from __future__ import annotations # => hygiene: postpones annotation evaluation, interpreter-version-agnostic
def average_precision(ranked: list[int], relevant: set[int]) -> float: # => defines average precision
if not relevant: # => true when not relevant
return 0.0 # => returns 0.0
precisions_at_hits: list[float] = [] # => starts empty, populated by the loop below
hits_so_far: int = 0 # => a running counter, starting at zero
for k, doc_id in enumerate(ranked, start=1): # => iterates one item at a time
if doc_id in relevant: # => true when doc_id in relevant
hits_so_far += 1 # => advances hits_so_far
precisions_at_hits.append(hits_so_far / k) # => records this item, in order
return sum(precisions_at_hits) / len(relevant) # => returns sum(precisions_at_hits) / len(relevant)
def mean_average_precision(rankings: dict[str, list[int]], qrels: dict[str, set[int]]) -> float: # => mAP: the mean of average_precision across every query in the set
"""MAP: the mean of average_precision across every query in the set."""
aps: list[float] = [average_precision(rankings[q], qrels[q]) for q in rankings] # => co-24: one AP per query
return sum(aps) / len(aps) if aps else 0.0 # => returns sum(aps) / len(aps) if aps else 0.0
def main() -> None: # => defines main
rankings: dict[str, list[int]] = { # => 3 queries, each with its own ranked result list
"q1": [1, 2, 3, 4], # => entry for 'q1'
"q2": [5, 6, 7, 8], # => entry for 'q2'
"q3": [9, 10, 11, 12], # => entry for 'q3'
} # => opens/closes this multi-line literal
qrels: dict[str, set[int]] = { # => 3 queries, each with its own relevant-doc set
"q1": {1, 3}, # => AP for q1: hits at rank 1 (p=1.0) and rank 3 (p=2/3) -> (1.0+0.6667)/2
"q2": {5, 6, 7, 8}, # => AP for q2: every result is relevant, all hits at consecutive ranks -> AP = 1.0
"q3": {12}, # => AP for q3: the only relevant doc is LAST -> AP = 1/4
} # => opens/closes this multi-line literal
map_score: float = mean_average_precision(rankings, qrels) # => co-24: one number over all 3 queries
per_query_ap: dict[str, float] = {q: average_precision(rankings[q], qrels[q]) for q in rankings} # => the per-query breakdown
for q, ap in per_query_ap.items(): # => iterates one item at a time
print(f"{q}: AP={ap:.4f}") # => prints this step's result
print(f"MAP: {map_score:.4f}") # => shows MAP
hand_ap_q1: float = (1 / 1 + 2 / 3) / 2 # => hits at rank 1 and rank 3
hand_ap_q2: float = 1.0 # => every result relevant, in order -- perfect AP
hand_ap_q3: float = (1 / 4) / 1 # => the only relevant doc is the LAST of 4 results
hand_map: float = (hand_ap_q1 + hand_ap_q2 + hand_ap_q3) / 3 # => hand map = (hand_ap_q1 + hand_ap_q2 + hand_ap_q3) / 3
assert abs(map_score - hand_map) < 1e-9, f"MAP must equal the hand-computed mean {hand_map}" # => MAP must equal the hand-computed mean {hand_map}
print(f"MATCH: MAP={map_score:.4f} equals the hand-computed mean of the 3 per-query APs ({hand_map:.4f})") # => shows MATCH: MAP=
if __name__ == "__main__": # => entry point -- runs only when this file executes directly, not on import
main() # => runs the example end to endRun: python3 map_multi_query.py
Output:
q1: AP=0.8333
q2: AP=1.0000
q3: AP=0.2500
MAP: 0.6944
MATCH: MAP=0.6944 equals the hand-computed mean of the 3 per-query APs (0.6944)Key takeaway: MAP compresses an entire evaluation run into one comparable number, but that same compression is why the per-query AP breakdown above is worth keeping around when something needs debugging.
Why It Matters: MAP is the single number production and academic relevance benchmarks report to summarize an entire evaluation run, because averaging per-query AP compresses thousands of queries' worth of ranking quality into one comparable score teams can track over time. That same compression is exactly why the per-query AP breakdown is worth keeping around: a MAP regression with no per-query detail tells you something got worse without telling you what or why. This example produces both, the discipline a real relevance-debugging workflow depends on.
Example 48: nDCG Compute
ex-48 · exercises co-24
Normalized Discounted Cumulative Gain (nDCG) handles graded relevance (not just binary) and discounts hits at lower ranks logarithmically -- a perfect ranking scores exactly 1.0, and any reordering scores strictly less (co-24).
learning/code/ex-48-ndcg-compute/ndcg_compute.py
# pyright: strict
"""Example 48: nDCG Compute (co-24)."""
from __future__ import annotations # => hygiene: postpones annotation evaluation, interpreter-version-agnostic
import math # => stdlib math -- log/sqrt for idf, cosine, and skip-pointer spacing
def dcg(graded_relevances: list[float]) -> float: # => discounted Cumulative Gain: sum of rel_i / log2(i + 1), i is the 1-based rank
"""Discounted Cumulative Gain: sum of rel_i / log2(i + 1), i is the 1-based rank."""
return sum(rel / math.log2(i + 1) for i, rel in enumerate(graded_relevances, start=1)) # => co-24: later ranks discounted MORE
def ndcg(ranked_relevances: list[float], ideal_relevances: list[float]) -> float: # => nDCG@len: DCG of the actual ranking, normalized by the IDEAL (best-possible) ranking's DCG
"""nDCG@len: DCG of the actual ranking, normalized by the IDEAL (best-possible) ranking's DCG."""
ideal_dcg: float = dcg(sorted(ideal_relevances, reverse=True)) # => co-24: the best possible ordering of these SAME grades
if ideal_dcg == 0: # => true when ideal_dcg == 0
return 0.0 # => returns 0.0
return dcg(ranked_relevances) / ideal_dcg # => co-24: normalized so a perfect ranking scores exactly 1.0
def main() -> None: # => defines main
grades: dict[int, float] = {1: 3.0, 2: 2.0, 3: 1.0, 4: 0.0} # => graded relevance, 3=highly relevant, 0=irrelevant
perfect_ranking: list[int] = [1, 2, 3, 4] # => best grade first -- the IDEAL order
shuffled_ranking: list[int] = [4, 1, 3, 2] # => worst grade FIRST -- a genuinely bad order
perfect_relevances: list[float] = [grades[d] for d in perfect_ranking] # => perfect relevances = [grades[d] for d in perfect_ranking]
shuffled_relevances: list[float] = [grades[d] for d in shuffled_ranking] # => shuffled relevances = [grades[d] for d in shuffled_ranking]
ideal_relevances: list[float] = list(grades.values()) # => the SAME grades, any order (sorted internally by dcg)
ndcg_perfect: float = ndcg(perfect_relevances, ideal_relevances) # => co-24: the perfect ranking's nDCG
ndcg_shuffled: float = ndcg(shuffled_relevances, ideal_relevances) # => co-24: the shuffled ranking's nDCG
print(f"perfect ranking {perfect_ranking}: nDCG={ndcg_perfect:.6f}") # => shows perfect ranking
print(f"shuffled ranking {shuffled_ranking}: nDCG={ndcg_shuffled:.6f}") # => shows shuffled ranking
assert math.isclose(ndcg_perfect, 1.0, abs_tol=1e-9), "a PERFECT ranking must score nDCG == 1.0 exactly" # => a PERFECT ranking must score nDCG == 1.0 exactly
assert ndcg_shuffled < ndcg_perfect, "a SHUFFLED (worse) ranking must score strictly LESS than the perfect one" # => a SHUFFLED (worse) ranking must score strictly LESS than the perfect one
print(f"MATCH: the perfect ranking scores exactly {ndcg_perfect}, the shuffled ranking scores less ({ndcg_shuffled:.6f})") # => shows MATCH: the perfect ranking scores exactly
if __name__ == "__main__": # => entry point -- runs only when this file executes directly, not on import
main() # => runs the example end to endRun: python3 ndcg_compute.py
Output:
perfect ranking [1, 2, 3, 4]: nDCG=1.000000
shuffled ranking [4, 1, 3, 2]: nDCG=0.683376
MATCH: the perfect ranking scores exactly 1.0, the shuffled ranking scores less (0.683376)Key takeaway: nDCG is the metric to reach for the moment relevance stops being binary -- a "somewhat relevant" result at rank 1 and a "highly relevant" result at rank 1 are not the same event, and nDCG is built to tell them apart.
Why It Matters: nDCG is the metric production search teams reach for the moment relevance stops being binary, which is almost always in real systems -- Elasticsearch and most enterprise evaluation frameworks support graded relevance judgments precisely because "somewhat relevant" and "highly relevant" are not the same outcome for a user. Discounting hits at lower ranks logarithmically, then normalizing against the best possible ranking, is what lets nDCG scores be compared meaningfully across queries with different relevant-document counts. This is the metric of choice whenever relevance judgments carry more than a yes/no label.
Example 49: Analyzer Pipeline Model
ex-49 · exercises co-25
A Lucene-family analyzer is modeled as three typed stages -- char filters, exactly one tokenizer, then token filters, applied strictly in that order -- matching Elastic's own documented "anatomy of an analyzer." This example verifies the staged output against a hand trace (co-25).
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC
%% TD: chain depth is 5, over LR's MaxWidth=4 -- TD keeps depth as the unchecked vertical axis
flowchart TD
A["raw text"]:::blue --> B["char filters<br/>0 or more"]:::orange
B --> C["ONE tokenizer"]:::teal
C --> D["token filters<br/>0 or more, in order"]:::purple
D --> E["index terms"]:::blue
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef purple fill:#CC78BC,stroke:#000000,color:#FFFFFF,stroke-width:2px
learning/code/ex-49-analyzer-pipeline-model/analyzer_pipeline_model.py
# pyright: strict
"""Example 49: Analyzer Pipeline Model (co-25)."""
from __future__ import annotations # => hygiene: postpones annotation evaluation, interpreter-version-agnostic
from dataclasses import dataclass, field # => from dataclasses: dataclass, field
from typing import Callable # => from typing: Callable
@dataclass # => part of this step's computation, continued from the line above
class Analyzer: # => part of this step's computation, continued from the line above
"""Char filters -> ONE tokenizer -> token filters, in that fixed order (Elastic's own model)."""
char_filters: list[Callable[[str], str]] = field(default_factory=lambda: []) # => co-25: text -> text, applied in order
tokenizer: Callable[[str], list[str]] = str.split # => co-25: text -> tokens, exactly ONE, never zero or many
token_filters: list[Callable[[list[str]], list[str]]] = field(default_factory=lambda: []) # => co-25: tokens -> tokens, in order
def analyze(self, text: str) -> list[str]: # => defines analyze
for char_filter in self.char_filters: # => co-25: stage 1 -- runs BEFORE tokenization
text = char_filter(text) # => text = char_filter(text)
tokens: list[str] = self.tokenizer(text) # => co-25: stage 2 -- exactly one tokenizer call
for token_filter in self.token_filters: # => co-25: stage 3 -- runs AFTER tokenization, in order
tokens = token_filter(tokens) # => tokens = token_filter(tokens)
return tokens # => returns tokens
def strip_html(text: str) -> str: # => a toy char filter: removes anything between angle brackets
"""A toy char filter: removes anything between angle brackets."""
result: list[str] = [] # => starts empty, populated by the loop below
in_tag: bool = False # => in tag = False
for ch in text: # => iterates one item at a time
if ch == "<": # => true when ch == "<"
in_tag = True # => in tag = True
elif ch == ">": # => otherwise, true when ch == ">"
in_tag = False # => in tag = False
elif not in_tag: # => otherwise, true when not in_tag
result.append(ch) # => records this item, in order
return "".join(result) # => returns "".join(result)
def lowercase_filter(tokens: list[str]) -> list[str]: # => defines lowercase filter
return [t.lower() for t in tokens] # => returns [t.lower() for t in tokens]
def main() -> None: # => defines main
analyzer = Analyzer( # => co-25: assembles the 3-stage pipeline
char_filters=[strip_html], # => char filters = [strip_html],
tokenizer=str.split, # => tokenizer = str.split,
token_filters=[lowercase_filter], # => token filters = [lowercase_filter],
) # => opens/closes this multi-line literal
text: str = "<b>Search</b> Engines are Fast" # => HTML markup + mixed case
result: list[str] = analyzer.analyze(text) # => co-25: char-filter -> tokenize -> token-filter, in order
print(f"input: {text!r}") # => shows input
print(f"output: {result}") # => shows output
expected: list[str] = ["search", "engines", "are", "fast"] # => hand-traced: strip_html then split then lowercase
assert result == expected, f"expected {expected}, got {result}" # => expected {expected}, got {result}
print(f"MATCH: the 3-stage analyzer's output equals the hand-traced fixture {expected}") # => shows MATCH: the 3-stage analyzer's output equals the hand-traced fixture
if __name__ == "__main__": # => entry point -- runs only when this file executes directly, not on import
main() # => runs the example end to endRun: python3 analyzer_pipeline_model.py
Output:
input: '<b>Search</b> Engines are Fast'
output: ['search', 'engines', 'are', 'fast']
MATCH: the 3-stage analyzer's output equals the hand-traced fixture ['search', 'engines', 'are', 'fast']Key takeaway: An analyzer's stage ORDER is part of its contract, not an implementation detail -- char filters must run before tokenization or an HTML tag could be split mid-token and never fully stripped.
Why It Matters: Modeling an analyzer as three strictly-ordered typed stages -- char filters, one tokenizer, token filters -- matches Elastic's own documented "anatomy of an analyzer" exactly, which is not a coincidence: char filters running before tokenization is a genuine contract, since an HTML tag split mid-token by a premature tokenizer pass could never be fully stripped afterward. Getting this order wrong is a real, documented class of custom-analyzer configuration bug in production Elasticsearch deployments. This example makes that ordering contract explicit and checkable in code, not just documentation.
Example 50: Analyzer Swap Filter
ex-50 · exercises co-25
Inserting a stemming token filter into the Example 49 pipeline changes only the token_filters stage -- verified by comparing the emitted index terms with and without it (co-25).
learning/code/ex-50-analyzer-swap-filter/analyzer_swap_filter.py
# pyright: strict
"""Example 50: Analyzer Swap Filter (co-25)."""
from __future__ import annotations # => hygiene: postpones annotation evaluation, interpreter-version-agnostic
from dataclasses import dataclass, field # => from dataclasses: dataclass, field
from typing import Callable # => from typing: Callable
def _cv_string(word: str) -> str: # => classify every letter of word as 'c' (consonant) or 'v' (vowel) per
"""Classify every letter of word as 'c' (consonant) or 'v' (vowel) per
Porter's definition, where 'y' is a vowel only when preceded by a
consonant (or is a consonant at word-start)."""
result: list[str] = [] # => starts empty, populated by the loop below
for i, ch in enumerate(word): # => iterates one item at a time
if ch in "aeiou": # => true when ch in "aeiou"
result.append("v") # => records this item, in order
elif ch == "y": # => otherwise, true when ch == "y"
result.append("c" if i == 0 or result[i - 1] == "v" else "v") # => records this item, in order
else: # => the fallback branch, when no prior condition matched
result.append("c") # => records this item, in order
return "".join(result) # => returns "".join(result)
def _measure(stem: str) -> int: # => porter's m: the count of VC repetitions in [C](VC)^m[V]
"""Porter's m: the count of VC repetitions in [C](VC)^m[V]."""
cv = _cv_string(stem) # => cv = _cv_string(stem)
collapsed: list[str] = [] # => starts empty, populated by the loop below
for ch in cv: # => collapses runs of the same letter type into one marker
if not collapsed or collapsed[-1] != ch: # => true when not collapsed or collapsed[-1] != ch
collapsed.append(ch) # => records this item, in order
pattern = "".join(collapsed) # => pattern = "".join(collapsed)
if pattern.startswith("c"): # => true when pattern.startswith("c")
pattern = pattern[1:] # => strips the optional leading consonant run
if pattern.endswith("v"): # => true when pattern.endswith("v")
pattern = pattern[:-1] # => strips the optional trailing vowel run
return len(pattern) // 2 # => what remains is (vc)^m -- each pair is one unit of measure
def _contains_vowel(stem: str) -> bool: # => defines contains vowel
return "v" in _cv_string(stem) # => the *v* condition Porter's ED/ING rules require
def _ends_double_consonant(stem: str) -> bool: # => defines ends double consonant
if len(stem) < 2: # => true when len(stem) < 2
return False # => returns False
cv = _cv_string(stem) # => cv = _cv_string(stem)
return cv[-1] == "c" and cv[-2] == "c" and stem[-1] == stem[-2] # => e.g. "hopp"
def _ends_cvc(stem: str) -> bool: # => defines ends cvc
if len(stem) < 3: # => true when len(stem) < 3
return False # => returns False
cv = _cv_string(stem) # => cv = _cv_string(stem)
return cv[-3:] == "cvc" and stem[-1] not in "wxy" # => Porter's *o condition
def _step1a(word: str) -> str: # => defines step1a
if word.endswith("sses"): # => true when word.endswith("sses")
return word[:-2] # => caresses -> caress
if word.endswith("ies"): # => true when word.endswith("ies")
return word[:-2] # => ponies -> poni
if word.endswith("ss"): # => true when word.endswith("ss")
return word # => caress -> caress (unchanged)
if word.endswith("s"): # => true when word.endswith("s")
return word[:-1] # => cats -> cat
return word # => returns word
def _step1b_cleanup(stem: str) -> str: # => defines step1b cleanup
if stem.endswith(("at", "bl", "iz")): # => true when stem.endswith(("at", "bl", "iz"))
return stem + "e" # => conflat(ed) -> conflate
if _ends_double_consonant(stem) and not stem.endswith(("l", "s", "z")): # => true when _ends_double_consonant(stem) and not stem.endswith((...
return stem[:-1] # => hopp(ing) -> hop
if _measure(stem) == 1 and _ends_cvc(stem): # => true when _measure(stem) == 1 and _ends_cvc(stem)
return stem + "e" # => fil(ing) -> file
return stem # => returns stem
def _step1b(word: str) -> str: # => defines step1b
if word.endswith("eed"): # => true when word.endswith("eed")
stem = word[:-3] # => stem = word[:-3]
return stem + "ee" if _measure(stem) > 0 else word # => agreed -> agree
if word.endswith("ed"): # => true when word.endswith("ed")
stem = word[:-2] # => stem = word[:-2]
return _step1b_cleanup(stem) if _contains_vowel(stem) else word # => returns _step1b_cleanup(stem) if _contains_vowel(stem) else word
if word.endswith("ing"): # => true when word.endswith("ing")
stem = word[:-3] # => stem = word[:-3]
return _step1b_cleanup(stem) if _contains_vowel(stem) else word # => returns _step1b_cleanup(stem) if _contains_vowel(stem) else word
return word # => returns word
def _step1c(word: str) -> str: # => defines step1c
if word.endswith("y") and _contains_vowel(word[:-1]): # => true when word.endswith("y") and _contains_vowel(word[:-1])
return word[:-1] + "i" # => happy -> happi
return word # => returns word
_STEP2 = [ # => STEP2 = [
("ational", "ate"), ("tional", "tion"), ("enci", "ence"), ("anci", "ance"), # => part of this step's computation, continued from the line above
("izer", "ize"), ("abli", "able"), ("alli", "al"), ("entli", "ent"), # => part of this step's computation, continued from the line above
("eli", "e"), ("ousli", "ous"), ("ization", "ize"), ("ation", "ate"), # => part of this step's computation, continued from the line above
("ator", "ate"), ("alism", "al"), ("iveness", "ive"), ("fulness", "ful"), # => part of this step's computation, continued from the line above
("ousness", "ous"), ("aliti", "al"), ("iviti", "ive"), ("biliti", "ble"), # => part of this step's computation, continued from the line above
] # => sorted longest-suffix-first below, so "ization" wins over "ation"
def _step2(word: str) -> str: # => defines step2
for suf, repl in sorted(_STEP2, key=lambda p: len(p[0]), reverse=True): # => iterates one item at a time
if word.endswith(suf): # => true when word.endswith(suf)
stem = word[: -len(suf)] # => stem = word[: -len(suf)]
return stem + repl if _measure(stem) > 0 else word # => relational -> relate
return word # => returns word
_STEP3 = [("icate", "ic"), ("ative", ""), ("alize", "al"), ("iciti", "ic"), ("ical", "ic"), ("ful", ""), ("ness", "")] # => STEP3 = [("icate", "ic"), ("ative", ""), ("alize", "al"...
def _step3(word: str) -> str: # => defines step3
for suf, repl in sorted(_STEP3, key=lambda p: len(p[0]), reverse=True): # => iterates one item at a time
if word.endswith(suf): # => true when word.endswith(suf)
stem = word[: -len(suf)] # => stem = word[: -len(suf)]
return stem + repl if _measure(stem) > 0 else word # => hopeful -> hope
return word # => returns word
_STEP4 = ["al", "ance", "ence", "er", "ic", "able", "ible", "ant", "ement", "ment", "ent", "ou", "ism", "ate", "iti", "ous", "ive", "ize"] # => STEP4 = ["al", "ance", "ence", "er", "ic", "able", "ibl...
def _step4(word: str) -> str: # => defines step4
if word.endswith("ion"): # => true when word.endswith("ion")
stem = word[:-3] # => stem = word[:-3]
if _measure(stem) > 1 and stem.endswith(("s", "t")): # => true when _measure(stem) > 1 and stem.endswith(("s", "t"))
return stem # => adoption -> adopt (only after s/t, and only if m>1)
for suf in sorted(_STEP4, key=len, reverse=True): # => iterates one item at a time
if word.endswith(suf): # => true when word.endswith(suf)
stem = word[: -len(suf)] # => stem = word[: -len(suf)]
return stem if _measure(stem) > 1 else word # => airliner -> airlin (m>1 required)
return word # => returns word
def _step5a(word: str) -> str: # => defines step5a
if word.endswith("e"): # => true when word.endswith("e")
stem = word[:-1] # => stem = word[:-1]
m = _measure(stem) # => m = _measure(stem)
if m > 1 or (m == 1 and not _ends_cvc(stem)): # => true when m > 1 or (m == 1 and not _ends_cvc(stem))
return stem # => probate -> probat, but cease stays cease (m==1 and *o)
return word # => returns word
def _step5b(word: str) -> str: # => defines step5b
if _measure(word) > 1 and _ends_double_consonant(word) and word.endswith("l"): # => true when _measure(word) > 1 and _ends_double_consonant(word) ...
return word[:-1] # => controll -> control
return word # => returns word
def porter_stem(word: str) -> str: # => reduce word to its Porter (1980) stem by chaining Steps 1a through 5b
"""Reduce word to its Porter (1980) stem by chaining Steps 1a through 5b."""
if len(word) <= 2: # => true when len(word) <= 2
return word # => Porter's algorithm is a no-op below 3 letters
w = word.lower() # => w = word.lower()
for step in (_step1a, _step1b, _step1c, _step2, _step3, _step4, _step5a, _step5b): # => iterates one item at a time
w = step(w) # => each step's OUTPUT feeds the next step's INPUT, in this fixed order
return w # => returns w
@dataclass # => part of this step's computation, continued from the line above
class Analyzer: # => part of this step's computation, continued from the line above
char_filters: list[Callable[[str], str]] = field(default_factory=lambda: []) # => char filters = field(default_factory=lambda: [])
tokenizer: Callable[[str], list[str]] = str.split # => tokenizer = str.split
token_filters: list[Callable[[list[str]], list[str]]] = field(default_factory=lambda: []) # => token filters = field(default_factory=lambda: [])
def analyze(self, text: str) -> list[str]: # => defines analyze
for char_filter in self.char_filters: # => iterates one item at a time
text = char_filter(text) # => text = char_filter(text)
tokens: list[str] = self.tokenizer(text) # => tokens = self.tokenizer(text)
for token_filter in self.token_filters: # => iterates one item at a time
tokens = token_filter(tokens) # => tokens = token_filter(tokens)
return tokens # => returns tokens
def lowercase_filter(tokens: list[str]) -> list[str]: # => defines lowercase filter
return [t.lower() for t in tokens] # => returns [t.lower() for t in tokens]
def stemming_filter(tokens: list[str]) -> list[str]: # => co-09,co-25: a Porter-stemming TOKEN FILTER, pluggable into the SAME analyzer shape
"""co-09,co-25: a Porter-stemming TOKEN FILTER, pluggable into the SAME analyzer shape."""
return [porter_stem(t) for t in tokens] # => returns [porter_stem(t) for t in tokens]
def main() -> None: # => defines main
text: str = "Running and jumping quickly" # => plural-ish/ing forms to make stemming's effect visible
without_stemming = Analyzer(tokenizer=str.split, token_filters=[lowercase_filter]) # => co-25: baseline pipeline
with_stemming = Analyzer(tokenizer=str.split, token_filters=[lowercase_filter, stemming_filter]) # => co-25: ONE filter added
result_without: list[str] = without_stemming.analyze(text) # => the SAME text, no stemming
result_with: list[str] = with_stemming.analyze(text) # => the SAME text, WITH stemming
print(f"input: {text!r}") # => shows input
print(f"without stemming: {result_without}") # => shows without stemming
print(f"with stemming: {result_with}") # => shows with stemming
assert result_without != result_with, "adding the stemming filter must CHANGE the emitted terms" # => adding the stemming filter must CHANGE the emitted terms
assert result_with[0] == "run", "the stemmed first token must be 'run', not 'running'" # => the stemmed first token must be 'run', not 'running'
assert len(result_without) == len(result_with), "swapping a token filter must NOT change the token COUNT, only their form" # => swapping a token filter must NOT change the token COUNT, only their form
print(f"MATCH: adding one token filter changed the terms ({result_without[0]!r} -> {result_with[0]!r}) without changing their count") # => shows MATCH: adding one token filter changed the terms (
if __name__ == "__main__": # => entry point -- runs only when this file executes directly, not on import
main() # => runs the example end to endRun: python3 analyzer_swap_filter.py
Output:
input: 'Running and jumping quickly'
without stemming: ['running', 'and', 'jumping', 'quickly']
with stemming: ['run', 'and', 'jump', 'quickli']
MATCH: adding one token filter changed the terms ('running' -> 'run') without changing their countKey takeaway: Because every stage in Example 49's Analyzer is just a plugged-in function, adding stemming is a one-line change -- exactly how a real analyzer configuration adds or removes a token filter.
Why It Matters: Swapping a token filter in or out of an analyzer's pipeline by changing exactly one stage, with the rest untouched, is precisely how a production Elasticsearch or OpenSearch analyzer configuration gets edited -- filters are composed, not rewritten from scratch. This example demonstrates that composability directly: adding stemming changes only the token_filters list, and the emitted terms differ in exactly the way that one change predicts. This is the mental model that makes real analyzer configuration changes predictable rather than a guessing game.
Example 51: Query DSL Parse
ex-51 · exercises co-26
A small must/should/must_not boolean query DSL, expressed as a plain dict[str, list[str]], parses into a typed BoolQuery tree -- verified by checking each field lands in the right place (co-26).
learning/code/ex-51-query-dsl-parse/query_dsl_parse.py
# pyright: strict
"""Example 51: Query DSL Parse (co-26)."""
from __future__ import annotations # => hygiene: postpones annotation evaluation, interpreter-version-agnostic
from dataclasses import dataclass # => from dataclasses: dataclass
@dataclass(frozen=True) # => part of this step's computation, continued from the line above
class BoolQuery: # => part of this step's computation, continued from the line above
"""A parsed boolean query tree: AND (must), OR (should), AND-NOT (must_not)."""
must: tuple[str, ...] = () # => co-26: every term here MUST be present
should: tuple[str, ...] = () # => co-26: at least ONE of these must be present, if the list is non-empty
must_not: tuple[str, ...] = () # => co-26: NONE of these may be present
def parse_query_dsl(raw: dict[str, list[str]]) -> BoolQuery: # => parse a raw {"must": [...], "should": [...], "must_not": [...]} dict into a BoolQuery tree
"""Parse a raw {"must": [...], "should": [...], "must_not": [...]} dict into a BoolQuery tree."""
return BoolQuery( # => co-26: builds the typed tree from the untyped raw DSL
must=tuple(raw.get("must", [])), # => must = tuple(raw.get("must", [])),
should=tuple(raw.get("should", [])), # => should = tuple(raw.get("should", [])),
must_not=tuple(raw.get("must_not", [])), # => must not = tuple(raw.get("must_not", [])),
) # => opens/closes this multi-line literal
def main() -> None: # => defines main
raw_dsl: dict[str, list[str]] = { # => a query DSL as an untyped dict, the way a JSON request body would arrive
"must": ["search", "engine"], # => entry for 'must'
"should": ["fast", "reliable"], # => entry for 'should'
"must_not": ["deprecated"], # => entry for 'must_not'
} # => opens/closes this multi-line literal
query: BoolQuery = parse_query_dsl(raw_dsl) # => co-26: the parsed, typed query tree
print(f"parsed query: must={query.must} should={query.should} must_not={query.must_not}") # => shows parsed query: must=
assert query.must == ("search", "engine"), "must must contain exactly the DSL's 'must' terms, in order" # => must must contain exactly the DSL's 'must' terms, in order
assert query.should == ("fast", "reliable"), "should must contain exactly the DSL's 'should' terms, in order" # => should must contain exactly the DSL's 'should' terms, in order
assert query.must_not == ("deprecated",), "must_not must contain exactly the DSL's 'must_not' terms" # => must_not must contain exactly the DSL's 'must_not' terms
print("MATCH: the parsed tree's must/should/must_not fields each hold exactly the DSL's own terms") # => shows MATCH: the parsed tree's must/should/must_not fields each hold exactly the DSL's own terms
if __name__ == "__main__": # => entry point -- runs only when this file executes directly, not on import
main() # => runs the example end to endRun: python3 query_dsl_parse.py
Output:
parsed query: must=('search', 'engine') should=('fast', 'reliable') must_not=('deprecated',)
MATCH: the parsed tree's must/should/must_not fields each hold exactly the DSL's own termsKey takeaway: Parsing a query DSL into a typed tree first is what lets Example 52 execute it generically -- the execution logic never has to re-inspect a raw, untyped dict.
Why It Matters: Parsing an untyped must/should/must_not structure into a typed tree before executing it mirrors exactly how Elasticsearch's own bool query DSL gets validated and compiled internally before it touches an index -- catching a malformed query at parse time, rather than mid-execution, is a real production robustness requirement. This example is the parsing half of the same pattern real query engines use: separate "understand what was asked" from "go compute the answer," so execution (Example 52) never re-inspects a raw, untyped payload.
Example 52: Query DSL Execute
ex-52 · exercises co-26
Executing a parsed BoolQuery tree against an inverted index intersects the must postings, ORs the should postings (if any), and subtracts must_not -- verified against an equivalent hand-written boolean merge (co-26).
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC
flowchart TD
A["BoolQuery tree"]:::blue --> B["must clauses<br/>intersect postings"]:::orange
A --> C["should clauses<br/>union postings"]:::teal
A --> D["must_not clauses<br/>subtract postings"]:::purple
B --> E["combine: AND, OR, subtract"]:::blue
C --> E
D --> E
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef purple fill:#CC78BC,stroke:#000000,color:#FFFFFF,stroke-width:2px
learning/code/ex-52-query-dsl-execute/query_dsl_execute.py
# pyright: strict
"""Example 52: Query DSL Execute (co-26)."""
from __future__ import annotations # => hygiene: postpones annotation evaluation, interpreter-version-agnostic
from dataclasses import dataclass # => from dataclasses: dataclass
@dataclass(frozen=True) # => part of this step's computation, continued from the line above
class BoolQuery: # => part of this step's computation, continued from the line above
must: tuple[str, ...] = () # => must = ()
should: tuple[str, ...] = () # => should = ()
must_not: tuple[str, ...] = () # => must not = ()
def execute_query(index: dict[str, set[int]], query: BoolQuery) -> set[int]: # => execute a parsed BoolQuery tree against a term -> doc-id-set index
"""Execute a parsed BoolQuery tree against a term -> doc-id-set index."""
all_docs: set[int] = set[int]().union(*index.values()) if index else set[int]() # => co-03: the universe, for must_not/should defaults
result: set[int] = set(all_docs) # => starts as "everything," narrowed by each clause below
for term in query.must: # => co-26: AND -- every must term narrows the result further
result &= index.get(term, set()) # => part of this step's computation, continued from the line above
if query.should: # => co-26: OR, but only if the should list is non-empty
should_hits: set[int] = set() # => should hits = set()
for term in query.should: # => iterates one item at a time
should_hits |= index.get(term, set()) # => part of this step's computation, continued from the line above
result &= should_hits # => part of this step's computation, continued from the line above
for term in query.must_not: # => co-26: AND-NOT -- removes anything matching a must_not term
result -= index.get(term, set()) # => part of this step's computation, continued from the line above
return result # => returns result
def main() -> None: # => defines main
index: dict[str, set[int]] = { # => a small inverted index, term -> doc-ids
"search": {0, 1, 2, 3}, # => entry for 'search'
"engine": {0, 1, 4}, # => entry for 'engine'
"fast": {1, 3}, # => entry for 'fast'
"deprecated": {3}, # => entry for 'deprecated'
} # => opens/closes this multi-line literal
query = BoolQuery(must=("search", "engine"), should=("fast",), must_not=("deprecated",)) # => co-26: must both terms, prefer fast, exclude deprecated
result: set[int] = execute_query(index, query) # => co-26: the tree-based execution
print(f"query: must={query.must} should={query.should} must_not={query.must_not}") # => shows query: must=
print(f"result: {sorted(result)}") # => shows result
# An equivalent HAND-WRITTEN boolean merge, computed a completely different way.
hand_must: set[int] = index["search"] & index["engine"] # => {0, 1}
hand_with_should: set[int] = hand_must & index["fast"] # => {0, 1} & {1, 3} = {1}
hand_result: set[int] = hand_with_should - index["deprecated"] # => {1} - {3} = {1}
assert result == hand_result, "execute_query's result must equal the hand-written boolean merge" # => execute_query's result must equal the hand-written boolean merge
print(f"MATCH: execute_query's result {sorted(result)} equals the hand-written merge {sorted(hand_result)}") # => shows MATCH: execute_query's result
if __name__ == "__main__": # => entry point -- runs only when this file executes directly, not on import
main() # => runs the example end to endRun: python3 query_dsl_execute.py
Output:
query: must=('search', 'engine') should=('fast',) must_not=('deprecated',)
result: [1]
MATCH: execute_query's result [1] equals the hand-written merge [1]Key takeaway: A query DSL executor is just Examples 6, 7, and 8's AND/OR/NOT operators applied in sequence -- the tree structure only decides which operator runs on which clause, and in what order.
Why It Matters: Executing a parsed boolean query tree by intersecting must, unioning should, and subtracting must_not is the actual execution model behind Elasticsearch's bool query -- there is no separate machinery beyond the AND/OR/NOT primitives from Examples 6-8, just a tree deciding which operator runs on which clause and in what order. This example is the moment the course's boolean-retrieval building blocks visibly compose into something recognizable as a real query language's execution engine, rather than three isolated operators.
Example 53: Positional Index Build
ex-53 · exercises co-28
A positional index stores every occurrence's position within a document, not just which documents contain a term -- dict[str, dict[int, list[int]]], verified against the term's actual offsets (co-28).
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73
flowchart LR
A["document text"]:::blue --> B["tokenize<br/>track position per occurrence"]:::orange
B --> C["term -> {doc_id: [positions]}"]:::teal
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
learning/code/ex-53-positional-index-build/positional_index_build.py
# pyright: strict
"""Example 53: Positional Index Build (co-28)."""
from __future__ import annotations # => hygiene: postpones annotation evaluation, interpreter-version-agnostic
def build_positional_index(docs: dict[int, list[str]]) -> dict[str, dict[int, list[int]]]: # => build term -> doc_id -> [positions] -- a richer posting than Example 21's bare tf count
"""Build term -> doc_id -> [positions] -- a richer posting than Example 21's bare tf count."""
index: dict[str, dict[int, list[int]]] = {} # => co-28: the positional index -- nested by term, then doc
for doc_id, tokens in docs.items(): # => iterates one item at a time
for position, term in enumerate(tokens): # => co-28: position is the 0-based offset WITHIN this document
index.setdefault(term, {}).setdefault(doc_id, []).append(position) # => part of this step's computation, continued from the line above
return index # => returns index
def main() -> None: # => defines main
docs: dict[int, list[str]] = { # => docs = {
0: ["the", "quick", "brown", "fox", "jumps", "over", "the", "lazy", "dog"], # => "the" appears TWICE, at 0 and 6
} # => opens/closes this multi-line literal
index: dict[str, dict[int, list[int]]] = build_positional_index(docs) # => co-28: this doc's own positional index
print(f"positions of 'the' in doc 0: {index['the'][0]}") # => shows positions of 'the' in doc 0
print(f"positions of 'fox' in doc 0: {index['fox'][0]}") # => shows positions of 'fox' in doc 0
hand_positions_the: list[int] = [i for i, t in enumerate(docs[0]) if t == "the"] # => an independent recount of "the"'s offsets
hand_positions_fox: list[int] = [i for i, t in enumerate(docs[0]) if t == "fox"] # => an independent recount of "fox"'s offset
assert index["the"][0] == hand_positions_the, "'the' positions must match a raw offset recount" # => 'the' positions must match a raw offset recount
assert index["fox"][0] == hand_positions_fox, "'fox' positions must match a raw offset recount" # => 'fox' positions must match a raw offset recount
print(f"MATCH: every term's stored positions match a raw recount of its offsets in the source document") # => shows MATCH: every term's stored positions match a raw recount of its offsets in the source document
if __name__ == "__main__": # => entry point -- runs only when this file executes directly, not on import
main() # => runs the example end to endRun: python3 positional_index_build.py
Output:
positions of 'the' in doc 0: [0, 6]
positions of 'fox' in doc 0: [3]
MATCH: every term's stored positions match a raw recount of its offsets in the source documentKey takeaway: Positions are the extra information tf/df alone cannot express -- and they are exactly what Examples 54 and 55 need to answer phrase and proximity queries.
Why It Matters: A positional index is what every production search engine actually stores on disk -- Lucene's postings format includes per-occurrence position data by default -- because tf and df alone cannot answer "do these words appear next to each other," which phrase and proximity queries (Examples 54, 55) require. Storing positions is a real storage-cost trade-off engines accept in exchange for enabling an entire category of query users expect to work correctly. This example is the extra dimension of information every phrase-search feature depends on.
Example 54: Phrase Query
ex-54 · exercises co-28
A phrase query for "quick brown" matches only documents where the two words appear at adjacent positions -- checked by verifying position(brown) == position(quick) + 1 (co-28).
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Gray #808080
flowchart LR
A["positions of 'quick'"]:::blue --> C{"position(brown) ==<br/>position(quick) + 1?"}:::orange
B["positions of 'brown'"]:::blue --> C
C -->|"yes"| D["phrase match"]:::teal
C -->|"no"| E["not a phrase match"]:::gray
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef gray fill:#808080,stroke:#000000,color:#FFFFFF,stroke-width:2px
learning/code/ex-54-phrase-query/phrase_query.py
# pyright: strict
"""Example 54: Phrase Query (co-28)."""
from __future__ import annotations # => hygiene: postpones annotation evaluation, interpreter-version-agnostic
def build_positional_index(docs: dict[int, list[str]]) -> dict[str, dict[int, list[int]]]: # => defines build positional index
index: dict[str, dict[int, list[int]]] = {} # => starts empty, populated by the loop below
for doc_id, tokens in docs.items(): # => iterates one item at a time
for position, term in enumerate(tokens): # => iterates one item at a time
index.setdefault(term, {}).setdefault(doc_id, []).append(position) # => part of this step's computation, continued from the line above
return index # => returns index
def phrase_query(index: dict[str, dict[int, list[int]]], term1: str, term2: str) -> set[int]: # => return doc-ids where term2 occurs at EXACTLY one position after term1 -- an adjacent phrase
"""Return doc-ids where term2 occurs at EXACTLY one position after term1 -- an adjacent phrase."""
hits: set[int] = set() # => co-28: documents where the exact phrase "term1 term2" appears
docs_with_both: set[int] = set(index.get(term1, {})) & set(index.get(term2, {})) # => co-04: must contain BOTH terms first
for doc_id in docs_with_both: # => iterates one item at a time
positions1: set[int] = set(index[term1][doc_id]) # => every position term1 occurs at, in this doc
positions2: set[int] = set(index[term2][doc_id]) # => every position term2 occurs at, in this doc
if any((p + 1) in positions2 for p in positions1): # => co-28: ADJACENT -- term2 right after term1
hits.add(doc_id) # => part of this step's computation, continued from the line above
return hits # => returns hits
def main() -> None: # => defines main
docs: dict[int, list[str]] = { # => docs = {
0: ["the", "quick", "brown", "fox"], # => "quick brown" ARE adjacent -- a phrase match
1: ["the", "brown", "quick", "fox"], # => both words present, but NOT adjacent (reversed) -- no match
2: ["a", "very", "quick", "and", "very", "brown", "animal"], # => both present, far apart -- no match
} # => opens/closes this multi-line literal
index: dict[str, dict[int, list[int]]] = build_positional_index(docs) # => co-28: this corpus's own positional index
hits: set[int] = phrase_query(index, "quick", "brown") # => co-28: the phrase query "quick brown"
print(f"phrase 'quick brown' matches docs: {sorted(hits)}") # => shows phrase 'quick brown' matches docs
assert hits == {0}, "only doc 0 has 'quick' immediately followed by 'brown'" # => only doc 0 has 'quick' immediately followed by 'brown'
print(f"MATCH: exactly doc 0 matches the phrase -- docs 1 and 2 contain both words but not adjacently") # => shows MATCH: exactly doc 0 matches the phrase -- docs 1 and 2 contain both words but not adjacently
if __name__ == "__main__": # => entry point -- runs only when this file executes directly, not on import
main() # => runs the example end to endRun: python3 phrase_query.py
Output:
phrase 'quick brown' matches docs: [0]
MATCH: exactly doc 0 matches the phrase -- docs 1 and 2 contain both words but not adjacentlyKey takeaway: A boolean AND finds documents containing both words anywhere; a phrase query is strictly stronger -- it additionally demands the exact adjacency positions encode.
Why It Matters: Phrase queries, the quoted "exact phrase" search box behavior, are strictly stronger than a boolean AND of the same words, and getting that distinction right is a real production correctness requirement -- Lucene's PhraseQuery and Elasticsearch's match_phrase both check adjacency, not mere co-occurrence, using exactly the position data built in Example 53. A search engine that silently treated a phrase query as an AND query would return false-positive matches users would immediately flag as wrong. This example is the adjacency check that makes quoted search behave as expected.
Example 55: Proximity Query
ex-55 · exercises co-28
A proximity query relaxes phrase matching to "within N positions of each other" rather than exactly adjacent -- verified by confirming a document with the terms N+1 apart is correctly excluded (co-28).
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Gray #808080
flowchart LR
A["distance between positions"]:::blue --> B{"distance <= max_distance?"}:::orange
B -->|"yes"| C["within N -- match"]:::teal
B -->|"no"| D["beyond N -- excluded"]:::gray
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef gray fill:#808080,stroke:#000000,color:#FFFFFF,stroke-width:2px
learning/code/ex-55-proximity-query/proximity_query.py
# pyright: strict
"""Example 55: Proximity Query (co-28)."""
from __future__ import annotations # => hygiene: postpones annotation evaluation, interpreter-version-agnostic
def build_positional_index(docs: dict[int, list[str]]) -> dict[str, dict[int, list[int]]]: # => defines build positional index
index: dict[str, dict[int, list[int]]] = {} # => starts empty, populated by the loop below
for doc_id, tokens in docs.items(): # => iterates one item at a time
for position, term in enumerate(tokens): # => iterates one item at a time
index.setdefault(term, {}).setdefault(doc_id, []).append(position) # => part of this step's computation, continued from the line above
return index # => returns index
def proximity_query(index: dict[str, dict[int, list[int]]], term1: str, term2: str, max_distance: int) -> set[int]: # => return doc-ids where term1 and term2 occur within max_distance positions of each other
"""Return doc-ids where term1 and term2 occur within max_distance positions of each other."""
hits: set[int] = set() # => co-28: documents where the two terms are CLOSE, not necessarily adjacent
docs_with_both: set[int] = set(index.get(term1, {})) & set(index.get(term2, {})) # => docs with both = set(index.get(term1, {})) & set(index.get(term2...
for doc_id in docs_with_both: # => iterates one item at a time
positions1: list[int] = index[term1][doc_id] # => positions1 = index[term1][doc_id]
positions2: list[int] = index[term2][doc_id] # => positions2 = index[term2][doc_id]
if any(abs(p1 - p2) <= max_distance for p1 in positions1 for p2 in positions2): # => co-28: WITHIN N, either direction
hits.add(doc_id) # => part of this step's computation, continued from the line above
return hits # => returns hits
def main() -> None: # => defines main
docs: dict[int, list[str]] = { # => docs = {
0: ["search", "a", "b", "engine"], # => "search" at 0, "engine" at 3 -- distance 3
1: ["search", "a", "b", "c", "d", "engine"], # => "search" at 0, "engine" at 5 -- distance 5
} # => opens/closes this multi-line literal
index: dict[str, dict[int, list[int]]] = build_positional_index(docs) # => co-28: this corpus's own positional index
max_distance: int = 3 # => N=3: terms within 3 positions of each other count
hits: set[int] = proximity_query(index, "search", "engine", max_distance) # => co-28: the proximity query
print(f"proximity('search', 'engine', N={max_distance}) matches: {sorted(hits)}") # => shows proximity('search', 'engine', N=
assert 0 in hits, "doc 0 has 'search' and 'engine' exactly 3 apart -- within N=3, must match" # => doc 0 has 'search' and 'engine' exactly 3 apart -- within N=3, must match
assert 1 not in hits, "doc 1 has 'search' and 'engine' 5 apart -- N+2 beyond N=3, must be excluded" # => doc 1 has 'search' and 'engine' 5 apart -- N+2 beyond N=3, must be excluded
print(f"MATCH: doc 0 (distance 3, within N={max_distance}) matches; doc 1 (distance 5, beyond N) is excluded") # => shows MATCH: doc 0 (distance 3, within N=
if __name__ == "__main__": # => entry point -- runs only when this file executes directly, not on import
main() # => runs the example end to endRun: python3 proximity_query.py
Output:
proximity('search', 'engine', N=3) matches: [0]
MATCH: doc 0 (distance 3, within N=3) matches; doc 1 (distance 5, beyond N) is excludedKey takeaway: Proximity queries generalize phrase queries with one parameter -- max_distance=1 in the right direction recovers exact phrase matching, everything larger loosens it.
Why It Matters: Proximity search, Elasticsearch's match_phrase with a slop parameter, generalizes exact phrase matching with a single distance parameter, letting a query for "quick brown" also match documents where an extra word slipped between the two terms -- a real usability improvement over rigid exact-phrase matching. Setting the max distance to exactly recover phrase matching, as this example verifies, shows proximity queries are a strict generalization, not a different feature. This is the mechanism behind every search engine's slop-tolerant matching option.
Example 56: Segment Merge Model
ex-56 · exercises co-27
Two immutable segments -- each built once, over disjoint document ranges -- merge into one combined index that answers a query identically to a from-scratch single-segment build over all documents, modeling Lucene's own segment architecture (co-27).
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73
flowchart LR
A["Segment 1<br/>docs 0-2, immutable"]:::blue --> C["merge"]:::orange
B["Segment 2<br/>docs 3-5, immutable"]:::blue --> C
C --> D["merged index<br/>docs 0-5"]:::teal
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
learning/code/ex-56-segment-merge-model/segment_merge_model.py
# pyright: strict
"""Example 56: Segment Merge Model (co-27)."""
from __future__ import annotations # => hygiene: postpones annotation evaluation, interpreter-version-agnostic
def build_index(docs: dict[int, list[str]]) -> dict[str, set[int]]: # => build a term -> doc-id-set index over the given documents -- one immutable segment
"""Build a term -> doc-id-set index over the given documents -- one immutable segment."""
index: dict[str, set[int]] = {} # => starts empty, populated by the loop below
for doc_id, tokens in docs.items(): # => iterates one item at a time
for term in tokens: # => iterates one item at a time
index.setdefault(term, set()).add(doc_id) # => part of this step's computation, continued from the line above
return index # => returns index
def merge_segments(segment_a: dict[str, set[int]], segment_b: dict[str, set[int]]) -> dict[str, set[int]]: # => merge two immutable per-segment indexes into one combined index -- co-27's own segment model
"""Merge two immutable per-segment indexes into one combined index -- co-27's own segment model."""
merged: dict[str, set[int]] = {term: set(doc_ids) for term, doc_ids in segment_a.items()} # => copies segment A's postings first
for term, doc_ids in segment_b.items(): # => co-27: folds segment B's postings in, union-ing where terms overlap
merged.setdefault(term, set()).update(doc_ids) # => part of this step's computation, continued from the line above
return merged # => returns merged
def main() -> None: # => defines main
docs_segment1: dict[int, list[str]] = {0: ["search", "engine"], 1: ["search", "index"], 2: ["fast", "ranking"]} # => an EARLIER write batch
docs_segment2: dict[int, list[str]] = {3: ["search", "results"], 4: ["engine", "speed"], 5: ["index", "structure"]} # => a LATER write batch
segment1: dict[str, set[int]] = build_index(docs_segment1) # => co-27: the first immutable segment, docs 0-2
segment2: dict[str, set[int]] = build_index(docs_segment2) # => co-27: the second immutable segment, docs 3-5
merged: dict[str, set[int]] = merge_segments(segment1, segment2) # => co-27: the two segments, merged into one
print(f"segment 1 ('search'): {sorted(segment1.get('search', set()))}") # => shows segment 1 ('search')
print(f"segment 2 ('search'): {sorted(segment2.get('search', set()))}") # => shows segment 2 ('search')
print(f"merged ('search'): {sorted(merged.get('search', set()))}") # => shows merged ('search')
all_docs: dict[int, list[str]] = {**docs_segment1, **docs_segment2} # => the SAME documents, built as ONE segment from scratch
single_segment: dict[str, set[int]] = build_index(all_docs) # => the reference: a from-scratch, single-segment build
for term in single_segment: # => checks EVERY term, not just "search"
assert merged.get(term, set()) == single_segment[term], f"{term!r}: merged postings must match the single-segment build" # => {term!r}: merged postings must match the single-segment build
print(f"MATCH: the merged 2-segment index answers every term identically to a from-scratch single-segment build") # => shows MATCH: the merged 2-segment index answers every term identically to a from-scratch single-segment build
if __name__ == "__main__": # => entry point -- runs only when this file executes directly, not on import
main() # => runs the example end to endRun: python3 segment_merge_model.py
Output:
segment 1 ('search'): [0, 1]
segment 2 ('search'): [3]
merged ('search'): [0, 1, 3]
MATCH: the merged 2-segment index answers every term identically to a from-scratch single-segment buildKey takeaway: Merging is what keeps a segmented index from fragmenting into more and more files over time -- Lucene runs exactly this union operation, at a larger scale, as a background merge policy.
Why It Matters: Segment merging is the background process Lucene runs continuously in every production index -- new documents land in fresh, small, immutable segments, and a merge policy periodically combines them into fewer, larger ones to keep query-time overhead from growing without bound. Verifying the merged result matches a from-scratch single-segment build is the same correctness guarantee a production merge policy must uphold: merging is an internal optimization that must never change what a query returns. This example is the model behind every Lucene-based engine's merge-policy tuning knobs.
Last updated July 26, 2026