zeppelin_embed/fts/bm25.rs
1//! The BM25 scorer.
2//!
3//! # The formula never mattered; the pipeline did
4//!
5//! All eight BM25 variants are statistically indistinguishable (Kamphuis
6//! ECIR'20, `research/02a:40`), so this module is deliberately boring and
7//! the effort goes where the measured differences live: analysis
8//! (`super::tokenizer`) and correct corpus-wide statistics. The prior
9//! engine's ~30% nDCG@10 gap was a pipeline defect, not a formula choice
10//! (`research/02a:256`, `:269`).
11//!
12//! # Global statistics, never segment-local
13//!
14//! [`CorpusStats`](crate::fts::bm25::CorpusStats) carries `doc_count` and
15//! `total_tokens` for the WHOLE
16//! store, across every segment. Segment-local IDF is a documented
17//! real-world failure (Milvus Lite) and the prime suspect for that 30% gap.
18//! `prop_engine_bm25_equals_model` is what makes it unconstructible here:
19//! it randomizes seal boundaries, so any statistic computed per segment
20//! diverges from the brute-force model immediately.
21//!
22//! # Sign convention
23//!
24//! **Higher is better.** SQLite FTS5 returns a NEGATIVE bm25 so that
25//! `ORDER BY rank ASC` puts the best row first; porting a query from FTS5
26//! and keeping its sort direction silently inverts relevance. This engine
27//! never negates. Every score here is non-negative.
28//!
29//! # Argument order is pinned by types
30//!
31//! `bm25_term_score(tf, df, len)` invites a silent transposition — three
32//! integers, any order compiles. [`Tf`](crate::fts::bm25::Tf),
33//! [`Df`](crate::fts::bm25::Df), and [`DocLen`](crate::fts::bm25::DocLen) are
34//! separate newtypes so a swap is a compile error. This trap is recorded in
35//! the prior engine's own notes; it is not hypothetical.
36
37/// Term frequency: occurrences of one term within one document.
38#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
39pub struct Tf(pub u32);
40
41/// Document frequency: documents containing one term, across the store.
42#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
43pub struct Df(pub u32);
44
45/// Document length in tokens after analysis.
46#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
47pub struct DocLen(pub u32);
48
49/// A rejected scorer configuration.
50#[derive(Clone, Copy, Debug, Eq, PartialEq)]
51pub enum Bm25Error {
52 /// `k1` was negative or not a number.
53 InvalidK1,
54 /// `b` was outside `0.0..=1.0` or not a number.
55 InvalidB,
56 /// The corpus had no documents, so `avgdl` is undefined.
57 EmptyCorpus,
58 /// The corpus had no tokens, so `avgdl` is undefined.
59 NoTokens,
60}
61
62impl std::fmt::Display for Bm25Error {
63 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64 let message = match self {
65 Self::InvalidK1 => "bm25 k1 must be a finite value at or above zero",
66 Self::InvalidB => "bm25 b must be a finite value in 0.0..=1.0",
67 Self::EmptyCorpus => "corpus statistics require at least one document",
68 Self::NoTokens => "corpus statistics require at least one analyzed token",
69 };
70 formatter.write_str(message)
71 }
72}
73
74impl std::error::Error for Bm25Error {}
75
76/// The two BM25 free parameters.
77#[derive(Clone, Copy, Debug, PartialEq)]
78pub struct Bm25Params {
79 /// Term-frequency saturation. Higher means tf keeps mattering longer.
80 pub k1: f64,
81 /// Length-normalization strength, `0.0..=1.0`.
82 pub b: f64,
83}
84
85impl Bm25Params {
86 /// The BEIR-paper defaults, `k1 = 1.2`, `b = 0.75`.
87 ///
88 /// These are what the published BEIR numbers task 13 gates against were
89 /// produced with, so they are the default here.
90 #[must_use]
91 pub const fn beir() -> Self {
92 Self { k1: 1.2, b: 0.75 }
93 }
94
95 /// Anserini's and Pyserini's defaults, `k1 = 0.9`, `b = 0.4`.
96 ///
97 /// Documented as the alternative. Domain tuning is claimed to be worth
98 /// 5-15%, but no systematic winner exists across corpora, so this is
99 /// exposed rather than chased.
100 #[must_use]
101 pub const fn anserini() -> Self {
102 Self { k1: 0.9, b: 0.4 }
103 }
104
105 /// Builds validated parameters.
106 ///
107 /// # Errors
108 ///
109 /// Returns [`Bm25Error::InvalidK1`] or [`Bm25Error::InvalidB`] when a
110 /// value is outside its domain or is not a number.
111 pub fn new(k1: f64, b: f64) -> Result<Self, Bm25Error> {
112 if !k1.is_finite() || k1 < 0.0 {
113 return Err(Bm25Error::InvalidK1);
114 }
115 if !b.is_finite() || !(0.0..=1.0).contains(&b) {
116 return Err(Bm25Error::InvalidB);
117 }
118 Ok(Self { k1, b })
119 }
120}
121
122impl Default for Bm25Params {
123 fn default() -> Self {
124 Self::beir()
125 }
126}
127
128/// Store-wide corpus statistics.
129///
130/// [`crate::fts::index::LexicalIndex`] derives these store-wide values from
131/// all of its sealed segments. Scoring never substitutes per-segment corpus
132/// statistics.
133#[derive(Clone, Copy, Debug, Eq, PartialEq)]
134pub struct CorpusStats {
135 doc_count: u64,
136 total_tokens: u64,
137}
138
139impl CorpusStats {
140 /// Builds validated statistics.
141 ///
142 /// # Errors
143 ///
144 /// Returns [`Bm25Error::EmptyCorpus`] or [`Bm25Error::NoTokens`] when
145 /// `avgdl` would be undefined. An empty corpus is not scored; it is
146 /// refused, because a silent `avgdl` of zero produces infinities.
147 pub const fn new(doc_count: u64, total_tokens: u64) -> Result<Self, Bm25Error> {
148 if doc_count == 0 {
149 return Err(Bm25Error::EmptyCorpus);
150 }
151 if total_tokens == 0 {
152 return Err(Bm25Error::NoTokens);
153 }
154 Ok(Self {
155 doc_count,
156 total_tokens,
157 })
158 }
159
160 /// Returns the store-wide document count, `N`.
161 #[must_use]
162 pub const fn document_count(&self) -> u64 {
163 self.doc_count
164 }
165
166 /// Returns the store-wide analyzed token count.
167 #[must_use]
168 pub const fn total_tokens(&self) -> u64 {
169 self.total_tokens
170 }
171
172 /// Returns `avgdl`, counted in tokens after analysis.
173 ///
174 /// The unit matters: `avgdl` must be counted over the same unit being
175 /// scored, in analyzed tokens, never in raw bytes or pre-analysis words.
176 #[must_use]
177 pub fn average_document_length(&self) -> f64 {
178 // Both counts are validated non-zero, so this cannot divide by zero.
179 self.total_tokens as f64 / self.doc_count as f64
180 }
181}
182
183/// The Lucene-variant inverse document frequency.
184///
185/// `ln(1 + (N - df + 0.5) / (df + 0.5))`.
186///
187/// The classic Robertson form omits the `1 +` and goes negative once a term
188/// appears in more than half the corpus, which lets a common term subtract
189/// from a document's score. The Lucene variant is non-negative everywhere,
190/// which is what makes the block-max upper bounds in task 14 sound: a bound
191/// built from per-term maxima is only an upper bound if no term can
192/// contribute a negative amount.
193#[must_use]
194pub fn idf(df: Df, doc_count: u64) -> f64 {
195 let n = doc_count as f64;
196 let df = f64::from(df.0).min(n);
197 (1.0 + (n - df + 0.5) / (df + 0.5)).ln()
198}
199
200/// One term's scoring constants, hoisted out of the per-posting loop.
201///
202/// `idf` costs a `ln()` and `avgdl` costs a division, and neither depends on
203/// the document being scored. Computing them once per (term, query) instead
204/// of once per posting is the whole purpose of this type.
205///
206/// # Bit-exactness is by construction
207///
208/// [`term_score`] is *implemented as* `TermScorer::new(..).score(..)`, so
209/// there is exactly one expression tree and one rounding order in the
210/// engine. Pre-multiplying `idf * (k1 + 1)` would be the obvious next
211/// saving and is deliberately refused: floating-point multiplication is not
212/// associative, so it moves scores by an ULP, and an ULP is a changed
213/// ranking and a changed published nDCG record.
214#[derive(Clone, Copy, Debug, PartialEq)]
215pub struct TermScorer {
216 idf: f64,
217 average_document_length: f64,
218 params: Bm25Params,
219}
220
221impl TermScorer {
222 /// Builds the constants for one term against one corpus.
223 #[must_use]
224 pub fn new(df: Df, stats: &CorpusStats, params: Bm25Params) -> Self {
225 #[cfg(any(test, feature = "test-seams"))]
226 super::preparation_observer::scorer();
227 Self {
228 idf: idf(df, stats.doc_count),
229 average_document_length: stats.average_document_length(),
230 params,
231 }
232 }
233
234 /// Returns the term's inverse document frequency.
235 #[must_use]
236 pub const fn idf(&self) -> f64 {
237 self.idf
238 }
239
240 /// Scores this term against one document.
241 ///
242 /// `idf * (tf * (k1 + 1)) / (tf + k1 * (1 - b + b * len / avgdl))`.
243 ///
244 /// Returns zero when `tf` is zero, so a term absent from a document
245 /// contributes nothing rather than a length-only artefact.
246 #[must_use]
247 pub fn score(&self, tf: Tf, length: DocLen) -> f64 {
248 if tf.0 == 0 {
249 return 0.0;
250 }
251 let frequency = f64::from(tf.0);
252 let normalization = 1.0 - self.params.b
253 + self.params.b * f64::from(length.0) / self.average_document_length;
254 let denominator = frequency + self.params.k1 * normalization;
255 if denominator <= 0.0 {
256 // Only reachable with k1 = 0 and tf = 0, which returned above.
257 return 0.0;
258 }
259 self.idf * (frequency * (self.params.k1 + 1.0)) / denominator
260 }
261
262 /// The largest score this term can contribute to any document.
263 ///
264 /// The term-frequency factor saturates at `k1 + 1` as `tf` grows without
265 /// bound, and the length normalization is smallest for the shortest
266 /// document, so the ceiling is `idf * (k1 + 1)`. Any real score is
267 /// strictly below it.
268 #[must_use]
269 pub fn ceiling(&self) -> f64 {
270 self.idf * (self.params.k1 + 1.0)
271 }
272}
273
274/// Scores one term against one document.
275///
276/// `idf * (tf * (k1 + 1)) / (tf + k1 * (1 - b + b * len / avgdl))`.
277///
278/// Returns zero when `tf` is zero, so a term absent from a document
279/// contributes nothing rather than a length-only artefact.
280///
281/// Hot loops should build a [`TermScorer`] once per term instead; this
282/// convenience form recomputes `idf` and `avgdl` on every call.
283#[must_use]
284pub fn term_score(tf: Tf, df: Df, length: DocLen, stats: &CorpusStats, params: Bm25Params) -> f64 {
285 TermScorer::new(df, stats, params).score(tf, length)
286}
287
288/// The largest score one term can contribute to any document.
289///
290/// Used by task 14 to build block-max upper bounds.
291#[must_use]
292pub fn term_score_ceiling(df: Df, stats: &CorpusStats, params: Bm25Params) -> f64 {
293 TermScorer::new(df, stats, params).ceiling()
294}
295
296#[cfg(test)]
297#[allow(
298 clippy::expect_used,
299 clippy::indexing_slicing,
300 clippy::panic,
301 clippy::unwrap_used
302)]
303mod tests {
304 use super::*;
305
306 #[test]
307 fn newtypes_prevent_argument_transposition() {
308 // This test exists to document the trap rather than to exercise
309 // behaviour: swapping the first two arguments below is a type error,
310 // which is the entire point of Tf/Df/DocLen being distinct.
311 let stats = CorpusStats::new(4, 20).expect("valid stats");
312 let score = term_score(Tf(3), Df(2), DocLen(5), &stats, Bm25Params::default());
313 assert!(score > 0.0);
314 }
315
316 #[test]
317 fn the_ceiling_bounds_every_reachable_score() {
318 let stats = CorpusStats::new(1_000, 50_000).expect("valid stats");
319 let params = Bm25Params::default();
320 for df in [1_u32, 2, 17, 500, 1_000] {
321 let ceiling = term_score_ceiling(Df(df), &stats, params);
322 for tf in [1_u32, 3, 40, 10_000, u32::MAX] {
323 for length in [1_u32, 5, 50, 5_000, u32::MAX] {
324 let score = term_score(Tf(tf), Df(df), DocLen(length), &stats, params);
325 assert!(
326 score <= ceiling,
327 "score {score} exceeded ceiling {ceiling} at df={df} tf={tf} len={length}"
328 );
329 }
330 }
331 }
332 }
333
334 #[test]
335 fn scores_are_never_negative() {
336 let stats = CorpusStats::new(10, 100).expect("valid stats");
337 for params in [Bm25Params::beir(), Bm25Params::anserini()] {
338 for df in 1..=10_u32 {
339 for tf in [0_u32, 1, 9] {
340 for length in [1_u32, 10, 1_000] {
341 let score = term_score(Tf(tf), Df(df), DocLen(length), &stats, params);
342 assert!(score >= 0.0, "negative score {score}");
343 assert!(score.is_finite(), "non-finite score {score}");
344 }
345 }
346 }
347 }
348 }
349
350 #[test]
351 fn df_above_the_document_count_is_clamped_rather_than_producing_a_negative_idf() {
352 // A caller that sums df across segments while a delete is in flight
353 // can momentarily exceed N. Clamping keeps idf non-negative; without
354 // it the log argument could fall below one.
355 let value = idf(Df(20), 10);
356 assert!(value >= 0.0, "clamping failed: {value}");
357 assert!((value - idf(Df(10), 10)).abs() < 1e-12);
358 }
359
360 #[test]
361 fn average_document_length_uses_analyzed_tokens() {
362 let stats = CorpusStats::new(3, 30).expect("valid stats");
363 assert!((stats.average_document_length() - 10.0).abs() < 1e-12);
364 assert_eq!(stats.document_count(), 3);
365 assert_eq!(stats.total_tokens(), 30);
366 }
367
368 #[test]
369 fn errors_render_useful_text() {
370 assert!(Bm25Error::InvalidK1.to_string().contains("k1"));
371 assert!(Bm25Error::InvalidB.to_string().contains("b"));
372 assert!(Bm25Error::EmptyCorpus.to_string().contains("document"));
373 assert!(Bm25Error::NoTokens.to_string().contains("token"));
374 }
375
376 /// The grid every hoisting test sweeps: statistics, parameters, and the
377 /// three scoring inputs, chosen to span short and long documents and
378 /// rare and common terms.
379 fn hoisting_grid() -> Vec<(CorpusStats, Bm25Params, Tf, Df, DocLen)> {
380 let mut grid = Vec::new();
381 for (documents, tokens) in [(1_000_u64, 40_000_u64), (7, 13), (171_332, 9_876_543)] {
382 let stats = CorpusStats::new(documents, tokens).expect("valid stats");
383 for params in [
384 Bm25Params::default(),
385 Bm25Params::anserini(),
386 Bm25Params::new(0.0, 0.0).expect("valid parameters"),
387 Bm25Params::new(3.0, 1.0).expect("valid parameters"),
388 ] {
389 for tf in [0_u32, 1, 3, 97, 65_535] {
390 for df in [1_u32, 2, 499, 170_000] {
391 for length in [1_u32, 4, 250, 100_000] {
392 grid.push((stats, params, Tf(tf), Df(df), DocLen(length)));
393 }
394 }
395 }
396 }
397 }
398 grid
399 }
400
401 #[test]
402 fn the_hoisted_scorer_reproduces_the_documented_expression_bit_for_bit() {
403 // The reference below is a literal transcription of the formula in
404 // the module documentation, written out independently of the
405 // implementation so a reassociation inside `TermScorer` shows up as
406 // a differing bit pattern rather than as a passing tautology.
407 for (stats, params, tf, df, length) in hoisting_grid() {
408 let scorer = TermScorer::new(df, &stats, params);
409 let reference = if tf.0 == 0 {
410 0.0_f64
411 } else {
412 let frequency = f64::from(tf.0);
413 let normalization = 1.0 - params.b
414 + params.b * f64::from(length.0) / stats.average_document_length();
415 let denominator = frequency + params.k1 * normalization;
416 if denominator <= 0.0 {
417 0.0
418 } else {
419 idf(df, stats.document_count()) * (frequency * (params.k1 + 1.0)) / denominator
420 }
421 };
422 assert_eq!(
423 scorer.score(tf, length).to_bits(),
424 reference.to_bits(),
425 "hoisted score diverged at tf={} df={} len={}",
426 tf.0,
427 df.0,
428 length.0
429 );
430 assert_eq!(
431 term_score(tf, df, length, &stats, params).to_bits(),
432 reference.to_bits(),
433 "the convenience form diverged from the hoisted one"
434 );
435 assert_eq!(
436 term_score_ceiling(df, &stats, params).to_bits(),
437 scorer.ceiling().to_bits()
438 );
439 }
440 }
441
442 #[test]
443 fn pre_multiplying_the_idf_factor_would_shift_bits() {
444 // This test exists to give the one above teeth. The tempting next
445 // saving — folding `idf * (k1 + 1)` into a single constant — is
446 // refused because floating-point multiplication is not associative.
447 // If this ever stops finding a divergence, the bit-parity test above
448 // has stopped being able to detect a reassociation.
449 let mut divergences = 0_usize;
450 for (stats, params, tf, df, length) in hoisting_grid() {
451 if tf.0 == 0 {
452 continue;
453 }
454 let scorer = TermScorer::new(df, &stats, params);
455 let frequency = f64::from(tf.0);
456 let normalization =
457 1.0 - params.b + params.b * f64::from(length.0) / stats.average_document_length();
458 let denominator = frequency + params.k1 * normalization;
459 if denominator <= 0.0 {
460 continue;
461 }
462 let premultiplied = (scorer.idf() * (params.k1 + 1.0)) * frequency / denominator;
463 if premultiplied.to_bits() != scorer.score(tf, length).to_bits() {
464 divergences += 1;
465 }
466 }
467 assert!(
468 divergences > 0,
469 "pre-multiplication was expected to shift at least one score"
470 );
471 }
472}