1pub const BM25_K1: f32 = 1.2;
9
10pub const BM25_B: f32 = 0.75;
13
14pub(super) struct NormTable([f32; 256]);
17
18impl NormTable {
19 pub(super) fn new(params: Bm25Params, average: f32) -> Self {
20 crate::observe::search_work!(norm_tables += 1);
21 Self(std::array::from_fn(|code| {
22 let length = crate::segment::norms::decode(code as u8) as f32;
23 params.k1 * (1.0 - params.b + params.b * (length / average.max(1.0)))
24 }))
25 }
26
27 #[allow(clippy::too_many_arguments)]
30 pub(super) fn score_batch(
31 &self,
32 params: Bm25Params,
33 idf: f32,
34 average: f32,
35 boost: f32,
36 codes: impl ExactSizeIterator<Item = u8>,
37 tfs: &[u32],
38 scores: &mut [f32],
39 ) {
40 debug_assert_eq!(tfs.len(), scores.len());
41 debug_assert_eq!(tfs.len(), codes.len());
42 if boost == 0.0 {
43 scores.fill(0.0);
44 return;
45 }
46 let mut norms = [0.0; crate::structures::postings::POSTING_BLOCK_SIZE];
47 for ((norm, &tf), code) in norms[..tfs.len()].iter_mut().zip(tfs).zip(codes) {
48 *norm = if code == 0 {
49 params.k1 * (1.0 - params.b + params.b * (tf as f32 / average.max(1.0)))
50 } else {
51 self.0[usize::from(code)]
52 };
53 }
54 for ((score, &tf), &norm) in scores.iter_mut().zip(tfs).zip(&norms) {
55 *score = if tf == 0 {
56 0.0
57 } else {
58 let boosted = tf as f32 * boost;
59 idf * ((boosted * (params.k1 + 1.0)) / (boosted + norm))
60 };
61 }
62 }
63
64 #[cfg(test)]
65 pub(super) fn score(
66 &self,
67 params: Bm25Params,
68 tf: f32,
69 idf: f32,
70 code: u8,
71 average: f32,
72 ) -> f32 {
73 if code == 0 {
74 return params.score(tf, idf, tf, average);
75 }
76 if tf == 0.0 {
77 return 0.0;
78 }
79 idf * ((tf * (params.k1 + 1.0)) / (tf + self.0[usize::from(code)]))
80 }
81
82 pub(super) fn score_boosted(
83 &self,
84 params: Bm25Params,
85 tf: f32,
86 idf: f32,
87 code: u8,
88 average: f32,
89 boost: f32,
90 ) -> f32 {
91 if code == 0 {
92 return params.score_boosted(tf, idf, tf, average, boost);
93 }
94 if tf == 0.0 || boost == 0.0 {
95 return 0.0;
96 }
97 idf * ((tf * boost * (params.k1 + 1.0)) / (tf * boost + self.0[usize::from(code)]))
98 }
99}
100
101#[derive(Debug, Clone, Copy, PartialEq)]
103pub struct Bm25Params {
104 pub k1: f32,
105 pub b: f32,
106}
107
108impl Default for Bm25Params {
109 fn default() -> Self {
110 Self {
111 k1: BM25_K1,
112 b: BM25_B,
113 }
114 }
115}
116
117impl Bm25Params {
118 pub fn for_field(schema: &crate::dsl::Schema, field: crate::dsl::Field) -> Self {
120 let entry = schema.get_field_entry(field);
121 Self {
122 k1: entry.and_then(|e| e.bm25_k1).unwrap_or(BM25_K1),
123 b: entry.and_then(|e| e.bm25_b).unwrap_or(BM25_B),
124 }
125 }
126
127 #[inline]
129 pub fn score(self, tf: f32, idf: f32, doc_len: f32, avg_doc_len: f32) -> f32 {
130 if tf == 0.0 {
133 return 0.0;
134 }
135 let length_norm = 1.0 - self.b + self.b * (doc_len / avg_doc_len.max(1.0));
136 let tf_norm = (tf * (self.k1 + 1.0)) / (tf + self.k1 * length_norm);
137 idf * tf_norm
138 }
139
140 #[inline]
142 pub fn score_boosted(
143 self,
144 tf: f32,
145 idf: f32,
146 doc_len: f32,
147 avg_doc_len: f32,
148 field_boost: f32,
149 ) -> f32 {
150 if tf == 0.0 || field_boost == 0.0 {
151 return 0.0;
152 }
153 let length_norm = 1.0 - self.b + self.b * (doc_len / avg_doc_len.max(1.0));
154 let tf_norm =
155 (tf * field_boost * (self.k1 + 1.0)) / (tf * field_boost + self.k1 * length_norm);
156 idf * tf_norm
157 }
158
159 #[cfg(test)]
164 pub(crate) fn upper_bound_with_ratio(
165 self,
166 max_tf: u32,
167 idf: f32,
168 ratio: f32,
169 avg_len: f32,
170 ) -> f32 {
171 PreparedBounds::new(self, max_tf, idf, avg_len)
172 .map_or(f32::INFINITY, |bounds| bounds.ratio(max_tf, ratio))
173 }
174
175 #[cfg(test)]
177 pub(crate) fn upper_bound_with_impacts(
178 self,
179 max_tf: u32,
180 idf: f32,
181 avg_len: f32,
182 minimum: impl FnOnce(f64, f64) -> Option<f64>,
183 ) -> f32 {
184 PreparedBounds::new(self, max_tf, idf, avg_len)
185 .map_or(f32::INFINITY, |bounds| bounds.impacts(minimum))
186 }
187
188 #[inline]
190 pub fn upper_bound(self, max_tf: f32, idf: f32) -> f32 {
191 if max_tf == 0.0 {
192 return 0.0;
193 }
194 let min_length_norm = 1.0 - self.b;
195 let tf_norm = (max_tf * (self.k1 + 1.0)) / (max_tf + self.k1 * min_length_norm);
196 idf * tf_norm
197 }
198
199 #[inline]
201 pub fn upper_bound_with_len(self, max_tf: f32, idf: f32, min_len: f32, avg_len: f32) -> f32 {
202 if max_tf == 0.0 {
203 return 0.0;
204 }
205 let length_norm = 1.0 - self.b + self.b * (min_len / avg_len.max(1.0));
206 let tf_norm = (max_tf * (self.k1 + 1.0)) / (max_tf + self.k1 * length_norm);
207 idf * tf_norm
208 }
209}
210
211pub(super) struct PreparedBounds {
215 numerator: f64,
216 k: f64,
217 reciprocal: f64,
218 length_ratio: f64,
219}
220
221impl PreparedBounds {
222 pub(super) fn new(params: Bm25Params, max_tf: u32, idf: f32, average: f32) -> Option<Self> {
223 if max_tf == 0
224 || !idf.is_finite()
225 || idf <= 0.0
226 || !average.is_finite()
227 || !(max_tf as f32 * (params.k1 + 1.0)).is_finite()
228 || !params.k1.is_finite()
229 || params.k1 < 0.0
230 || !(0.0..=1.0).contains(¶ms.b)
231 {
232 return None;
233 }
234 let b = f64::from(params.b);
235 let k = f64::from(params.k1);
236 Some(Self {
237 numerator: f64::from(idf) * (k + 1.0),
238 k,
239 reciprocal: 1.0 - b,
240 length_ratio: b / f64::from(average.max(1.0)),
241 })
242 }
243
244 fn score(&self, minimum: f64) -> f32 {
245 let bound = self.numerator / (1.0 + self.k * minimum);
246 Self::inflate(bound)
247 }
248
249 fn inflate(bound: f64) -> f32 {
250 (bound * (1.0 + 16.0 * f64::from(f32::EPSILON)) + 16.0 * f64::from(f32::MIN_POSITIVE))
253 as f32
254 }
255
256 pub(super) fn pair(&self, max_tf: u32, length: u32) -> f32 {
260 if max_tf == 0 {
261 return f32::INFINITY;
262 }
263 let tf = f64::from(max_tf);
264 let norm = self.k * (self.reciprocal + self.length_ratio * f64::from(length));
265 Self::inflate(self.numerator * (tf / (tf + norm)))
266 }
267
268 pub(super) fn length_cutoff_hint(&self, tf: u32, score: f32) -> Option<u32> {
271 if self.k == 0.0 || self.length_ratio == 0.0 || score <= 0.0 {
272 return None;
273 }
274 let inflation = 1.0 + 16.0 * f64::from(f32::EPSILON);
275 let length = (f64::from(tf) * (self.numerator * inflation / f64::from(score) - 1.0)
276 / self.k
277 - self.reciprocal)
278 / self.length_ratio;
279 length
280 .is_finite()
281 .then(|| (length.floor() + 1.0).clamp(1.0, 65536.0) as u32)
282 }
283
284 pub(super) fn ratio(&self, max_tf: u32, ratio: f32) -> f32 {
285 if max_tf == 0 || ratio <= 0.0 || !ratio.is_finite() {
286 return f32::INFINITY;
287 }
288 self.score(self.reciprocal / f64::from(max_tf) + self.length_ratio * f64::from(ratio))
289 }
290
291 pub(super) fn impacts(&self, minimum: impl FnOnce(f64, f64) -> Option<f64>) -> f32 {
292 match minimum(self.reciprocal, self.length_ratio) {
293 Some(value) if value.is_finite() && value >= 0.0 => self.score(value),
294 _ => f32::INFINITY,
295 }
296 }
297}
298
299#[inline]
307pub fn bm25_score(tf: f32, idf: f32, doc_len: f32, avg_doc_len: f32) -> f32 {
308 Bm25Params::default().score(tf, idf, doc_len, avg_doc_len)
309}
310
311#[inline]
320pub fn bm25f_score(tf: f32, idf: f32, doc_len: f32, avg_doc_len: f32, field_boost: f32) -> f32 {
321 Bm25Params::default().score_boosted(tf, idf, doc_len, avg_doc_len, field_boost)
322}
323
324#[inline]
330pub fn bm25_upper_bound(max_tf: f32, idf: f32) -> f32 {
331 Bm25Params::default().upper_bound(max_tf, idf)
332}
333
334#[inline]
338pub fn bm25_upper_bound_with_len(max_tf: f32, idf: f32, min_len: f32, avg_len: f32) -> f32 {
339 Bm25Params::default().upper_bound_with_len(max_tf, idf, min_len, avg_len)
340}
341
342#[inline]
349pub fn bm25f_upper_bound(max_tf: f32, idf: f32, field_boost: f32) -> f32 {
350 Bm25Params::default().score_boosted(max_tf, idf, 0.0, 1.0, field_boost)
351}
352
353#[inline]
359pub fn bm25_idf(doc_freq: f32, total_docs: f32) -> f32 {
360 ((total_docs - doc_freq + 0.5) / (doc_freq + 0.5) + 1.0).ln()
361}
362
363#[cfg(test)]
364mod norm_lookup_tests {
365 use super::*;
366 #[test]
367 fn batch_normalization_preserves_scalar_scores_and_tails() {
368 for params in [
369 Bm25Params::default(),
370 Bm25Params { k1: 0.0, b: 1.0 },
371 Bm25Params { k1: 2.3, b: 0.0 },
372 Bm25Params { k1: 1.2, b: 1.0 },
373 ] {
374 for average in [0.0, 1.0, 173.25, 65535.0] {
375 let table = NormTable::new(params, average);
376 for count in [0, 1, 17, 127, 128] {
377 for start in [0u8, 128] {
378 for boost in [0.0, 1.0, 2.3, -1.0] {
379 let tfs: Vec<_> = (0..count)
380 .map(|i| [0, 1, 2, 17, 65535, u32::MAX][i % 6])
381 .collect();
382 let codes: Vec<_> = (0..count).map(|i| start + i as u8).collect();
383 let mut scores = vec![0.0; count];
384 table.score_batch(
385 params,
386 1.37,
387 average,
388 boost,
389 codes.iter().copied(),
390 &tfs,
391 &mut scores,
392 );
393 for i in 0..count {
394 assert_eq!(
395 scores[i].to_bits(),
396 table
397 .score_boosted(
398 params,
399 tfs[i] as f32,
400 1.37,
401 codes[i],
402 average,
403 boost,
404 )
405 .to_bits()
406 );
407 }
408 }
409 }
410 }
411 }
412 }
413 }
414
415 #[test]
416 fn lookup_normalization_matches_canonical_arithmetic_for_every_byte_norm() {
417 for params in [
418 Bm25Params::default(),
419 Bm25Params { k1: 0.0, b: 1.0 },
420 Bm25Params { k1: 2.3, b: 0.0 },
421 Bm25Params { k1: 1.2, b: 1.0 },
422 ] {
423 for avg in [0.0, 1.0, 173.25, 65535.0] {
424 let table = NormTable::new(params, avg);
425 for code in 0..=255 {
426 for tf in [0.0, 1.0, 2.0, 17.0, 65535.0] {
427 let length = if code == 0 {
428 tf
429 } else {
430 crate::segment::norms::decode(code) as f32
431 };
432 assert_eq!(
433 table.score(params, tf, 1.37, code, avg).to_bits(),
434 params.score(tf, 1.37, length, avg).to_bits()
435 );
436 for boost in [0.0, 1.0, 2.3] {
437 assert_eq!(
438 table
439 .score_boosted(params, tf, 1.37, code, avg, boost)
440 .to_bits(),
441 params.score_boosted(tf, 1.37, length, avg, boost).to_bits()
442 );
443 }
444 }
445 }
446 }
447 }
448 }
449}
450
451#[cfg(test)]
452mod ratio_tests {
453 use super::*;
454
455 #[test]
456 fn ratio_bound_covers_canonical_scores_across_frequency_length_and_parameter_extremes() {
457 let values = [
458 1u32,
459 2,
460 3,
461 127,
462 255,
463 65535,
464 65536,
465 100_000,
466 16_777_217,
467 u32::MAX,
468 ];
469 for tf in values {
470 for length in values {
471 let ratio = ((length as f64 / tf as f64) as f32).next_down();
472 for max_tf in [tf, tf.saturating_mul(7), u32::MAX] {
473 for k1 in [0.0, 0.1, 1.2, 16.0, 1e6] {
474 for b in [0.0, 0.01, 0.75, 1.0 - f32::EPSILON, 1.0] {
475 for avg in [0.0, 1.0, 500.3, 1e8] {
476 for idf in [f32::MIN_POSITIVE, 0.001, 1.0, 20.0] {
477 let params = Bm25Params { k1, b };
478 let bound =
479 params.upper_bound_with_ratio(max_tf, idf, ratio, avg);
480 let score = params.score(tf as f32, idf, length as f32, avg);
481 let pair = PreparedBounds::new(params, max_tf, idf, avg)
482 .unwrap()
483 .pair(max_tf, length);
484 assert!(
485 pair >= score,
486 "pair {params:?} tf={tf} len={length} max={max_tf} avg={avg} idf={idf} score={score} bound={pair}"
487 );
488 assert!(
489 bound >= score,
490 "{params:?} tf={tf} len={length} max={max_tf} avg={avg} idf={idf} score={score} bound={bound}"
491 );
492 }
493 }
494 }
495 }
496 }
497 }
498 }
499 }
500
501 #[test]
502 fn ratio_bound_does_not_pair_a_long_documents_tf_with_a_short_documents_length() {
503 let params = Bm25Params::default();
504 let loose = params.upper_bound_with_len(100.0, 1.0, 10.0, 500.0);
505 let tight = params.upper_bound_with_ratio(100, 1.0, 10.0f32.next_down(), 500.0);
506 assert!(tight < loose);
507 for (tf, len) in [(1.0, 10.0), (100.0, 1000.0)] {
508 assert!(tight >= params.score(tf, 1.0, len, 500.0));
509 }
510 for (k1, b) in [(-1.0, 0.5), (1.0, 2.0), (f32::NAN, 0.5)] {
511 assert!(
512 Bm25Params { k1, b }
513 .upper_bound_with_ratio(1, 1.0, 1.0, 1.0)
514 .is_infinite()
515 );
516 }
517 }
518 #[test]
519 fn complete_impact_envelopes_bound_canonical_f32_scores_and_disable_unsupported_parameters() {
520 use crate::structures::{BlockPostingList, PostingCodec, PostingList};
521 let mut seed = 17u64;
522 for trial in 0..128 {
523 let mut next = || {
524 seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
525 (seed >> 32) as u32
526 };
527 let values = [1, 2, 3, 127, 65535, 65536, 16777217, u32::MAX];
528 let mut lengths = [1u32; 256];
529 let mut postings = PostingList::new();
530 for doc in 0..256 {
531 let (tf, length) = if trial % 3 == 0 {
532 let tf = doc % 8 + 1;
533 (tf, tf * tf)
534 } else if trial % 3 == 1 {
535 (values[next() as usize % 8], values[next() as usize % 8])
536 } else {
537 (next().max(1), next().max(1))
538 };
539 lengths[doc as usize] = length;
540 postings.push(doc, tf);
541 }
542 let list = BlockPostingList::from_posting_list_with_impact_bounds(
543 &postings,
544 false,
545 Some(&|d| lengths[d as usize]),
546 PostingCodec::Rounded,
547 )
548 .unwrap();
549 for k1 in [0.0, 0.9, 1.2, 100.0, f32::MAX] {
550 for b in [0.0, 0.25, 0.75, 1.0f32.next_down(), 1.0] {
551 let params = Bm25Params { k1, b };
552 for avg in [0.0, 1.0, 37.5, 65535.0, f32::MAX] {
553 for idf in [f32::MIN_POSITIVE, 0.001, 1.7, f32::MAX] {
554 for block in 0..2 {
555 let bound = params.upper_bound_with_impacts(
556 list.block_max_tf(block).unwrap(),
557 idf,
558 avg,
559 |a, c| list.block_impact_minimum(block, a, c),
560 );
561 let group_bound = params.upper_bound_with_impacts(
562 list.max_tf(),
563 idf,
564 avg,
565 |a, c| list.group_impact_minimum(block, a, c),
566 );
567 for posting in postings.iter().skip(block * 128).take(128) {
568 let score = params.score(
569 posting.term_freq as f32,
570 idf,
571 lengths[posting.doc_id as usize] as f32,
572 avg,
573 );
574 assert!(
575 group_bound == f32::INFINITY || score <= group_bound,
576 "group trial={trial} params={params:?} avg={avg} idf={idf} score={score} bound={group_bound}"
577 );
578 assert!(
579 bound == f32::INFINITY || score <= bound,
580 "trial={trial} params={params:?} avg={avg} idf={idf} score={score} bound={bound}"
581 );
582 }
583 }
584 }
585 }
586 }
587 }
588 }
589 for params in [
590 Bm25Params { k1: -1.0, b: 0.5 },
591 Bm25Params {
592 k1: f32::NAN,
593 b: 0.5,
594 },
595 Bm25Params { k1: 1.2, b: -0.1 },
596 Bm25Params {
597 k1: 1.2,
598 b: f32::NAN,
599 },
600 ] {
601 assert_eq!(
602 params.upper_bound_with_impacts(1, 1.0, 1.0, |_, _| panic!(
603 "unsupported scorer must not decode envelopes"
604 )),
605 f32::INFINITY
606 );
607 }
608 let p = Bm25Params::default();
609 for (tf, idf, avg) in [
610 (0, 1.0, 1.0),
611 (1, -1.0, 1.0),
612 (1, f32::NAN, 1.0),
613 (1, 1.0, f32::INFINITY),
614 ] {
615 assert_eq!(
616 p.upper_bound_with_impacts(tf, idf, avg, |_, _| panic!(
617 "invalid input must not decode envelopes"
618 )),
619 f32::INFINITY
620 );
621 }
622 assert_eq!(
623 p.upper_bound_with_impacts(1, 1.0, 1.0, |_, _| None),
624 f32::INFINITY
625 );
626 }
627}
628
629#[cfg(test)]
630mod zero_frequency_tests {
631 use super::*;
632
633 #[test]
634 fn zero_frequency_and_zero_boost_produce_finite_zero_scores_and_bounds() {
635 for params in [
636 Bm25Params::default(),
637 Bm25Params { k1: 0.0, b: 1.0 },
638 Bm25Params { k1: 1.2, b: 1.0 },
639 ] {
640 for length in [0.0, 1.0, 100.0] {
641 for average in [0.0, 1.0, 175.0] {
642 assert_eq!(
643 params.score(0.0, 2.3, length, average).to_bits(),
644 0,
645 "{params:?} length={length}"
646 );
647 assert_eq!(
648 params
649 .score_boosted(0.0, 2.3, length, average, 7.0)
650 .to_bits(),
651 0
652 );
653 assert_eq!(
654 params
655 .score_boosted(13.0, 2.3, length, average, 0.0)
656 .to_bits(),
657 0
658 );
659 assert_eq!(params.upper_bound(0.0, 2.3).to_bits(), 0);
660 assert_eq!(
661 params
662 .upper_bound_with_len(0.0, 2.3, length, average)
663 .to_bits(),
664 0
665 );
666 }
667 }
668 }
669 assert_eq!(bm25_score(0.0, 2.3, 0.0, 0.0).to_bits(), 0);
670 assert_eq!(bm25f_score(13.0, 2.3, 0.0, 0.0, 0.0).to_bits(), 0);
671 assert_eq!(bm25_upper_bound(0.0, 2.3).to_bits(), 0);
672 assert_eq!(bm25_upper_bound_with_len(0.0, 2.3, 0.0, 0.0).to_bits(), 0);
673 assert_eq!(bm25f_upper_bound(13.0, 2.3, 0.0).to_bits(), 0);
674 }
675}