Skip to main content

hermes_core/query/
reranker.rs

1//! L2 reranker: rerank L1 candidates by exact dense vector distance on stored vectors
2//!
3//! Optimized for throughput:
4//! - Candidates grouped by segment for batched I/O
5//! - Flat indexes sorted for sequential mmap access (OS readahead)
6//! - Single SIMD batch-score call per segment (not per candidate)
7//! - Reusable buffers across segments (no per-candidate heap allocation)
8//! - unit_norm fast path: skip per-vector norm when vectors are pre-normalized
9
10use rustc_hash::FxHashMap;
11
12use crate::dsl::Field;
13
14use super::{MultiValueCombiner, ScoredPosition, SearchResult};
15
16/// Precomputed query data for dense reranking (computed once, reused across segments).
17struct PrecompQuery<'a> {
18    query: &'a [f32],
19    inv_norm_q: f32,
20    query_f16: &'a [u16],
21}
22
23/// Batch SIMD scoring with precomputed query norm + f16 query.
24#[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            // Safety: Vec<u8> from the global allocator is guaranteed to be at least
43            // 8-byte aligned on 64-bit platforms (aligned to max_align_t). Assert at
44            // runtime to guard against custom allocators with weaker guarantees.
45            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/// Configuration for L2 dense/binary vector reranking
82#[derive(Debug, Clone)]
83pub struct RerankerConfig {
84    /// Vector field (dense or binary dense)
85    pub field: Field,
86    /// Query vector (f32, for dense fields)
87    pub vector: Vec<f32>,
88    /// Query vector (packed bits, for binary dense fields).
89    /// When non-empty, Hamming distance scoring is used instead of cosine.
90    pub binary_vector: Vec<u8>,
91    /// How to combine scores for multi-valued documents
92    pub combiner: MultiValueCombiner,
93    /// Whether stored vectors are pre-normalized to unit L2 norm.
94    /// When true, scoring uses dot-product only (skips per-vector norm — ~40% faster).
95    /// Ignored for binary fields.
96    pub unit_norm: bool,
97    /// Matryoshka pre-filter: number of leading dimensions to use for cheap
98    /// approximate scoring before full-dimension exact reranking.
99    /// Ignored for binary fields.
100    pub matryoshka_dims: Option<usize>,
101    /// Reciprocal Rank Fusion k parameter. When > 0, fuses L1 (first-stage) and
102    /// L2 (reranker) rankings: `score(d) = 1/(k + rank_L1) + 1/(k + rank_L2)`.
103    /// Typical value: 60. When 0, RRF is disabled and only L2 scores are used.
104    pub rrf_k: f32,
105}
106
107/// Score a single document against the query vector (used by tests).
108#[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    // Sort ordinals by score descending (best chunk first)
136    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
145/// Apply Reciprocal Rank Fusion to combine L1 and L2 rankings.
146///
147/// `candidates` is sorted by L1 score descending (first-stage query output).
148/// `scored` is sorted by L2 score descending (reranker output).
149/// Replaces each result's score with the RRF fused score, re-sorts, and truncates.
150///
151/// Formula (Cormack, Clarke, Buettcher 2009):
152///   `RRF(d) = 1/(k + rank_L1(d)) + 1/(k + rank_L2(d))`
153/// where ranks are 1-based.
154fn apply_rrf(
155    candidates: &[SearchResult],
156    scored: &mut Vec<SearchResult>,
157    k: f32,
158    final_limit: usize,
159) {
160    // Build L1 rank map: (segment_id, doc_id) → 1-based rank
161    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    // scored is sorted by L2 score desc → enumerate index + 1 = L2 rank
168    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
181/// Rerank L1 candidates by exact dense vector distance.
182///
183/// Groups candidates by segment for batched I/O, sorts flat indexes for
184/// sequential mmap access, and scores all vectors in a single SIMD batch
185/// per segment. Reuses buffers across segments to avoid per-candidate
186/// heap allocation.
187///
188/// When `unit_norm` is set in the config, scoring uses dot-product only
189/// (skips per-vector norm computation — ~40% less work).
190pub 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    // Dispatch: binary vector → Hamming, f32 vector → cosine/dot
197    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    // Precompute query inverse-norm and f16 query once (reused across all segments)
213    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    // ── Phase 1: Group candidates by segment ──────────────────────────────
228    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    // ── Phase 2: Per-segment batched resolve + read + score (concurrent) ──
240    // Each segment runs independently: resolve flat indexes, read vectors,
241    // and score — all overlapping I/O across segments via join_all.
242    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                // Resolve flat indexes for all candidates in this segment
279                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                // Sort by flat_idx for sequential mmap access
301                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                    // Scattered reads: batch-prefetch candidate pages so
322                    // page-ins overlap instead of one major fault per vector
323                    // (same treatment as the ANN rerank path).
324                    #[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                // Reconstruct PrecompQuery from captured components
339                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                // Matryoshka pre-filter
348                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    // ── Phase 3: Combine scores and build results ─────────────────────────
486    // Sort flat buffer by candidate_idx so contiguous runs belong to the same doc
487    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        // Build (ordinal, score) slice for combiner (reuses hoisted buffer)
501        ordinal_pairs.clear();
502        ordinal_pairs.extend(run.iter().map(|&(_, ord, s)| (ord, s)));
503        let combined = config.combiner.combine(&ordinal_pairs);
504
505        // Sort positions by score descending (best chunk first)
506        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
544/// Rerank L1 candidates by exact Hamming distance on stored binary vectors.
545async 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    // Group candidates by segment
563    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    // Concurrent per-segment scoring (same pattern as dense reranker)
574    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                // Resolve flat indexes
593                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                // Batch Hamming scoring
638                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    // Combine ordinal scores per candidate and apply combiner
661    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        // cosine([1,0,0], [1,0,0]) = 1.0
734        assert!((score - 1.0).abs() < 1e-6);
735        assert_eq!(positions.len(), 1);
736        assert_eq!(positions[0].position, 0); // ordinal 0
737    }
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        // cosine([1,0,0], [0,1,0]) = 0.0
747        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]); // cos=1.0 (same direction)
754        doc.add_dense_vector(Field(0), vec![0.0, 1.0, 0.0]); // cos=0.0 (orthogonal)
755
756        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        // Best chunk first
760        assert_eq!(positions.len(), 2);
761        assert_eq!(positions[0].position, 0); // ordinal 0 scored highest
762        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]); // cos=1.0
769        doc.add_dense_vector(Field(0), vec![0.0, 1.0, 0.0]); // cos=0.0
770
771        let config = make_config(vec![1.0, 0.0, 0.0], MultiValueCombiner::Avg);
772        let (score, _) = score_document(&doc, &config).unwrap();
773        // avg(1.0, 0.0) = 0.5
774        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        // Add to field 1, not field 0
781        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]); // 2D
800
801        let config = make_config(vec![1.0, 0.0, 0.0], MultiValueCombiner::Max); // 3D query
802        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        // Empty query can't match any stored vector (dimension mismatch)
812        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        // L1 ranking: doc A(rank 1), B(rank 2), C(rank 3)
827        let candidates = vec![
828            make_result(1, 10.0, 1), // A: L1 rank 1
829            make_result(2, 8.0, 1),  // B: L1 rank 2
830            make_result(3, 5.0, 1),  // C: L1 rank 3
831        ];
832
833        // L2 ranking (reversed): C(rank 1), B(rank 2), A(rank 3)
834        let mut scored = vec![
835            make_result(3, 0.9, 1), // C: L2 rank 1
836            make_result(2, 0.7, 1), // B: L2 rank 2
837            make_result(1, 0.3, 1), // A: L2 rank 3
838        ];
839
840        let k = 60.0;
841        apply_rrf(&candidates, &mut scored, k, 10);
842
843        // B should win: rank 2 in both → 2/(k+2) vs split ranks for A and C
844        // A: 1/(61) + 1/(63) = 0.01639 + 0.01587 = 0.03226
845        // B: 1/(62) + 1/(62) = 0.01613 + 0.01613 = 0.03226
846        // C: 1/(63) + 1/(61) = 0.01587 + 0.01639 = 0.03226
847        // All equal! (symmetric: rank sum = 4 for each)
848        // Actually: A: 1/61 + 1/63, B: 1/62 + 1/62, C: 1/63 + 1/61
849        // A = C by symmetry, B is slightly different
850        // 1/61 + 1/63 = (63+61)/(61*63) = 124/3843 = 0.032267
851        // 1/62 + 1/62 = 2/62 = 1/31 = 0.032258
852        // So A = C > B (very slightly). Top result should be doc 3 (C) or doc 1 (A)
853        // since they have the same RRF score but C appeared first in scored.
854
855        assert_eq!(scored.len(), 3);
856        // All three should have very similar RRF scores
857        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        // Doc X is rank 1 in both L1 and L2 → should clearly win
867        let candidates = vec![
868            make_result(1, 10.0, 1), // X: L1 rank 1
869            make_result(2, 8.0, 1),  // Y: L1 rank 2
870            make_result(3, 5.0, 1),  // Z: L1 rank 3
871        ];
872
873        // L2 ranking: X still rank 1
874        let mut scored = vec![
875            make_result(1, 0.95, 1), // X: L2 rank 1
876            make_result(3, 0.50, 1), // Z: L2 rank 2
877            make_result(2, 0.30, 1), // Y: L2 rank 3
878        ];
879
880        let k = 60.0;
881        apply_rrf(&candidates, &mut scored, k, 10);
882
883        // X: 1/(61) + 1/(61) = 2/61 = 0.03279 (best)
884        // Y: 1/(62) + 1/(63) = 0.03200 (worst)
885        // Z: 1/(63) + 1/(62) = 0.03200 (same as Y by symmetry)
886        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        // L1 has docs 1, 2. L2 scored doc 3 which wasn't in L1 candidates.
915        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), // not in L1 → gets worst L1 rank
919            make_result(1, 0.5, 1),
920        ];
921
922        apply_rrf(&candidates, &mut scored, 60.0, 10);
923
924        // Doc 1: L1 rank 1 → 1/61, L2 rank 2 → 1/62  = 0.03252
925        // Doc 3: L1 rank 3 (fallback) → 1/63, L2 rank 1 → 1/61 = 0.03226
926        // Doc 1 should win because it has a better L1 rank
927        assert_eq!(scored[0].doc_id, 1);
928    }
929
930    #[test]
931    fn test_rrf_small_k() {
932        // With small k, rank differences matter more
933        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), // L2 rank 1
937            make_result(1, 0.5, 1), // L2 rank 2
938        ];
939
940        apply_rrf(&candidates, &mut scored, 1.0, 10);
941
942        // k=1: Doc 1: 1/(1+1) + 1/(1+2) = 0.5 + 0.333 = 0.833
943        //       Doc 2: 1/(1+2) + 1/(1+1) = 0.333 + 0.5 = 0.833
944        // With k=1 and symmetric ranks, scores are equal
945        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}