use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Bm25Params {
pub k1: f32,
pub b: f32,
pub stopwords: bool,
}
impl Default for Bm25Params {
fn default() -> Self {
Self {
k1: 1.2,
b: 0.75,
stopwords: true,
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
pub struct FtsStats {
pub doc_count: u64,
pub total_len: u64,
}
impl FtsStats {
pub fn avg_doc_len(&self) -> f32 {
if self.doc_count == 0 {
0.0
} else {
self.total_len as f32 / self.doc_count as f32
}
}
pub fn add_doc(&mut self, len: u32) {
self.doc_count = self.doc_count.saturating_add(1);
self.total_len = self.total_len.saturating_add(u64::from(len));
}
pub fn remove_doc(&mut self, len: u32) {
self.doc_count = self.doc_count.saturating_sub(1);
self.total_len = self.total_len.saturating_sub(u64::from(len));
}
}
pub fn idf(doc_count: u64, doc_freq: u64) -> f32 {
let n = doc_count as f32;
let df = (doc_freq as f32).min(n);
((n - df + 0.5) / (df + 0.5)).ln_1p()
}
pub fn term_score(tf: u32, doc_len: u32, avg_doc_len: f32, idf: f32, params: &Bm25Params) -> f32 {
if tf == 0 {
return 0.0;
}
let tf = tf as f32;
let length_ratio = if avg_doc_len > 0.0 {
doc_len as f32 / avg_doc_len
} else {
1.0
};
let denominator = tf + params.k1 * (1.0 - params.b + params.b * length_ratio);
if denominator <= 0.0 {
return 0.0;
}
idf * (tf * (params.k1 + 1.0)) / denominator
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct RrfParams {
pub k: f32,
pub text_weight: f32,
pub vector_weight: f32,
}
impl Default for RrfParams {
fn default() -> Self {
Self {
k: 60.0,
text_weight: 1.0,
vector_weight: 1.0,
}
}
}
pub fn rrf_contribution(rank: usize, k: f32, weight: f32) -> f32 {
let denominator = k + rank as f32 + 1.0;
if denominator <= 0.0 {
return 0.0;
}
weight / denominator
}
#[cfg(test)]
mod tests {
use super::*;
const P: Bm25Params = Bm25Params {
k1: 1.2,
b: 0.75,
stopwords: true,
};
#[test]
fn idf_never_goes_negative_for_common_terms() {
assert!(idf(100, 100) >= 0.0);
assert!(idf(100, 99) >= 0.0);
assert!(idf(1, 1) >= 0.0);
}
#[test]
fn idf_survives_a_doc_freq_larger_than_the_corpus() {
assert!(idf(10, 50).is_finite());
assert!(idf(10, 50) >= 0.0);
}
#[test]
fn rarer_terms_score_higher() {
let rare = idf(1000, 5);
let common = idf(1000, 800);
assert!(rare > common, "rare {rare} should beat common {common}");
}
#[test]
fn term_frequency_saturates() {
let one = term_score(1, 100, 100.0, 1.0, &P);
let two = term_score(2, 100, 100.0, 1.0, &P);
let ten = term_score(10, 100, 100.0, 1.0, &P);
assert!(two > one);
assert!(ten > two);
assert!(two < one * 2.0, "tf=2 should not be worth twice tf=1");
assert!(ten < one * (P.k1 + 1.0));
}
#[test]
fn shorter_documents_win_at_equal_term_frequency() {
let short = term_score(3, 20, 100.0, 1.0, &P);
let long = term_score(3, 500, 100.0, 1.0, &P);
assert!(short > long, "short {short} should beat long {long}");
}
#[test]
fn length_normalisation_can_be_disabled() {
let no_norm = Bm25Params {
k1: 1.2,
b: 0.0,
stopwords: true,
};
let short = term_score(3, 20, 100.0, 1.0, &no_norm);
let long = term_score(3, 500, 100.0, 1.0, &no_norm);
assert!((short - long).abs() < f32::EPSILON);
}
#[test]
fn absent_terms_contribute_nothing() {
assert_eq!(term_score(0, 100, 100.0, 5.0, &P), 0.0);
}
#[test]
fn an_empty_corpus_still_produces_a_finite_score() {
assert!(term_score(1, 10, 0.0, 1.0, &P).is_finite());
}
#[test]
fn stats_track_mean_length() {
let mut s = FtsStats::default();
assert_eq!(s.avg_doc_len(), 0.0);
s.add_doc(10);
s.add_doc(20);
assert_eq!(s.doc_count, 2);
assert_eq!(s.avg_doc_len(), 15.0);
s.remove_doc(20);
assert_eq!(s.doc_count, 1);
assert_eq!(s.avg_doc_len(), 10.0);
}
#[test]
fn rrf_rewards_better_ranks() {
let d = RrfParams::default();
let first = rrf_contribution(0, d.k, 1.0);
let second = rrf_contribution(1, d.k, 1.0);
let hundredth = rrf_contribution(99, d.k, 1.0);
assert!(first > second);
assert!(second > hundredth);
assert!(hundredth > 0.0, "every rank still contributes something");
}
#[test]
fn rrf_matches_the_published_formula() {
assert!((rrf_contribution(0, 60.0, 1.0) - 1.0 / 61.0).abs() < 1e-9);
assert!((rrf_contribution(4, 60.0, 1.0) - 1.0 / 65.0).abs() < 1e-9);
}
#[test]
fn rrf_weights_scale_a_retriever() {
let unweighted = rrf_contribution(0, 60.0, 1.0);
let doubled = rrf_contribution(0, 60.0, 2.0);
assert!((doubled - unweighted * 2.0).abs() < 1e-9);
assert_eq!(rrf_contribution(0, 60.0, 0.0), 0.0, "zero weight disables");
}
#[test]
fn agreeing_retrievers_beat_a_single_strong_hit() {
let k = 60.0;
let agreed = rrf_contribution(1, k, 1.0) + rrf_contribution(1, k, 1.0);
let lopsided = rrf_contribution(0, k, 1.0);
assert!(
agreed > lopsided,
"consensus {agreed} should outrank a lone top hit {lopsided}"
);
}
#[test]
fn stats_do_not_underflow_when_over_removed() {
let mut s = FtsStats::default();
s.remove_doc(5);
assert_eq!(s.doc_count, 0);
assert_eq!(s.total_len, 0);
assert_eq!(s.avg_doc_len(), 0.0);
}
}