1use super::{FusionMethod, MultiValueCombiner, SearchResult, ranked_chunks, rrf_contribution};
3use crate::query::ScoreScope;
4
5pub 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#[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 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
53pub 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 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 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}