1use rustc_hash::FxHashMap;
11
12use crate::dsl::Field;
13
14use super::{MultiValueCombiner, ScoredPosition, SearchResult};
15
16struct PrecompQuery<'a> {
18 query: &'a [f32],
19 inv_norm_q: f32,
20 query_f16: &'a [u16],
21}
22
23#[inline]
25#[allow(clippy::too_many_arguments)]
26fn score_batch_precomp(
27 pq: &PrecompQuery<'_>,
28 raw: &[u8],
29 quant: crate::dsl::DenseVectorQuantization,
30 dim: usize,
31 scores: &mut [f32],
32 unit_norm: bool,
33) {
34 let query = pq.query;
35 let inv_norm_q = pq.inv_norm_q;
36 let query_f16 = pq.query_f16;
37 use crate::dsl::DenseVectorQuantization;
38 use crate::structures::simd;
39 match (quant, unit_norm) {
40 (DenseVectorQuantization::F32, false) => {
41 let num_floats = scores.len() * dim;
42 assert!(
46 (raw.as_ptr() as usize).is_multiple_of(std::mem::align_of::<f32>()),
47 "f32 vector data not 4-byte aligned"
48 );
49 let vectors: &[f32] =
50 unsafe { std::slice::from_raw_parts(raw.as_ptr() as *const f32, num_floats) };
51 simd::batch_cosine_scores_precomp(query, vectors, dim, scores, inv_norm_q);
52 }
53 (DenseVectorQuantization::F32, true) => {
54 let num_floats = scores.len() * dim;
55 assert!(
56 (raw.as_ptr() as usize).is_multiple_of(std::mem::align_of::<f32>()),
57 "f32 vector data not 4-byte aligned"
58 );
59 let vectors: &[f32] =
60 unsafe { std::slice::from_raw_parts(raw.as_ptr() as *const f32, num_floats) };
61 simd::batch_dot_scores_precomp(query, vectors, dim, scores, inv_norm_q);
62 }
63 (DenseVectorQuantization::F16, false) => {
64 simd::batch_cosine_scores_f16_precomp(query_f16, raw, dim, scores, inv_norm_q);
65 }
66 (DenseVectorQuantization::F16, true) => {
67 simd::batch_dot_scores_f16_precomp(query_f16, raw, dim, scores, inv_norm_q);
68 }
69 (DenseVectorQuantization::UInt8, false) => {
70 simd::batch_cosine_scores_u8_precomp(query, raw, dim, scores, inv_norm_q);
71 }
72 (DenseVectorQuantization::UInt8, true) => {
73 simd::batch_dot_scores_u8_precomp(query, raw, dim, scores, inv_norm_q);
74 }
75 (DenseVectorQuantization::Binary, _) => {
76 unreachable!("Binary quantization should not reach score_batch_precomp");
77 }
78 }
79}
80
81#[derive(Debug, Clone)]
83pub struct RerankerConfig {
84 pub field: Field,
86 pub vector: Vec<f32>,
88 pub binary_vector: Vec<u8>,
91 pub combiner: MultiValueCombiner,
93 pub unit_norm: bool,
97 pub matryoshka_dims: Option<usize>,
101 pub rrf_k: f32,
105}
106
107#[cfg(test)]
109use crate::structures::simd::cosine_similarity;
110#[cfg(test)]
111fn score_document(
112 doc: &crate::dsl::Document,
113 config: &RerankerConfig,
114) -> Option<(f32, Vec<ScoredPosition>)> {
115 let query_dim = config.vector.len();
116 let mut values: Vec<(u32, f32)> = doc
117 .get_all(config.field)
118 .filter_map(|fv| fv.as_dense_vector())
119 .enumerate()
120 .filter_map(|(ordinal, vec)| {
121 if vec.len() != query_dim {
122 return None;
123 }
124 let score = cosine_similarity(&config.vector, vec);
125 Some((ordinal as u32, score))
126 })
127 .collect();
128
129 if values.is_empty() {
130 return None;
131 }
132
133 let combined = config.combiner.combine(&values);
134
135 values.sort_unstable_by(|a, b| b.1.total_cmp(&a.1));
137 let positions: Vec<ScoredPosition> = values
138 .into_iter()
139 .map(|(ordinal, score)| ScoredPosition::new(ordinal, score))
140 .collect();
141
142 Some((combined, positions))
143}
144
145fn apply_rrf(
155 candidates: &[SearchResult],
156 scored: &mut Vec<SearchResult>,
157 k: f32,
158 final_limit: usize,
159) {
160 let l1_ranks: FxHashMap<(u128, u32), usize> = candidates
162 .iter()
163 .enumerate()
164 .map(|(idx, c)| ((c.segment_id, c.doc_id), idx + 1))
165 .collect();
166
167 for (l2_idx, result) in scored.iter_mut().enumerate() {
169 let l1_rank = l1_ranks
170 .get(&(result.segment_id, result.doc_id))
171 .copied()
172 .unwrap_or(candidates.len() + 1);
173 result.score = super::fusion::rrf_contribution(k, l1_rank)
174 + super::fusion::rrf_contribution(k, l2_idx + 1);
175 }
176
177 scored.sort_unstable_by(|a, b| b.score.total_cmp(&a.score));
178 scored.truncate(final_limit);
179}
180
181pub async fn rerank<D: crate::directories::Directory + 'static>(
191 searcher: &crate::index::Searcher<D>,
192 candidates: &[SearchResult],
193 config: &RerankerConfig,
194 final_limit: usize,
195) -> crate::error::Result<Vec<SearchResult>> {
196 if !config.binary_vector.is_empty() {
198 return rerank_binary(searcher, candidates, config, final_limit).await;
199 }
200
201 if config.vector.is_empty() || candidates.is_empty() {
202 return Ok(Vec::new());
203 }
204
205 let t0 = std::time::Instant::now();
206 let field_id = config.field.0;
207 let query = &config.vector;
208 let query_dim = query.len();
209 let segments = searcher.segment_readers();
210 let seg_by_id = searcher.segment_map();
211
212 use crate::structures::simd;
214 let norm_q_sq = simd::dot_product_f32(query, query, query_dim);
215 let inv_norm_q = if norm_q_sq < f32::EPSILON {
216 0.0
217 } else {
218 simd::fast_inv_sqrt(norm_q_sq)
219 };
220 let query_f16: Vec<u16> = query.iter().map(|&v| simd::f32_to_f16(v)).collect();
221 let pq = PrecompQuery {
222 query,
223 inv_norm_q,
224 query_f16: &query_f16,
225 };
226
227 let mut segment_groups: FxHashMap<usize, Vec<usize>> = FxHashMap::default();
229 let mut skipped = 0u32;
230
231 for (ci, candidate) in candidates.iter().enumerate() {
232 if let Some(&si) = seg_by_id.get(&candidate.segment_id) {
233 segment_groups.entry(si).or_default().push(ci);
234 } else {
235 skipped += 1;
236 }
237 }
238
239 let query_ref = pq.query;
243 let inv_norm_q_val = pq.inv_norm_q;
244 let query_f16_ref = pq.query_f16;
245
246 let segment_futs: Vec<_> = segment_groups
247 .into_iter()
248 .map(|(si, candidate_indices)| {
249 #[allow(clippy::redundant_locals)]
250 let segments = &segments;
251 #[allow(clippy::redundant_locals)]
252 let candidates = candidates;
253 #[allow(clippy::redundant_locals)]
254 let query_ref = query_ref;
255 #[allow(clippy::redundant_locals)]
256 let query_f16_ref = query_f16_ref;
257 #[allow(clippy::redundant_locals)]
258 let config = config;
259 async move {
260 let mut scores: Vec<(usize, u32, f32)> = Vec::new();
261 let mut vectors = 0usize;
262 let mut seg_skipped = 0u32;
263
264 let Some(lazy_flat) = segments[si].flat_vectors().get(&field_id) else {
265 return Ok::<_, crate::error::Error>((
266 scores,
267 vectors,
268 candidate_indices.len() as u32,
269 ));
270 };
271 if lazy_flat.dim != query_dim {
272 return Ok((scores, vectors, candidate_indices.len() as u32));
273 }
274
275 let vbs = lazy_flat.vector_byte_size();
276 let quant = lazy_flat.quantization;
277
278 let mut resolved: Vec<(usize, usize, u32)> = Vec::new();
280 for &ci in &candidate_indices {
281 let local_doc_id = candidates[ci].doc_id;
282 let (start, count) = lazy_flat.flat_indexes_for_doc_range(local_doc_id);
283 if count == 0 {
284 seg_skipped += 1;
285 continue;
286 }
287 for j in 0..count {
288 let (_, ordinal) = lazy_flat.get_doc_id(start + j);
289 resolved.push((ci, start + j, ordinal as u32));
290 }
291 }
292
293 if resolved.is_empty() {
294 return Ok((scores, vectors, seg_skipped));
295 }
296
297 let n = resolved.len();
298 vectors = n;
299
300 resolved.sort_unstable_by_key(|&(_, flat_idx, _)| flat_idx);
302
303 let first_idx = resolved[0].1;
304 let last_idx = resolved[n - 1].1;
305 let span = last_idx - first_idx + 1;
306
307 let mut raw_buf: Vec<u8> = vec![0u8; n * vbs];
308
309 if span <= n * 4 {
310 let range_bytes = lazy_flat
311 .read_vectors_batch(first_idx, span)
312 .await
313 .map_err(crate::error::Error::Io)?;
314 let rb = range_bytes.as_slice();
315 for (buf_idx, &(_, flat_idx, _)) in resolved.iter().enumerate() {
316 let rel = flat_idx - first_idx;
317 let src = &rb[rel * vbs..(rel + 1) * vbs];
318 raw_buf[buf_idx * vbs..(buf_idx + 1) * vbs].copy_from_slice(src);
319 }
320 } else {
321 #[cfg(feature = "native")]
325 lazy_flat.prefetch_vectors(resolved.iter().map(|&(_, flat_idx, _)| flat_idx));
326
327 for (buf_idx, &(_, flat_idx, _)) in resolved.iter().enumerate() {
328 lazy_flat
329 .read_vector_raw_into(
330 flat_idx,
331 &mut raw_buf[buf_idx * vbs..(buf_idx + 1) * vbs],
332 )
333 .await
334 .map_err(crate::error::Error::Io)?;
335 }
336 }
337
338 let pq = PrecompQuery {
340 query: query_ref,
341 inv_norm_q: inv_norm_q_val,
342 query_f16: query_f16_ref,
343 };
344
345 let mut scores_buf: Vec<f32> = vec![0.0; n];
346
347 if let Some(mdims) = config.matryoshka_dims
349 && mdims < query_dim
350 && n > final_limit * 2
351 {
352 let trunc_dim = mdims;
353 let trunc_pq = PrecompQuery {
354 query: &query_ref[..trunc_dim],
355 inv_norm_q: {
356 let nq = simd::dot_product_f32(
357 &query_ref[..trunc_dim],
358 &query_ref[..trunc_dim],
359 trunc_dim,
360 );
361 if nq < f32::EPSILON {
362 0.0
363 } else {
364 simd::fast_inv_sqrt(nq)
365 }
366 },
367 query_f16: &query_f16_ref[..trunc_dim],
368 };
369 let trunc_vbs = trunc_dim * quant.element_size();
370 for i in 0..n {
371 let vec_start = i * vbs;
372 score_batch_precomp(
373 &trunc_pq,
374 &raw_buf[vec_start..vec_start + trunc_vbs],
375 quant,
376 trunc_dim,
377 &mut scores_buf[i..i + 1],
378 config.unit_norm,
379 );
380 }
381
382 let per_doc_cap: usize = match &config.combiner {
383 super::MultiValueCombiner::Max => 1,
384 super::MultiValueCombiner::WeightedTopK { k, .. } => *k,
385 _ => usize::MAX,
386 };
387
388 let mut ranked: Vec<(usize, f32)> =
389 (0..n).map(|i| (i, scores_buf[i])).collect();
390 ranked.sort_unstable_by(|a, b| b.1.total_cmp(&a.1));
391
392 let mut survivors: Vec<(usize, f32)> =
393 Vec::with_capacity(n.min(final_limit * 4));
394 let mut doc_vector_counts: FxHashMap<usize, usize> = FxHashMap::default();
395 let mut unique_docs = 0usize;
396
397 for &(orig_idx, score) in &ranked {
398 let ci = resolved[orig_idx].0;
399 let count = doc_vector_counts.entry(ci).or_insert(0);
400
401 if *count >= per_doc_cap {
402 continue;
403 }
404 if *count == 0 {
405 unique_docs += 1;
406 }
407 *count += 1;
408 survivors.push((orig_idx, score));
409
410 if unique_docs >= final_limit && survivors.len() >= final_limit * 2 {
411 break;
412 }
413 }
414
415 scores.reserve(survivors.len());
416 for &(orig_idx, _) in &survivors {
417 let vec_start = orig_idx * vbs;
418 let mut score = 0.0f32;
419 score_batch_precomp(
420 &pq,
421 &raw_buf[vec_start..vec_start + vbs],
422 quant,
423 query_dim,
424 std::slice::from_mut(&mut score),
425 config.unit_norm,
426 );
427 let (ci, _, ordinal) = resolved[orig_idx];
428 scores.push((ci, ordinal, score));
429 }
430
431 let filtered = n - survivors.len();
432 log::debug!(
433 "[reranker] matryoshka pre-filter: {}/{} dims, {}/{} vectors survived from {} unique docs (filtered {}, per_doc_cap={})",
434 trunc_dim,
435 query_dim,
436 survivors.len(),
437 n,
438 unique_docs,
439 filtered,
440 per_doc_cap
441 );
442 } else {
443 score_batch_precomp(
444 &pq,
445 &raw_buf[..n * vbs],
446 quant,
447 query_dim,
448 &mut scores_buf[..n],
449 config.unit_norm,
450 );
451
452 scores.reserve(n);
453 for (buf_idx, &(ci, _, ordinal)) in resolved.iter().enumerate() {
454 scores.push((ci, ordinal, scores_buf[buf_idx]));
455 }
456 }
457
458 Ok((scores, vectors, seg_skipped))
459 }
460 })
461 .collect();
462
463 let results = futures::future::join_all(segment_futs).await;
464
465 let mut all_scores: Vec<(usize, u32, f32)> = Vec::new();
466 let mut total_vectors = 0usize;
467 for result in results {
468 let (scores, vectors, seg_skipped) = result?;
469 all_scores.extend(scores);
470 total_vectors += vectors;
471 skipped += seg_skipped;
472 }
473
474 let read_score_elapsed = t0.elapsed();
475
476 if total_vectors == 0 {
477 log::debug!(
478 "[reranker] field {}: {} candidates, all skipped (no flat vectors)",
479 field_id,
480 candidates.len()
481 );
482 return Ok(Vec::new());
483 }
484
485 all_scores.sort_unstable_by_key(|&(ci, _, _)| ci);
488
489 let mut scored: Vec<SearchResult> = Vec::with_capacity(candidates.len().min(final_limit * 2));
490 let mut ordinal_pairs: Vec<(u32, f32)> = Vec::new();
491 let mut i = 0;
492 while i < all_scores.len() {
493 let ci = all_scores[i].0;
494 let run_start = i;
495 while i < all_scores.len() && all_scores[i].0 == ci {
496 i += 1;
497 }
498 let run = &mut all_scores[run_start..i];
499
500 ordinal_pairs.clear();
502 ordinal_pairs.extend(run.iter().map(|&(_, ord, s)| (ord, s)));
503 let combined = config.combiner.combine(&ordinal_pairs);
504
505 run.sort_unstable_by(|a, b| b.2.total_cmp(&a.2));
507 let positions: Vec<ScoredPosition> = run
508 .iter()
509 .map(|&(_, ord, score)| ScoredPosition::new(ord, score))
510 .collect();
511
512 scored.push(SearchResult {
513 doc_id: candidates[ci].doc_id,
514 score: combined,
515 segment_id: candidates[ci].segment_id,
516 positions: vec![(field_id, positions)],
517 });
518 }
519
520 scored.sort_unstable_by(|a, b| b.score.total_cmp(&a.score));
521
522 if config.rrf_k > 0.0 {
523 apply_rrf(candidates, &mut scored, config.rrf_k, final_limit);
524 } else {
525 scored.truncate(final_limit);
526 }
527
528 log::debug!(
529 "[reranker] field {}: {} candidates -> {} results (skipped {}, {} vectors, unit_norm={}, rrf_k={}): read+score={:.1}ms total={:.1}ms",
530 field_id,
531 candidates.len(),
532 scored.len(),
533 skipped,
534 total_vectors,
535 config.unit_norm,
536 config.rrf_k,
537 read_score_elapsed.as_secs_f64() * 1000.0,
538 t0.elapsed().as_secs_f64() * 1000.0,
539 );
540
541 Ok(scored)
542}
543
544async fn rerank_binary<D: crate::directories::Directory + 'static>(
546 searcher: &crate::index::Searcher<D>,
547 candidates: &[SearchResult],
548 config: &RerankerConfig,
549 final_limit: usize,
550) -> crate::error::Result<Vec<SearchResult>> {
551 if config.binary_vector.is_empty() || candidates.is_empty() {
552 return Ok(Vec::new());
553 }
554
555 let t0 = std::time::Instant::now();
556 let field_id = config.field.0;
557 let query = &config.binary_vector;
558 let byte_len = query.len();
559 let segments = searcher.segment_readers();
560 let seg_by_id = searcher.segment_map();
561
562 let mut segment_groups: FxHashMap<usize, Vec<usize>> = FxHashMap::default();
564 for (ci, cand) in candidates.iter().enumerate() {
565 if let Some(&seg_idx) = seg_by_id.get(&cand.segment_id) {
566 let reader = &segments[seg_idx];
567 if reader.flat_vectors().contains_key(&field_id) {
568 segment_groups.entry(seg_idx).or_default().push(ci);
569 }
570 }
571 }
572
573 let segment_futs: Vec<_> = segment_groups
575 .into_iter()
576 .map(|(seg_idx, cand_indices)| {
577 #[allow(clippy::redundant_locals)]
578 let segments = &segments;
579 #[allow(clippy::redundant_locals)]
580 let candidates = candidates;
581 async move {
582 let mut scores: Vec<(usize, u32, f32)> = Vec::new();
583
584 let Some(lazy_flat) = segments[seg_idx].flat_vectors().get(&field_id) else {
585 return Ok::<_, crate::error::Error>(scores);
586 };
587 let vbs = lazy_flat.vector_byte_size();
588 if vbs != byte_len {
589 return Ok(scores);
590 }
591
592 let mut resolved: Vec<(usize, usize)> = Vec::new();
594 for &ci in &cand_indices {
595 let doc_id = candidates[ci].doc_id;
596 let (start, count) = lazy_flat.flat_indexes_for_doc_range(doc_id);
597 for j in 0..count {
598 resolved.push((ci, start + j));
599 }
600 }
601 if resolved.is_empty() {
602 return Ok(scores);
603 }
604
605 resolved.sort_unstable_by_key(|&(_, flat_idx)| flat_idx);
606
607 let n = resolved.len();
608 let first_idx = resolved[0].1;
609 let last_idx = resolved[n - 1].1;
610 let span = last_idx - first_idx + 1;
611
612 let mut raw_buf = vec![0u8; n * vbs];
613
614 if span <= n * 4 {
615 let range_bytes = lazy_flat
616 .read_vectors_batch(first_idx, span)
617 .await
618 .map_err(crate::error::Error::Io)?;
619 let rb = range_bytes.as_slice();
620 for (buf_idx, &(_, flat_idx)) in resolved.iter().enumerate() {
621 let rel = flat_idx - first_idx;
622 raw_buf[buf_idx * vbs..(buf_idx + 1) * vbs]
623 .copy_from_slice(&rb[rel * vbs..(rel + 1) * vbs]);
624 }
625 } else {
626 for (buf_idx, &(_, flat_idx)) in resolved.iter().enumerate() {
627 lazy_flat
628 .read_vector_raw_into(
629 flat_idx,
630 &mut raw_buf[buf_idx * vbs..(buf_idx + 1) * vbs],
631 )
632 .await
633 .map_err(crate::error::Error::Io)?;
634 }
635 }
636
637 let dim_bits = lazy_flat.dim;
639 let mut scores_buf = vec![0f32; n];
640 crate::structures::simd::batch_hamming_scores(
641 query,
642 &raw_buf,
643 byte_len,
644 dim_bits,
645 &mut scores_buf,
646 );
647
648 for (buf_idx, &(ci, flat_idx)) in resolved.iter().enumerate() {
649 let (_, ordinal) = lazy_flat.get_doc_id(flat_idx);
650 scores.push((ci, ordinal as u32, scores_buf[buf_idx]));
651 }
652
653 Ok(scores)
654 }
655 })
656 .collect();
657
658 let results = futures::future::join_all(segment_futs).await;
659
660 let mut cand_ordinal_scores: FxHashMap<usize, Vec<(u32, f32)>> = FxHashMap::default();
662 for result in results {
663 for (ci, ordinal, score) in result? {
664 cand_ordinal_scores
665 .entry(ci)
666 .or_default()
667 .push((ordinal, score));
668 }
669 }
670
671 let total_vectors = cand_ordinal_scores.len();
672 let mut scored: Vec<SearchResult> = Vec::with_capacity(total_vectors);
673 for (ci, ordinal_scores) in cand_ordinal_scores {
674 let combined = config.combiner.combine(&ordinal_scores);
675 let positions: Vec<ScoredPosition> = ordinal_scores
676 .iter()
677 .map(|&(ord, s)| ScoredPosition::new(ord, s))
678 .collect();
679 scored.push(SearchResult {
680 doc_id: candidates[ci].doc_id,
681 score: combined,
682 segment_id: candidates[ci].segment_id,
683 positions: vec![(field_id, positions)],
684 });
685 }
686
687 scored.sort_unstable_by(|a, b| b.score.total_cmp(&a.score));
688
689 if config.rrf_k > 0.0 {
690 apply_rrf(candidates, &mut scored, config.rrf_k, final_limit);
691 } else {
692 scored.truncate(final_limit);
693 }
694
695 log::debug!(
696 "[reranker-binary] field {}: {} candidates -> {} results ({} docs scored, {} bytes/vec, rrf_k={}): {:.1}ms",
697 field_id,
698 candidates.len(),
699 scored.len(),
700 total_vectors,
701 byte_len,
702 config.rrf_k,
703 t0.elapsed().as_secs_f64() * 1000.0,
704 );
705
706 Ok(scored)
707}
708
709#[cfg(test)]
710mod tests {
711 use super::*;
712 use crate::dsl::{Document, Field};
713
714 fn make_config(vector: Vec<f32>, combiner: MultiValueCombiner) -> RerankerConfig {
715 RerankerConfig {
716 field: Field(0),
717 vector,
718 binary_vector: Vec::new(),
719 combiner,
720 unit_norm: false,
721 matryoshka_dims: None,
722 rrf_k: 0.0,
723 }
724 }
725
726 #[test]
727 fn test_score_document_single_value() {
728 let mut doc = Document::new();
729 doc.add_dense_vector(Field(0), vec![1.0, 0.0, 0.0]);
730
731 let config = make_config(vec![1.0, 0.0, 0.0], MultiValueCombiner::Max);
732 let (score, positions) = score_document(&doc, &config).unwrap();
733 assert!((score - 1.0).abs() < 1e-6);
735 assert_eq!(positions.len(), 1);
736 assert_eq!(positions[0].position, 0); }
738
739 #[test]
740 fn test_score_document_orthogonal() {
741 let mut doc = Document::new();
742 doc.add_dense_vector(Field(0), vec![0.0, 1.0, 0.0]);
743
744 let config = make_config(vec![1.0, 0.0, 0.0], MultiValueCombiner::Max);
745 let (score, _) = score_document(&doc, &config).unwrap();
746 assert!(score.abs() < 1e-6);
748 }
749
750 #[test]
751 fn test_score_document_multi_value_max() {
752 let mut doc = Document::new();
753 doc.add_dense_vector(Field(0), vec![1.0, 0.0, 0.0]); doc.add_dense_vector(Field(0), vec![0.0, 1.0, 0.0]); let config = make_config(vec![1.0, 0.0, 0.0], MultiValueCombiner::Max);
757 let (score, positions) = score_document(&doc, &config).unwrap();
758 assert!((score - 1.0).abs() < 1e-6);
759 assert_eq!(positions.len(), 2);
761 assert_eq!(positions[0].position, 0); assert!((positions[0].score - 1.0).abs() < 1e-6);
763 }
764
765 #[test]
766 fn test_score_document_multi_value_avg() {
767 let mut doc = Document::new();
768 doc.add_dense_vector(Field(0), vec![1.0, 0.0, 0.0]); doc.add_dense_vector(Field(0), vec![0.0, 1.0, 0.0]); let config = make_config(vec![1.0, 0.0, 0.0], MultiValueCombiner::Avg);
772 let (score, _) = score_document(&doc, &config).unwrap();
773 assert!((score - 0.5).abs() < 1e-6);
775 }
776
777 #[test]
778 fn test_score_document_missing_field() {
779 let mut doc = Document::new();
780 doc.add_dense_vector(Field(1), vec![1.0, 0.0, 0.0]);
782
783 let config = make_config(vec![1.0, 0.0, 0.0], MultiValueCombiner::Max);
784 assert!(score_document(&doc, &config).is_none());
785 }
786
787 #[test]
788 fn test_score_document_wrong_field_type() {
789 let mut doc = Document::new();
790 doc.add_text(Field(0), "not a vector");
791
792 let config = make_config(vec![1.0, 0.0, 0.0], MultiValueCombiner::Max);
793 assert!(score_document(&doc, &config).is_none());
794 }
795
796 #[test]
797 fn test_score_document_dimension_mismatch() {
798 let mut doc = Document::new();
799 doc.add_dense_vector(Field(0), vec![1.0, 0.0]); let config = make_config(vec![1.0, 0.0, 0.0], MultiValueCombiner::Max); assert!(score_document(&doc, &config).is_none());
803 }
804
805 #[test]
806 fn test_score_document_empty_query_vector() {
807 let mut doc = Document::new();
808 doc.add_dense_vector(Field(0), vec![1.0, 0.0, 0.0]);
809
810 let config = make_config(vec![], MultiValueCombiner::Max);
811 assert!(score_document(&doc, &config).is_none());
813 }
814
815 fn make_result(doc_id: u32, score: f32, segment_id: u128) -> SearchResult {
816 SearchResult {
817 doc_id,
818 score,
819 segment_id,
820 positions: Vec::new(),
821 }
822 }
823
824 #[test]
825 fn test_rrf_basic_fusion() {
826 let candidates = vec![
828 make_result(1, 10.0, 1), make_result(2, 8.0, 1), make_result(3, 5.0, 1), ];
832
833 let mut scored = vec![
835 make_result(3, 0.9, 1), make_result(2, 0.7, 1), make_result(1, 0.3, 1), ];
839
840 let k = 60.0;
841 apply_rrf(&candidates, &mut scored, k, 10);
842
843 assert_eq!(scored.len(), 3);
856 let spread = scored[0].score - scored[2].score;
858 assert!(
859 spread < 0.001,
860 "All docs have near-equal RRF scores, spread={spread}"
861 );
862 }
863
864 #[test]
865 fn test_rrf_clear_winner() {
866 let candidates = vec![
868 make_result(1, 10.0, 1), make_result(2, 8.0, 1), make_result(3, 5.0, 1), ];
872
873 let mut scored = vec![
875 make_result(1, 0.95, 1), make_result(3, 0.50, 1), make_result(2, 0.30, 1), ];
879
880 let k = 60.0;
881 apply_rrf(&candidates, &mut scored, k, 10);
882
883 assert_eq!(scored[0].doc_id, 1, "Doc 1 (rank 1 in both) should be top");
887 assert!(scored[0].score > scored[1].score);
888 }
889
890 #[test]
891 fn test_rrf_truncation() {
892 let candidates = vec![
893 make_result(1, 10.0, 1),
894 make_result(2, 8.0, 1),
895 make_result(3, 5.0, 1),
896 make_result(4, 3.0, 1),
897 make_result(5, 1.0, 1),
898 ];
899
900 let mut scored = vec![
901 make_result(5, 0.9, 1),
902 make_result(4, 0.8, 1),
903 make_result(3, 0.7, 1),
904 make_result(2, 0.6, 1),
905 make_result(1, 0.5, 1),
906 ];
907
908 apply_rrf(&candidates, &mut scored, 60.0, 3);
909 assert_eq!(scored.len(), 3, "Should truncate to final_limit=3");
910 }
911
912 #[test]
913 fn test_rrf_missing_l1_candidate() {
914 let candidates = vec![make_result(1, 10.0, 1), make_result(2, 8.0, 1)];
916
917 let mut scored = vec![
918 make_result(3, 0.9, 1), make_result(1, 0.5, 1),
920 ];
921
922 apply_rrf(&candidates, &mut scored, 60.0, 10);
923
924 assert_eq!(scored[0].doc_id, 1);
928 }
929
930 #[test]
931 fn test_rrf_small_k() {
932 let candidates = vec![make_result(1, 10.0, 1), make_result(2, 8.0, 1)];
934
935 let mut scored = vec![
936 make_result(2, 0.9, 1), make_result(1, 0.5, 1), ];
939
940 apply_rrf(&candidates, &mut scored, 1.0, 10);
941
942 let diff = (scored[0].score - scored[1].score).abs();
946 assert!(
947 diff < 1e-6,
948 "Symmetric ranks should produce equal RRF scores"
949 );
950 }
951}