1use std::collections::HashMap;
41
42use thiserror::Error;
43
44#[derive(Debug, Error, Clone, PartialEq)]
50pub enum RetrieverError {
51 #[error("maximum document capacity ({0}) reached")]
53 MaxDocumentsReached(usize),
54
55 #[error("embedding dimension mismatch: expected {expected}, got {got}")]
57 DimensionMismatch {
58 expected: usize,
60 got: usize,
62 },
63
64 #[error("document not found: {0}")]
66 DocumentNotFound(String),
67}
68
69#[derive(Debug, Clone)]
75pub struct Document {
76 pub id: String,
78 pub content: String,
80 pub embedding: Vec<f64>,
82 pub metadata: HashMap<String, String>,
84}
85
86#[derive(Debug, Clone)]
88pub struct RetrievalQuery {
89 pub text: String,
91 pub embedding: Vec<f64>,
93 pub top_k: usize,
95 pub hybrid_alpha: f64,
97}
98
99#[derive(Debug, Clone)]
101pub struct RetrievalResult {
102 pub doc_id: String,
104 pub dense_score: f64,
106 pub sparse_score: f64,
108 pub hybrid_score: f64,
110 pub rank: usize,
112}
113
114#[derive(Debug, Clone)]
116pub struct RetrieverStats {
117 pub document_count: usize,
119 pub total_queries: u64,
121 pub avg_doc_length: f64,
123 pub vocabulary_size: usize,
125}
126
127#[derive(Debug, Clone)]
133pub struct RetrieverConfig {
134 pub embedding_dim: usize,
136 pub max_documents: usize,
138 pub bm25_k1: f64,
140 pub bm25_b: f64,
142}
143
144impl Default for RetrieverConfig {
145 fn default() -> Self {
146 Self {
147 embedding_dim: 128,
148 max_documents: 100_000,
149 bm25_k1: 1.2,
150 bm25_b: 0.75,
151 }
152 }
153}
154
155#[derive(Debug, Clone, Default)]
161pub struct BM25Index {
162 pub doc_lengths: Vec<usize>,
164 pub term_freq: HashMap<String, Vec<(usize, f64)>>,
166 pub doc_freq: HashMap<String, usize>,
168 pub avg_doc_length: f64,
170}
171
172impl BM25Index {
173 pub fn new() -> Self {
175 Self::default()
176 }
177
178 pub fn tokenize(text: &str) -> Vec<String> {
180 text.split(|c: char| !c.is_alphanumeric())
181 .filter(|s| !s.is_empty())
182 .map(|s| s.to_lowercase())
183 .collect()
184 }
185
186 fn recompute_avg(&mut self) {
188 if self.doc_lengths.is_empty() {
189 self.avg_doc_length = 0.0;
190 } else {
191 let total: usize = self.doc_lengths.iter().sum();
192 self.avg_doc_length = total as f64 / self.doc_lengths.len() as f64;
193 }
194 }
195}
196
197pub struct DenseRetriever {
205 pub config: RetrieverConfig,
207 pub documents: Vec<Document>,
209 pub bm25: BM25Index,
211 pub total_queries: u64,
213}
214
215impl DenseRetriever {
216 pub fn new(config: RetrieverConfig) -> Self {
222 Self {
223 config,
224 documents: Vec::new(),
225 bm25: BM25Index::new(),
226 total_queries: 0,
227 }
228 }
229
230 pub fn add_document(&mut self, doc: Document) -> Result<(), RetrieverError> {
242 if self.documents.len() >= self.config.max_documents {
243 return Err(RetrieverError::MaxDocumentsReached(
244 self.config.max_documents,
245 ));
246 }
247 if doc.embedding.len() != self.config.embedding_dim {
248 return Err(RetrieverError::DimensionMismatch {
249 expected: self.config.embedding_dim,
250 got: doc.embedding.len(),
251 });
252 }
253
254 let doc_idx = self.documents.len();
255 let tokens = BM25Index::tokenize(&doc.content);
256 let doc_len = tokens.len();
257
258 let mut local_tf: HashMap<String, f64> = HashMap::new();
260 for token in &tokens {
261 *local_tf.entry(token.clone()).or_insert(0.0) += 1.0;
262 }
263
264 for (term, tf) in local_tf {
266 let posts = self.bm25.term_freq.entry(term.clone()).or_default();
267 posts.push((doc_idx, tf));
268 *self.bm25.doc_freq.entry(term).or_insert(0) += 1;
269 }
270
271 self.bm25.doc_lengths.push(doc_len);
272 self.documents.push(doc);
273 self.bm25.recompute_avg();
274 Ok(())
275 }
276
277 pub fn remove_document(&mut self, id: &str) -> bool {
282 let pos = self.documents.iter().position(|d| d.id == id);
283 match pos {
284 None => false,
285 Some(idx) => {
286 self.documents.swap_remove(idx);
287 self.rebuild_bm25();
288 true
289 }
290 }
291 }
292
293 pub fn rebuild_bm25(&mut self) {
297 self.bm25 = BM25Index::new();
298
299 for (doc_idx, doc) in self.documents.iter().enumerate() {
300 let tokens = BM25Index::tokenize(&doc.content);
301 let doc_len = tokens.len();
302
303 let mut local_tf: HashMap<String, f64> = HashMap::new();
304 for token in &tokens {
305 *local_tf.entry(token.clone()).or_insert(0.0) += 1.0;
306 }
307
308 for (term, tf) in local_tf {
309 let posts = self.bm25.term_freq.entry(term.clone()).or_default();
310 posts.push((doc_idx, tf));
311 *self.bm25.doc_freq.entry(term).or_insert(0) += 1;
312 }
313
314 self.bm25.doc_lengths.push(doc_len);
315 }
316
317 self.bm25.recompute_avg();
318 }
319
320 pub fn dense_search(&self, query_embedding: &[f64], k: usize) -> Vec<(usize, f64)> {
328 if self.documents.is_empty() || k == 0 {
329 return Vec::new();
330 }
331
332 let q_norm = l2_norm(query_embedding);
333
334 let mut scores: Vec<(usize, f64)> = self
335 .documents
336 .iter()
337 .enumerate()
338 .map(|(idx, doc)| {
339 let sim = cosine_sim_normed(query_embedding, &doc.embedding, q_norm);
340 (idx, sim)
341 })
342 .collect();
343
344 scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
346 scores.truncate(k);
347 scores
348 }
349
350 pub fn bm25_score(&self, doc_idx: usize, query_terms: &[String]) -> f64 {
352 let n = self.documents.len() as f64;
353 let dl = self.bm25.doc_lengths.get(doc_idx).copied().unwrap_or(0) as f64;
354 let avg_dl = self.bm25.avg_doc_length.max(1e-9);
355 let k1 = self.config.bm25_k1;
356 let b = self.config.bm25_b;
357
358 let mut score = 0.0_f64;
359
360 for term in query_terms {
361 let df = self.bm25.doc_freq.get(term).copied().unwrap_or(0) as f64;
362 if df == 0.0 {
363 continue;
364 }
365 let tf = self
367 .bm25
368 .term_freq
369 .get(term)
370 .and_then(|posts| {
371 posts
372 .iter()
373 .find(|(idx, _)| *idx == doc_idx)
374 .map(|(_, tf)| *tf)
375 })
376 .unwrap_or(0.0);
377
378 if tf == 0.0 {
379 continue;
380 }
381
382 let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
384 let tf_norm = tf * (k1 + 1.0) / (tf + k1 * (1.0 - b + b * dl / avg_dl));
385 score += idf * tf_norm;
386 }
387
388 score
389 }
390
391 pub fn sparse_search(&self, query_text: &str, k: usize) -> Vec<(usize, f64)> {
395 if self.documents.is_empty() || k == 0 {
396 return Vec::new();
397 }
398
399 let terms = BM25Index::tokenize(query_text);
400 if terms.is_empty() {
401 return Vec::new();
402 }
403
404 let mut scores: Vec<(usize, f64)> = (0..self.documents.len())
405 .map(|idx| (idx, self.bm25_score(idx, &terms)))
406 .collect();
407
408 scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
409 scores.truncate(k);
410 scores
411 }
412
413 pub fn hybrid_search(&mut self, query: &RetrievalQuery) -> Vec<RetrievalResult> {
430 self.total_queries += 1;
431
432 let alpha = query.hybrid_alpha.clamp(0.0, 1.0);
433 let k = query.top_k.max(1);
434
435 let candidate_k = (k * 4).max(k + 10).min(self.documents.len().max(1));
437
438 let dense_raw = self.dense_search(&query.embedding, candidate_k);
439 let sparse_raw = self.sparse_search(&query.text, candidate_k);
440
441 let dense_norm = min_max_normalise(&dense_raw);
443 let sparse_norm = min_max_normalise(&sparse_raw);
444
445 let mut merged: HashMap<usize, (f64, f64)> = HashMap::new();
447
448 for (doc_idx, score) in &dense_norm {
449 merged.entry(*doc_idx).or_insert((0.0, 0.0)).0 = *score;
450 }
451 for (doc_idx, score) in &sparse_norm {
452 merged.entry(*doc_idx).or_insert((0.0, 0.0)).1 = *score;
453 }
454
455 let mut fused: Vec<(usize, f64, f64, f64)> = merged
457 .into_iter()
458 .map(|(idx, (d, s))| {
459 let h = alpha * d + (1.0 - alpha) * s;
460 (idx, d, s, h)
461 })
462 .collect();
463
464 fused.sort_by(|a, b| b.3.partial_cmp(&a.3).unwrap_or(std::cmp::Ordering::Equal));
465 fused.truncate(k);
466
467 fused
468 .into_iter()
469 .enumerate()
470 .filter_map(|(rank_idx, (doc_idx, d, s, h))| {
471 let doc_id = self.documents.get(doc_idx)?.id.clone();
472 Some(RetrievalResult {
473 doc_id,
474 dense_score: d,
475 sparse_score: s,
476 hybrid_score: h,
477 rank: rank_idx + 1,
478 })
479 })
480 .collect()
481 }
482
483 pub fn get_document(&self, id: &str) -> Option<&Document> {
489 self.documents.iter().find(|d| d.id == id)
490 }
491
492 pub fn document_count(&self) -> usize {
494 self.documents.len()
495 }
496
497 pub fn retriever_stats(&self) -> RetrieverStats {
499 RetrieverStats {
500 document_count: self.documents.len(),
501 total_queries: self.total_queries,
502 avg_doc_length: self.bm25.avg_doc_length,
503 vocabulary_size: self.bm25.doc_freq.len(),
504 }
505 }
506}
507
508fn l2_norm(v: &[f64]) -> f64 {
514 v.iter().map(|x| x * x).sum::<f64>().sqrt()
515}
516
517fn cosine_sim_normed(query: &[f64], doc: &[f64], q_norm: f64) -> f64 {
521 if q_norm < 1e-12 {
522 return 0.0;
523 }
524 let d_norm = l2_norm(doc);
525 if d_norm < 1e-12 {
526 return 0.0;
527 }
528 let dot: f64 = query.iter().zip(doc.iter()).map(|(q, d)| q * d).sum();
529 dot / (q_norm * d_norm)
530}
531
532fn min_max_normalise(scores: &[(usize, f64)]) -> Vec<(usize, f64)> {
537 if scores.is_empty() {
538 return Vec::new();
539 }
540 let min = scores.iter().map(|(_, s)| *s).fold(f64::INFINITY, f64::min);
541 let max = scores
542 .iter()
543 .map(|(_, s)| *s)
544 .fold(f64::NEG_INFINITY, f64::max);
545
546 let range = max - min;
547 scores
548 .iter()
549 .map(|(idx, s)| {
550 let norm = if range < 1e-12 {
551 1.0
552 } else {
553 (s - min) / range
554 };
555 (*idx, norm)
556 })
557 .collect()
558}
559
560#[cfg(test)]
565mod tests {
566 use std::collections::HashMap;
567
568 use crate::dense_retriever::{
569 min_max_normalise, BM25Index, DenseRetriever, Document, RetrievalQuery, RetrieverConfig,
570 RetrieverError,
571 };
572
573 fn make_config(dim: usize) -> RetrieverConfig {
578 RetrieverConfig {
579 embedding_dim: dim,
580 max_documents: 100,
581 bm25_k1: 1.2,
582 bm25_b: 0.75,
583 }
584 }
585
586 fn make_doc(id: &str, content: &str, emb: Vec<f64>) -> Document {
587 Document {
588 id: id.to_string(),
589 content: content.to_string(),
590 embedding: emb,
591 metadata: HashMap::new(),
592 }
593 }
594
595 fn unit_vec(dim: usize, fill: f64) -> Vec<f64> {
596 vec![fill; dim]
597 }
598
599 #[test]
604 fn test_new_retriever_is_empty() {
605 let r = DenseRetriever::new(make_config(4));
606 assert_eq!(r.document_count(), 0);
607 assert_eq!(r.total_queries, 0);
608 }
609
610 #[test]
611 fn test_default_config_values() {
612 let cfg = RetrieverConfig::default();
613 assert_eq!(cfg.embedding_dim, 128);
614 assert_eq!(cfg.max_documents, 100_000);
615 assert!((cfg.bm25_k1 - 1.2).abs() < 1e-9);
616 assert!((cfg.bm25_b - 0.75).abs() < 1e-9);
617 }
618
619 #[test]
624 fn test_add_single_document() {
625 let mut r = DenseRetriever::new(make_config(4));
626 let doc = make_doc("d1", "hello world", vec![0.1, 0.2, 0.3, 0.4]);
627 assert!(r.add_document(doc).is_ok());
628 assert_eq!(r.document_count(), 1);
629 }
630
631 #[test]
632 fn test_add_document_dimension_mismatch() {
633 let mut r = DenseRetriever::new(make_config(4));
634 let doc = make_doc("d1", "hello", vec![0.1, 0.2]); let err = r
636 .add_document(doc)
637 .expect_err("test: add_document with wrong dimension should return error");
638 assert!(matches!(
639 err,
640 RetrieverError::DimensionMismatch {
641 expected: 4,
642 got: 2
643 }
644 ));
645 }
646
647 #[test]
648 fn test_add_document_capacity_limit() {
649 let mut cfg = make_config(2);
650 cfg.max_documents = 2;
651 let mut r = DenseRetriever::new(cfg);
652 r.add_document(make_doc("d1", "a", vec![1.0, 0.0]))
653 .expect("test: add_document should succeed");
654 r.add_document(make_doc("d2", "b", vec![0.0, 1.0]))
655 .expect("test: add_document should succeed");
656 let err = r
657 .add_document(make_doc("d3", "c", vec![0.5, 0.5]))
658 .expect_err("test: add_document beyond capacity should return error");
659 assert!(matches!(err, RetrieverError::MaxDocumentsReached(2)));
660 }
661
662 #[test]
663 fn test_bm25_index_updated_on_add() {
664 let mut r = DenseRetriever::new(make_config(2));
665 r.add_document(make_doc("d1", "rust is great", vec![1.0, 0.0]))
666 .expect("test: add_document should succeed");
667 assert!(r.bm25.doc_freq.contains_key("rust"));
668 assert!(r.bm25.doc_freq.contains_key("is"));
669 assert!(r.bm25.doc_freq.contains_key("great"));
670 }
671
672 #[test]
673 fn test_avg_doc_length_updated() {
674 let mut r = DenseRetriever::new(make_config(2));
675 r.add_document(make_doc("d1", "one two three", vec![1.0, 0.0]))
676 .expect("test: add_document should succeed");
677 r.add_document(make_doc("d2", "four five", vec![0.0, 1.0]))
678 .expect("test: add_document should succeed");
679 assert!((r.bm25.avg_doc_length - 2.5).abs() < 1e-9);
681 }
682
683 #[test]
688 fn test_remove_existing_document() {
689 let mut r = DenseRetriever::new(make_config(2));
690 r.add_document(make_doc("d1", "hello world", vec![1.0, 0.0]))
691 .expect("test: add_document should succeed");
692 let removed = r.remove_document("d1");
693 assert!(removed);
694 assert_eq!(r.document_count(), 0);
695 }
696
697 #[test]
698 fn test_remove_nonexistent_returns_false() {
699 let mut r = DenseRetriever::new(make_config(2));
700 r.add_document(make_doc("d1", "hello", vec![1.0, 0.0]))
701 .expect("test: add_document should succeed");
702 assert!(!r.remove_document("does_not_exist"));
703 }
704
705 #[test]
706 fn test_bm25_rebuilt_after_remove() {
707 let mut r = DenseRetriever::new(make_config(2));
708 r.add_document(make_doc("d1", "alpha beta", vec![1.0, 0.0]))
709 .expect("test: add_document should succeed");
710 r.add_document(make_doc("d2", "alpha gamma", vec![0.0, 1.0]))
711 .expect("test: add_document should succeed");
712 r.remove_document("d1");
713 assert!(!r.bm25.doc_freq.contains_key("beta"));
715 assert!(r.bm25.doc_freq.contains_key("alpha"));
716 }
717
718 #[test]
723 fn test_tokenizer_splits_on_whitespace() {
724 let tokens = BM25Index::tokenize("hello world foo");
725 assert_eq!(tokens, vec!["hello", "world", "foo"]);
726 }
727
728 #[test]
729 fn test_tokenizer_splits_on_punctuation() {
730 let tokens = BM25Index::tokenize("hello, world! foo.");
731 assert_eq!(tokens, vec!["hello", "world", "foo"]);
732 }
733
734 #[test]
735 fn test_tokenizer_lowercases() {
736 let tokens = BM25Index::tokenize("Hello WORLD");
737 assert_eq!(tokens, vec!["hello", "world"]);
738 }
739
740 #[test]
741 fn test_tokenizer_empty_string() {
742 let tokens = BM25Index::tokenize("");
743 assert!(tokens.is_empty());
744 }
745
746 #[test]
747 fn test_tokenizer_only_punctuation() {
748 let tokens = BM25Index::tokenize("!!! ,,, ...");
749 assert!(tokens.is_empty());
750 }
751
752 #[test]
757 fn test_dense_search_empty_index() {
758 let r = DenseRetriever::new(make_config(4));
759 let res = r.dense_search(&[1.0, 0.0, 0.0, 0.0], 5);
760 assert!(res.is_empty());
761 }
762
763 #[test]
764 fn test_dense_search_returns_at_most_k() {
765 let mut r = DenseRetriever::new(make_config(2));
766 for i in 0..10u32 {
767 r.add_document(make_doc(
768 &i.to_string(),
769 "doc",
770 vec![i as f64, (10 - i) as f64],
771 ))
772 .expect("test: add_document should succeed");
773 }
774 let res = r.dense_search(&[1.0, 0.0], 3);
775 assert_eq!(res.len(), 3);
776 }
777
778 #[test]
779 fn test_dense_search_highest_similarity_first() {
780 let mut r = DenseRetriever::new(make_config(2));
781 r.add_document(make_doc("d1", "a", vec![1.0, 0.0]))
782 .expect("test: add_document should succeed");
783 r.add_document(make_doc("d2", "b", vec![0.0, 1.0]))
784 .expect("test: add_document should succeed");
785
786 let res = r.dense_search(&[1.0, 0.0], 2);
788 assert_eq!(res[0].0, 0); }
790
791 #[test]
792 fn test_dense_search_zero_query_vector() {
793 let mut r = DenseRetriever::new(make_config(2));
794 r.add_document(make_doc("d1", "x", vec![1.0, 0.0]))
795 .expect("test: add_document should succeed");
796 let res = r.dense_search(&[0.0, 0.0], 1);
797 assert_eq!(res.len(), 1);
799 assert!((res[0].1).abs() < 1e-9);
800 }
801
802 #[test]
807 fn test_bm25_score_zero_for_missing_term() {
808 let mut r = DenseRetriever::new(make_config(2));
809 r.add_document(make_doc("d1", "apple banana", vec![1.0, 0.0]))
810 .expect("test: add_document should succeed");
811 let score = r.bm25_score(0, &["pear".to_string()]);
812 assert!((score).abs() < 1e-9);
813 }
814
815 #[test]
816 fn test_bm25_score_positive_for_matching_term() {
817 let mut r = DenseRetriever::new(make_config(2));
818 r.add_document(make_doc("d1", "rust programming language", vec![1.0, 0.0]))
819 .expect("test: add_document should succeed");
820 let score = r.bm25_score(0, &["rust".to_string()]);
821 assert!(score > 0.0);
822 }
823
824 #[test]
825 fn test_bm25_score_increases_with_tf() {
826 let mut r = DenseRetriever::new(make_config(2));
827 r.add_document(make_doc(
828 "d1",
829 "rust rust rust other words here",
830 vec![1.0, 0.0],
831 ))
832 .expect("test: add_document should succeed");
833 r.add_document(make_doc("d2", "rust other words here", vec![0.0, 1.0]))
834 .expect("test: add_document should succeed");
835 let s1 = r.bm25_score(0, &["rust".to_string()]);
836 let s2 = r.bm25_score(1, &["rust".to_string()]);
837 assert!(s1 > s2, "s1={s1} s2={s2}");
838 }
839
840 #[test]
845 fn test_sparse_search_empty_query() {
846 let mut r = DenseRetriever::new(make_config(2));
847 r.add_document(make_doc("d1", "hello world", vec![1.0, 0.0]))
848 .expect("test: add_document should succeed");
849 let res = r.sparse_search("", 5);
850 assert!(res.is_empty());
851 }
852
853 #[test]
854 fn test_sparse_search_returns_sorted_desc() {
855 let mut r = DenseRetriever::new(make_config(2));
856 r.add_document(make_doc("d1", "rust", vec![1.0, 0.0]))
857 .expect("test: add_document should succeed");
858 r.add_document(make_doc("d2", "rust rust programming", vec![0.0, 1.0]))
859 .expect("test: add_document should succeed");
860 let res = r.sparse_search("rust", 2);
861 assert!(!res.is_empty());
862 assert!(res[0].1 >= res[1].1);
863 }
864
865 #[test]
866 fn test_sparse_search_no_match_returns_all_zero() {
867 let mut r = DenseRetriever::new(make_config(2));
868 r.add_document(make_doc("d1", "apple banana", vec![1.0, 0.0]))
869 .expect("test: add_document should succeed");
870 let res = r.sparse_search("zephyr", 1);
871 if !res.is_empty() {
873 assert!((res[0].1).abs() < 1e-9);
874 }
875 }
876
877 fn make_query(text: &str, emb: Vec<f64>, k: usize, alpha: f64) -> RetrievalQuery {
882 RetrievalQuery {
883 text: text.to_string(),
884 embedding: emb,
885 top_k: k,
886 hybrid_alpha: alpha,
887 }
888 }
889
890 #[test]
891 fn test_hybrid_search_increments_query_count() {
892 let mut r = DenseRetriever::new(make_config(2));
893 r.add_document(make_doc("d1", "hello", vec![1.0, 0.0]))
894 .expect("test: add_document should succeed");
895 let q = make_query("hello", vec![1.0, 0.0], 1, 0.5);
896 r.hybrid_search(&q);
897 r.hybrid_search(&q);
898 assert_eq!(r.total_queries, 2);
899 }
900
901 #[test]
902 fn test_hybrid_search_returns_at_most_top_k() {
903 let mut r = DenseRetriever::new(make_config(2));
904 for i in 0..10u32 {
905 r.add_document(make_doc(
906 &i.to_string(),
907 "hello world",
908 vec![i as f64 + 1.0, 1.0],
909 ))
910 .expect("test: add_document should succeed");
911 }
912 let q = make_query("hello world", vec![1.0, 0.5], 3, 0.5);
913 let res = r.hybrid_search(&q);
914 assert!(res.len() <= 3);
915 }
916
917 #[test]
918 fn test_hybrid_search_ranks_start_at_one() {
919 let mut r = DenseRetriever::new(make_config(2));
920 r.add_document(make_doc("d1", "hello", vec![1.0, 0.0]))
921 .expect("test: add_document should succeed");
922 r.add_document(make_doc("d2", "world", vec![0.0, 1.0]))
923 .expect("test: add_document should succeed");
924 let q = make_query("hello world", vec![0.8, 0.2], 2, 0.5);
925 let res = r.hybrid_search(&q);
926 assert_eq!(res[0].rank, 1);
927 if res.len() > 1 {
928 assert_eq!(res[1].rank, 2);
929 }
930 }
931
932 #[test]
933 fn test_hybrid_search_pure_dense_alpha_one() {
934 let mut r = DenseRetriever::new(make_config(2));
935 r.add_document(make_doc("d1", "irrelevant text abc", vec![1.0, 0.0]))
936 .expect("test: add_document should succeed");
937 r.add_document(make_doc("d2", "irrelevant text xyz", vec![0.0, 1.0]))
938 .expect("test: add_document should succeed");
939
940 let q = make_query("unrelated", vec![1.0, 0.0], 2, 1.0);
942 let res = r.hybrid_search(&q);
943 assert_eq!(res[0].doc_id, "d1");
945 assert!((res[0].hybrid_score - res[0].dense_score).abs() < 1e-9);
948 }
949
950 #[test]
951 fn test_hybrid_search_pure_sparse_alpha_zero() {
952 let mut r = DenseRetriever::new(make_config(2));
953 r.add_document(make_doc("d1", "rust programming systems", vec![0.5, 0.5]))
954 .expect("test: add_document should succeed");
955 r.add_document(make_doc("d2", "python scripting", vec![0.5, 0.5]))
956 .expect("test: add_document should succeed");
957
958 let q = make_query("rust", vec![0.5, 0.5], 2, 0.0);
960 let res = r.hybrid_search(&q);
961 assert_eq!(res[0].doc_id, "d1", "BM25 should prefer d1 for 'rust'");
962 }
963
964 #[test]
965 fn test_hybrid_score_formula() {
966 let alpha = 0.6_f64;
968 let dense = 0.8_f64;
969 let sparse = 0.5_f64;
970 let expected = alpha * dense + (1.0 - alpha) * sparse;
971 let computed = alpha * dense + (1.0 - alpha) * sparse;
972 assert!((expected - computed).abs() < 1e-12);
973 }
974
975 #[test]
976 fn test_hybrid_search_empty_index() {
977 let mut r = DenseRetriever::new(make_config(2));
978 let q = make_query("hello", vec![1.0, 0.0], 5, 0.5);
979 let res = r.hybrid_search(&q);
980 assert!(res.is_empty());
981 }
982
983 #[test]
984 fn test_hybrid_search_alpha_clamp_above_one() {
985 let mut r = DenseRetriever::new(make_config(2));
986 r.add_document(make_doc("d1", "foo", vec![1.0, 0.0]))
987 .expect("test: add_document should succeed");
988 let q = make_query("foo", vec![1.0, 0.0], 1, 2.5); let res = r.hybrid_search(&q);
990 assert_eq!(res.len(), 1);
991 assert!((res[0].hybrid_score - res[0].dense_score).abs() < 1e-9);
993 }
994
995 #[test]
996 fn test_hybrid_search_alpha_clamp_below_zero() {
997 let mut r = DenseRetriever::new(make_config(2));
998 r.add_document(make_doc("d1", "foo", vec![1.0, 0.0]))
999 .expect("test: add_document should succeed");
1000 let q = make_query("foo", vec![1.0, 0.0], 1, -0.5); let res = r.hybrid_search(&q);
1002 assert_eq!(res.len(), 1);
1003 assert!((res[0].hybrid_score - res[0].sparse_score).abs() < 1e-9);
1005 }
1006
1007 #[test]
1012 fn test_get_document_found() {
1013 let mut r = DenseRetriever::new(make_config(2));
1014 r.add_document(make_doc("d1", "hello", vec![1.0, 0.0]))
1015 .expect("test: add_document should succeed");
1016 let doc = r.get_document("d1");
1017 assert!(doc.is_some());
1018 assert_eq!(
1019 doc.expect("test: get_document should return Some after insert")
1020 .id,
1021 "d1"
1022 );
1023 }
1024
1025 #[test]
1026 fn test_get_document_not_found() {
1027 let r = DenseRetriever::new(make_config(2));
1028 assert!(r.get_document("missing").is_none());
1029 }
1030
1031 #[test]
1032 fn test_document_count_after_operations() {
1033 let mut r = DenseRetriever::new(make_config(2));
1034 assert_eq!(r.document_count(), 0);
1035 r.add_document(make_doc("d1", "a", vec![1.0, 0.0]))
1036 .expect("test: add_document should succeed");
1037 assert_eq!(r.document_count(), 1);
1038 r.add_document(make_doc("d2", "b", vec![0.0, 1.0]))
1039 .expect("test: add_document should succeed");
1040 assert_eq!(r.document_count(), 2);
1041 r.remove_document("d1");
1042 assert_eq!(r.document_count(), 1);
1043 }
1044
1045 #[test]
1050 fn test_stats_after_queries() {
1051 let mut r = DenseRetriever::new(make_config(2));
1052 r.add_document(make_doc("d1", "word1 word2", vec![1.0, 0.0]))
1053 .expect("test: add_document should succeed");
1054 let q = make_query("word1", vec![1.0, 0.0], 1, 0.5);
1055 r.hybrid_search(&q);
1056 let stats = r.retriever_stats();
1057 assert_eq!(stats.document_count, 1);
1058 assert_eq!(stats.total_queries, 1);
1059 assert!(stats.vocabulary_size >= 2);
1060 }
1061
1062 #[test]
1063 fn test_stats_vocabulary_size() {
1064 let mut r = DenseRetriever::new(make_config(2));
1065 r.add_document(make_doc("d1", "apple banana cherry", vec![1.0, 0.0]))
1066 .expect("test: add_document should succeed");
1067 r.add_document(make_doc("d2", "cherry date elderberry", vec![0.0, 1.0]))
1068 .expect("test: add_document should succeed");
1069 let stats = r.retriever_stats();
1070 assert_eq!(stats.vocabulary_size, 5);
1072 }
1073
1074 #[test]
1079 fn test_min_max_normalise_empty() {
1080 let out = min_max_normalise(&[]);
1081 assert!(out.is_empty());
1082 }
1083
1084 #[test]
1085 fn test_min_max_normalise_single_element() {
1086 let out = min_max_normalise(&[(0, 5.0)]);
1087 assert!((out[0].1 - 1.0).abs() < 1e-9);
1089 }
1090
1091 #[test]
1092 fn test_min_max_normalise_range() {
1093 let scores = vec![(0, 0.0), (1, 5.0), (2, 10.0)];
1094 let out = min_max_normalise(&scores);
1095 assert!((out[0].1 - 0.0).abs() < 1e-9);
1096 assert!((out[1].1 - 0.5).abs() < 1e-9);
1097 assert!((out[2].1 - 1.0).abs() < 1e-9);
1098 }
1099
1100 #[test]
1101 fn test_min_max_normalise_all_equal() {
1102 let scores = vec![(0, 3.0), (1, 3.0), (2, 3.0)];
1103 let out = min_max_normalise(&scores);
1104 for (_, s) in &out {
1105 assert!((s - 1.0).abs() < 1e-9);
1106 }
1107 }
1108
1109 #[test]
1114 fn test_rebuild_bm25_idempotent() {
1115 let mut r = DenseRetriever::new(make_config(2));
1116 r.add_document(make_doc("d1", "foo bar", vec![1.0, 0.0]))
1117 .expect("test: add_document should succeed");
1118 r.add_document(make_doc("d2", "bar baz", vec![0.0, 1.0]))
1119 .expect("test: add_document should succeed");
1120 let avg_before = r.bm25.avg_doc_length;
1121 let vocab_before = r.bm25.doc_freq.len();
1122 r.rebuild_bm25();
1123 let avg_after = r.bm25.avg_doc_length;
1124 let vocab_after = r.bm25.doc_freq.len();
1125 assert!((avg_before - avg_after).abs() < 1e-9);
1126 assert_eq!(vocab_before, vocab_after);
1127 }
1128
1129 #[test]
1130 fn test_bm25_doc_freq_counts_documents_not_occurrences() {
1131 let mut r = DenseRetriever::new(make_config(2));
1132 r.add_document(make_doc("d1", "rust rust rust", vec![1.0, 0.0]))
1134 .expect("test: add_document should succeed");
1135 assert_eq!(*r.bm25.doc_freq.get("rust").unwrap_or(&0), 1);
1136 r.add_document(make_doc("d2", "rust code", vec![0.0, 1.0]))
1137 .expect("test: add_document should succeed");
1138 assert_eq!(*r.bm25.doc_freq.get("rust").unwrap_or(&0), 2);
1139 }
1140
1141 #[test]
1146 fn test_error_display_max_documents_reached() {
1147 let err = RetrieverError::MaxDocumentsReached(50);
1148 let s = err.to_string();
1149 assert!(s.contains("50"));
1150 }
1151
1152 #[test]
1153 fn test_error_display_dimension_mismatch() {
1154 let err = RetrieverError::DimensionMismatch {
1155 expected: 128,
1156 got: 64,
1157 };
1158 let s = err.to_string();
1159 assert!(s.contains("128") && s.contains("64"));
1160 }
1161
1162 #[test]
1163 fn test_error_display_not_found() {
1164 let err = RetrieverError::DocumentNotFound("abc".to_string());
1165 assert!(err.to_string().contains("abc"));
1166 }
1167
1168 #[test]
1173 fn test_large_corpus_hybrid_search() {
1174 use std::collections::HashMap;
1175 let dim = 8_usize;
1176 let mut r = DenseRetriever::new(RetrieverConfig {
1177 embedding_dim: dim,
1178 max_documents: 500,
1179 bm25_k1: 1.2,
1180 bm25_b: 0.75,
1181 });
1182
1183 let words = ["alpha", "beta", "gamma", "delta", "epsilon"];
1184 for i in 0..200u32 {
1185 let word = words[(i as usize) % words.len()];
1186 let emb: Vec<f64> = (0..dim).map(|j| (i as f64 + j as f64) / 200.0).collect();
1187 r.add_document(Document {
1188 id: format!("d{i}"),
1189 content: format!("{word} document number {i}"),
1190 embedding: emb,
1191 metadata: HashMap::new(),
1192 })
1193 .expect("test: add_document in large corpus test should succeed");
1194 }
1195
1196 let q_emb: Vec<f64> = (0..dim).map(|j| j as f64 / 8.0).collect();
1197 let q = RetrievalQuery {
1198 text: "alpha document".to_string(),
1199 embedding: q_emb,
1200 top_k: 10,
1201 hybrid_alpha: 0.5,
1202 };
1203 let res = r.hybrid_search(&q);
1204 assert!(!res.is_empty());
1205 assert!(res.len() <= 10);
1206
1207 for (i, hit) in res.iter().enumerate() {
1209 assert_eq!(hit.rank, i + 1);
1210 }
1211
1212 for w in res.windows(2) {
1214 assert!(w[0].hybrid_score >= w[1].hybrid_score);
1215 }
1216 }
1217
1218 #[test]
1219 fn test_unit_embeddings_give_cosine_one() {
1220 let mut r = DenseRetriever::new(make_config(3));
1221 r.add_document(make_doc("d1", "x", unit_vec(3, 1.0)))
1222 .expect("test: add_document should succeed");
1223 let res = r.dense_search(&unit_vec(3, 1.0), 1);
1225 assert!((res[0].1 - 1.0).abs() < 1e-6);
1226 }
1227}