Skip to main content

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}