1use std::collections::HashMap;
42use std::time::Instant;
43
44#[derive(Debug, Clone)]
50pub struct SearchDocument {
51 pub id: String,
52 pub content: String,
53 pub embedding: Vec<f64>,
54 pub metadata: HashMap<String, String>,
55}
56
57#[derive(Debug, Clone)]
61pub struct SpSearchQuery {
62 pub text: String,
63 pub embedding: Option<Vec<f64>>,
64 pub filters: HashMap<String, String>,
65 pub top_k: usize,
66 pub min_score: f64,
67}
68
69#[derive(Debug, Clone)]
71pub struct SearchHit {
72 pub doc_id: String,
73 pub score: f64,
74 pub vector_score: f64,
75 pub bm25_score: f64,
76 pub rank: usize,
77}
78
79#[derive(Debug, Clone)]
81pub struct SearchPipelineResult {
82 pub query_text: String,
83 pub hits: Vec<SearchHit>,
84 pub total_candidates: usize,
85 pub search_time_ms: u64,
86}
87
88#[derive(Debug, Clone)]
90pub enum FusionMethod {
91 ReciprocalRankFusion { k: f64 },
94 LinearCombination {
96 vector_weight: f64,
97 bm25_weight: f64,
98 },
99 CombSUM,
101}
102
103impl Default for FusionMethod {
104 fn default() -> Self {
105 FusionMethod::ReciprocalRankFusion { k: 60.0 }
106 }
107}
108
109#[derive(Debug, Clone)]
113pub struct SpPipelineConfig {
114 pub fusion_method: FusionMethod,
115 pub vector_candidates: usize,
117 pub bm25_candidates: usize,
119 pub rerank_top_n: usize,
121}
122
123impl Default for SpPipelineConfig {
124 fn default() -> Self {
125 SpPipelineConfig {
126 fusion_method: FusionMethod::ReciprocalRankFusion { k: 60.0 },
127 vector_candidates: 100,
128 bm25_candidates: 100,
129 rerank_top_n: 20,
130 }
131 }
132}
133
134#[derive(Debug, Clone)]
138pub struct SpPipelineStats {
139 pub doc_count: usize,
140 pub vocabulary_size: usize,
141 pub avg_doc_length: f64,
142 pub total_searches: u64,
143 pub avg_hits_per_search: f64,
144}
145
146#[derive(Debug, Clone)]
152struct DocBm25 {
153 tokens: Vec<String>,
154 tf: HashMap<String, f64>,
155}
156
157impl DocBm25 {
158 fn from_content(content: &str) -> Self {
159 let tokens: Vec<String> = tokenize(content);
160 let mut tf: HashMap<String, f64> = HashMap::new();
161 for t in &tokens {
162 *tf.entry(t.clone()).or_insert(0.0) += 1.0;
163 }
164 DocBm25 { tokens, tf }
165 }
166}
167
168fn tokenize(text: &str) -> Vec<String> {
174 text.split_whitespace().map(|w| w.to_lowercase()).collect()
175}
176
177fn normalise(scores: &[(String, f64)]) -> Vec<(String, f64)> {
180 let max = scores.iter().map(|(_, s)| *s).fold(0.0_f64, f64::max);
181 if max == 0.0 {
182 return scores.iter().map(|(id, _)| (id.clone(), 0.0)).collect();
183 }
184 scores.iter().map(|(id, s)| (id.clone(), s / max)).collect()
185}
186
187#[derive(Debug)]
197pub struct SemanticSearchPipeline {
198 pub config: SpPipelineConfig,
199 pub documents: HashMap<String, SearchDocument>,
201 bm25_data: HashMap<String, DocBm25>,
203 pub idf: HashMap<String, f64>,
205 df: HashMap<String, usize>,
207 pub total_docs: usize,
209 total_searches: u64,
211 total_hits: u64,
213}
214
215impl SemanticSearchPipeline {
216 pub fn new(config: SpPipelineConfig) -> Self {
222 SemanticSearchPipeline {
223 config,
224 documents: HashMap::new(),
225 bm25_data: HashMap::new(),
226 idf: HashMap::new(),
227 df: HashMap::new(),
228 total_docs: 0,
229 total_searches: 0,
230 total_hits: 0,
231 }
232 }
233
234 pub fn add_document(&mut self, doc: SearchDocument) {
240 let bm25 = DocBm25::from_content(&doc.content);
241
242 for term in bm25.tf.keys() {
244 *self.df.entry(term.clone()).or_insert(0) += 1;
245 }
246
247 let doc_id = doc.id.clone();
248 self.documents.insert(doc_id.clone(), doc);
249 self.bm25_data.insert(doc_id, bm25);
250 self.total_docs = self.documents.len();
251 self.recompute_idf();
252 }
253
254 pub fn remove_document(&mut self, doc_id: &str) -> bool {
257 if let Some(bm25) = self.bm25_data.remove(doc_id) {
258 self.documents.remove(doc_id);
259 for term in bm25.tf.keys() {
261 if let Some(count) = self.df.get_mut(term.as_str()) {
262 if *count <= 1 {
263 self.df.remove(term.as_str());
264 } else {
265 *count -= 1;
266 }
267 }
268 }
269 self.total_docs = self.documents.len();
270 self.recompute_idf();
271 true
272 } else {
273 false
274 }
275 }
276
277 pub fn doc_count(&self) -> usize {
279 self.documents.len()
280 }
281
282 pub fn stats(&self) -> SpPipelineStats {
284 let avg_doc_length = if self.bm25_data.is_empty() {
285 0.0
286 } else {
287 let total_tokens: usize = self.bm25_data.values().map(|b| b.tokens.len()).sum();
288 total_tokens as f64 / self.bm25_data.len() as f64
289 };
290
291 let avg_hits_per_search = if self.total_searches == 0 {
292 0.0
293 } else {
294 self.total_hits as f64 / self.total_searches as f64
295 };
296
297 SpPipelineStats {
298 doc_count: self.total_docs,
299 vocabulary_size: self.idf.len(),
300 avg_doc_length,
301 total_searches: self.total_searches,
302 avg_hits_per_search,
303 }
304 }
305
306 pub fn search(&mut self, query: &SpSearchQuery) -> SearchPipelineResult {
319 let start = Instant::now();
320
321 let vector_results: Vec<(String, f64)> = query
323 .embedding
324 .as_deref()
325 .map(|emb| self.vector_search(emb, self.config.vector_candidates))
326 .unwrap_or_default();
327
328 let bm25_results = self.bm25_search(&query.text, self.config.bm25_candidates);
330
331 let mut candidate_ids: std::collections::HashSet<&str> = std::collections::HashSet::new();
333 for (id, _) in &vector_results {
334 candidate_ids.insert(id.as_str());
335 }
336 for (id, _) in &bm25_results {
337 candidate_ids.insert(id.as_str());
338 }
339 let total_candidates = candidate_ids.len();
340
341 let vector_map: HashMap<&str, f64> = vector_results
343 .iter()
344 .map(|(id, s)| (id.as_str(), *s))
345 .collect();
346 let bm25_map: HashMap<&str, f64> = bm25_results
347 .iter()
348 .map(|(id, s)| (id.as_str(), *s))
349 .collect();
350
351 let mut fused = self.fuse(&vector_results, &bm25_results);
353 fused.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
354
355 let rerank_top_n = self.config.rerank_top_n;
356 let min_score = query.min_score;
357 let top_k = query.top_k;
358
359 let mut hits: Vec<SearchHit> = fused
361 .into_iter()
362 .take(rerank_top_n)
363 .filter(|(_, score)| *score >= min_score)
364 .filter(|(id, _)| self.matches_filters(id, &query.filters))
365 .take(top_k)
366 .map(|(id, score)| {
367 let vs = vector_map.get(id.as_str()).copied().unwrap_or(0.0);
368 let bs = bm25_map.get(id.as_str()).copied().unwrap_or(0.0);
369 SearchHit {
370 doc_id: id,
371 score,
372 vector_score: vs,
373 bm25_score: bs,
374 rank: 0, }
376 })
377 .collect();
378
379 hits.sort_by(|a, b| {
381 b.score
382 .partial_cmp(&a.score)
383 .unwrap_or(std::cmp::Ordering::Equal)
384 });
385 for (rank, hit) in hits.iter_mut().enumerate() {
386 hit.rank = rank + 1;
387 }
388
389 self.total_searches += 1;
391 self.total_hits += hits.len() as u64;
392
393 let search_time_ms = start.elapsed().as_millis() as u64;
394
395 SearchPipelineResult {
396 query_text: query.text.clone(),
397 hits,
398 total_candidates,
399 search_time_ms,
400 }
401 }
402
403 pub fn vector_search(&self, embedding: &[f64], top_k: usize) -> Vec<(String, f64)> {
410 let mut scores: Vec<(String, f64)> = self
411 .documents
412 .iter()
413 .map(|(id, doc)| {
414 let sim = Self::cosine_similarity(embedding, &doc.embedding);
415 (id.clone(), sim)
416 })
417 .collect();
418
419 scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
420 scores.truncate(top_k);
421 scores
422 }
423
424 pub fn bm25_search(&self, query_text: &str, top_k: usize) -> Vec<(String, f64)> {
433 let query_tokens = tokenize(query_text);
434 if query_tokens.is_empty() || self.total_docs == 0 {
435 return Vec::new();
436 }
437
438 let avgdl = self.average_doc_length();
439 let k1 = 1.5_f64;
440 let b = 0.75_f64;
441
442 let mut scores: Vec<(String, f64)> = self
443 .bm25_data
444 .iter()
445 .filter_map(|(doc_id, bm25)| {
446 let dl = bm25.tokens.len() as f64;
447 let score: f64 = query_tokens
448 .iter()
449 .map(|term| {
450 let idf = self.idf.get(term.as_str()).copied().unwrap_or(0.0);
451 if idf <= 0.0 {
452 return 0.0;
453 }
454 let tf = bm25.tf.get(term.as_str()).copied().unwrap_or(0.0);
455 let denom = tf + k1 * (1.0 - b + b * dl / avgdl.max(1.0));
456 idf * tf * (k1 + 1.0) / denom
457 })
458 .sum();
459 if score > 0.0 {
460 Some((doc_id.clone(), score))
461 } else {
462 None
463 }
464 })
465 .collect();
466
467 scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
468 scores.truncate(top_k);
469 scores
470 }
471
472 pub fn fuse(
481 &self,
482 vector_results: &[(String, f64)],
483 bm25_results: &[(String, f64)],
484 ) -> Vec<(String, f64)> {
485 match &self.config.fusion_method {
486 FusionMethod::ReciprocalRankFusion { k } => {
487 self.rrf_fuse(vector_results, bm25_results, *k)
488 }
489 FusionMethod::LinearCombination {
490 vector_weight,
491 bm25_weight,
492 } => self.linear_fuse(vector_results, bm25_results, *vector_weight, *bm25_weight),
493 FusionMethod::CombSUM => self.linear_fuse(vector_results, bm25_results, 0.5, 0.5),
494 }
495 }
496
497 pub fn cosine_similarity(a: &[f64], b: &[f64]) -> f64 {
505 if a.is_empty() || a.len() != b.len() {
506 return 0.0;
507 }
508 let dot: f64 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
509 let norm_a: f64 = a.iter().map(|x| x * x).sum::<f64>().sqrt();
510 let norm_b: f64 = b.iter().map(|x| x * x).sum::<f64>().sqrt();
511 if norm_a == 0.0 || norm_b == 0.0 {
512 return 0.0;
513 }
514 dot / (norm_a * norm_b)
515 }
516
517 fn recompute_idf(&mut self) {
523 let n = self.total_docs as f64;
524 self.idf = self
525 .df
526 .iter()
527 .map(|(term, &df_count)| {
528 let df_f = df_count as f64;
529 let idf = ((n - df_f + 0.5) / (df_f + 0.5) + 1.0).ln();
531 (term.clone(), idf)
532 })
533 .collect();
534 }
535
536 fn average_doc_length(&self) -> f64 {
539 if self.bm25_data.is_empty() {
540 return 1.0;
541 }
542 let total: usize = self.bm25_data.values().map(|b| b.tokens.len()).sum();
543 total as f64 / self.bm25_data.len() as f64
544 }
545
546 fn matches_filters(&self, doc_id: &str, filters: &HashMap<String, String>) -> bool {
548 if filters.is_empty() {
549 return true;
550 }
551 self.documents
552 .get(doc_id)
553 .map(|doc| {
554 filters.iter().all(|(k, v)| {
555 doc.metadata
556 .get(k.as_str())
557 .map(|mv| mv == v)
558 .unwrap_or(false)
559 })
560 })
561 .unwrap_or(false)
562 }
563
564 fn rrf_fuse(
569 &self,
570 vector_results: &[(String, f64)],
571 bm25_results: &[(String, f64)],
572 k: f64,
573 ) -> Vec<(String, f64)> {
574 let mut scores: HashMap<String, f64> = HashMap::new();
575
576 for (rank, (doc_id, _)) in vector_results.iter().enumerate() {
577 *scores.entry(doc_id.clone()).or_insert(0.0) += 1.0 / (k + rank as f64 + 1.0);
579 }
580 for (rank, (doc_id, _)) in bm25_results.iter().enumerate() {
581 *scores.entry(doc_id.clone()).or_insert(0.0) += 1.0 / (k + rank as f64 + 1.0);
582 }
583
584 scores.into_iter().collect()
585 }
586
587 fn linear_fuse(
589 &self,
590 vector_results: &[(String, f64)],
591 bm25_results: &[(String, f64)],
592 vector_weight: f64,
593 bm25_weight: f64,
594 ) -> Vec<(String, f64)> {
595 let norm_vec = normalise(vector_results);
596 let norm_bm25 = normalise(bm25_results);
597
598 let mut scores: HashMap<String, f64> = HashMap::new();
599
600 for (id, s) in &norm_vec {
601 *scores.entry(id.clone()).or_insert(0.0) += vector_weight * s;
602 }
603 for (id, s) in &norm_bm25 {
604 *scores.entry(id.clone()).or_insert(0.0) += bm25_weight * s;
605 }
606
607 scores.into_iter().collect()
608 }
609}
610
611#[cfg(test)]
616mod tests {
617 use std::collections::HashMap;
618
619 use crate::search_pipeline::{
620 FusionMethod, SearchDocument, SemanticSearchPipeline, SpPipelineConfig, SpSearchQuery,
621 };
622
623 fn make_doc(id: &str, content: &str, embedding: Vec<f64>) -> SearchDocument {
628 SearchDocument {
629 id: id.to_string(),
630 content: content.to_string(),
631 embedding,
632 metadata: HashMap::new(),
633 }
634 }
635
636 fn make_doc_meta(
637 id: &str,
638 content: &str,
639 embedding: Vec<f64>,
640 metadata: HashMap<String, String>,
641 ) -> SearchDocument {
642 SearchDocument {
643 id: id.to_string(),
644 content: content.to_string(),
645 embedding,
646 metadata,
647 }
648 }
649
650 fn default_pipeline() -> SemanticSearchPipeline {
651 SemanticSearchPipeline::new(SpPipelineConfig::default())
652 }
653
654 fn pipeline_with_docs() -> SemanticSearchPipeline {
655 let mut p = default_pipeline();
656 p.add_document(make_doc(
657 "d1",
658 "rust programming language",
659 vec![1.0, 0.0, 0.0],
660 ));
661 p.add_document(make_doc(
662 "d2",
663 "python data science machine learning",
664 vec![0.0, 1.0, 0.0],
665 ));
666 p.add_document(make_doc(
667 "d3",
668 "rust systems programming performance",
669 vec![0.9, 0.1, 0.0],
670 ));
671 p
672 }
673
674 fn simple_query(text: &str, embedding: Option<Vec<f64>>) -> SpSearchQuery {
675 SpSearchQuery {
676 text: text.to_string(),
677 embedding,
678 filters: HashMap::new(),
679 top_k: 10,
680 min_score: 0.0,
681 }
682 }
683
684 #[test]
689 fn test_new_empty_pipeline() {
690 let p = default_pipeline();
691 assert_eq!(p.doc_count(), 0);
692 assert_eq!(p.total_docs, 0);
693 }
694
695 #[test]
696 fn test_config_default_vector_candidates() {
697 let cfg = SpPipelineConfig::default();
698 assert_eq!(cfg.vector_candidates, 100);
699 }
700
701 #[test]
702 fn test_config_default_bm25_candidates() {
703 let cfg = SpPipelineConfig::default();
704 assert_eq!(cfg.bm25_candidates, 100);
705 }
706
707 #[test]
708 fn test_config_default_rerank_top_n() {
709 let cfg = SpPipelineConfig::default();
710 assert_eq!(cfg.rerank_top_n, 20);
711 }
712
713 #[test]
718 fn test_add_single_document() {
719 let mut p = default_pipeline();
720 p.add_document(make_doc("x", "hello world", vec![1.0, 0.0]));
721 assert_eq!(p.doc_count(), 1);
722 assert_eq!(p.total_docs, 1);
723 }
724
725 #[test]
726 fn test_add_multiple_documents() {
727 let p = pipeline_with_docs();
728 assert_eq!(p.doc_count(), 3);
729 assert_eq!(p.total_docs, 3);
730 }
731
732 #[test]
733 fn test_remove_existing_document() {
734 let mut p = pipeline_with_docs();
735 let removed = p.remove_document("d1");
736 assert!(removed);
737 assert_eq!(p.doc_count(), 2);
738 }
739
740 #[test]
741 fn test_remove_missing_document() {
742 let mut p = pipeline_with_docs();
743 let removed = p.remove_document("nonexistent");
744 assert!(!removed);
745 assert_eq!(p.doc_count(), 3);
746 }
747
748 #[test]
749 fn test_remove_then_readd() {
750 let mut p = pipeline_with_docs();
751 p.remove_document("d1");
752 p.add_document(make_doc(
753 "d1",
754 "rust programming language",
755 vec![1.0, 0.0, 0.0],
756 ));
757 assert_eq!(p.doc_count(), 3);
758 }
759
760 #[test]
761 fn test_doc_count_matches_total_docs() {
762 let p = pipeline_with_docs();
763 assert_eq!(p.doc_count(), p.total_docs);
764 }
765
766 #[test]
771 fn test_idf_populated_after_add() {
772 let p = pipeline_with_docs();
773 assert!(!p.idf.is_empty());
774 }
775
776 #[test]
777 fn test_idf_rust_is_nonnegative() {
778 let p = pipeline_with_docs();
779 let idf_rust = p.idf.get("rust").copied().unwrap_or(0.0);
780 assert!(idf_rust >= 0.0);
781 }
782
783 #[test]
784 fn test_idf_decreases_as_df_increases() {
785 let mut p = default_pipeline();
786 p.add_document(make_doc("a", "rust programming", vec![1.0]));
787 let idf_before = p.idf.get("rust").copied().unwrap_or(0.0);
788 p.add_document(make_doc("b", "rust is great", vec![0.5]));
789 let idf_after = p.idf.get("rust").copied().unwrap_or(0.0);
790 assert!(idf_after <= idf_before);
792 }
793
794 #[test]
795 fn test_vocabulary_grows_on_new_terms() {
796 let mut p = default_pipeline();
797 p.add_document(make_doc("a", "hello", vec![1.0]));
798 let v1 = p.idf.len();
799 p.add_document(make_doc("b", "world unique_term_xyz", vec![0.5]));
800 let v2 = p.idf.len();
801 assert!(v2 > v1);
802 }
803
804 #[test]
805 fn test_idf_term_removed_when_only_doc_deleted() {
806 let mut p = default_pipeline();
807 p.add_document(make_doc("only", "unique_xyz_term_qwerty", vec![1.0]));
808 assert!(p.idf.contains_key("unique_xyz_term_qwerty"));
809 p.remove_document("only");
810 assert!(!p.idf.contains_key("unique_xyz_term_qwerty"));
811 }
812
813 #[test]
818 fn test_cosine_identical_vectors() {
819 let v = vec![1.0, 2.0, 3.0];
820 assert!((SemanticSearchPipeline::cosine_similarity(&v, &v) - 1.0).abs() < 1e-9);
821 }
822
823 #[test]
824 fn test_cosine_orthogonal_vectors() {
825 let a = vec![1.0, 0.0];
826 let b = vec![0.0, 1.0];
827 assert!(SemanticSearchPipeline::cosine_similarity(&a, &b).abs() < 1e-9);
828 }
829
830 #[test]
831 fn test_cosine_opposite_vectors() {
832 let a = vec![1.0, 0.0];
833 let b = vec![-1.0, 0.0];
834 assert!((SemanticSearchPipeline::cosine_similarity(&a, &b) + 1.0).abs() < 1e-9);
835 }
836
837 #[test]
838 fn test_cosine_zero_vector_returns_zero() {
839 let a = vec![0.0, 0.0];
840 let b = vec![1.0, 2.0];
841 assert_eq!(SemanticSearchPipeline::cosine_similarity(&a, &b), 0.0);
842 }
843
844 #[test]
845 fn test_cosine_empty_vectors() {
846 assert_eq!(SemanticSearchPipeline::cosine_similarity(&[], &[]), 0.0);
847 }
848
849 #[test]
850 fn test_cosine_mismatched_lengths() {
851 let a = vec![1.0, 2.0];
852 let b = vec![1.0];
853 assert_eq!(SemanticSearchPipeline::cosine_similarity(&a, &b), 0.0);
854 }
855
856 #[test]
861 fn test_vector_search_returns_top_k() {
862 let p = pipeline_with_docs();
863 let results = p.vector_search(&[1.0, 0.0, 0.0], 2);
864 assert_eq!(results.len(), 2);
865 }
866
867 #[test]
868 fn test_vector_search_sorted_descending() {
869 let p = pipeline_with_docs();
870 let results = p.vector_search(&[1.0, 0.0, 0.0], 3);
871 for w in results.windows(2) {
872 assert!(w[0].1 >= w[1].1);
873 }
874 }
875
876 #[test]
877 fn test_vector_search_correct_top_result() {
878 let p = pipeline_with_docs();
879 let results = p.vector_search(&[1.0, 0.0, 0.0], 1);
881 assert_eq!(results[0].0, "d1");
882 assert!((results[0].1 - 1.0).abs() < 1e-9);
883 }
884
885 #[test]
886 fn test_vector_search_empty_corpus() {
887 let p = default_pipeline();
888 let results = p.vector_search(&[1.0, 0.0], 5);
889 assert!(results.is_empty());
890 }
891
892 #[test]
893 fn test_vector_search_top_k_larger_than_corpus() {
894 let p = pipeline_with_docs();
895 let results = p.vector_search(&[1.0, 0.0, 0.0], 1000);
896 assert!(results.len() <= p.doc_count());
897 }
898
899 #[test]
904 fn test_bm25_search_returns_rust_docs() {
905 let p = pipeline_with_docs();
906 let results = p.bm25_search("rust", 5);
907 let ids: Vec<&str> = results.iter().map(|(id, _)| id.as_str()).collect();
908 assert!(ids.contains(&"d1") || ids.contains(&"d3"));
909 }
910
911 #[test]
912 fn test_bm25_empty_query_returns_empty() {
913 let p = pipeline_with_docs();
914 let results = p.bm25_search("", 5);
915 assert!(results.is_empty());
916 }
917
918 #[test]
919 fn test_bm25_unknown_term_returns_empty() {
920 let p = pipeline_with_docs();
921 let results = p.bm25_search("zzz_nonexistent_term", 5);
922 assert!(results.is_empty());
923 }
924
925 #[test]
926 fn test_bm25_scores_sorted_descending() {
927 let p = pipeline_with_docs();
928 let results = p.bm25_search("rust programming", 5);
929 for w in results.windows(2) {
930 assert!(w[0].1 >= w[1].1);
931 }
932 }
933
934 #[test]
935 fn test_bm25_top_k_respected() {
936 let p = pipeline_with_docs();
937 let results = p.bm25_search("rust", 1);
938 assert_eq!(results.len(), 1);
939 }
940
941 #[test]
942 fn test_bm25_higher_tf_scores_higher() {
943 let mut p = default_pipeline();
944 p.add_document(make_doc("doc_high", "rust rust language", vec![1.0]));
945 p.add_document(make_doc("doc_low", "rust language", vec![0.5]));
946 let results = p.bm25_search("rust", 2);
947 assert_eq!(results[0].0, "doc_high");
948 }
949
950 #[test]
951 fn test_bm25_score_positive_for_matching_terms() {
952 let p = pipeline_with_docs();
953 let results = p.bm25_search("python", 5);
954 let d2_score = results
955 .iter()
956 .find(|(id, _)| id == "d2")
957 .map(|(_, s)| *s)
958 .unwrap_or(0.0);
959 assert!(d2_score > 0.0);
960 }
961
962 #[test]
967 fn test_rrf_fusion_contains_all_ids() {
968 let p = default_pipeline();
969 let vec_res = vec![("a".to_string(), 0.9), ("b".to_string(), 0.7)];
970 let bm25_res = vec![("b".to_string(), 5.0), ("c".to_string(), 3.0)];
971 let fused = p.fuse(&vec_res, &bm25_res);
972 let ids: Vec<&str> = fused.iter().map(|(id, _)| id.as_str()).collect();
973 assert!(ids.contains(&"a"));
974 assert!(ids.contains(&"b"));
975 assert!(ids.contains(&"c"));
976 }
977
978 #[test]
979 fn test_rrf_shared_doc_scores_higher_than_unique() {
980 let p = default_pipeline();
981 let vec_res = vec![("shared".to_string(), 0.9), ("only_vec".to_string(), 0.8)];
982 let bm25_res = vec![("shared".to_string(), 8.0), ("only_bm25".to_string(), 6.0)];
983 let fused = p.fuse(&vec_res, &bm25_res);
984 let shared_score = fused
985 .iter()
986 .find(|(id, _)| id == "shared")
987 .map(|(_, s)| *s)
988 .unwrap_or(0.0);
989 let only_vec_score = fused
990 .iter()
991 .find(|(id, _)| id == "only_vec")
992 .map(|(_, s)| *s)
993 .unwrap_or(0.0);
994 assert!(shared_score > only_vec_score);
995 }
996
997 #[test]
998 fn test_linear_fusion_zero_bm25_weight() {
999 let config = SpPipelineConfig {
1000 fusion_method: FusionMethod::LinearCombination {
1001 vector_weight: 1.0,
1002 bm25_weight: 0.0,
1003 },
1004 ..Default::default()
1005 };
1006 let p = SemanticSearchPipeline::new(config);
1007 let vec_res = vec![("a".to_string(), 1.0)];
1008 let bm25_res = vec![("b".to_string(), 10.0)];
1009 let fused = p.fuse(&vec_res, &bm25_res);
1010 let b_score = fused
1011 .iter()
1012 .find(|(id, _)| id == "b")
1013 .map(|(_, s)| *s)
1014 .unwrap_or(0.0);
1015 assert!((b_score).abs() < 1e-9);
1016 }
1017
1018 #[test]
1019 fn test_combsum_equal_weights_sums_to_one() {
1020 let config = SpPipelineConfig {
1021 fusion_method: FusionMethod::CombSUM,
1022 ..Default::default()
1023 };
1024 let p = SemanticSearchPipeline::new(config);
1025 let vec_res = vec![("a".to_string(), 1.0)];
1026 let bm25_res = vec![("a".to_string(), 2.0)];
1027 let fused = p.fuse(&vec_res, &bm25_res);
1028 let a_score = fused
1029 .iter()
1030 .find(|(id, _)| id == "a")
1031 .map(|(_, s)| *s)
1032 .unwrap_or(0.0);
1033 assert!((a_score - 1.0).abs() < 1e-9);
1035 }
1036
1037 #[test]
1038 fn test_fusion_empty_inputs_returns_empty() {
1039 let p = default_pipeline();
1040 let fused = p.fuse(&[], &[]);
1041 assert!(fused.is_empty());
1042 }
1043
1044 #[test]
1045 fn test_fusion_single_list_passthrough() {
1046 let p = default_pipeline();
1047 let vec_res = vec![("a".to_string(), 1.0), ("b".to_string(), 0.5)];
1048 let fused = p.fuse(&vec_res, &[]);
1049 assert_eq!(fused.len(), 2);
1050 }
1051
1052 #[test]
1053 fn test_rrf_k_lower_gives_higher_score() {
1054 let p_low = SemanticSearchPipeline::new(SpPipelineConfig {
1055 fusion_method: FusionMethod::ReciprocalRankFusion { k: 1.0 },
1056 ..Default::default()
1057 });
1058 let p_high = SemanticSearchPipeline::new(SpPipelineConfig {
1059 fusion_method: FusionMethod::ReciprocalRankFusion { k: 1000.0 },
1060 ..Default::default()
1061 });
1062 let vec_res = vec![("a".to_string(), 1.0)];
1063 let bm25_res = vec![("a".to_string(), 1.0)];
1064 let score_low = p_low
1065 .fuse(&vec_res, &bm25_res)
1066 .first()
1067 .map(|(_, s)| *s)
1068 .unwrap_or(0.0);
1069 let score_high = p_high
1070 .fuse(&vec_res, &bm25_res)
1071 .first()
1072 .map(|(_, s)| *s)
1073 .unwrap_or(0.0);
1074 assert!(score_low > score_high);
1075 }
1076
1077 #[test]
1082 fn test_search_returns_hits() {
1083 let mut p = pipeline_with_docs();
1084 let q = simple_query("rust", Some(vec![1.0, 0.0, 0.0]));
1085 let result = p.search(&q);
1086 assert!(!result.hits.is_empty());
1087 }
1088
1089 #[test]
1090 fn test_search_hits_sorted_by_score_desc() {
1091 let mut p = pipeline_with_docs();
1092 let q = simple_query("rust programming", Some(vec![1.0, 0.0, 0.0]));
1093 let result = p.search(&q);
1094 for w in result.hits.windows(2) {
1095 assert!(w[0].score >= w[1].score);
1096 }
1097 }
1098
1099 #[test]
1100 fn test_search_rank_starts_at_one() {
1101 let mut p = pipeline_with_docs();
1102 let q = simple_query("rust", None);
1103 let result = p.search(&q);
1104 if !result.hits.is_empty() {
1105 assert_eq!(result.hits[0].rank, 1);
1106 }
1107 }
1108
1109 #[test]
1110 fn test_search_ranks_are_sequential() {
1111 let mut p = pipeline_with_docs();
1112 let q = simple_query("rust programming", Some(vec![1.0, 0.0, 0.0]));
1113 let result = p.search(&q);
1114 for (i, hit) in result.hits.iter().enumerate() {
1115 assert_eq!(hit.rank, i + 1);
1116 }
1117 }
1118
1119 #[test]
1120 fn test_search_respects_top_k() {
1121 let mut p = pipeline_with_docs();
1122 let q = SpSearchQuery {
1123 text: "rust programming language systems".to_string(),
1124 embedding: Some(vec![1.0, 0.0, 0.0]),
1125 filters: HashMap::new(),
1126 top_k: 1,
1127 min_score: 0.0,
1128 };
1129 let result = p.search(&q);
1130 assert!(result.hits.len() <= 1);
1131 }
1132
1133 #[test]
1134 fn test_search_min_score_filters_all() {
1135 let mut p = pipeline_with_docs();
1136 let q = SpSearchQuery {
1137 text: "rust".to_string(),
1138 embedding: Some(vec![1.0, 0.0, 0.0]),
1139 filters: HashMap::new(),
1140 top_k: 10,
1141 min_score: 9999.0,
1142 };
1143 let result = p.search(&q);
1144 assert!(result.hits.is_empty());
1145 }
1146
1147 #[test]
1148 fn test_search_total_candidates_positive() {
1149 let mut p = pipeline_with_docs();
1150 let q = simple_query("rust", Some(vec![1.0, 0.0, 0.0]));
1151 let result = p.search(&q);
1152 assert!(result.total_candidates > 0);
1153 }
1154
1155 #[test]
1156 fn test_search_query_text_in_result() {
1157 let mut p = pipeline_with_docs();
1158 let q = simple_query("hello world", None);
1159 let result = p.search(&q);
1160 assert_eq!(result.query_text, "hello world");
1161 }
1162
1163 #[test]
1164 fn test_search_vector_only_finds_similar_doc() {
1165 let mut p = pipeline_with_docs();
1166 let q = SpSearchQuery {
1167 text: String::new(),
1168 embedding: Some(vec![0.0, 1.0, 0.0]),
1169 filters: HashMap::new(),
1170 top_k: 5,
1171 min_score: 0.0,
1172 };
1173 let result = p.search(&q);
1174 assert!(!result.hits.is_empty());
1176 assert_eq!(result.hits[0].doc_id, "d2");
1177 }
1178
1179 #[test]
1180 fn test_search_on_empty_corpus() {
1181 let mut p = default_pipeline();
1182 let q = simple_query("rust", Some(vec![1.0, 0.0]));
1183 let result = p.search(&q);
1184 assert!(result.hits.is_empty());
1185 assert_eq!(result.total_candidates, 0);
1186 }
1187
1188 #[test]
1189 fn test_hit_fields_all_populated() {
1190 let mut p = pipeline_with_docs();
1191 let q = simple_query("rust", Some(vec![1.0, 0.0, 0.0]));
1192 let result = p.search(&q);
1193 for hit in &result.hits {
1194 assert!(!hit.doc_id.is_empty());
1195 assert!(hit.score >= 0.0);
1196 assert!(hit.rank >= 1);
1197 }
1198 }
1199
1200 #[test]
1205 fn test_metadata_filter_exact_match() {
1206 let mut meta = HashMap::new();
1207 meta.insert("lang".to_string(), "rust".to_string());
1208 let mut p = default_pipeline();
1209 p.add_document(make_doc_meta(
1210 "d1",
1211 "systems programming",
1212 vec![1.0, 0.0],
1213 meta,
1214 ));
1215 p.add_document(make_doc("d2", "systems programming", vec![1.0, 0.0]));
1216
1217 let mut filters = HashMap::new();
1218 filters.insert("lang".to_string(), "rust".to_string());
1219 let q = SpSearchQuery {
1220 text: "systems programming".to_string(),
1221 embedding: Some(vec![1.0, 0.0]),
1222 filters,
1223 top_k: 10,
1224 min_score: 0.0,
1225 };
1226 let result = p.search(&q);
1227 assert!(result.hits.iter().all(|h| h.doc_id == "d1"));
1228 }
1229
1230 #[test]
1231 fn test_metadata_filter_no_match_returns_empty() {
1232 let mut p = pipeline_with_docs();
1233 let mut filters = HashMap::new();
1234 filters.insert("nonexistent".to_string(), "value".to_string());
1235 let q = SpSearchQuery {
1236 text: "rust".to_string(),
1237 embedding: Some(vec![1.0, 0.0, 0.0]),
1238 filters,
1239 top_k: 10,
1240 min_score: 0.0,
1241 };
1242 let result = p.search(&q);
1243 assert!(result.hits.is_empty());
1244 }
1245
1246 #[test]
1247 fn test_metadata_empty_filter_passes_all() {
1248 let mut p = pipeline_with_docs();
1249 let q = simple_query("rust", Some(vec![1.0, 0.0, 0.0]));
1250 let result = p.search(&q);
1251 assert!(!result.hits.is_empty());
1252 }
1253
1254 #[test]
1259 fn test_stats_initial_state() {
1260 let p = default_pipeline();
1261 let s = p.stats();
1262 assert_eq!(s.doc_count, 0);
1263 assert_eq!(s.total_searches, 0);
1264 assert_eq!(s.vocabulary_size, 0);
1265 assert_eq!(s.avg_doc_length, 0.0);
1266 assert_eq!(s.avg_hits_per_search, 0.0);
1267 }
1268
1269 #[test]
1270 fn test_stats_after_docs_added() {
1271 let p = pipeline_with_docs();
1272 let s = p.stats();
1273 assert_eq!(s.doc_count, 3);
1274 assert!(s.vocabulary_size > 0);
1275 assert!(s.avg_doc_length > 0.0);
1276 }
1277
1278 #[test]
1279 fn test_stats_total_searches_increments() {
1280 let mut p = pipeline_with_docs();
1281 let q = simple_query("rust", None);
1282 p.search(&q);
1283 p.search(&q);
1284 assert_eq!(p.stats().total_searches, 2);
1285 }
1286
1287 #[test]
1288 fn test_stats_avg_hits_computed_correctly() {
1289 let mut p = pipeline_with_docs();
1290 let q = simple_query("rust", None);
1291 let r = p.search(&q);
1292 let hits = r.hits.len() as f64;
1293 let s = p.stats();
1294 assert!((s.avg_hits_per_search - hits).abs() < 1e-9);
1295 }
1296}