1use candle_core::{Device, Tensor};
35
36use crate::{
37 Result,
38 codec::DecodeTable,
39 device::default_device,
40 distance::dot,
41 index::Index,
42};
43
44#[derive(Debug, Clone, Copy)]
46pub struct SearchParams {
47 pub top_k: usize,
49 pub n_probe: usize,
51 pub n_candidate_docs: Option<usize>,
57 pub centroid_score_threshold: Option<f32>,
63}
64
65impl SearchParams {
66 pub const fn paper_defaults(top_k: usize) -> Self {
81 let (n_probe, t_cs, ndocs) = if top_k <= 10 {
82 (1, 0.5_f32, 256)
83 } else if top_k <= 100 {
84 (2, 0.45_f32, 1024)
85 } else {
86 (4, 0.4_f32, 4096)
87 };
88 let ndocs = if ndocs < top_k.saturating_mul(4) {
89 top_k.saturating_mul(4)
90 } else {
91 ndocs
92 };
93 Self {
94 top_k,
95 n_probe,
96 n_candidate_docs: Some(ndocs),
97 centroid_score_threshold: Some(t_cs),
98 }
99 }
100}
101
102#[derive(Debug, Clone, Copy, PartialEq)]
104pub struct SearchResult {
105 pub doc_id: u64,
106 pub score: f32,
107}
108
109pub fn search(
128 index: &Index,
129 query_tokens: &[f32],
130 params: SearchParams,
131) -> Result<Vec<SearchResult>> {
132 let dim = index.params.dim;
133 assert!(params.top_k > 0, "search: top_k must be positive");
134 assert!(params.n_probe > 0, "search: n_probe must be positive");
135 assert!(
136 query_tokens.len().is_multiple_of(dim),
137 "search: query length {} is not a multiple of dim {}",
138 query_tokens.len(),
139 dim,
140 );
141
142 if query_tokens.is_empty() || index.num_documents() == 0 {
143 return Ok(Vec::new());
144 }
145
146 let n_centroids = index.codec.num_centroids();
147 let n_probe = params.n_probe.min(n_centroids);
148
149 let qc_scores: Option<Vec<f32>> = (params
156 .centroid_score_threshold
157 .is_some()
158 || params.n_candidate_docs.is_some())
159 .then(|| {
160 query_centroid_score_matrix(query_tokens, &index.codec.centroids, dim)
161 });
162 let pruned_mask: Option<Vec<bool>> =
163 match (qc_scores.as_ref(), params.centroid_score_threshold) {
164 (Some(scores), Some(threshold)) => {
165 let per_cent = per_centroid_max_scores(
166 scores,
167 query_tokens.len() / dim,
168 n_centroids,
169 );
170 Some(per_cent.iter().map(|&s| s < threshold).collect())
171 }
172 _ => None,
173 };
174
175 let mut candidate_docs: Vec<bool> = vec![false; index.num_documents()];
183 for query_token in query_tokens.chunks_exact(dim) {
184 for centroid_id in
185 top_n_centroids(query_token, &index.codec.centroids, dim, n_probe)
186 {
187 if let Some(mask) = pruned_mask.as_ref()
188 && mask[centroid_id]
189 {
190 continue;
191 }
192 for &doc_idx in index.ivf.docs_for_centroid(centroid_id) {
193 candidate_docs[doc_idx as usize] = true;
194 }
195 }
196 }
197
198 let mut candidate_idxs: Vec<usize> = candidate_docs
199 .iter()
200 .enumerate()
201 .filter_map(|(idx, &is_cand)| {
202 (is_cand && index.doc_token_count(idx) > 0).then_some(idx)
203 })
204 .collect();
205
206 if let (Some(n_stage2), Some(qc_scores)) =
215 (params.n_candidate_docs, qc_scores.as_ref())
216 {
217 let n_q = query_tokens.len() / dim;
218 let n_c = index.codec.num_centroids();
219
220 candidate_idxs = shortlist_by_approx_score(
223 candidate_idxs,
224 index,
225 qc_scores,
226 n_q,
227 n_c,
228 pruned_mask.as_deref(),
229 n_stage2,
230 );
231
232 let n_stage3 = n_stage2.div_ceil(4).max(1);
239 candidate_idxs = shortlist_by_approx_score(
240 candidate_idxs,
241 index,
242 qc_scores,
243 n_q,
244 n_c,
245 None,
246 n_stage3,
247 );
248 }
249
250 let mut scored: Vec<SearchResult> = if candidate_idxs.is_empty() {
253 Vec::new()
254 } else {
255 batch_maxsim(query_tokens, &candidate_idxs, index, dim)?
256 .into_iter()
257 .map(|(doc_idx, score)| SearchResult {
258 doc_id: index.doc_ids[doc_idx],
259 score,
260 })
261 .collect()
262 };
263
264 scored.sort_by(|a, b| {
266 b.score
267 .partial_cmp(&a.score)
268 .unwrap_or(std::cmp::Ordering::Equal)
269 .then_with(|| a.doc_id.cmp(&b.doc_id))
270 });
271 scored.truncate(params.top_k);
272 Ok(scored)
273}
274
275pub fn top_n_centroids(
295 point: &[f32],
296 centroids: &[f32],
297 dim: usize,
298 n: usize,
299) -> Vec<usize> {
300 assert_eq!(
301 point.len(),
302 dim,
303 "top_n_centroids: point length {} does not match dim {}",
304 point.len(),
305 dim,
306 );
307 assert!(dim > 0, "top_n_centroids: dim must be positive");
308 assert!(
309 !centroids.is_empty() && centroids.len().is_multiple_of(dim),
310 "top_n_centroids: centroids length {} is not a positive multiple of dim {}",
311 centroids.len(),
312 dim,
313 );
314
315 let k = centroids.len() / dim;
316 let mut scored: Vec<(usize, f32)> = centroids
317 .chunks_exact(dim)
318 .enumerate()
319 .map(|(i, c)| (i, dot(point, c)))
320 .collect();
321 scored.sort_by(|a, b| {
322 b.1.partial_cmp(&a.1)
323 .unwrap_or(std::cmp::Ordering::Equal)
324 .then_with(|| a.0.cmp(&b.0))
325 });
326 scored.into_iter().take(n.min(k)).map(|(i, _)| i).collect()
327}
328
329fn query_centroid_score_matrix(
336 query_tokens: &[f32],
337 centroids: &[f32],
338 dim: usize,
339) -> Vec<f32> {
340 let n_q = query_tokens.len() / dim;
341 let n_c = centroids.len() / dim;
342 let mut out = vec![0.0f32; n_q * n_c];
343 for (qi, q) in query_tokens.chunks_exact(dim).enumerate() {
344 for (ci, c) in centroids.chunks_exact(dim).enumerate() {
345 out[qi * n_c + ci] = dot(q, c);
346 }
347 }
348 out
349}
350
351fn shortlist_by_approx_score(
359 candidate_idxs: Vec<usize>,
360 index: &Index,
361 qc_scores: &[f32],
362 n_q: usize,
363 n_centroids: usize,
364 mask: Option<&[bool]>,
365 limit: usize,
366) -> Vec<usize> {
367 if candidate_idxs.len() <= limit {
368 return candidate_idxs;
369 }
370 let mut approx: Vec<(usize, f32)> = candidate_idxs
371 .into_iter()
372 .map(|doc_idx| {
373 let score = approx_centroid_interaction_score(
374 index.doc_centroid_ids(doc_idx),
375 qc_scores,
376 n_q,
377 n_centroids,
378 mask,
379 );
380 (doc_idx, score)
381 })
382 .collect();
383 approx.sort_by(|a, b| {
384 b.1.partial_cmp(&a.1)
385 .unwrap_or(std::cmp::Ordering::Equal)
386 .then_with(|| a.0.cmp(&b.0))
387 });
388 approx.truncate(limit);
389 approx.into_iter().map(|(idx, _)| idx).collect()
390}
391
392fn per_centroid_max_scores(
400 qc_scores: &[f32],
401 n_q: usize,
402 n_centroids: usize,
403) -> Vec<f32> {
404 let mut out = vec![f32::NEG_INFINITY; n_centroids];
405 for q in 0..n_q {
406 let row = &qc_scores[q * n_centroids..(q + 1) * n_centroids];
407 for (c, &s) in row.iter().enumerate() {
408 if s > out[c] {
409 out[c] = s;
410 }
411 }
412 }
413 out
414}
415
416fn approx_centroid_interaction_score(
431 doc_centroid_ids: &[u32],
432 qc_scores: &[f32],
433 n_q: usize,
434 n_centroids: usize,
435 pruned_mask: Option<&[bool]>,
436) -> f32 {
437 if doc_centroid_ids.is_empty() {
438 return 0.0;
439 }
440 if let Some(mask) = pruned_mask
444 && doc_centroid_ids.iter().all(|cid| mask[*cid as usize])
445 {
446 return f32::NEG_INFINITY;
447 }
448 let mut total = 0.0f32;
449 for q in 0..n_q {
450 let row = &qc_scores[q * n_centroids..(q + 1) * n_centroids];
451 let mut best = f32::NEG_INFINITY;
452 for &cid in doc_centroid_ids {
453 if let Some(mask) = pruned_mask
454 && mask[cid as usize]
455 {
456 continue;
457 }
458 let s = row[cid as usize];
459 if s > best {
460 best = s;
461 }
462 }
463 if best.is_finite() {
464 total += best;
465 }
466 }
467 total
468}
469
470fn batch_maxsim(
490 query_tokens: &[f32],
491 candidate_idxs: &[usize],
492 index: &Index,
493 dim: usize,
494) -> Result<Vec<(usize, f32)>> {
495 let n_q = query_tokens.len() / dim;
496
497 let decode_table = DecodeTable::new(&index.codec);
498 let total_tokens: usize = candidate_idxs
499 .iter()
500 .map(|&i| index.doc_token_count(i))
501 .sum();
502 let mut offsets: Vec<usize> = Vec::with_capacity(candidate_idxs.len() + 1);
503 offsets.push(0);
504
505 if total_tokens == 0 || n_q == 0 {
506 return Ok(candidate_idxs.iter().map(|&i| (i, 0.0)).collect());
507 }
508
509 let device = default_device();
510 let ctx = DecodeCtx {
511 index,
512 candidate_idxs,
513 dim,
514 total_tokens,
515 decode_table: &decode_table,
516 device,
517 };
518 let decoded = if matches!(device, Device::Cpu) {
519 decode_on_cpu(&ctx, &mut offsets)?
520 } else {
521 decode_on_device(&ctx, &mut offsets)?
522 };
523
524 let q_t = Tensor::from_slice(query_tokens, (n_q, dim), device)?;
527 let q_transposed = q_t.t()?.contiguous()?;
528 let scores_flat: Vec<f32> = decoded
529 .matmul(&q_transposed)?
530 .to_vec2::<f32>()?
531 .into_iter()
532 .flatten()
533 .collect();
534
535 let mut out = Vec::with_capacity(candidate_idxs.len());
537 for (i, &doc_idx) in candidate_idxs.iter().enumerate() {
538 let start = offsets[i];
539 let end = offsets[i + 1];
540 if start == end {
541 out.push((doc_idx, 0.0));
542 continue;
543 }
544 let mut total = 0.0f32;
545 for q in 0..n_q {
546 let mut best = f32::NEG_INFINITY;
547 for t in start..end {
548 let s = scores_flat[t * n_q + q];
549 if s > best {
550 best = s;
551 }
552 }
553 if best.is_finite() {
554 total += best;
555 }
556 }
557 out.push((doc_idx, total));
558 }
559 Ok(out)
560}
561
562struct DecodeCtx<'a> {
566 index: &'a Index,
567 candidate_idxs: &'a [usize],
568 dim: usize,
569 total_tokens: usize,
570 decode_table: &'a DecodeTable,
571 device: &'a Device,
572}
573
574fn decode_on_cpu(
583 ctx: &DecodeCtx<'_>,
584 offsets: &mut Vec<usize>,
585) -> Result<Tensor> {
586 let codec = &ctx.index.codec;
587 let packed_bytes = codec.packed_bytes();
588 let mut packed: Vec<f32> = Vec::with_capacity(ctx.total_tokens * ctx.dim);
589 let mut ev = crate::codec::EncodedVector {
590 centroid_id: 0,
591 codes: vec![0u8; packed_bytes],
592 };
593 for &doc_idx in ctx.candidate_idxs {
594 let cids = ctx.index.doc_centroid_ids(doc_idx);
595 let bytes = ctx.index.doc_residual_bytes(doc_idx);
596 for (i, &cid) in cids.iter().enumerate() {
597 ev.centroid_id = cid;
598 ev.codes.copy_from_slice(
599 &bytes[i * packed_bytes..(i + 1) * packed_bytes],
600 );
601 let decoded =
602 codec.decode_vector_with_table(&ev, ctx.decode_table)?;
603 let norm = decoded
607 .iter()
608 .map(|v| v * v)
609 .sum::<f32>()
610 .sqrt()
611 .max(1e-12_f32);
612 packed.extend(decoded.iter().map(|v| v / norm));
613 }
614 offsets.push(packed.len() / ctx.dim);
615 }
616 Ok(Tensor::from_vec(
617 packed,
618 (ctx.total_tokens, ctx.dim),
619 ctx.device,
620 )?)
621}
622
623fn decode_on_device(
634 ctx: &DecodeCtx<'_>,
635 offsets: &mut Vec<usize>,
636) -> Result<Tensor> {
637 let packed_bytes_per_vec = ctx.index.codec.packed_bytes();
638 let codes_per_byte = ctx.decode_table.codes_per_byte();
639 let decoded_row_width = packed_bytes_per_vec * codes_per_byte;
640 debug_assert!(decoded_row_width >= ctx.dim);
641
642 let mut centroid_ids: Vec<u32> = Vec::with_capacity(ctx.total_tokens);
648 let mut packed_bytes: Vec<u32> =
649 Vec::with_capacity(ctx.total_tokens * packed_bytes_per_vec);
650 for &doc_idx in ctx.candidate_idxs {
651 let doc_cids = ctx.index.doc_centroid_ids(doc_idx);
652 let doc_bytes = ctx.index.doc_residual_bytes(doc_idx);
653 centroid_ids.extend_from_slice(doc_cids);
654 packed_bytes.extend(doc_bytes.iter().map(|&b| b as u32));
655 offsets.push(centroid_ids.len());
656 }
657
658 let n_centroids = ctx.index.codec.num_centroids();
659 let centroids_t = Tensor::from_slice(
660 ctx.index.codec.centroids.as_slice(),
661 (n_centroids, ctx.dim),
662 ctx.device,
663 )?;
664 let weights_t = Tensor::from_slice(
665 ctx.decode_table.weights_flat(),
666 (256, codes_per_byte),
667 ctx.device,
668 )?;
669 let cent_idx_t =
670 Tensor::from_vec(centroid_ids, (ctx.total_tokens,), ctx.device)?;
671 let byte_idx_t = Tensor::from_vec(
672 packed_bytes,
673 (ctx.total_tokens * packed_bytes_per_vec,),
674 ctx.device,
675 )?;
676
677 let centroid_emb = centroids_t.index_select(¢_idx_t, 0)?;
678 let residuals_flat = weights_t.index_select(&byte_idx_t, 0)?;
679 let residuals_padded =
680 residuals_flat.reshape((ctx.total_tokens, decoded_row_width))?;
681 let residuals = if decoded_row_width == ctx.dim {
682 residuals_padded
683 } else {
684 residuals_padded.narrow(1, 0, ctx.dim)?
685 };
686 let decoded = (centroid_emb + residuals)?;
687 l2_normalize_rows(&decoded)
688}
689
690fn l2_normalize_rows(decoded: &Tensor) -> Result<Tensor> {
697 let squared = decoded.sqr()?;
698 let norm_sq = squared.sum_keepdim(1)?; let norm = norm_sq.sqrt()?.clamp(1e-12f32, f32::INFINITY)?;
703 Ok(decoded.broadcast_div(&norm)?)
704}
705
706#[cfg(test)]
707mod tests {
708 use super::*;
709 use crate::{
710 distance::dot,
711 index::{DocumentTokens, IndexParams, build_index},
712 };
713
714 fn params() -> IndexParams {
715 IndexParams {
716 dim: 2,
717 nbits: 2,
718 k_centroids: 2,
719 max_kmeans_iters: 50,
720 }
721 }
722
723 fn corpus() -> Vec<DocumentTokens> {
729 let east_a = [1.0f32, 0.0];
731 let east_b = normalize([0.98, 0.2]);
732 let east_c = normalize([0.97, -0.24]);
733 let north_a = [0.0f32, 1.0];
734 let north_b = normalize([0.2, 0.98]);
735 let north_c = normalize([-0.24, 0.97]);
736
737 let mut doc1 = Vec::new();
738 doc1.extend_from_slice(&east_a);
739 doc1.extend_from_slice(&east_b);
740 doc1.extend_from_slice(&east_c);
741
742 let mut doc2 = Vec::new();
743 doc2.extend_from_slice(&north_a);
744 doc2.extend_from_slice(&north_b);
745 doc2.extend_from_slice(&north_c);
746
747 let mut doc3 = Vec::new();
748 doc3.extend_from_slice(&east_a);
749 doc3.extend_from_slice(&north_a);
750
751 vec![
752 DocumentTokens {
753 doc_id: 1,
754 tokens: doc1,
755 n_tokens: 3,
756 },
757 DocumentTokens {
758 doc_id: 2,
759 tokens: doc2,
760 n_tokens: 3,
761 },
762 DocumentTokens {
763 doc_id: 3,
764 tokens: doc3,
765 n_tokens: 2,
766 },
767 ]
768 }
769
770 fn normalize(v: [f32; 2]) -> [f32; 2] {
771 let norm = (v[0] * v[0] + v[1] * v[1]).sqrt();
772 [v[0] / norm, v[1] / norm]
773 }
774
775 #[test]
776 fn top_n_centroids_ranks_by_descending_dot_product() {
777 let centroids = [0.0, 0.0, 5.0, 0.0, 12.0, 0.0];
782 let out = top_n_centroids(&[4.0, 0.0], ¢roids, 2, 3);
783 assert_eq!(out, vec![2, 1, 0]);
784 }
785
786 #[test]
787 fn top_n_centroids_breaks_ties_toward_earlier_index() {
788 let centroids = [1.0, 0.0, 0.0, 1.0, -1.0, 0.0];
791 let out = top_n_centroids(&[0.0, 0.0], ¢roids, 2, 3);
792 assert_eq!(out, vec![0, 1, 2]);
793 }
794
795 #[test]
796 fn top_n_centroids_on_unit_norm_centroids_picks_most_aligned() {
797 let centroids = [
801 0.6, 0.8, 0.0, 1.0, -1.0, 0.0, 1.0, 0.0,
805 ];
806 let out = top_n_centroids(&[1.0, 0.0], ¢roids, 2, 2);
807 assert_eq!(out, vec![3, 0]);
808 }
809
810 #[test]
811 fn top_n_centroids_caps_at_num_centroids() {
812 let centroids = [0.0, 0.0, 5.0, 0.0];
813 let out = top_n_centroids(&[0.0, 0.0], ¢roids, 2, 10);
814 assert_eq!(out, vec![0, 1]);
815 }
816
817 #[test]
818 fn search_returns_empty_for_empty_query() {
819 let index = build_index(&corpus(), params()).unwrap();
820 let out = search(
821 &index,
822 &[],
823 SearchParams {
824 top_k: 3,
825 n_probe: 2,
826 n_candidate_docs: None,
827 centroid_score_threshold: None,
828 },
829 )
830 .unwrap();
831 assert!(out.is_empty());
832 }
833
834 #[test]
835 fn search_ranks_matching_cluster_highest() {
836 let index = build_index(&corpus(), params()).unwrap();
840 let out = search(
841 &index,
842 &[1.0, 0.0],
843 SearchParams {
844 top_k: 3,
845 n_probe: 2,
846 n_candidate_docs: None,
847 centroid_score_threshold: None,
848 },
849 )
850 .unwrap();
851 assert!(!out.is_empty(), "should surface at least one doc");
852 assert_eq!(out[0].doc_id, 1, "closest doc should rank first");
853 }
854
855 #[test]
856 fn search_respects_top_k() {
857 let index = build_index(&corpus(), params()).unwrap();
858 let out = search(
859 &index,
860 &[1.0, 0.0],
861 SearchParams {
862 top_k: 1,
863 n_probe: 2,
864 n_candidate_docs: None,
865 centroid_score_threshold: None,
866 },
867 )
868 .unwrap();
869 assert_eq!(out.len(), 1);
870 }
871
872 #[test]
873 fn search_scores_are_non_increasing() {
874 let index = build_index(&corpus(), params()).unwrap();
875 let out = search(
877 &index,
878 &[1.0, 0.0, 0.0, 1.0],
879 SearchParams {
880 top_k: 3,
881 n_probe: 2,
882 n_candidate_docs: None,
883 centroid_score_threshold: None,
884 },
885 )
886 .unwrap();
887 for pair in out.windows(2) {
888 assert!(
889 pair[0].score >= pair[1].score,
890 "scores must be descending: {pair:?}"
891 );
892 }
893 }
894
895 #[test]
896 fn search_with_single_probe_still_finds_the_right_cluster() {
897 let index = build_index(&corpus(), params()).unwrap();
901 let out = search(
902 &index,
903 &[0.0, 1.0],
904 SearchParams {
905 top_k: 1,
906 n_probe: 1,
907 n_candidate_docs: None,
908 centroid_score_threshold: None,
909 },
910 )
911 .unwrap();
912 assert_eq!(out[0].doc_id, 2);
913 }
914
915 #[test]
916 fn search_skips_documents_with_no_tokens() {
917 let mut docs = corpus();
918 docs.push(DocumentTokens {
919 doc_id: 42,
920 tokens: vec![],
921 n_tokens: 0,
922 });
923 let index = build_index(&docs, params()).unwrap();
924 let out = search(
925 &index,
926 &[1.0, 0.0],
927 SearchParams {
928 top_k: 10,
929 n_probe: 2,
930 n_candidate_docs: None,
931 centroid_score_threshold: None,
932 },
933 )
934 .unwrap();
935 assert!(
936 out.iter().all(|r| r.doc_id != 42),
937 "empty doc should not appear in results"
938 );
939 }
940
941 #[test]
942 fn search_score_equals_sum_of_maxsim_over_decoded_tokens() {
943 let index = build_index(&corpus(), params()).unwrap();
947 let query = [1.0f32, 0.0];
948
949 let out = search(
950 &index,
951 &query,
952 SearchParams {
953 top_k: 1,
954 n_probe: index.codec.num_centroids(),
955 n_candidate_docs: None,
956 centroid_score_threshold: None,
957 },
958 )
959 .unwrap();
960 assert!(!out.is_empty());
961
962 let top = out[0];
963 let doc_idx = index.position_of(top.doc_id).expect("doc present");
964 let decoded: Vec<Vec<f32>> = index
968 .doc_tokens_vec(doc_idx)
969 .iter()
970 .map(|ev| {
971 let raw = index.codec.decode_vector(ev).unwrap();
972 let norm =
973 raw.iter().map(|v| v * v).sum::<f32>().sqrt().max(1e-12);
974 raw.iter().map(|v| v / norm).collect()
975 })
976 .collect();
977
978 let mut expected = 0.0f32;
980 for q in query.chunks_exact(2) {
981 let best = decoded
982 .iter()
983 .map(|d| dot(q, d))
984 .fold(f32::NEG_INFINITY, f32::max);
985 expected += best;
986 }
987 assert!(
988 (expected - top.score).abs() < 1e-5,
989 "score {} differs from ground-truth MaxSim {}",
990 top.score,
991 expected,
992 );
993 }
994
995 #[test]
996 fn search_works_with_four_bit_residuals() {
997 let params_4bit = IndexParams {
1001 dim: 2,
1002 nbits: 4,
1003 k_centroids: 2,
1004 max_kmeans_iters: 50,
1005 };
1006 let index = build_index(&corpus(), params_4bit).unwrap();
1007 let out = search(
1008 &index,
1009 &[1.0, 0.0],
1010 SearchParams {
1011 top_k: 1,
1012 n_probe: 2,
1013 n_candidate_docs: None,
1014 centroid_score_threshold: None,
1015 },
1016 )
1017 .unwrap();
1018 assert_eq!(out[0].doc_id, 1);
1019 }
1020
1021 #[test]
1022 fn search_survives_large_synthetic_corpus_on_both_clusters() {
1023 let mut docs = Vec::new();
1026 for i in 0..20 {
1027 let jitter = i as f32 * 0.003;
1028 let east = normalize([1.0 - jitter, 0.05 + jitter]);
1029 docs.push(DocumentTokens {
1030 doc_id: 100 + i,
1031 tokens: east.to_vec(),
1032 n_tokens: 1,
1033 });
1034 let north = normalize([0.05 + jitter, 1.0 - jitter]);
1035 docs.push(DocumentTokens {
1036 doc_id: 200 + i,
1037 tokens: north.to_vec(),
1038 n_tokens: 1,
1039 });
1040 }
1041 let index = build_index(&docs, params()).unwrap();
1042
1043 let east = search(
1044 &index,
1045 &[1.0, 0.0],
1046 SearchParams {
1047 top_k: 5,
1048 n_probe: 2,
1049 n_candidate_docs: None,
1050 centroid_score_threshold: None,
1051 },
1052 )
1053 .unwrap();
1054 for r in &east {
1055 assert!(
1056 (100..120).contains(&r.doc_id),
1057 "east query should only surface east docs: got {}",
1058 r.doc_id,
1059 );
1060 }
1061
1062 let north = search(
1063 &index,
1064 &[0.0, 1.0],
1065 SearchParams {
1066 top_k: 5,
1067 n_probe: 2,
1068 n_candidate_docs: None,
1069 centroid_score_threshold: None,
1070 },
1071 )
1072 .unwrap();
1073 for r in &north {
1074 assert!(
1075 (200..220).contains(&r.doc_id),
1076 "north query should only surface north docs: got {}",
1077 r.doc_id,
1078 );
1079 }
1080 }
1081
1082 #[test]
1083 #[should_panic(expected = "top_k must be positive")]
1084 fn search_panics_on_zero_top_k() {
1085 let index = build_index(&corpus(), params()).unwrap();
1086 let _ = search(
1087 &index,
1088 &[1.0, 0.0],
1089 SearchParams {
1090 top_k: 0,
1091 n_probe: 1,
1092 n_candidate_docs: None,
1093 centroid_score_threshold: None,
1094 },
1095 )
1096 .unwrap();
1097 }
1098
1099 #[test]
1100 #[should_panic(expected = "n_probe must be positive")]
1101 fn search_panics_on_zero_n_probe() {
1102 let index = build_index(&corpus(), params()).unwrap();
1103 let _ = search(
1104 &index,
1105 &[1.0, 0.0],
1106 SearchParams {
1107 top_k: 1,
1108 n_probe: 0,
1109 n_candidate_docs: None,
1110 centroid_score_threshold: None,
1111 },
1112 )
1113 .unwrap();
1114 }
1115
1116 #[test]
1117 #[should_panic(expected = "query length")]
1118 fn search_panics_on_ragged_query() {
1119 let index = build_index(&corpus(), params()).unwrap();
1120 let _ = search(
1121 &index,
1122 &[1.0, 0.0, 0.5],
1123 SearchParams {
1124 top_k: 1,
1125 n_probe: 1,
1126 n_candidate_docs: None,
1127 centroid_score_threshold: None,
1128 },
1129 )
1130 .unwrap();
1131 }
1132
1133 #[test]
1134 fn centroid_interaction_shortlist_caps_candidates_surviving_to_decode() {
1135 let index = build_index(&corpus(), params()).unwrap();
1139 let out = search(
1140 &index,
1141 &[1.0, 0.0],
1142 SearchParams {
1143 top_k: 5,
1144 n_probe: index.codec.num_centroids(),
1145 n_candidate_docs: Some(1),
1146 centroid_score_threshold: None,
1147 },
1148 )
1149 .unwrap();
1150 assert_eq!(out.len(), 1, "shortlist of 1 must cap output at 1");
1151 assert_eq!(
1152 out[0].doc_id, 1,
1153 "east query should surface the east doc as the survivor",
1154 );
1155 }
1156
1157 #[test]
1158 fn centroid_interaction_with_large_shortlist_matches_no_shortlist() {
1159 let index = build_index(&corpus(), params()).unwrap();
1164 let query = [1.0, 0.0, 0.0, 1.0];
1165
1166 let legacy = search(
1167 &index,
1168 &query,
1169 SearchParams {
1170 top_k: 3,
1171 n_probe: index.codec.num_centroids(),
1172 n_candidate_docs: None,
1173 centroid_score_threshold: None,
1174 },
1175 )
1176 .unwrap();
1177 let shortlisted = search(
1178 &index,
1179 &query,
1180 SearchParams {
1181 top_k: 3,
1182 n_probe: index.codec.num_centroids(),
1183 n_candidate_docs: Some(1_000),
1184 centroid_score_threshold: None,
1185 },
1186 )
1187 .unwrap();
1188 assert_eq!(legacy, shortlisted);
1189 }
1190
1191 #[test]
1192 fn centroid_pruning_with_low_threshold_matches_no_pruning() {
1193 let index = build_index(&corpus(), params()).unwrap();
1197 let query = [1.0, 0.0, 0.0, 1.0];
1198 let unpruned = search(
1199 &index,
1200 &query,
1201 SearchParams {
1202 top_k: 3,
1203 n_probe: index.codec.num_centroids(),
1204 n_candidate_docs: None,
1205 centroid_score_threshold: None,
1206 },
1207 )
1208 .unwrap();
1209 let pruned = search(
1210 &index,
1211 &query,
1212 SearchParams {
1213 top_k: 3,
1214 n_probe: index.codec.num_centroids(),
1215 n_candidate_docs: None,
1216 centroid_score_threshold: Some(-1.0),
1217 },
1218 )
1219 .unwrap();
1220 assert_eq!(unpruned, pruned);
1221 }
1222
1223 #[test]
1224 fn two_stage_interaction_caps_survivors_to_ndocs_div_four() {
1225 let mut docs = Vec::new();
1231 for i in 0..20 {
1232 let jitter = i as f32 * 0.003;
1233 let east = normalize([1.0 - jitter, 0.05 + jitter]);
1234 docs.push(DocumentTokens {
1235 doc_id: 100 + i,
1236 tokens: east.to_vec(),
1237 n_tokens: 1,
1238 });
1239 }
1240 let index = build_index(&docs, params()).unwrap();
1241 let out = search(
1242 &index,
1243 &[1.0, 0.0],
1244 SearchParams {
1245 top_k: 20,
1246 n_probe: index.codec.num_centroids(),
1247 n_candidate_docs: Some(16),
1248 centroid_score_threshold: None,
1249 },
1250 )
1251 .unwrap();
1252 assert!(
1253 out.len() <= 4,
1254 "Stage 3 must cap the decoded set at ndocs/4 = 4; got {}",
1255 out.len(),
1256 );
1257 assert!(
1258 !out.is_empty(),
1259 "Stage 3 should still return at least one result"
1260 );
1261 }
1262
1263 #[test]
1264 fn approx_score_skips_tokens_whose_centroid_is_pruned() {
1265 let n_q = 2;
1277 let n_c = 3;
1278 let qc = [0.9, 0.0, 0.4, 0.0, 0.9, 0.4];
1282 let doc_cids: [u32; 2] = [0, 2];
1283
1284 let unpruned =
1285 approx_centroid_interaction_score(&doc_cids, &qc, n_q, n_c, None);
1286 assert!((unpruned - 1.3).abs() < 1e-5, "unpruned was {unpruned}");
1288
1289 let pruned_mask = [false, false, true];
1291 let pruned = approx_centroid_interaction_score(
1292 &doc_cids,
1293 &qc,
1294 n_q,
1295 n_c,
1296 Some(&pruned_mask),
1297 );
1298 assert!((pruned - 0.9).abs() < 1e-5, "pruned was {pruned}");
1301 }
1302
1303 #[test]
1304 fn approx_score_on_all_pruned_doc_is_negative_infinity() {
1305 let qc = [0.5, 0.5];
1309 let doc_cids: [u32; 1] = [0];
1310 let mask = [true];
1311 let score = approx_centroid_interaction_score(
1312 &doc_cids,
1313 &qc,
1314 1,
1315 1,
1316 Some(&mask),
1317 );
1318 assert!(
1319 score == f32::NEG_INFINITY,
1320 "all-pruned doc should score -inf, got {score}",
1321 );
1322 }
1323
1324 #[test]
1325 fn centroid_pruning_excludes_docs_whose_only_centroid_is_below_threshold() {
1326 let index = build_index(&corpus(), params()).unwrap();
1332 let out = search(
1333 &index,
1334 &[1.0f32, 0.0],
1335 SearchParams {
1336 top_k: 5,
1337 n_probe: index.codec.num_centroids(),
1338 n_candidate_docs: None,
1339 centroid_score_threshold: Some(0.5),
1340 },
1341 )
1342 .unwrap();
1343 let ids: Vec<u64> = out.iter().map(|r| r.doc_id).collect();
1344 assert!(
1345 !ids.contains(&2),
1346 "pure-north doc must be pruned when only the east centroid survives the threshold: got {ids:?}",
1347 );
1348 assert!(
1349 ids.contains(&1),
1350 "east-cluster doc must still rank when its centroid survives: got {ids:?}",
1351 );
1352 }
1353
1354 #[test]
1355 fn centroid_pruning_with_unreachable_threshold_drops_every_candidate() {
1356 let index = build_index(&corpus(), params()).unwrap();
1360 let out = search(
1361 &index,
1362 &[1.0, 0.0],
1363 SearchParams {
1364 top_k: 3,
1365 n_probe: index.codec.num_centroids(),
1366 n_candidate_docs: None,
1367 centroid_score_threshold: Some(10.0),
1368 },
1369 )
1370 .unwrap();
1371 assert!(
1372 out.is_empty(),
1373 "every centroid below threshold should yield empty results",
1374 );
1375 }
1376
1377 #[test]
1378 fn centroid_interaction_preserves_exact_maxsim_on_surviving_candidates() {
1379 let index = build_index(&corpus(), params()).unwrap();
1383 let query = [1.0f32, 0.0];
1384 let out = search(
1385 &index,
1386 &query,
1387 SearchParams {
1388 top_k: 1,
1389 n_probe: index.codec.num_centroids(),
1390 n_candidate_docs: Some(1_000),
1391 centroid_score_threshold: None,
1392 },
1393 )
1394 .unwrap();
1395 let top = out[0];
1396 let doc_idx = index.position_of(top.doc_id).unwrap();
1397 let decoded: Vec<Vec<f32>> = index
1400 .doc_tokens_vec(doc_idx)
1401 .iter()
1402 .map(|ev| {
1403 let raw = index.codec.decode_vector(ev).unwrap();
1404 let norm =
1405 raw.iter().map(|v| v * v).sum::<f32>().sqrt().max(1e-12);
1406 raw.iter().map(|v| v / norm).collect()
1407 })
1408 .collect();
1409 let expected: f32 = query
1410 .chunks_exact(2)
1411 .map(|q| {
1412 decoded
1413 .iter()
1414 .map(|d| dot(q, d))
1415 .fold(f32::NEG_INFINITY, f32::max)
1416 })
1417 .sum();
1418 assert!((expected - top.score).abs() < 1e-5);
1419 }
1420
1421 #[test]
1429 fn gpu_decode_path_matches_cpu_decode_element_wise() {
1430 let index = build_index(&corpus(), params()).unwrap();
1431 let candidate_idxs: Vec<usize> = (0..index.num_documents()).collect();
1432 let total_tokens: usize = candidate_idxs
1433 .iter()
1434 .map(|&i| index.doc_token_count(i))
1435 .sum();
1436 let decode_table = DecodeTable::new(&index.codec);
1437 let device = Device::Cpu;
1438
1439 let ctx = DecodeCtx {
1440 index: &index,
1441 candidate_idxs: &candidate_idxs,
1442 dim: index.params.dim,
1443 total_tokens,
1444 decode_table: &decode_table,
1445 device: &device,
1446 };
1447
1448 let mut offsets_gpu = vec![0usize];
1449 let decoded_gpu = decode_on_device(&ctx, &mut offsets_gpu).unwrap();
1450 let gpu_rows: Vec<f32> = decoded_gpu
1451 .to_vec2::<f32>()
1452 .unwrap()
1453 .into_iter()
1454 .flatten()
1455 .collect();
1456
1457 let mut offsets_cpu = vec![0usize];
1458 let decoded_cpu = decode_on_cpu(&ctx, &mut offsets_cpu).unwrap();
1459 let cpu_rows: Vec<f32> = decoded_cpu
1460 .to_vec2::<f32>()
1461 .unwrap()
1462 .into_iter()
1463 .flatten()
1464 .collect();
1465
1466 assert_eq!(offsets_gpu, offsets_cpu);
1467 assert_eq!(gpu_rows.len(), cpu_rows.len());
1468 for (g, c) in gpu_rows.iter().zip(cpu_rows.iter()) {
1469 assert!(
1470 (g - c).abs() < 1e-6,
1471 "GPU decode value {g} != CPU decode value {c}",
1472 );
1473 }
1474 }
1475
1476 #[test]
1479 fn paper_defaults_match_table_2_verbatim() {
1480 let p10 = SearchParams::paper_defaults(10);
1485 assert_eq!(p10.n_probe, 1);
1486 assert_eq!(p10.centroid_score_threshold, Some(0.5));
1487 assert_eq!(p10.n_candidate_docs, Some(256));
1488
1489 let p100 = SearchParams::paper_defaults(100);
1490 assert_eq!(p100.n_probe, 2);
1491 assert_eq!(p100.centroid_score_threshold, Some(0.45));
1492 assert_eq!(p100.n_candidate_docs, Some(1024));
1493
1494 let p1000 = SearchParams::paper_defaults(1000);
1495 assert_eq!(p1000.n_probe, 4);
1496 assert_eq!(p1000.centroid_score_threshold, Some(0.4));
1497 assert_eq!(p1000.n_candidate_docs, Some(4096));
1498 }
1499
1500 #[test]
1501 fn paper_defaults_clamp_ndocs_to_at_least_4x_top_k() {
1502 let p = SearchParams::paper_defaults(2000);
1507 assert!(p.n_candidate_docs.unwrap() >= 4 * 2000);
1508 }
1509
1510 #[test]
1511 fn paper_defaults_always_enable_pruning() {
1512 for &k in &[1_usize, 10, 50, 100, 500, 1000, 10_000] {
1516 assert!(
1517 SearchParams::paper_defaults(k)
1518 .centroid_score_threshold
1519 .is_some(),
1520 "paper_defaults({k}) left pruning disabled"
1521 );
1522 }
1523 }
1524}