mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
* feat: configurable hybrid search fusion strategy (#169) Add WeightedScore fusion as an alternative to the default RRF algorithm. Users can now tune search behavior via env vars (SEARCH_FUSION_STRATEGY, SEARCH_FTS_WEIGHT, SEARCH_VECTOR_WEIGHT, SEARCH_RRF_K) or by passing SearchConfig with the new fields. Default behavior (RRF, k=60) is unchanged. - Add FusionStrategy enum (Rrf/WeightedScore) to workspace::search - Add weighted_score_fusion() and fuse_results() dispatcher - Add config/search.rs with WorkspaceSearchConfig from env vars - Wire search defaults through Workspace struct - Update both postgres and libsql backends to use fuse_results() - Add 7 new tests (4 fusion + 3 config) Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: swap default search weights to match issue #169 spec (0.7 vector / 0.3 FTS) The issue spec says "0.7/0.3 (vector/keyword) for weighted mode" but our defaults had fts_weight=0.7, vector_weight=0.3 (inverted). Also fixes the misleading docstring on weighted_score_fusion that claimed 1/rank normalizes to [0,1]. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: validate weight inputs and update stale doc comments - Reject NaN, infinite, and negative values for SEARCH_FTS_WEIGHT and SEARCH_VECTOR_WEIGHT with a clear ConfigError - Fix module-level docs that incorrectly claimed WeightedScore "normalizes per-method scores to [0,1]" - Update SearchResult.score doc from "Combined RRF score" to strategy-agnostic "Combined fusion score" Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: validate weight setters against NaN/inf/negative values with_fts_weight() and with_vector_weight() now silently ignore non-finite (NaN, ±inf) and negative values, matching the env var validation already in place for SEARCH_FTS_WEIGHT / SEARCH_VECTOR_WEIGHT. Values > 1.0 remain valid since weights are normalized internally. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: use crate-wide ENV_MUTEX in search config tests Replace the module-local `ENV_MUTEX` in `search.rs` with a shared `crate::config::helpers::ENV_MUTEX` to prevent cross-module env races when `cargo test` runs tests in parallel. Addresses copilot review comment. Tracked in #245. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: per-strategy weight defaults to match issue #169 spec RRF mode now defaults to 0.5/0.5 (fts/vector) and WeightedScore defaults to 0.3/0.7, matching the acceptance criteria in #169. Previously both modes used 0.3/0.7 uniformly. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: reject both weights=0 in weighted fusion mode When both SEARCH_FTS_WEIGHT and SEARCH_VECTOR_WEIGHT are 0.0 under WeightedScore strategy, all scores would be 0.0, producing arbitrary ordering. RRF mode is unaffected since it ignores weights entirely. Addresses Copilot review comment. The other comment (rrf_k=0 division by zero) is a false positive — ranks are 1-based, so k=0 just gives inverse-rank scoring with no infinity. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: clarify weight doc comments and error key - SearchConfig field docs: clarify that Default always uses 0.5, per-strategy defaults only apply via WorkspaceSearchConfig::resolve() - WorkspaceSearchConfig field docs: same clarification - Error key for both-weights-zero now references both env vars Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: remove broken intra-doc links to pub(crate) resolve() WorkspaceSearchConfig::resolve is pub(crate), so linking to it from public field docs triggers rustdoc private_intra_doc_links warnings. Switch to plain-text references. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: add document_path to weighted_score_fusion results The weighted_score_fusion function was missing the document_path field added in a recent main branch commit, causing a compile error after rebase. Co-Authored-By: Claude Opus 4.6 <[email protected]> * chore: trigger CI re-check after rebase * fix: resolve pre-existing staging fmt and clippy issues - Fix import ordering in cli/mod.rs (cargo fmt) - Fix line wrapping in tools/mcp/auth.rs (cargo fmt) - Move path_routing_tests before MemoryTreeTool to fix clippy::items_after_test_module [skip-regression-check] * fix: remove duplicate path_routing_tests module after rebase [skip-regression-check] --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
938 lines
31 KiB
Rust
938 lines
31 KiB
Rust
//! 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<u32>,
|
|
/// Rank in vector results (1-based, None if not in vector results).
|
|
pub vector_rank: Option<u32>,
|
|
}
|
|
|
|
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<RankedResult>,
|
|
vector_results: Vec<RankedResult>,
|
|
config: &SearchConfig,
|
|
) -> Vec<SearchResult> {
|
|
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<RankedResult>,
|
|
vector_results: Vec<RankedResult>,
|
|
config: &SearchConfig,
|
|
) -> Vec<SearchResult> {
|
|
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<u32>,
|
|
vector_rank: Option<u32>,
|
|
}
|
|
|
|
let mut chunk_scores: HashMap<Uuid, ChunkInfo> = 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<SearchResult> = 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<RankedResult>,
|
|
vector_results: Vec<RankedResult>,
|
|
config: &SearchConfig,
|
|
) -> Vec<SearchResult> {
|
|
struct ChunkInfo {
|
|
document_id: Uuid,
|
|
document_path: String,
|
|
content: String,
|
|
score: f32,
|
|
fts_rank: Option<u32>,
|
|
vector_rank: Option<u32>,
|
|
}
|
|
|
|
let mut chunk_scores: HashMap<Uuid, ChunkInfo> = 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<SearchResult> = 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);
|
|
}
|
|
}
|