use rustc_hash::FxHashMap;
use super::vector::MultiValueCombiner;
use super::{ScoredPosition, 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
}
pub fn fuse_ranked_lists_chunked(
lists: Vec<(Vec<SearchResult>, f32)>,
method: FusionMethod,
combiner: MultiValueCombiner,
limit: usize,
) -> Vec<SearchResult> {
type ChunkKey = (u128, u32, u32);
let mut fused: FxHashMap<ChunkKey, f32> = FxHashMap::default();
let mut chunks: Vec<(ChunkKey, f32)> = Vec::new();
for (list, weight) in lists {
chunks.clear();
for result in &list {
let mut had_positions = false;
for (_field_id, scored_positions) in &result.positions {
for sp in scored_positions {
had_positions = true;
chunks.push(((result.segment_id, result.doc_id, sp.position), sp.score));
}
}
if !had_positions {
chunks.push(((result.segment_id, result.doc_id, 0), result.score));
}
}
if chunks.is_empty() {
continue;
}
chunks.sort_unstable_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
let (min_score, inv_range) = match method {
FusionMethod::NormalizedWeightedSum => {
let max = chunks.first().map(|c| c.1).unwrap_or(0.0);
let min = chunks.last().map(|c| c.1).unwrap_or(0.0);
let range = max - min;
(min, if range > 0.0 { 1.0 / range } else { 0.0 })
}
_ => (0.0, 0.0),
};
for (rank, &(key, score)) in chunks.iter().enumerate() {
let contribution = match method {
FusionMethod::Rrf { k } => weight * rrf_contribution(k, rank + 1),
FusionMethod::NormalizedWeightedSum => {
if inv_range > 0.0 {
weight * (score - min_score) * inv_range
} else {
weight
}
}
};
*fused.entry(key).or_insert(0.0) += contribution;
}
}
let mut docs: FxHashMap<(u128, u32), Vec<(u32, f32)>> = FxHashMap::default();
for ((segment_id, doc_id, ordinal), score) in fused {
docs.entry((segment_id, doc_id))
.or_default()
.push((ordinal, score));
}
let mut results: Vec<SearchResult> = docs
.into_iter()
.map(|((segment_id, doc_id), mut ordinals)| {
ordinals.sort_unstable_by_key(|&(ord, _)| ord);
let score = combiner.combine(&ordinals);
let scored_positions: Vec<ScoredPosition> = ordinals
.into_iter()
.map(|(ord, s)| ScoredPosition::new(ord, s))
.collect();
SearchResult {
doc_id,
score,
segment_id,
positions: vec![(0, scored_positions)],
}
})
.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);
}
fn chunked(doc_id: u32, chunks: &[(u32, f32)]) -> SearchResult {
let positions = vec![(
0u32,
chunks
.iter()
.map(|&(ord, s)| ScoredPosition::new(ord, s))
.collect(),
)];
SearchResult {
doc_id,
score: chunks.iter().map(|&(_, s)| s).fold(0.0, f32::max),
segment_id: 1,
positions,
}
}
#[test]
fn test_chunked_fusion_junk_vertical_does_not_outvote() {
let sparse = vec![
chunked(1, &[(0, 10.0)]),
chunked(2, &[(0, 5.0)]),
chunked(3, &[(0, 4.0)]),
chunked(4, &[(0, 3.0)]),
chunked(9, &[(2, 2.0)]),
];
let dense = vec![
chunked(7, &[(0, 0.31)]),
chunked(8, &[(1, 0.30)]),
chunked(6, &[(0, 0.29)]),
chunked(5, &[(3, 0.28)]),
chunked(9, &[(5, 0.27)]),
];
let fused = fuse_ranked_lists_chunked(
vec![(sparse, 1.0), (dense, 1.0)],
FusionMethod::Rrf { k: 60.0 },
MultiValueCombiner::Max,
10,
);
assert_eq!(
fused[0].doc_id, 1,
"sparse rank-1 doc must win over doc 9 (present in both lists on different chunks)"
);
}
#[test]
fn test_chunked_fusion_same_chunk_corroboration_wins() {
let sparse = vec![chunked(1, &[(3, 9.0)]), chunked(2, &[(0, 8.0)])];
let dense = vec![chunked(1, &[(3, 0.9)]), chunked(2, &[(7, 0.8)])];
let fused = fuse_ranked_lists_chunked(
vec![(sparse, 1.0), (dense, 1.0)],
FusionMethod::Rrf { k: 60.0 },
MultiValueCombiner::Max,
10,
);
assert_eq!(fused[0].doc_id, 1);
let expected_doc1 = 2.0 / 61.0;
assert!((fused[0].score - expected_doc1).abs() < 1e-6);
assert!(fused[1].score < expected_doc1 / 1.9);
let (_, positions) = &fused[0].positions[0..1][0];
assert_eq!(positions.len(), 1);
assert_eq!(positions[0].position, 3, "fused chunk ordinal preserved");
}
#[test]
fn test_chunked_fusion_pseudo_chunk_for_docs_without_positions() {
let text = vec![result(1, 3.0), result(2, 2.0)]; let dense = vec![chunked(1, &[(0, 0.9)])];
let fused = fuse_ranked_lists_chunked(
vec![(text, 1.0), (dense, 1.0)],
FusionMethod::Rrf { k: 60.0 },
MultiValueCombiner::Max,
10,
);
assert_eq!(fused[0].doc_id, 1);
assert!((fused[0].score - 2.0 / 61.0).abs() < 1e-6);
assert_eq!(fused.len(), 2);
}
#[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);
}
}