use super::{FusionMethod, MultiValueCombiner, SearchResult, ranked_chunks, rrf_contribution};
use crate::query::ScoreScope;
pub struct RrfRankedList<'a> {
pub query_index: usize,
pub scope: Option<ScoreScope>,
pub weight: f32,
pub hits: &'a [SearchResult],
}
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
pub struct RrfContribution {
pub query_index: usize,
pub rank: usize,
pub score: f32,
#[serde(skip_serializing_if = "Option::is_none")]
pub ordinal: Option<u32>,
}
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize)]
pub struct RrfScore {
pub score: f32,
pub contributions: Vec<RrfContribution>,
}
impl RrfScore {
pub(crate) fn document_context(&self) -> f32 {
self.contributions
.iter()
.take_while(|vote| vote.ordinal.is_none())
.map(|vote| vote.score)
.sum()
}
pub(crate) fn passage_score(&self, ordinal: u32, context: f32) -> f32 {
let first = self
.contributions
.partition_point(|vote| vote.ordinal < Some(ordinal));
let passage: f32 = self.contributions[first..]
.iter()
.take_while(|vote| vote.ordinal == Some(ordinal))
.map(|vote| vote.score)
.sum();
passage + context
}
}
pub fn rrf_scores_for_hits(
lists: &[RrfRankedList<'_>],
selected: &[(u128, u32)],
k: f32,
combiner: MultiValueCombiner,
) -> Result<Vec<RrfScore>, String> {
let borrowed: Vec<_> = lists.iter().map(|list| (list.hits, list.weight)).collect();
super::validate_fusion_lists(&borrowed, FusionMethod::Rrf { k }, combiner)?;
if selected.len() > super::MAX_FUSION_CANDIDATE_SLOTS {
return Err("RRF selected hit budget exceeded".into());
}
let mut indexes: Vec<_> = selected
.iter()
.copied()
.enumerate()
.map(|(i, key)| (key, i))
.collect();
indexes.sort_unstable_by_key(|row| row.0);
if indexes.windows(2).any(|pair| pair[0].0 == pair[1].0) {
return Err("duplicate RRF selected address".into());
}
if lists.iter().any(|list| {
list.query_index >= super::MAX_FUSION_SUB_QUERIES
|| list.hits.iter().any(|hit| {
!hit.score.is_finite()
|| hit
.positions
.iter()
.any(|(_, positions)| positions.iter().any(|p| !p.score.is_finite()))
})
}) {
return Err("invalid RRF branch identity or nomination score".into());
}
let mut branch_indexes: Vec<_> = lists.iter().map(|list| list.query_index).collect();
branch_indexes.sort_unstable();
if branch_indexes.windows(2).any(|pair| pair[0] == pair[1]) {
return Err("duplicate RRF branch identity".into());
}
let mut output = vec![RrfScore::default(); selected.len()];
let mut chunks = Vec::new();
let mut documents = Vec::new();
for list in lists {
if list.scope == Some(ScoreScope::Document) {
documents.clear();
documents.extend(
list.hits
.iter()
.map(|hit| ((hit.segment_id, hit.doc_id), hit.score)),
);
documents.sort_unstable_by(|a, b| a.0.cmp(&b.0).then_with(|| b.1.total_cmp(&a.1)));
documents.dedup_by_key(|row| row.0);
documents.sort_unstable_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0)));
for (rank, &(key, _)) in documents.iter().enumerate() {
if let Ok(index) = indexes.binary_search_by_key(&key, |row| row.0) {
output[indexes[index].1]
.contributions
.push(RrfContribution {
query_index: list.query_index,
rank: rank + 1,
score: list.weight * rrf_contribution(k, rank + 1),
ordinal: None,
});
}
}
} else {
ranked_chunks(list.hits, &mut chunks);
for (rank, &((segment, doc, ordinal), _)) in chunks.iter().enumerate() {
if let Ok(index) = indexes.binary_search_by_key(&(segment, doc), |row| row.0) {
output[indexes[index].1]
.contributions
.push(RrfContribution {
query_index: list.query_index,
rank: rank + 1,
score: list.weight * rrf_contribution(k, rank + 1),
ordinal: Some(ordinal),
});
}
}
}
}
let mut passages = Vec::new();
for hit in &mut output {
hit.contributions
.sort_unstable_by_key(|vote| (vote.ordinal, vote.query_index));
if hit.contributions.is_empty() {
return Err("selected hit is absent from RRF nomination lists".into());
}
let context: f32 = hit
.contributions
.iter()
.filter(|vote| vote.ordinal.is_none())
.map(|vote| vote.score)
.sum();
passages.clear();
for vote in &hit.contributions {
let Some(ordinal) = vote.ordinal else {
continue;
};
if let Some((last, total)) = passages.last_mut()
&& *last == ordinal
{
*total += vote.score;
} else {
passages.push((ordinal, vote.score));
}
}
for (_, score) in &mut passages {
*score += context;
}
hit.score = if passages.is_empty() {
context
} else {
combiner.combine(&passages)
};
if !hit.score.is_finite() || hit.contributions.iter().any(|vote| !vote.score.is_finite()) {
return Err("RRF diagnostic score overflow".into());
}
}
Ok(output)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::query::ScoredPosition;
fn hit(segment: u128, doc_id: u32, score: f32, positions: &[(u32, f32)]) -> SearchResult {
SearchResult {
segment_id: segment,
doc_id,
score,
positions: vec![(
0,
positions
.iter()
.map(|&(ordinal, score)| ScoredPosition::new(ordinal, score))
.collect(),
)],
}
}
#[test]
fn diagnostics_match_legacy_chunk_fusion_bit_for_bit_for_every_combiner() {
let lists = [
vec![
hit(2, 0, 8.0, &[(0, 8.0), (1, 3.0), (0, 7.0)]),
hit(1, 1, 8.0, &[]),
hit(1, 0, 7.0, &[(2, 7.0)]),
],
vec![
hit(1, 0, 5.0, &[(2, 5.0), (0, 0.0)]),
hit(2, 0, 6.0, &[(1, 6.0)]),
],
];
let weights = [0.7, 2.3];
let borrowed: Vec<_> = lists
.iter()
.zip(weights)
.map(|(hits, weight)| (hits.as_slice(), weight))
.collect();
let ranked: Vec<_> = lists
.iter()
.zip(weights)
.enumerate()
.map(|(query_index, (hits, weight))| RrfRankedList {
query_index,
scope: None,
weight,
hits,
})
.collect();
for combiner in [
MultiValueCombiner::Max,
MultiValueCombiner::Avg,
MultiValueCombiner::Sum,
MultiValueCombiner::LogSumExp { temperature: 1.5 },
MultiValueCombiner::WeightedTopK { k: 5, decay: 0.7 },
] {
for k in [0.0, 60.0] {
let fused = super::super::try_fuse_ranked_lists_chunked_borrowed(
&borrowed,
FusionMethod::Rrf { k },
combiner,
10,
)
.unwrap();
let selected: Vec<_> = fused
.iter()
.rev()
.map(|hit| (hit.segment_id, hit.doc_id))
.collect();
let scores = rrf_scores_for_hits(&ranked, &selected, k, combiner).unwrap();
for (fused, score) in fused.iter().rev().zip(scores) {
assert_eq!(fused.score.to_bits(), score.score.to_bits());
}
}
}
let scores =
rrf_scores_for_hits(&ranked, &[(2, 0)], 60.0, MultiValueCombiner::Max).unwrap();
assert_eq!(
scores[0]
.contributions
.iter()
.map(|v| (v.query_index, v.ordinal, v.rank))
.collect::<Vec<_>>(),
vec![(0, Some(0), 2), (0, Some(1), 4), (1, Some(1), 1)]
);
}
#[test]
fn document_votes_broadcast_to_real_passages_without_creating_ordinal_zero() {
let document = [hit(1, 0, 10.0, &[(99, 123.0)]), hit(2, 0, 20.0, &[])];
let chunk = [hit(1, 0, 8.0, &[(3, 8.0), (7, 5.0)])];
let lists = [
RrfRankedList {
query_index: 0,
scope: Some(ScoreScope::Document),
weight: 2.0,
hits: &document,
},
RrfRankedList {
query_index: 2,
scope: Some(ScoreScope::Chunk),
weight: 1.0,
hits: &chunk,
},
];
let scores =
rrf_scores_for_hits(&lists, &[(1, 0), (2, 0)], 60.0, MultiValueCombiner::Max).unwrap();
assert_eq!(scores[0].score, 2.0 / 62.0 + 1.0 / 61.0);
assert_eq!(
scores[0]
.contributions
.iter()
.map(|v| (v.query_index, v.ordinal, v.rank))
.collect::<Vec<_>>(),
vec![(0, None, 2), (2, Some(3), 1), (2, Some(7), 2)]
);
assert_eq!(scores[1].score, 2.0 / 61.0);
assert_eq!(scores[1].contributions[0].ordinal, None);
}
#[test]
fn diagnostics_reject_incomplete_identity_nonfinite_scores_and_unbounded_inputs() {
let hits = [hit(1, 0, 1.0, &[])];
let list = |query_index| RrfRankedList {
query_index,
scope: None,
weight: 1.0,
hits: &hits,
};
let check = |lists: &[RrfRankedList<'_>], selected: &[(u128, u32)]| {
rrf_scores_for_hits(lists, selected, 60.0, MultiValueCombiner::Max)
};
assert!(check(&[list(0)], &[(2, 0)]).is_err());
assert!(check(&[list(0)], &[(1, 0), (1, 0)]).is_err());
assert!(check(&[list(0), list(0)], &[(1, 0)]).is_err());
assert!(check(&[list(16)], &[(1, 0)]).is_err());
let nonfinite = [hit(1, 0, f32::NAN, &[])];
assert!(
check(
&[RrfRankedList {
hits: &nonfinite,
..list(0)
}],
&[(1, 0)]
)
.is_err()
);
let oversized = vec![hit(1, 0, 1.0, &[]); super::super::MAX_FUSION_CANDIDATE_SLOTS + 1];
assert!(
check(
&[RrfRankedList {
hits: &oversized,
..list(0)
}],
&[(1, 0)]
)
.is_err()
);
let zero = check(
&[RrfRankedList {
weight: 0.0,
..list(0)
}],
&[(1, 0)],
)
.unwrap();
assert_eq!(zero[0].score, 0.0);
assert_eq!(zero[0].contributions.len(), 1);
}
}