use rustc_hash::FxHashMap;
use super::SearchResult;
pub const DEFAULT_RRF_K: f32 = 60.0;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum FusionMethod {
Rrf { k: f32 },
NormalizedWeightedSum,
}
impl Default for FusionMethod {
fn default() -> Self {
FusionMethod::Rrf { k: DEFAULT_RRF_K }
}
}
#[inline]
pub(crate) fn rrf_contribution(k: f32, rank: usize) -> f32 {
1.0 / (k + rank as f32)
}
pub fn fuse_ranked_lists(
lists: Vec<(Vec<SearchResult>, f32)>,
method: FusionMethod,
limit: usize,
) -> Vec<SearchResult> {
let capacity = lists.iter().map(|(l, _)| l.len()).sum();
let mut fused: FxHashMap<(u128, u32), SearchResult> =
FxHashMap::with_capacity_and_hasher(capacity, Default::default());
for (list, weight) in lists {
let (min_score, inv_range) = match method {
FusionMethod::NormalizedWeightedSum if !list.is_empty() => {
let mut min = f32::INFINITY;
let mut max = f32::NEG_INFINITY;
for r in &list {
min = min.min(r.score);
max = max.max(r.score);
}
let range = max - min;
(min, if range > 0.0 { 1.0 / range } else { 0.0 })
}
_ => (0.0, 0.0),
};
for (idx, result) in list.into_iter().enumerate() {
let contribution = match method {
FusionMethod::Rrf { k } => weight * rrf_contribution(k, idx + 1),
FusionMethod::NormalizedWeightedSum => {
if inv_range > 0.0 {
weight * (result.score - min_score) * inv_range
} else {
weight
}
}
};
fused
.entry((result.segment_id, result.doc_id))
.and_modify(|r| r.score += contribution)
.or_insert_with(|| SearchResult {
score: contribution,
..result
});
}
}
let mut results: Vec<SearchResult> = fused.into_values().collect();
if results.len() > limit {
results.select_nth_unstable_by(limit, |a, b| b.score.total_cmp(&a.score));
results.truncate(limit);
}
results.sort_unstable_by(|a, b| {
b.score
.total_cmp(&a.score)
.then_with(|| a.doc_id.cmp(&b.doc_id))
});
results
}
#[cfg(test)]
mod tests {
use super::*;
fn result(doc_id: u32, score: f32) -> SearchResult {
SearchResult {
doc_id,
score,
segment_id: 1,
positions: Vec::new(),
}
}
#[test]
fn test_rrf_union_includes_single_list_docs() {
let sparse = vec![result(1, 10.0), result(2, 5.0)];
let dense = vec![result(3, 0.9), result(1, 0.8)];
let fused = fuse_ranked_lists(
vec![(sparse, 1.0), (dense, 1.0)],
FusionMethod::Rrf { k: 60.0 },
10,
);
assert_eq!(fused.len(), 3);
assert_eq!(fused[0].doc_id, 1);
let expected = 1.0 / 61.0 + 1.0 / 62.0;
assert!((fused[0].score - expected).abs() < 1e-6);
let ids: Vec<u32> = fused.iter().map(|r| r.doc_id).collect();
assert!(ids.contains(&2) && ids.contains(&3));
}
#[test]
fn test_rrf_weights_scale_contribution() {
let a = vec![result(1, 1.0)];
let b = vec![result(2, 1.0)];
let fused = fuse_ranked_lists(vec![(a, 1.0), (b, 2.0)], FusionMethod::Rrf { k: 60.0 }, 10);
assert_eq!(fused[0].doc_id, 2);
assert!((fused[0].score - 2.0 / 61.0).abs() < 1e-6);
}
#[test]
fn test_normalized_weighted_sum() {
let sparse = vec![result(1, 20.0), result(2, 10.0), result(3, 0.0)];
let dense = vec![result(2, 0.99), result(1, 0.55), result(3, 0.11)];
let fused = fuse_ranked_lists(
vec![(sparse, 0.5), (dense, 0.5)],
FusionMethod::NormalizedWeightedSum,
10,
);
assert_eq!(fused.len(), 3);
assert_eq!(fused[0].doc_id, 1);
assert!((fused[0].score - 0.75).abs() < 1e-6);
assert!((fused[1].score - 0.75).abs() < 1e-6);
assert_eq!(fused[2].doc_id, 3);
assert!(fused[2].score.abs() < 1e-6);
}
#[test]
fn test_limit_truncation() {
let list: Vec<SearchResult> = (0..100).map(|i| result(i, 100.0 - i as f32)).collect();
let fused = fuse_ranked_lists(vec![(list, 1.0)], FusionMethod::default(), 5);
assert_eq!(fused.len(), 5);
assert_eq!(fused[0].doc_id, 0);
}
#[test]
fn test_duplicate_across_segments_not_merged() {
let mut a = result(1, 1.0);
a.segment_id = 1;
let mut b = result(1, 1.0);
b.segment_id = 2;
let fused = fuse_ranked_lists(
vec![(vec![a], 1.0), (vec![b], 1.0)],
FusionMethod::default(),
10,
);
assert_eq!(fused.len(), 2);
}
}