Skip to main content

summa_core/query/fusion/
rrf_scores.rs

1//! Bounded attribution of organic RRF votes, independent of final ranking.
2use super::{FusionMethod, MultiValueCombiner, SearchResult, ranked_chunks, rrf_contribution};
3use crate::query::ScoreScope;
4
5/// A nomination branch. None scope preserves legacy chunk fusion semantics.
6pub struct RrfRankedList<'a> {
7    pub query_index: usize,
8    pub scope: Option<ScoreScope>,
9    pub weight: f32,
10    pub hits: &'a [SearchResult],
11}
12
13/// One vote from a branch's complete nomination list, before backfill.
14#[derive(Debug, Clone, PartialEq, serde::Serialize)]
15pub struct RrfContribution {
16    pub query_index: usize,
17    pub rank: usize,
18    pub score: f32,
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub ordinal: Option<u32>,
21}
22
23#[derive(Debug, Clone, Default, PartialEq, serde::Serialize)]
24pub struct RrfScore {
25    pub score: f32,
26    pub contributions: Vec<RrfContribution>,
27}
28
29impl RrfScore {
30    pub(crate) fn document_context(&self) -> f32 {
31        self.contributions
32            .iter()
33            .take_while(|vote| vote.ordinal.is_none())
34            .map(|vote| vote.score)
35            .sum()
36    }
37
38    /// Votes are ordered by ordinal/query index. A lookup is O(log votes +
39    /// branches), avoiding a full vote scan for each of up to 65,536 passages.
40    pub(crate) fn passage_score(&self, ordinal: u32, context: f32) -> f32 {
41        let first = self
42            .contributions
43            .partition_point(|vote| vote.ordinal < Some(ordinal));
44        let passage: f32 = self.contributions[first..]
45            .iter()
46            .take_while(|vote| vote.ordinal == Some(ordinal))
47            .map(|vote| vote.score)
48            .sum();
49        passage + context
50    }
51}
52
53/// Compute RRF only for the selected hits, using ranks from complete lists.
54/// Input/output hit order is preserved. No retrieval, hydration or L1 scoring
55/// occurs here. Document context is broadcast to nominated passage scores.
56pub fn rrf_scores_for_hits(
57    lists: &[RrfRankedList<'_>],
58    selected: &[(u128, u32)],
59    k: f32,
60    combiner: MultiValueCombiner,
61) -> Result<Vec<RrfScore>, String> {
62    let borrowed: Vec<_> = lists.iter().map(|list| (list.hits, list.weight)).collect();
63    super::validate_fusion_lists(&borrowed, FusionMethod::Rrf { k }, combiner)?;
64    if selected.len() > super::MAX_FUSION_CANDIDATE_SLOTS {
65        return Err("RRF selected hit budget exceeded".into());
66    }
67    let mut indexes: Vec<_> = selected
68        .iter()
69        .copied()
70        .enumerate()
71        .map(|(i, key)| (key, i))
72        .collect();
73    indexes.sort_unstable_by_key(|row| row.0);
74    if indexes.windows(2).any(|pair| pair[0].0 == pair[1].0) {
75        return Err("duplicate RRF selected address".into());
76    }
77    if lists.iter().any(|list| {
78        list.query_index >= super::MAX_FUSION_SUB_QUERIES
79            || list.hits.iter().any(|hit| {
80                !hit.score.is_finite()
81                    || hit
82                        .positions
83                        .iter()
84                        .any(|(_, positions)| positions.iter().any(|p| !p.score.is_finite()))
85            })
86    }) {
87        return Err("invalid RRF branch identity or nomination score".into());
88    }
89    let mut branch_indexes: Vec<_> = lists.iter().map(|list| list.query_index).collect();
90    branch_indexes.sort_unstable();
91    if branch_indexes.windows(2).any(|pair| pair[0] == pair[1]) {
92        return Err("duplicate RRF branch identity".into());
93    }
94    let mut output = vec![RrfScore::default(); selected.len()];
95    let mut chunks = Vec::new();
96    let mut documents = Vec::new();
97    for list in lists {
98        if list.scope == Some(ScoreScope::Document) {
99            documents.clear();
100            documents.extend(
101                list.hits
102                    .iter()
103                    .map(|hit| ((hit.segment_id, hit.doc_id), hit.score)),
104            );
105            // Keep one organic vote even if a trusted caller repeats a doc.
106            documents.sort_unstable_by(|a, b| a.0.cmp(&b.0).then_with(|| b.1.total_cmp(&a.1)));
107            documents.dedup_by_key(|row| row.0);
108            documents.sort_unstable_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0)));
109            for (rank, &(key, _)) in documents.iter().enumerate() {
110                if let Ok(index) = indexes.binary_search_by_key(&key, |row| row.0) {
111                    output[indexes[index].1]
112                        .contributions
113                        .push(RrfContribution {
114                            query_index: list.query_index,
115                            rank: rank + 1,
116                            score: list.weight * rrf_contribution(k, rank + 1),
117                            ordinal: None,
118                        });
119                }
120            }
121        } else {
122            ranked_chunks(list.hits, &mut chunks);
123            for (rank, &((segment, doc, ordinal), _)) in chunks.iter().enumerate() {
124                if let Ok(index) = indexes.binary_search_by_key(&(segment, doc), |row| row.0) {
125                    output[indexes[index].1]
126                        .contributions
127                        .push(RrfContribution {
128                            query_index: list.query_index,
129                            rank: rank + 1,
130                            score: list.weight * rrf_contribution(k, rank + 1),
131                            ordinal: Some(ordinal),
132                        });
133                }
134            }
135        }
136    }
137    let mut passages = Vec::new();
138    for hit in &mut output {
139        hit.contributions
140            .sort_unstable_by_key(|vote| (vote.ordinal, vote.query_index));
141        if hit.contributions.is_empty() {
142            return Err("selected hit is absent from RRF nomination lists".into());
143        }
144        let context: f32 = hit
145            .contributions
146            .iter()
147            .filter(|vote| vote.ordinal.is_none())
148            .map(|vote| vote.score)
149            .sum();
150        passages.clear();
151        for vote in &hit.contributions {
152            let Some(ordinal) = vote.ordinal else {
153                continue;
154            };
155            if let Some((last, total)) = passages.last_mut()
156                && *last == ordinal
157            {
158                *total += vote.score;
159            } else {
160                passages.push((ordinal, vote.score));
161            }
162        }
163        for (_, score) in &mut passages {
164            *score += context;
165        }
166        hit.score = if passages.is_empty() {
167            context
168        } else {
169            combiner.combine(&passages)
170        };
171        if !hit.score.is_finite() || hit.contributions.iter().any(|vote| !vote.score.is_finite()) {
172            return Err("RRF diagnostic score overflow".into());
173        }
174    }
175    Ok(output)
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use crate::query::ScoredPosition;
182
183    fn hit(segment: u128, doc_id: u32, score: f32, positions: &[(u32, f32)]) -> SearchResult {
184        SearchResult {
185            segment_id: segment,
186            doc_id,
187            score,
188            positions: vec![(
189                0,
190                positions
191                    .iter()
192                    .map(|&(ordinal, score)| ScoredPosition::new(ordinal, score))
193                    .collect(),
194            )],
195        }
196    }
197
198    #[test]
199    fn diagnostics_match_legacy_chunk_fusion_bit_for_bit_for_every_combiner() {
200        let lists = [
201            vec![
202                hit(2, 0, 8.0, &[(0, 8.0), (1, 3.0), (0, 7.0)]),
203                hit(1, 1, 8.0, &[]),
204                hit(1, 0, 7.0, &[(2, 7.0)]),
205            ],
206            vec![
207                hit(1, 0, 5.0, &[(2, 5.0), (0, 0.0)]),
208                hit(2, 0, 6.0, &[(1, 6.0)]),
209            ],
210        ];
211        let weights = [0.7, 2.3];
212        let borrowed: Vec<_> = lists
213            .iter()
214            .zip(weights)
215            .map(|(hits, weight)| (hits.as_slice(), weight))
216            .collect();
217        let ranked: Vec<_> = lists
218            .iter()
219            .zip(weights)
220            .enumerate()
221            .map(|(query_index, (hits, weight))| RrfRankedList {
222                query_index,
223                scope: None,
224                weight,
225                hits,
226            })
227            .collect();
228        for combiner in [
229            MultiValueCombiner::Max,
230            MultiValueCombiner::Avg,
231            MultiValueCombiner::Sum,
232            MultiValueCombiner::LogSumExp { temperature: 1.5 },
233            MultiValueCombiner::WeightedTopK { k: 5, decay: 0.7 },
234        ] {
235            for k in [0.0, 60.0] {
236                let fused = super::super::try_fuse_ranked_lists_chunked_borrowed(
237                    &borrowed,
238                    FusionMethod::Rrf { k },
239                    combiner,
240                    10,
241                )
242                .unwrap();
243                let selected: Vec<_> = fused
244                    .iter()
245                    .rev()
246                    .map(|hit| (hit.segment_id, hit.doc_id))
247                    .collect();
248                let scores = rrf_scores_for_hits(&ranked, &selected, k, combiner).unwrap();
249                for (fused, score) in fused.iter().rev().zip(scores) {
250                    assert_eq!(fused.score.to_bits(), score.score.to_bits());
251                }
252            }
253        }
254        // Duplicate ordinal 0 did not shift any rank; tied scores use the full address.
255        let scores =
256            rrf_scores_for_hits(&ranked, &[(2, 0)], 60.0, MultiValueCombiner::Max).unwrap();
257        assert_eq!(
258            scores[0]
259                .contributions
260                .iter()
261                .map(|v| (v.query_index, v.ordinal, v.rank))
262                .collect::<Vec<_>>(),
263            vec![(0, Some(0), 2), (0, Some(1), 4), (1, Some(1), 1)]
264        );
265    }
266
267    #[test]
268    fn document_votes_broadcast_to_real_passages_without_creating_ordinal_zero() {
269        let document = [hit(1, 0, 10.0, &[(99, 123.0)]), hit(2, 0, 20.0, &[])];
270        let chunk = [hit(1, 0, 8.0, &[(3, 8.0), (7, 5.0)])];
271        let lists = [
272            RrfRankedList {
273                query_index: 0,
274                scope: Some(ScoreScope::Document),
275                weight: 2.0,
276                hits: &document,
277            },
278            RrfRankedList {
279                query_index: 2,
280                scope: Some(ScoreScope::Chunk),
281                weight: 1.0,
282                hits: &chunk,
283            },
284        ];
285        let scores =
286            rrf_scores_for_hits(&lists, &[(1, 0), (2, 0)], 60.0, MultiValueCombiner::Max).unwrap();
287        assert_eq!(scores[0].score, 2.0 / 62.0 + 1.0 / 61.0);
288        assert_eq!(
289            scores[0]
290                .contributions
291                .iter()
292                .map(|v| (v.query_index, v.ordinal, v.rank))
293                .collect::<Vec<_>>(),
294            vec![(0, None, 2), (2, Some(3), 1), (2, Some(7), 2)]
295        );
296        assert_eq!(scores[1].score, 2.0 / 61.0);
297        assert_eq!(scores[1].contributions[0].ordinal, None);
298    }
299
300    #[test]
301    fn diagnostics_reject_incomplete_identity_nonfinite_scores_and_unbounded_inputs() {
302        let hits = [hit(1, 0, 1.0, &[])];
303        let list = |query_index| RrfRankedList {
304            query_index,
305            scope: None,
306            weight: 1.0,
307            hits: &hits,
308        };
309        let check = |lists: &[RrfRankedList<'_>], selected: &[(u128, u32)]| {
310            rrf_scores_for_hits(lists, selected, 60.0, MultiValueCombiner::Max)
311        };
312        assert!(check(&[list(0)], &[(2, 0)]).is_err());
313        assert!(check(&[list(0)], &[(1, 0), (1, 0)]).is_err());
314        assert!(check(&[list(0), list(0)], &[(1, 0)]).is_err());
315        assert!(check(&[list(16)], &[(1, 0)]).is_err());
316        let nonfinite = [hit(1, 0, f32::NAN, &[])];
317        assert!(
318            check(
319                &[RrfRankedList {
320                    hits: &nonfinite,
321                    ..list(0)
322                }],
323                &[(1, 0)]
324            )
325            .is_err()
326        );
327        let oversized = vec![hit(1, 0, 1.0, &[]); super::super::MAX_FUSION_CANDIDATE_SLOTS + 1];
328        assert!(
329            check(
330                &[RrfRankedList {
331                    hits: &oversized,
332                    ..list(0)
333                }],
334                &[(1, 0)]
335            )
336            .is_err()
337        );
338        let zero = check(
339            &[RrfRankedList {
340                weight: 0.0,
341                ..list(0)
342            }],
343            &[(1, 0)],
344        )
345        .unwrap();
346        assert_eq!(zero[0].score, 0.0);
347        assert_eq!(zero[0].contributions.len(), 1);
348    }
349}