1use crate::types::NodeId;
9use std::collections::HashMap;
10
11#[derive(Debug, Clone, Copy)]
13pub struct Bm25Params {
14 pub k1: f32,
15 pub b: f32,
16}
17
18impl Default for Bm25Params {
19 fn default() -> Self {
20 Self { k1: 1.2, b: 0.75 }
21 }
22}
23
24#[derive(Debug, Clone)]
26pub struct Bm25Index {
27 params: Bm25Params,
28 postings: HashMap<String, Vec<(u32, u32)>>,
30 doc_ids: Vec<NodeId>,
31 doc_len: Vec<u32>,
32 avgdl: f32,
33}
34
35impl Bm25Index {
36 pub fn tokenize(text: &str) -> Vec<String> {
38 text.split(|c: char| !c.is_alphanumeric())
39 .filter(|t| !t.is_empty())
40 .map(|t| t.to_ascii_lowercase())
41 .collect()
42 }
43
44 pub fn build<I, S>(docs: I, params: Bm25Params) -> Self
46 where
47 I: IntoIterator<Item = (NodeId, S)>,
48 S: AsRef<str>,
49 {
50 let mut postings: HashMap<String, Vec<(u32, u32)>> = HashMap::new();
51 let mut doc_ids = Vec::new();
52 let mut doc_len = Vec::new();
53 let mut total_len: u64 = 0;
54
55 for (id, text) in docs {
56 let doc_idx = doc_ids.len() as u32;
57 let tokens = Self::tokenize(text.as_ref());
58 doc_len.push(tokens.len() as u32);
59 total_len += tokens.len() as u64;
60
61 let mut tf: HashMap<String, u32> = HashMap::new();
63 for tok in tokens {
64 *tf.entry(tok).or_insert(0) += 1;
65 }
66 for (term, freq) in tf {
67 postings.entry(term).or_default().push((doc_idx, freq));
68 }
69 doc_ids.push(id);
70 }
71
72 let n = doc_ids.len().max(1) as f32;
73 let avgdl = if doc_ids.is_empty() {
74 0.0
75 } else {
76 total_len as f32 / n
77 };
78 Self {
79 params,
80 postings,
81 doc_ids,
82 doc_len,
83 avgdl,
84 }
85 }
86
87 pub fn len(&self) -> usize {
89 self.doc_ids.len()
90 }
91 pub fn is_empty(&self) -> bool {
92 self.doc_ids.is_empty()
93 }
94
95 pub fn search(&self, query: &str, k: usize) -> Vec<(NodeId, f32)> {
98 if self.doc_ids.is_empty() || k == 0 {
99 return Vec::new();
100 }
101 let n = self.doc_ids.len() as f32;
102 let (k1, b) = (self.params.k1, self.params.b);
103 let mut scores: HashMap<u32, f32> = HashMap::new();
104
105 let mut seen_terms = std::collections::HashSet::new();
107 for term in Self::tokenize(query) {
108 if !seen_terms.insert(term.clone()) {
109 continue;
110 }
111 let Some(postings) = self.postings.get(&term) else {
112 continue;
113 };
114 let df = postings.len() as f32;
115 let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
117 for &(doc_idx, freq) in postings {
118 let dl = self.doc_len[doc_idx as usize] as f32;
119 let tf = freq as f32;
120 let denom = tf + k1 * (1.0 - b + b * dl / self.avgdl.max(1e-6));
121 let contribution = idf * (tf * (k1 + 1.0)) / denom;
122 *scores.entry(doc_idx).or_insert(0.0) += contribution;
123 }
124 }
125
126 let mut ranked: Vec<(NodeId, f32)> = scores
127 .into_iter()
128 .map(|(idx, s)| (self.doc_ids[idx as usize].clone(), s))
129 .collect();
130 ranked.sort_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
133 ranked.truncate(k);
134 ranked
135 }
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141
142 fn corpus() -> Vec<(NodeId, &'static str)> {
143 vec![
144 ("d1".into(), "the quick brown fox jumps over the lazy dog"),
145 ("d2".into(), "machine learning models for vector search"),
146 (
147 "d3".into(),
148 "vector databases enable semantic search at scale",
149 ),
150 ("d4".into(), "a recipe for italian pasta with tomato sauce"),
151 ]
152 }
153
154 #[test]
155 fn ranks_relevant_docs_first() {
156 let idx = Bm25Index::build(corpus(), Bm25Params::default());
157 assert_eq!(idx.len(), 4);
158 let res = idx.search("vector search", 4);
159 assert!(!res.is_empty());
160 assert!(res[0].0 == "d2" || res[0].0 == "d3");
162 assert!(res.iter().all(|(id, _)| id != "d4") || res.last().unwrap().0 == "d4");
163 }
164
165 #[test]
166 fn idf_downweights_common_terms() {
167 let idx = Bm25Index::build(corpus(), Bm25Params::default());
168 let res = idx.search("pasta", 4);
170 assert_eq!(res[0].0, "d4");
171 }
172
173 #[test]
174 fn empty_query_and_index_safe() {
175 let empty = Bm25Index::build(Vec::<(NodeId, &str)>::new(), Bm25Params::default());
176 assert!(empty.search("anything", 5).is_empty());
177 let idx = Bm25Index::build(corpus(), Bm25Params::default());
178 assert!(idx.search("", 5).is_empty());
179 assert!(idx.search("zzz nonexistent", 5).is_empty());
180 }
181}