//! Hybrid search combining full-text and semantic search. //! //! Supports two fusion strategies: //! 1. **RRF** (Reciprocal Rank Fusion) — the default, rank-based method. //! `score = sum(1 / (k + rank))` for each retrieval method. //! 2. **WeightedScore** — converts ranks to scores via `1/rank`, combines with //! configurable weights (`fts_weight * fts_score + vector_weight * vector_score`), //! then normalizes to \[0,1\] by dividing by the maximum combined score. //! //! Both strategies combine results from: //! - PostgreSQL / libSQL full-text search //! - pgvector / libsql_vector cosine similarity search use std::collections::HashMap; use uuid::Uuid; /// Strategy used to fuse FTS and vector search results. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub enum FusionStrategy { /// Reciprocal Rank Fusion (default). Ignores `fts_weight`/`vector_weight`. #[default] Rrf, /// Weighted score fusion using normalized rank-derived scores. WeightedScore, } /// Configuration for hybrid search. #[derive(Debug, Clone)] pub struct SearchConfig { /// Maximum number of results to return. pub limit: usize, /// RRF constant (typically 60). Higher values favor top results more. pub rrf_k: u32, /// Whether to include FTS results. pub use_fts: bool, /// Whether to include vector results. pub use_vector: bool, /// Minimum score threshold (0.0-1.0). pub min_score: f32, /// Maximum results to fetch from each method before fusion. pub pre_fusion_limit: usize, /// Fusion strategy to use when combining results. pub fusion_strategy: FusionStrategy, /// Weight for FTS results in `WeightedScore` fusion (default 0.5). /// Ignored by `Rrf` fusion. For env-based config via /// `WorkspaceSearchConfig::resolve`, defaults are per-strategy. pub fts_weight: f32, /// Weight for vector results in `WeightedScore` fusion (default 0.5). /// Ignored by `Rrf` fusion. For env-based config via /// `WorkspaceSearchConfig::resolve`, defaults are per-strategy. pub vector_weight: f32, } impl Default for SearchConfig { fn default() -> Self { Self { limit: 10, rrf_k: 60, use_fts: true, use_vector: true, min_score: 0.0, pre_fusion_limit: 50, fusion_strategy: FusionStrategy::default(), fts_weight: 0.5, vector_weight: 0.5, } } } impl SearchConfig { /// Set the result limit. pub fn with_limit(mut self, limit: usize) -> Self { self.limit = limit; self } /// Set the RRF constant. pub fn with_rrf_k(mut self, k: u32) -> Self { self.rrf_k = k; self } /// Disable FTS (only use vector search). pub fn vector_only(mut self) -> Self { self.use_fts = false; self.use_vector = true; self } /// Disable vector search (only use FTS). pub fn fts_only(mut self) -> Self { self.use_fts = true; self.use_vector = false; self } /// Set minimum score threshold. pub fn with_min_score(mut self, score: f32) -> Self { self.min_score = score.clamp(0.0, 1.0); self } /// Set the fusion strategy. pub fn with_fusion_strategy(mut self, strategy: FusionStrategy) -> Self { self.fusion_strategy = strategy; self } /// Set the FTS weight for `WeightedScore` fusion. /// /// Non-finite (NaN, ±inf) or negative values are ignored. pub fn with_fts_weight(mut self, weight: f32) -> Self { if weight.is_finite() && weight >= 0.0 { self.fts_weight = weight; } self } /// Set the vector weight for `WeightedScore` fusion. /// /// Non-finite (NaN, ±inf) or negative values are ignored. pub fn with_vector_weight(mut self, weight: f32) -> Self { if weight.is_finite() && weight >= 0.0 { self.vector_weight = weight; } self } } /// A search result with hybrid scoring. #[derive(Debug, Clone)] pub struct SearchResult { /// Document ID containing this chunk. pub document_id: Uuid, /// File path of the source document. pub document_path: String, /// Chunk ID. pub chunk_id: Uuid, /// Chunk content. pub content: String, /// Combined fusion score (0.0-1.0 normalized). Strategy-dependent (RRF or WeightedScore). pub score: f32, /// Rank in FTS results (1-based, None if not in FTS results). pub fts_rank: Option, /// Rank in vector results (1-based, None if not in vector results). pub vector_rank: Option, } impl SearchResult { /// Check if this result came from FTS. pub fn from_fts(&self) -> bool { self.fts_rank.is_some() } /// Check if this result came from vector search. pub fn from_vector(&self) -> bool { self.vector_rank.is_some() } /// Check if this result came from both methods (hybrid match). pub fn is_hybrid(&self) -> bool { self.fts_rank.is_some() && self.vector_rank.is_some() } } /// Raw result from a single search method. #[derive(Debug, Clone)] pub struct RankedResult { pub chunk_id: Uuid, pub document_id: Uuid, /// File path of the source document. pub document_path: String, pub content: String, pub rank: u32, // 1-based rank } /// Fuse FTS and vector search results using the strategy specified in `config`. /// /// This is the primary entry point for result fusion. Delegates to /// [`reciprocal_rank_fusion`] or [`weighted_score_fusion`] based on /// `config.fusion_strategy`. pub fn fuse_results( fts_results: Vec, vector_results: Vec, config: &SearchConfig, ) -> Vec { match config.fusion_strategy { FusionStrategy::Rrf => reciprocal_rank_fusion(fts_results, vector_results, config), FusionStrategy::WeightedScore => weighted_score_fusion(fts_results, vector_results, config), } } /// Reciprocal Rank Fusion algorithm. /// /// Combines ranked results from multiple retrieval methods using the formula: /// score(d) = sum(1 / (k + rank(d))) for each method where d appears /// /// # Arguments /// /// * `fts_results` - Results from full-text search, ordered by relevance /// * `vector_results` - Results from vector search, ordered by similarity /// * `config` - Search configuration /// /// # Returns /// /// Combined results sorted by RRF score (descending). pub fn reciprocal_rank_fusion( fts_results: Vec, vector_results: Vec, config: &SearchConfig, ) -> Vec { let k = config.rrf_k as f32; // Track scores and metadata for each chunk struct ChunkInfo { document_id: Uuid, document_path: String, content: String, score: f32, fts_rank: Option, vector_rank: Option, } let mut chunk_scores: HashMap = HashMap::new(); // Process FTS results for result in fts_results { let rrf_score = 1.0 / (k + result.rank as f32); chunk_scores .entry(result.chunk_id) .and_modify(|info| { info.score += rrf_score; info.fts_rank = Some(result.rank); }) .or_insert(ChunkInfo { document_id: result.document_id, document_path: result.document_path, content: result.content, score: rrf_score, fts_rank: Some(result.rank), vector_rank: None, }); } // Process vector results for result in vector_results { let rrf_score = 1.0 / (k + result.rank as f32); chunk_scores .entry(result.chunk_id) .and_modify(|info| { info.score += rrf_score; info.vector_rank = Some(result.rank); }) .or_insert(ChunkInfo { document_id: result.document_id, document_path: result.document_path, content: result.content, score: rrf_score, fts_rank: None, vector_rank: Some(result.rank), }); } // Convert to SearchResult and sort by score let mut results: Vec = chunk_scores .into_iter() .map(|(chunk_id, info)| SearchResult { document_id: info.document_id, document_path: info.document_path, chunk_id, content: info.content, score: info.score, fts_rank: info.fts_rank, vector_rank: info.vector_rank, }) .collect(); // Normalize scores to 0-1 range if let Some(max_score) = results.iter().map(|r| r.score).reduce(f32::max) && max_score > 0.0 { for result in &mut results { result.score /= max_score; } } // Filter by minimum score if config.min_score > 0.0 { results.retain(|r| r.score >= config.min_score); } // Sort by score descending results.sort_by(|a, b| { b.score .partial_cmp(&a.score) .unwrap_or(std::cmp::Ordering::Equal) }); // Limit results results.truncate(config.limit); results } /// Weighted score fusion. /// /// Converts ranks from each method into scores using `1/rank` /// (so rank 1 → 1.0, rank N → 1/N), then combines them with /// configurable weights: `fts_weight * fts_score + vector_weight * vector_score`. /// /// The combined scores are then normalized to [0,1] by dividing by the /// maximum score; post-processing (normalization, min_score filter, sort, /// truncate) matches RRF. pub fn weighted_score_fusion( fts_results: Vec, vector_results: Vec, config: &SearchConfig, ) -> Vec { struct ChunkInfo { document_id: Uuid, document_path: String, content: String, score: f32, fts_rank: Option, vector_rank: Option, } let mut chunk_scores: HashMap = HashMap::new(); // Process FTS results: score = fts_weight * (1 / rank) for result in fts_results { let score = config.fts_weight * (1.0 / result.rank as f32); chunk_scores .entry(result.chunk_id) .and_modify(|info| { info.score += score; info.fts_rank = Some(result.rank); }) .or_insert(ChunkInfo { document_id: result.document_id, document_path: result.document_path, content: result.content, score, fts_rank: Some(result.rank), vector_rank: None, }); } // Process vector results: score = vector_weight * (1 / rank) for result in vector_results { let score = config.vector_weight * (1.0 / result.rank as f32); chunk_scores .entry(result.chunk_id) .and_modify(|info| { info.score += score; info.vector_rank = Some(result.rank); }) .or_insert(ChunkInfo { document_id: result.document_id, document_path: result.document_path, content: result.content, score, fts_rank: None, vector_rank: Some(result.rank), }); } let mut results: Vec = chunk_scores .into_iter() .map(|(chunk_id, info)| SearchResult { document_id: info.document_id, document_path: info.document_path, chunk_id, content: info.content, score: info.score, fts_rank: info.fts_rank, vector_rank: info.vector_rank, }) .collect(); // Normalize scores to 0-1 range if let Some(max_score) = results.iter().map(|r| r.score).reduce(f32::max) && max_score > 0.0 { for result in &mut results { result.score /= max_score; } } // Filter by minimum score if config.min_score > 0.0 { results.retain(|r| r.score >= config.min_score); } // Sort by score descending results.sort_by(|a, b| { b.score .partial_cmp(&a.score) .unwrap_or(std::cmp::Ordering::Equal) }); // Limit results results.truncate(config.limit); results } #[cfg(test)] mod tests { use super::*; fn make_result(chunk_id: Uuid, doc_id: Uuid, rank: u32) -> RankedResult { RankedResult { chunk_id, document_id: doc_id, document_path: format!("docs/{}.md", doc_id), content: format!("content for chunk {}", chunk_id), rank, } } fn make_result_with_path(chunk_id: Uuid, doc_id: Uuid, path: &str, rank: u32) -> RankedResult { RankedResult { chunk_id, document_id: doc_id, document_path: path.to_string(), content: format!("content for chunk {}", chunk_id), rank, } } #[test] fn test_rrf_propagates_document_path() { // Regression test: search results must carry the source document's // file path, not the document UUID. See PR #503 / issue #481. let config = SearchConfig::default().with_limit(10); let doc_a = Uuid::new_v4(); let doc_b = Uuid::new_v4(); let chunk1 = Uuid::new_v4(); let chunk2 = Uuid::new_v4(); let chunk3 = Uuid::new_v4(); let fts_results = vec![ make_result_with_path(chunk1, doc_a, "notes/todo.md", 1), make_result_with_path(chunk2, doc_b, "journal/2024-01-15.md", 2), ]; let vector_results = vec![ make_result_with_path(chunk1, doc_a, "notes/todo.md", 1), make_result_with_path(chunk3, doc_b, "journal/2024-01-15.md", 2), ]; let results = reciprocal_rank_fusion(fts_results, vector_results, &config); for result in &results { // The path must be a real file path, never a UUID string assert!( Uuid::parse_str(&result.document_path).is_err(), "document_path looks like a UUID ('{}'), expected a file path", result.document_path ); } // Verify exact paths are preserved let paths: Vec<&str> = results.iter().map(|r| r.document_path.as_str()).collect(); assert!( paths.contains(&"notes/todo.md"), "missing notes/todo.md in {:?}", paths ); assert!( paths.contains(&"journal/2024-01-15.md"), "missing journal/2024-01-15.md in {:?}", paths ); // Hybrid match (chunk1) should preserve the correct path let hybrid = results.iter().find(|r| r.chunk_id == chunk1).unwrap(); assert_eq!(hybrid.document_path, "notes/todo.md"); assert!(hybrid.is_hybrid()); } #[test] fn test_rrf_single_method() { let config = SearchConfig::default().with_limit(10); let chunk1 = Uuid::new_v4(); let chunk2 = Uuid::new_v4(); let doc = Uuid::new_v4(); let fts_results = vec![make_result(chunk1, doc, 1), make_result(chunk2, doc, 2)]; let results = reciprocal_rank_fusion(fts_results, Vec::new(), &config); assert_eq!(results.len(), 2); // First result should have higher score assert!(results[0].score > results[1].score); // All should have FTS rank assert!(results.iter().all(|r| r.fts_rank.is_some())); assert!(results.iter().all(|r| r.vector_rank.is_none())); } #[test] fn test_rrf_hybrid_match_boosted() { let config = SearchConfig::default().with_limit(10); let chunk1 = Uuid::new_v4(); // In both let chunk2 = Uuid::new_v4(); // FTS only let chunk3 = Uuid::new_v4(); // Vector only let doc = Uuid::new_v4(); let fts_results = vec![make_result(chunk1, doc, 1), make_result(chunk2, doc, 2)]; let vector_results = vec![make_result(chunk1, doc, 1), make_result(chunk3, doc, 2)]; let results = reciprocal_rank_fusion(fts_results, vector_results, &config); assert_eq!(results.len(), 3); // chunk1 should be first (hybrid match) assert_eq!(results[0].chunk_id, chunk1); assert!(results[0].is_hybrid()); assert!(results[0].score > results[1].score); // Other chunks should not be hybrid assert!(!results[1].is_hybrid()); assert!(!results[2].is_hybrid()); } #[test] fn test_rrf_score_normalization() { let config = SearchConfig::default(); let chunk1 = Uuid::new_v4(); let doc = Uuid::new_v4(); let fts_results = vec![make_result(chunk1, doc, 1)]; let results = reciprocal_rank_fusion(fts_results, Vec::new(), &config); // Single result should have normalized score of 1.0 assert_eq!(results.len(), 1); assert!((results[0].score - 1.0).abs() < 0.001); } #[test] fn test_rrf_min_score_filter() { let config = SearchConfig::default().with_limit(10).with_min_score(0.5); let chunk1 = Uuid::new_v4(); let chunk2 = Uuid::new_v4(); let chunk3 = Uuid::new_v4(); let doc = Uuid::new_v4(); // chunk1 has rank 1, chunk3 has rank 100 (low score) let fts_results = vec![ make_result(chunk1, doc, 1), make_result(chunk2, doc, 50), make_result(chunk3, doc, 100), ]; let results = reciprocal_rank_fusion(fts_results, Vec::new(), &config); // Low-scoring results should be filtered out // All results should have score >= 0.5 for result in &results { assert!(result.score >= 0.5); } } #[test] fn test_rrf_limit() { let config = SearchConfig::default().with_limit(2); let doc = Uuid::new_v4(); let fts_results: Vec<_> = (1..=5) .map(|i| make_result(Uuid::new_v4(), doc, i)) .collect(); let results = reciprocal_rank_fusion(fts_results, Vec::new(), &config); assert_eq!(results.len(), 2); } #[test] fn test_rrf_k_parameter() { // Higher k values make ranking differences less pronounced let chunk1 = Uuid::new_v4(); let chunk2 = Uuid::new_v4(); let doc = Uuid::new_v4(); let fts_results = vec![make_result(chunk1, doc, 1), make_result(chunk2, doc, 2)]; // Low k: rank 1 score = 1/(10+1) = 0.091, rank 2 = 1/(10+2) = 0.083 let config_low_k = SearchConfig::default().with_rrf_k(10); let results_low = reciprocal_rank_fusion(fts_results.clone(), Vec::new(), &config_low_k); // High k: rank 1 score = 1/(100+1) = 0.0099, rank 2 = 1/(100+2) = 0.0098 let config_high_k = SearchConfig::default().with_rrf_k(100); let results_high = reciprocal_rank_fusion(fts_results, Vec::new(), &config_high_k); // With low k, the score difference is larger (relatively) let diff_low = results_low[0].score - results_low[1].score; let diff_high = results_high[0].score - results_high[1].score; // Low k should have larger relative difference assert!(diff_low > diff_high); } #[test] fn test_search_config_builders() { let config = SearchConfig::default() .with_limit(20) .with_rrf_k(30) .with_min_score(0.1); assert_eq!(config.limit, 20); assert_eq!(config.rrf_k, 30); assert!((config.min_score - 0.1).abs() < 0.001); assert!(config.use_fts); assert!(config.use_vector); let fts_only = SearchConfig::default().fts_only(); assert!(fts_only.use_fts); assert!(!fts_only.use_vector); let vector_only = SearchConfig::default().vector_only(); assert!(!vector_only.use_fts); assert!(vector_only.use_vector); let weighted = SearchConfig::default() .with_fusion_strategy(FusionStrategy::WeightedScore) .with_fts_weight(0.8) .with_vector_weight(0.2); assert_eq!(weighted.fusion_strategy, FusionStrategy::WeightedScore); assert!((weighted.fts_weight - 0.8).abs() < 0.001); assert!((weighted.vector_weight - 0.2).abs() < 0.001); } #[test] fn test_weighted_fusion_basic() { // With equal weights, a hybrid match should still rank highest. let config = SearchConfig::default() .with_fusion_strategy(FusionStrategy::WeightedScore) .with_fts_weight(1.0) .with_vector_weight(1.0) .with_limit(10); let chunk1 = Uuid::new_v4(); // In both let chunk2 = Uuid::new_v4(); // FTS only let chunk3 = Uuid::new_v4(); // Vector only let doc = Uuid::new_v4(); let fts = vec![make_result(chunk1, doc, 1), make_result(chunk2, doc, 2)]; let vec_results = vec![make_result(chunk1, doc, 1), make_result(chunk3, doc, 2)]; let results = weighted_score_fusion(fts, vec_results, &config); assert_eq!(results.len(), 3); // Hybrid match (chunk1) should be first — it gets score from both assert_eq!(results[0].chunk_id, chunk1); assert!(results[0].is_hybrid()); assert!(results[0].score > results[1].score); } #[test] fn test_weighted_fusion_fts_boost() { // High FTS weight should elevate FTS-only results above vector-only. let config = SearchConfig::default() .with_fusion_strategy(FusionStrategy::WeightedScore) .with_fts_weight(2.0) .with_vector_weight(0.5) .with_limit(10); let chunk_fts = Uuid::new_v4(); // FTS only, rank 2 let chunk_vec = Uuid::new_v4(); // Vector only, rank 2 let doc = Uuid::new_v4(); let fts = vec![make_result(chunk_fts, doc, 2)]; let vec_results = vec![make_result(chunk_vec, doc, 2)]; let results = weighted_score_fusion(fts, vec_results, &config); assert_eq!(results.len(), 2); // FTS result should rank higher because of the 2.0 weight vs 0.5 assert_eq!(results[0].chunk_id, chunk_fts); assert!(results[0].from_fts()); assert!(!results[0].from_vector()); } #[test] fn test_weighted_fusion_single_source() { // Only FTS results — should still work correctly. let config = SearchConfig::default() .with_fusion_strategy(FusionStrategy::WeightedScore) .with_limit(10); let chunk1 = Uuid::new_v4(); let chunk2 = Uuid::new_v4(); let doc = Uuid::new_v4(); let fts = vec![make_result(chunk1, doc, 1), make_result(chunk2, doc, 3)]; let results = weighted_score_fusion(fts, Vec::new(), &config); assert_eq!(results.len(), 2); assert_eq!(results[0].chunk_id, chunk1); assert!(results[0].score > results[1].score); // Top result should be normalized to 1.0 assert!((results[0].score - 1.0).abs() < 0.001); } #[test] fn test_weight_setters_reject_invalid() { let config = SearchConfig::default(); let original_fts = config.fts_weight; let original_vec = config.vector_weight; // NaN is ignored let c = config.clone().with_fts_weight(f32::NAN); assert!((c.fts_weight - original_fts).abs() < 0.001); // Infinity is ignored let c = config.clone().with_vector_weight(f32::INFINITY); assert!((c.vector_weight - original_vec).abs() < 0.001); // Negative is ignored let c = config.clone().with_fts_weight(-1.0); assert!((c.fts_weight - original_fts).abs() < 0.001); // Negative infinity is ignored let c = config.clone().with_vector_weight(f32::NEG_INFINITY); assert!((c.vector_weight - original_vec).abs() < 0.001); // Valid values > 1.0 are accepted (weights don't need to sum to 1.0) let c = config.clone().with_fts_weight(2.0); assert!((c.fts_weight - 2.0).abs() < 0.001); // Zero is valid let c = config.clone().with_vector_weight(0.0); assert!(c.vector_weight.abs() < 0.001); } #[test] fn test_fuse_results_dispatches_correctly() { let chunk1 = Uuid::new_v4(); let doc = Uuid::new_v4(); let fts = vec![make_result(chunk1, doc, 1)]; // RRF strategy let rrf_config = SearchConfig::default().with_limit(10); let rrf_results = fuse_results(fts.clone(), Vec::new(), &rrf_config); assert_eq!(rrf_results.len(), 1); // Weighted strategy let weighted_config = SearchConfig::default() .with_fusion_strategy(FusionStrategy::WeightedScore) .with_limit(10); let weighted_results = fuse_results(fts, Vec::new(), &weighted_config); assert_eq!(weighted_results.len(), 1); // Both should normalize single result to 1.0 assert!((rrf_results[0].score - 1.0).abs() < 0.001); assert!((weighted_results[0].score - 1.0).abs() < 0.001); } // --- Edge case tests --- #[test] fn test_rrf_both_empty() { let config = SearchConfig::default(); let results = reciprocal_rank_fusion(Vec::new(), Vec::new(), &config); assert!(results.is_empty()); } #[test] fn test_rrf_fts_only_no_vector() { let config = SearchConfig::default().with_limit(10); let chunk1 = Uuid::new_v4(); let chunk2 = Uuid::new_v4(); let chunk3 = Uuid::new_v4(); let doc = Uuid::new_v4(); let fts_results = vec![ make_result(chunk1, doc, 1), make_result(chunk2, doc, 2), make_result(chunk3, doc, 3), ]; let results = reciprocal_rank_fusion(fts_results, Vec::new(), &config); assert_eq!(results.len(), 3); // All results should come from FTS only assert!(results.iter().all(|r| r.from_fts())); assert!(results.iter().all(|r| !r.from_vector())); assert!(results.iter().all(|r| !r.is_hybrid())); // Scores should be in descending order for w in results.windows(2) { assert!(w[0].score >= w[1].score); } } #[test] fn test_rrf_vector_only_no_fts() { let config = SearchConfig::default().with_limit(10); let chunk1 = Uuid::new_v4(); let chunk2 = Uuid::new_v4(); let chunk3 = Uuid::new_v4(); let doc = Uuid::new_v4(); let vector_results = vec![ make_result(chunk1, doc, 1), make_result(chunk2, doc, 2), make_result(chunk3, doc, 3), ]; let results = reciprocal_rank_fusion(Vec::new(), vector_results, &config); assert_eq!(results.len(), 3); // All results should come from vector only assert!(results.iter().all(|r| r.from_vector())); assert!(results.iter().all(|r| !r.from_fts())); assert!(results.iter().all(|r| !r.is_hybrid())); // Scores should be in descending order for w in results.windows(2) { assert!(w[0].score >= w[1].score); } } #[test] fn test_rrf_duplicate_chunks_merged() { let config = SearchConfig::default().with_limit(10); let shared_chunk = Uuid::new_v4(); let fts_only_chunk = Uuid::new_v4(); let vector_only_chunk = Uuid::new_v4(); let doc = Uuid::new_v4(); // shared_chunk appears at rank 2 in FTS and rank 3 in vector let fts_results = vec![ make_result(fts_only_chunk, doc, 1), make_result(shared_chunk, doc, 2), ]; let vector_results = vec![ make_result(vector_only_chunk, doc, 1), make_result(shared_chunk, doc, 3), ]; let results = reciprocal_rank_fusion(fts_results, vector_results, &config); // Should have 3 unique chunks (not 4) assert_eq!(results.len(), 3); // Find the shared chunk in results let shared = results.iter().find(|r| r.chunk_id == shared_chunk).unwrap(); assert!(shared.is_hybrid()); assert_eq!(shared.fts_rank, Some(2)); assert_eq!(shared.vector_rank, Some(3)); // The shared chunk's pre-normalization score is 1/(k+2) + 1/(k+3), // which is higher than either single-method chunk at rank 1: 1/(k+1). // After normalization the shared chunk should be the top result. assert_eq!(results[0].chunk_id, shared_chunk); } #[test] fn test_rrf_limit_zero_returns_empty() { let config = SearchConfig::default().with_limit(0); let doc = Uuid::new_v4(); let fts_results = vec![ make_result(Uuid::new_v4(), doc, 1), make_result(Uuid::new_v4(), doc, 2), ]; let results = reciprocal_rank_fusion(fts_results, Vec::new(), &config); assert!(results.is_empty()); } #[test] fn test_rrf_min_score_one_filters_all() { // RRF scores are always < 1.0 before normalization (1/(k+rank) where k>=1, rank>=1). // After normalization the top result gets score=1.0, so min_score=1.0 should // keep only the single top result. To truly filter everything, we need // min_score > 1.0 -- but with_min_score clamps to 1.0. // With a single result: normalized score = 1.0, so it passes min_score=1.0. // With multiple results: only the top (score=1.0) survives. // To filter ALL results we need to ensure none reach 1.0 -- but normalization // always makes the max = 1.0. So min_score=1.0 keeps exactly 1 result (the top). // // Verified: the retain check is `score >= min_score` and the top score // is normalized to exactly 1.0, so one result survives. let config = SearchConfig::default().with_limit(10).with_min_score(1.0); let doc = Uuid::new_v4(); let fts_results = vec![ make_result(Uuid::new_v4(), doc, 1), make_result(Uuid::new_v4(), doc, 2), make_result(Uuid::new_v4(), doc, 3), ]; let results = reciprocal_rank_fusion(fts_results, Vec::new(), &config); // After normalization the top result has score 1.0, so exactly 1 survives assert_eq!(results.len(), 1); assert!((results[0].score - 1.0).abs() < 0.001); } #[test] fn test_search_config_fts_only() { let config = SearchConfig::default().fts_only(); assert!(config.use_fts); assert!(!config.use_vector); // Other defaults should be preserved assert_eq!(config.limit, 10); assert_eq!(config.rrf_k, 60); assert!((config.min_score - 0.0).abs() < f32::EPSILON); } #[test] fn test_search_config_vector_only() { let config = SearchConfig::default().vector_only(); assert!(!config.use_fts); assert!(config.use_vector); // Other defaults should be preserved assert_eq!(config.limit, 10); assert_eq!(config.rrf_k, 60); assert!((config.min_score - 0.0).abs() < f32::EPSILON); } }