1use std::collections::BTreeMap;
6
7use thiserror::Error;
8
9use crate::{
10 ExactAbstentionReason, ExactRetrievalOutcome, LexicalAbstentionReason, LexicalOutcome,
11};
12
13pub const HYBRID_RRF_CONSTANT: u64 = 60;
15const CONTRIBUTION_SCALE: u64 = 1_000_000_000;
16const MAX_WEIGHT: u32 = 1_000_000;
17
18#[derive(Clone, Debug, Eq, PartialEq)]
20pub struct HybridRequest {
21 pub lexical_weight: u32,
23 pub vector_weight: u32,
25 pub limit: usize,
27}
28
29#[derive(Clone, Copy, Debug, Eq, PartialEq)]
31pub enum HybridBranchAbsence {
32 LexicalNoCandidates,
34 VectorNoCandidates,
36 VectorBelowThreshold,
38 VectorAmbiguous,
40}
41
42#[derive(Clone, Debug, Eq, PartialEq)]
44pub struct HybridExplanation {
45 pub lexical_rank: Option<u64>,
47 pub lexical_score_nanos: Option<i64>,
49 pub vector_rank: Option<u64>,
51 pub vector_score_nanos: Option<i64>,
53 pub lexical_contribution: u64,
55 pub vector_contribution: u64,
57 pub fusion_score: u64,
59 pub final_rank: u64,
61}
62
63#[derive(Clone, Debug, Eq, PartialEq)]
65pub struct HybridMatch {
66 pub key: Vec<u8>,
68 pub explanation: HybridExplanation,
70}
71
72#[derive(Clone, Debug, Eq, PartialEq)]
74pub struct HybridAbstention {
75 pub lexical: HybridBranchAbsence,
77 pub vector: HybridBranchAbsence,
79}
80
81#[derive(Clone, Debug, Eq, PartialEq)]
83pub enum HybridOutcome {
84 Matches {
86 matches: Vec<HybridMatch>,
88 lexical_absence: Option<HybridBranchAbsence>,
90 vector_absence: Option<HybridBranchAbsence>,
92 },
93 Abstained(HybridAbstention),
95}
96
97#[derive(Clone, Debug, Error, Eq, PartialEq)]
99pub enum HybridError {
100 #[error("hybrid branch weights must be in 1..=1000000")]
102 InvalidWeight,
103 #[error("hybrid result limit must be nonzero")]
105 ZeroLimit,
106 #[error("hybrid branch contains a duplicate key")]
108 DuplicateBranchKey,
109 #[error("hybrid contribution arithmetic overflow")]
111 ArithmeticOverflow,
112}
113
114#[derive(Clone, Copy, Default)]
115struct Accumulator {
116 lexical_rank: Option<u64>,
117 lexical_score_nanos: Option<i64>,
118 vector_rank: Option<u64>,
119 vector_score_nanos: Option<i64>,
120}
121
122pub fn fuse_hybrid(
129 lexical: &LexicalOutcome,
130 vector: &ExactRetrievalOutcome,
131 request: &HybridRequest,
132) -> Result<HybridOutcome, HybridError> {
133 validate_request(request)?;
134 let (lexical_matches, lexical_absence) = lexical_branch(lexical);
135 let (vector_matches, vector_absence) = vector_branch(vector);
136 if let (Some(lexical), Some(vector)) = (lexical_absence, vector_absence) {
137 return Ok(HybridOutcome::Abstained(HybridAbstention {
138 lexical,
139 vector,
140 }));
141 }
142 let mut combined = BTreeMap::<Vec<u8>, Accumulator>::new();
143 if let Some(matches) = lexical_matches {
144 for (index, matched) in matches.iter().enumerate() {
145 let rank = one_based(index)?;
146 let entry = combined.entry(matched.key.clone()).or_default();
147 if entry.lexical_rank.replace(rank).is_some() {
148 return Err(HybridError::DuplicateBranchKey);
149 }
150 entry.lexical_score_nanos = Some(matched.score_nanos);
151 }
152 }
153 if let Some(matches) = vector_matches {
154 for (index, matched) in matches.iter().enumerate() {
155 let rank = one_based(index)?;
156 let entry = combined.entry(matched.key.clone()).or_default();
157 if entry.vector_rank.replace(rank).is_some() {
158 return Err(HybridError::DuplicateBranchKey);
159 }
160 entry.vector_score_nanos = Some(matched.score_nanos);
161 }
162 }
163 let mut matches = combined
164 .into_iter()
165 .map(|(key, entry)| build_match(key, entry, request))
166 .collect::<Result<Vec<_>, HybridError>>()?;
167 matches.sort_by(|left, right| {
168 right
169 .explanation
170 .fusion_score
171 .cmp(&left.explanation.fusion_score)
172 .then_with(|| left.key.cmp(&right.key))
173 });
174 matches.truncate(request.limit);
175 for (index, matched) in matches.iter_mut().enumerate() {
176 matched.explanation.final_rank = one_based(index)?;
177 }
178 Ok(HybridOutcome::Matches {
179 matches,
180 lexical_absence,
181 vector_absence,
182 })
183}
184
185fn validate_request(request: &HybridRequest) -> Result<(), HybridError> {
186 if !(1..=MAX_WEIGHT).contains(&request.lexical_weight)
187 || !(1..=MAX_WEIGHT).contains(&request.vector_weight)
188 {
189 return Err(HybridError::InvalidWeight);
190 }
191 if request.limit == 0 {
192 return Err(HybridError::ZeroLimit);
193 }
194 Ok(())
195}
196
197fn lexical_branch(
198 lexical: &LexicalOutcome,
199) -> (Option<&[crate::LexicalMatch]>, Option<HybridBranchAbsence>) {
200 match lexical {
201 LexicalOutcome::Matches { matches, .. } => (Some(matches.as_slice()), None),
202 LexicalOutcome::Abstained(abstention) => (
203 None,
204 Some(match abstention.reason {
205 LexicalAbstentionReason::NoCandidates => HybridBranchAbsence::LexicalNoCandidates,
206 }),
207 ),
208 }
209}
210
211fn vector_branch(
212 vector: &ExactRetrievalOutcome,
213) -> (
214 Option<&[crate::ExactRetrievalMatch]>,
215 Option<HybridBranchAbsence>,
216) {
217 match vector {
218 ExactRetrievalOutcome::Matches { matches, .. } => (Some(matches.as_slice()), None),
219 ExactRetrievalOutcome::Abstained(abstention) => (
220 None,
221 Some(match abstention.reason {
222 ExactAbstentionReason::NoCandidates => HybridBranchAbsence::VectorNoCandidates,
223 ExactAbstentionReason::BelowThreshold => HybridBranchAbsence::VectorBelowThreshold,
224 ExactAbstentionReason::Ambiguous => HybridBranchAbsence::VectorAmbiguous,
225 }),
226 ),
227 }
228}
229
230fn build_match(
231 key: Vec<u8>,
232 entry: Accumulator,
233 request: &HybridRequest,
234) -> Result<HybridMatch, HybridError> {
235 let lexical_contribution = contribution(request.lexical_weight, entry.lexical_rank)?;
236 let vector_contribution = contribution(request.vector_weight, entry.vector_rank)?;
237 let fusion_score = lexical_contribution
238 .checked_add(vector_contribution)
239 .ok_or(HybridError::ArithmeticOverflow)?;
240 Ok(HybridMatch {
241 key,
242 explanation: HybridExplanation {
243 lexical_rank: entry.lexical_rank,
244 lexical_score_nanos: entry.lexical_score_nanos,
245 vector_rank: entry.vector_rank,
246 vector_score_nanos: entry.vector_score_nanos,
247 lexical_contribution,
248 vector_contribution,
249 fusion_score,
250 final_rank: 0,
251 },
252 })
253}
254
255fn one_based(index: usize) -> Result<u64, HybridError> {
256 u64::try_from(index)
257 .ok()
258 .and_then(|rank| rank.checked_add(1))
259 .ok_or(HybridError::ArithmeticOverflow)
260}
261
262fn contribution(weight: u32, rank: Option<u64>) -> Result<u64, HybridError> {
263 let Some(rank) = rank else {
264 return Ok(0);
265 };
266 let denominator = HYBRID_RRF_CONSTANT
267 .checked_add(rank)
268 .ok_or(HybridError::ArithmeticOverflow)?;
269 u64::from(weight)
270 .checked_mul(CONTRIBUTION_SCALE)
271 .and_then(|numerator| numerator.checked_div(denominator))
272 .ok_or(HybridError::ArithmeticOverflow)
273}
274
275#[cfg(test)]
276mod tests {
277 use crate::{ExactAbstention, ExactRetrievalMatch, LexicalMatch};
278
279 use super::*;
280
281 fn lexical(keys: &[&[u8]]) -> LexicalOutcome {
282 LexicalOutcome::Matches {
283 matches: keys
284 .iter()
285 .enumerate()
286 .map(|(index, key)| LexicalMatch {
287 key: key.to_vec(),
288 score_nanos: 100 - i64::try_from(index).unwrap_or(0),
289 terms: Vec::new(),
290 })
291 .collect(),
292 scanned_documents: keys.len() as u64,
293 matched_documents: keys.len() as u64,
294 query_tokens: vec!["x".into()],
295 }
296 }
297
298 fn vector(keys: &[&[u8]]) -> ExactRetrievalOutcome {
299 ExactRetrievalOutcome::Matches {
300 matches: keys
301 .iter()
302 .enumerate()
303 .map(|(index, key)| ExactRetrievalMatch {
304 key: key.to_vec(),
305 score_nanos: 100 - i64::try_from(index).unwrap_or(0),
306 })
307 .collect(),
308 scanned_candidates: keys.len() as u64,
309 }
310 }
311
312 #[test]
313 fn rrf_deduplicates_and_explains_rank() -> Result<(), HybridError> {
314 let outcome = fuse_hybrid(
315 &lexical(&[b"a", b"b"]),
316 &vector(&[b"b", b"c"]),
317 &HybridRequest {
318 lexical_weight: 1,
319 vector_weight: 1,
320 limit: 3,
321 },
322 )?;
323 let HybridOutcome::Matches { matches, .. } = outcome else {
324 return Err(HybridError::ArithmeticOverflow);
325 };
326 assert_eq!(matches[0].key, b"b");
327 assert_eq!(matches[0].explanation.lexical_rank, Some(2));
328 assert_eq!(matches[0].explanation.vector_rank, Some(1));
329 assert_eq!(matches[0].explanation.final_rank, 1);
330 Ok(())
331 }
332
333 #[test]
334 fn one_abstaining_branch_is_explicit() -> Result<(), HybridError> {
335 let lexical = LexicalOutcome::Abstained(crate::LexicalAbstention {
336 reason: LexicalAbstentionReason::NoCandidates,
337 scanned_documents: 1,
338 query_tokens: vec!["x".into()],
339 });
340 let outcome = fuse_hybrid(
341 &lexical,
342 &vector(&[b"a"]),
343 &HybridRequest {
344 lexical_weight: 1,
345 vector_weight: 1,
346 limit: 1,
347 },
348 )?;
349 assert!(matches!(
350 outcome,
351 HybridOutcome::Matches {
352 lexical_absence: Some(HybridBranchAbsence::LexicalNoCandidates),
353 ..
354 }
355 ));
356 let both = fuse_hybrid(
357 &lexical,
358 &ExactRetrievalOutcome::Abstained(ExactAbstention {
359 reason: ExactAbstentionReason::NoCandidates,
360 best_score_nanos: None,
361 runner_up_score_nanos: None,
362 scanned_candidates: 0,
363 }),
364 &HybridRequest {
365 lexical_weight: 1,
366 vector_weight: 1,
367 limit: 1,
368 },
369 )?;
370 assert!(matches!(both, HybridOutcome::Abstained(_)));
371 Ok(())
372 }
373}