1use std::collections::HashMap;
6
7const K1: f64 = 1.2;
9
10const B: f64 = 0.75;
12
13const DELTA: f64 = 1.0;
16
17const MU: f64 = 2000.0;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
23pub enum Scorer {
24 Bm25,
26 #[default]
29 Bm25Plus,
30 Dirichlet,
32}
33
34impl Scorer {
35 #[must_use]
38 pub fn token(self) -> &'static str {
39 match self {
40 Self::Bm25 => "bm25",
41 Self::Bm25Plus => "bm25+",
42 Self::Dirichlet => "dirichlet",
43 }
44 }
45
46 #[must_use]
48 pub fn parse(name: &str) -> Option<Self> {
49 match name.trim().to_ascii_lowercase().as_str() {
50 "bm25" | "okapi" => Some(Self::Bm25),
51 "bm25+" | "bm25plus" => Some(Self::Bm25Plus),
52 "dirichlet" | "ql" | "lm" => Some(Self::Dirichlet),
53 _ => None,
54 }
55 }
56}
57
58type Posting = (u32, u32);
60
61#[derive(Debug, Clone, Default)]
63pub struct Index {
64 postings: HashMap<String, Vec<Posting>>,
65 lengths: Vec<u32>,
66 total_length: u64,
67 occurrences: HashMap<String, u64>,
70}
71
72impl Index {
73 #[must_use]
75 pub fn build<'a>(documents: impl IntoIterator<Item = &'a [String]>) -> Self {
76 let mut index = Self::default();
77 for tokens in documents {
78 let ordinal = u32::try_from(index.lengths.len()).unwrap_or(u32::MAX);
79 index
80 .lengths
81 .push(u32::try_from(tokens.len()).unwrap_or(u32::MAX));
82 index.total_length += tokens.len() as u64;
83 let mut counts: HashMap<&str, u32> = HashMap::new();
84 for term in tokens {
85 *counts.entry(term.as_str()).or_insert(0) += 1;
86 }
87 for (term, count) in counts {
88 index
89 .postings
90 .entry(term.to_string())
91 .or_default()
92 .push((ordinal, count));
93 *index.occurrences.entry(term.to_string()).or_insert(0) += u64::from(count);
94 }
95 }
96 index
97 }
98
99 #[must_use]
101 pub fn len(&self) -> usize {
102 self.lengths.len()
103 }
104
105 #[must_use]
107 pub fn is_empty(&self) -> bool {
108 self.lengths.is_empty()
109 }
110
111 #[must_use]
113 pub fn average_length(&self) -> f64 {
114 if self.lengths.is_empty() {
115 0.0
116 } else {
117 self.total_length as f64 / self.lengths.len() as f64
118 }
119 }
120
121 #[must_use]
124 pub fn idf(&self, term: &str) -> f64 {
125 let n = self.lengths.len() as f64;
126 let df = self.postings.get(term).map_or(0, Vec::len) as f64;
127 (1.0 + (n - df + 0.5) / (df + 0.5)).ln()
128 }
129
130 fn norm(&self, ordinal: usize) -> f64 {
132 let average = self.average_length();
133 if average <= 0.0 {
134 return 1.0;
135 }
136 let length = f64::from(self.lengths.get(ordinal).copied().unwrap_or(0));
137 B.mul_add(length / average, 1.0 - B)
138 }
139
140 fn background(&self, term: &str) -> f64 {
142 if self.total_length == 0 {
143 return 0.0;
144 }
145 self.occurrences.get(term).copied().unwrap_or(0) as f64 / self.total_length as f64
146 }
147
148 fn term_score(&self, term: &str, ordinal: usize, count: u32) -> f64 {
150 self.term_score_by(Scorer::Bm25, term, ordinal, count)
151 }
152
153 fn term_score_by(&self, scorer: Scorer, term: &str, ordinal: usize, count: u32) -> f64 {
155 let count = f64::from(count);
156 match scorer {
157 Scorer::Bm25 => {
158 self.idf(term) * (count * (K1 + 1.0))
159 / (K1 * self.norm(ordinal)).mul_add(1.0, count)
160 }
161 Scorer::Bm25Plus => {
164 self.idf(term)
165 * ((count * (K1 + 1.0)) / (K1 * self.norm(ordinal)).mul_add(1.0, count) + DELTA)
166 }
167 Scorer::Dirichlet => {
170 let background = self.background(term);
171 if background <= 0.0 {
172 return 0.0;
173 }
174 let length = f64::from(self.lengths.get(ordinal).copied().unwrap_or(0));
175 (count / (MU * background)).ln_1p() + (MU / (length + MU)).ln()
176 }
177 }
178 }
179
180 #[must_use]
182 pub fn score(&self, query: &[String]) -> Vec<(usize, f64)> {
183 let mut totals: HashMap<u32, f64> = HashMap::new();
184 for term in query {
185 let Some(postings) = self.postings.get(term.as_str()) else {
186 continue;
187 };
188 for (ordinal, count) in postings {
189 *totals.entry(*ordinal).or_insert(0.0) +=
190 self.term_score(term, *ordinal as usize, *count);
191 }
192 }
193 let mut scored: Vec<(usize, f64)> = totals
194 .into_iter()
195 .map(|(ordinal, score)| (ordinal as usize, score))
196 .collect();
197 scored.sort_unstable_by_key(|(ordinal, _)| *ordinal);
198 scored
199 }
200
201 #[must_use]
203 pub fn score_weighted(&self, query: &[(String, f64)]) -> Vec<(usize, f64)> {
204 self.score_weighted_by(Scorer::default(), query)
205 }
206
207 #[must_use]
210 pub fn score_weighted_by(&self, scorer: Scorer, query: &[(String, f64)]) -> Vec<(usize, f64)> {
211 let mut totals: HashMap<u32, f64> = HashMap::new();
212 for (term, weight) in query {
213 if *weight <= 0.0 {
214 continue;
215 }
216 let Some(postings) = self.postings.get(term.as_str()) else {
217 continue;
218 };
219 for (ordinal, count) in postings {
220 *totals.entry(*ordinal).or_insert(0.0) +=
221 weight * self.term_score_by(scorer, term, *ordinal as usize, *count);
222 }
223 }
224 let mut scored: Vec<(usize, f64)> = totals
225 .into_iter()
226 .map(|(ordinal, score)| (ordinal as usize, score))
227 .collect();
228 scored.sort_unstable_by_key(|(ordinal, _)| *ordinal);
229 scored
230 }
231
232 #[must_use]
236 pub fn expand(
237 &self,
238 query: &[String],
239 feedback: &[(&[String], f64)],
240 terms: usize,
241 alpha: f64,
242 ) -> Vec<(String, f64)> {
243 let mut weights: HashMap<String, f64> = HashMap::new();
244 if !query.is_empty() {
246 let each = alpha / query.len() as f64;
247 for term in query {
248 *weights.entry(term.clone()).or_insert(0.0) += each;
249 }
250 }
251 let mass: f64 = feedback.iter().map(|(_, score)| score.max(0.0)).sum();
252 if mass <= 0.0 || terms == 0 || alpha >= 1.0 {
253 let mut out: Vec<(String, f64)> = weights.into_iter().collect();
254 out.sort_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
255 return out;
256 }
257 let mut model: HashMap<&str, f64> = HashMap::new();
259 for (tokens, score) in feedback {
260 if tokens.is_empty() || *score <= 0.0 {
261 continue;
262 }
263 let share = score / mass;
264 let length = tokens.len() as f64;
265 let mut counts: HashMap<&str, u32> = HashMap::new();
266 for term in *tokens {
267 *counts.entry(term.as_str()).or_insert(0) += 1;
268 }
269 for (term, count) in counts {
270 *model.entry(term).or_insert(0.0) += share * f64::from(count) / length;
271 }
272 }
273 let mut ranked: Vec<(&str, f64)> = model.into_iter().collect();
275 ranked.sort_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(b.0)));
276 ranked.truncate(terms);
277 let total: f64 = ranked.iter().map(|(_, value)| *value).sum();
278 if total > 0.0 {
279 for (term, value) in ranked {
280 *weights.entry(term.to_string()).or_insert(0.0) += (1.0 - alpha) * value / total;
281 }
282 }
283 let mut out: Vec<(String, f64)> = weights.into_iter().collect();
284 out.sort_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
285 out
286 }
287
288 #[must_use]
290 pub fn score_foreign(&self, query: &[String], document: &[String]) -> f64 {
291 if document.is_empty() || self.is_empty() {
292 return 0.0;
293 }
294 let average = self.average_length();
295 let norm = if average > 0.0 {
296 B.mul_add(document.len() as f64 / average, 1.0 - B)
297 } else {
298 1.0
299 };
300 let mut counts: HashMap<&str, u32> = HashMap::new();
301 for term in document {
302 *counts.entry(term.as_str()).or_insert(0) += 1;
303 }
304 query
305 .iter()
306 .map(|term| {
307 let count = f64::from(counts.get(term.as_str()).copied().unwrap_or(0));
308 if count == 0.0 {
309 return 0.0;
310 }
311 self.idf(term) * (count * (K1 + 1.0)) / (K1 * norm).mul_add(1.0, count)
312 })
313 .sum()
314 }
315
316 #[must_use]
318 pub fn score_foreign_weighted(&self, query: &[(String, f64)], document: &[String]) -> f64 {
319 self.score_foreign_weighted_by(Scorer::default(), query, document)
320 }
321
322 #[must_use]
325 pub fn score_foreign_weighted_by(
326 &self,
327 scorer: Scorer,
328 query: &[(String, f64)],
329 document: &[String],
330 ) -> f64 {
331 if document.is_empty() || self.is_empty() {
332 return 0.0;
333 }
334 let average = self.average_length();
335 let length = document.len() as f64;
336 let norm = if average > 0.0 {
337 B.mul_add(length / average, 1.0 - B)
338 } else {
339 1.0
340 };
341 let mut counts: HashMap<&str, u32> = HashMap::new();
342 for term in document {
343 *counts.entry(term.as_str()).or_insert(0) += 1;
344 }
345 query
346 .iter()
347 .map(|(term, weight)| {
348 let count = f64::from(counts.get(term.as_str()).copied().unwrap_or(0));
349 if count == 0.0 || *weight <= 0.0 {
350 return 0.0;
351 }
352 let saturated = (count * (K1 + 1.0)) / (K1 * norm).mul_add(1.0, count);
353 weight
354 * match scorer {
355 Scorer::Bm25 => self.idf(term) * saturated,
356 Scorer::Bm25Plus => self.idf(term) * (saturated + DELTA),
357 Scorer::Dirichlet => {
358 let background = self.background(term);
359 if background <= 0.0 {
360 return 0.0;
361 }
362 (count / (MU * background)).ln_1p() + (MU / (length + MU)).ln()
363 }
364 }
365 })
366 .sum()
367 }
368}
369
370#[cfg(test)]
371mod scorers {
372 use super::*;
373
374 fn words(text: &str) -> Vec<String> {
375 text.split_whitespace().map(str::to_string).collect()
376 }
377
378 #[test]
381 fn a_long_document_stops_being_punished_for_its_length() {
382 let filler = "alpha beta gamma delta epsilon zeta eta theta ".repeat(400);
383 let long = words(&format!("{filler} lease"));
384 let mut corpus: Vec<Vec<String>> = vec![long];
385 for n in 0..200 {
388 corpus.push(words(&format!("alpha beta gamma note {n}")));
389 }
390 let index = Index::build(corpus.iter().map(Vec::as_slice));
391
392 let query = vec![("lease".to_string(), 1.0)];
393 let plain = index.score_weighted_by(Scorer::Bm25, &query);
394 let floored = index.score_weighted_by(Scorer::Bm25Plus, &query);
395
396 assert_eq!(plain.len(), 1);
398 assert_eq!(floored.len(), 1);
399
400 let (_, thin) = plain[0];
401 let (_, held) = floored[0];
402 assert!(held > thin, "the floor took a point away: {held} vs {thin}");
403 assert!(
404 thin < 0.25 * index.idf("lease"),
405 "this corpus does not show the defect: {thin}"
406 );
407 assert!(
408 held > index.idf("lease"),
409 "the floor did not restore the occurrence: {held}"
410 );
411 }
412
413 #[test]
416 fn the_language_model_is_a_different_opinion() {
417 let corpus: Vec<Vec<String>> = vec![
418 words("lease lease lease renew renew"),
419 words("lease renew claim generation fence token holder quiet reclaim node"),
420 words("claim generation fence token"),
421 ];
422 let index = Index::build(corpus.iter().map(Vec::as_slice));
423 let query = vec![("lease".to_string(), 1.0), ("renew".to_string(), 1.0)];
424
425 let best = |scored: Vec<(usize, f64)>| -> usize {
426 scored
427 .into_iter()
428 .max_by(|a, b| a.1.partial_cmp(&b.1).expect("finite"))
429 .expect("a hit")
430 .0
431 };
432 assert_eq!(best(index.score_weighted_by(Scorer::Bm25, &query)), 0);
433 assert_eq!(best(index.score_weighted_by(Scorer::Dirichlet, &query)), 0);
434
435 let by_bm25 = index.score_weighted_by(Scorer::Bm25, &query);
436 let by_lm = index.score_weighted_by(Scorer::Dirichlet, &query);
437 assert_eq!(by_bm25.len(), by_lm.len());
438 assert!(
439 by_bm25
440 .iter()
441 .zip(&by_lm)
442 .any(|((_, one), (_, two))| (one - two).abs() > 1e-9),
443 "two derivations returned the same numbers"
444 );
445 }
446
447 #[test]
449 fn a_scorer_is_named_or_refused() {
450 for (name, want) in [
451 ("bm25", Scorer::Bm25),
452 ("BM25+", Scorer::Bm25Plus),
453 (" dirichlet ", Scorer::Dirichlet),
454 ("ql", Scorer::Dirichlet),
455 ] {
456 assert_eq!(Scorer::parse(name), Some(want), "{name}");
457 }
458 assert_eq!(Scorer::parse("tf-idf"), None);
459 assert_eq!(Scorer::parse(""), None);
460 for scorer in [Scorer::Bm25, Scorer::Bm25Plus, Scorer::Dirichlet] {
461 assert_eq!(Scorer::parse(scorer.token()), Some(scorer));
462 }
463 }
464}
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469
470 fn doc(text: &str) -> Vec<String> {
471 crate::search::tokens(text)
472 }
473
474 fn corpus(texts: &[&str]) -> Index {
475 let docs: Vec<Vec<String>> = texts.iter().map(|t| doc(t)).collect();
476 Index::build(docs.iter().map(Vec::as_slice))
477 }
478
479 fn best(index: &Index, query: &str) -> usize {
480 index
481 .score(&doc(query))
482 .into_iter()
483 .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap())
484 .expect("a hit")
485 .0
486 }
487
488 #[test]
490 fn a_rare_word_says_more_than_a_common_one() {
491 let index = corpus(&[
492 "the parser reads a header",
493 "the parser reads a manifest",
494 "the parser reads a record",
495 "the ripgrep overlay reads a header",
496 ]);
497 assert!(index.idf("ripgrep") > index.idf("parser"));
498 assert_eq!(best(&index, "ripgrep parser"), 3);
501 }
502
503 #[test]
505 fn a_word_everything_carries_never_scores_below_nothing() {
506 let index = corpus(&["parser one", "parser two", "parser three"]);
507 assert!(index.idf("parser") > 0.0);
508 let scored = index.score(&doc("parser"));
509 assert_eq!(scored.len(), 3);
510 assert!(scored.iter().all(|(_, score)| *score > 0.0), "{scored:?}");
511 }
512
513 #[test]
515 fn a_longer_atom_does_not_win_on_length_alone() {
516 let padding = "header manifest record token commit branch index atom workspace daemon";
517 let index = corpus(&["the parser reads", &format!("the parser reads {padding}")]);
518 let scored = index.score(&doc("parser"));
519 assert_eq!(scored.len(), 2);
520 assert!(scored[0].1 > scored[1].1, "padding raised the score");
521 }
522
523 #[test]
525 fn repeating_a_word_pays_less_each_time() {
526 let index = corpus(&["parser", "parser parser", "parser parser parser"]);
527 let scored = index.score(&doc("parser"));
528 let (once, twice, thrice) = (scored[0].1, scored[1].1, scored[2].1);
529 assert!(twice > once);
530 assert!(thrice - twice < twice - once);
531 }
532
533 #[test]
536 fn a_document_carrying_no_query_term_is_not_in_the_answer() {
537 let index = corpus(&["the parser reads a header", "the overlay writes a record"]);
538 assert_eq!(
539 index.score(&doc("parser")),
540 vec![(0, index.score(&doc("parser"))[0].1)]
541 );
542 assert!(index.score(&doc("kubernetes")).is_empty());
543 }
544
545 #[test]
546 fn an_empty_corpus_scores_nothing_rather_than_dividing_by_it() {
547 let index = Index::build(std::iter::empty());
548 assert!(index.is_empty());
549 assert!(index.score(&doc("parser")).is_empty());
550 assert_eq!(index.score_foreign(&doc("parser"), &doc("parser")), 0.0);
551 }
552
553 #[test]
556 fn an_expansion_reaches_what_the_question_did_not_say() {
557 let index = corpus(&[
558 "the parser reads a ripgrep header",
559 "the ripgrep overlay writes a header",
560 "the kubernetes operator reconciles a deployment",
561 ]);
562 let documents: Vec<Vec<String>> = [
563 "the parser reads a ripgrep header",
564 "the ripgrep overlay writes a header",
565 "the kubernetes operator reconciles a deployment",
566 ]
567 .iter()
568 .map(|text| doc(text))
569 .collect();
570 let query = doc("parser");
571 let first = index.score(&query);
572 assert_eq!(first.len(), 1);
574 let feedback: Vec<(&[String], f64)> = first
575 .iter()
576 .map(|(ordinal, score)| (documents[*ordinal].as_slice(), *score))
577 .collect();
578 let expanded = index.expand(&query, &feedback, 10, 0.5);
579 assert!(
580 expanded.iter().any(|(term, _)| term == "ripgrep"),
581 "{expanded:?}"
582 );
583 let second = index.score_weighted(&expanded);
584 assert!(
586 second.iter().any(|(ordinal, _)| *ordinal == 1),
587 "{second:?}"
588 );
589 assert!(
591 !second.iter().any(|(ordinal, _)| *ordinal == 2),
592 "{second:?}"
593 );
594 }
595
596 #[test]
599 fn the_words_asked_for_keep_their_share() {
600 let index = corpus(&["parser header", "overlay record"]);
601 let documents: Vec<Vec<String>> = ["parser header", "overlay record"]
602 .iter()
603 .map(|text| doc(text))
604 .collect();
605 let query = doc("parser");
606 let feedback: Vec<(&[String], f64)> = vec![(documents[1].as_slice(), 1.0)];
607 let expanded = index.expand(&query, &feedback, 10, 0.5);
608 let asked: f64 = expanded
609 .iter()
610 .filter(|(term, _)| term == "parser")
611 .map(|(_, weight)| *weight)
612 .sum();
613 assert!((asked - 0.5).abs() < 1e-9, "{expanded:?}");
614 let guessed: f64 = expanded
615 .iter()
616 .filter(|(term, _)| term != "parser")
617 .map(|(_, weight)| *weight)
618 .sum();
619 assert!((guessed - 0.5).abs() < 1e-9, "{expanded:?}");
620 }
621
622 #[test]
624 fn no_feedback_leaves_the_query_alone() {
625 let index = corpus(&["parser header", "overlay record"]);
626 let query = doc("parser header");
627 let expanded = index.expand(&query, &[], 10, 0.5);
628 assert_eq!(expanded.len(), 2);
629 let plain = index.score(&query);
630 let weighted = index.score_weighted(&expanded);
631 assert_eq!(plain.len(), weighted.len());
632 let best = |scored: &[(usize, f64)]| {
634 scored
635 .iter()
636 .max_by(|a, b| a.1.total_cmp(&b.1))
637 .map(|(ordinal, _)| *ordinal)
638 };
639 assert_eq!(best(&plain), best(&weighted));
640 }
641
642 #[test]
645 fn a_foreign_document_is_weighed_against_the_indexed_corpus() {
646 let index = corpus(&["the parser reads a header", "the parser reads a manifest"]);
647 let indexed = index.score(&doc("parser"))[0].1;
648 let foreign = index.score_foreign(&doc("parser"), &doc("the parser reads a header"));
649 assert!((indexed - foreign).abs() < 1e-9, "{indexed} vs {foreign}");
650 }
651}