forked from quickwit-oss/tantivy
-
Notifications
You must be signed in to change notification settings - Fork 10
Expand file tree
/
Copy pathbackend.rs
More file actions
2892 lines (2723 loc) · 117 KB
/
Copy pathbackend.rs
File metadata and controls
2892 lines (2723 loc) · 117 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
//! Per-segment vector search execution.
//!
//! Built once per segment by
//! [`TopDocsByVectorSimilarity`](super::collector::TopDocsByVectorSimilarity)
//! around the segment's cached [`VectorIndexReader`]. The search strategy
//! branches once, on whether the reader carries an [`IvfIndex`]: with it, the
//! filter is drained into a bitmap and the routed clusters are probed
//! adaptively; without it, the filter `Scorer` is iterated doc-by-doc and
//! every vector is scored exactly. Either way, every survivor's bytes are
//! fetched with one stride-sized read ([`VectorIndexReader::vector_bytes_for_row`])
//! — the unit the pg-backed `Directory` can serve zero-copy.
use std::ops::Range;
use std::sync::Arc;
use common::BitSet;
use super::distance::Similarity;
use super::index_reader::VectorIndexReader;
use super::ivf::{AdaptiveProbeParams, Candidate, IvfIndex, IvfSearchMetrics, Workspace};
use super::prepared::PreparedQuery;
use super::VectorElement;
use crate::collector::sort_key::NaturalComparator;
use crate::collector::TopNComputer;
use crate::fastfield::AliveBitSet;
use crate::query::Weight;
use crate::schema::{Field, Metric};
use crate::{DocAddress, DocId, Score, SegmentOrdinal, SegmentReader, TantivyError};
/// Per-segment vector search: the segment's [`VectorIndexReader`] plus the
/// per-query state. Build via [`VectorBackend::for_segment`].
pub struct VectorBackend<T: VectorElement> {
reader: Arc<VectorIndexReader>,
query: Arc<PreparedQuery<T>>,
adaptive: AdaptiveProbeParams,
segment_ord: SegmentOrdinal,
}
impl<T: VectorElement> VectorBackend<T> {
/// Opens the segment's cached vector reader for `field` and prepares the
/// query against the field's metric. A segment with no vector data gets
/// the empty reader and yields no hits.
pub fn for_segment(
segment_reader: &SegmentReader,
segment_ord: SegmentOrdinal,
field: Field,
query: Arc<Vec<T>>,
adaptive: AdaptiveProbeParams,
) -> crate::Result<Self> {
let reader = segment_reader.vector_index(field)?;
let query = Arc::new(PreparedQuery::<T>::new(reader.options().metric(), query));
Ok(Self {
reader,
query,
adaptive,
segment_ord,
})
}
/// Top-N within this segment: probe routed clusters when the reader has
/// an index, exact-scan otherwise. Hits come back already tagged with
/// `DocAddress`, so the collector doesn't need a second pass to attach
/// the segment. The segment's [`ProbeStats`] ride along: the IVF path
/// fills the probe-loop counters, the flat/exact path only
/// `exact_rows_read`.
pub fn top_n(
&self,
weight: &dyn Weight,
segment_reader: &SegmentReader,
top_n: usize,
) -> crate::Result<(Vec<(Score, DocAddress)>, ProbeStats)> {
let mut stats = ProbeStats::default();
let hits = match self.reader.index() {
Some(index) => self.probe_top_n(index, weight, segment_reader, top_n, &mut stats)?,
None => self.exact_top_n(weight, segment_reader, top_n, &mut stats)?,
};
Ok((hits, stats))
}
/// Flat/exact scan: drain the filter DocSet doc-by-doc, scoring each
/// survivor from one stride-sized row read. Fills only the
/// `exact_rows_read` stat.
fn exact_top_n(
&self,
weight: &dyn Weight,
segment_reader: &SegmentReader,
top_n: usize,
stats: &mut ProbeStats,
) -> crate::Result<Vec<(Score, DocAddress)>> {
// `for_each_no_score` walks the filter DocSet in ascending doc order,
// which permits the fast `TopNComputer::push` path (valid only under
// ascending-doc pushes).
// `NaturalComparator` because similarity is "higher = better" — see
// the note on `scan_clusters`.
let mut topn = TopNComputer::<Score, DocId, NaturalComparator>::new_with_comparator(
top_n,
NaturalComparator,
);
let alive = segment_reader.alive_bitset();
let mut rows_read = 0usize;
// Row reads are ranged and can fail; the `for_each` closure can't
// return an error, so the first one is parked here and re-raised
// after the walk.
let mut read_err: Option<TantivyError> = None;
weight.for_each_no_score(segment_reader, &mut |docs| {
if read_err.is_some() {
return;
}
for &doc in docs {
if let Some(bs) = alive {
if !bs.is_alive(doc) {
continue;
}
}
let Some(row) = self.reader.row_id(doc) else {
continue;
};
match self.reader.vector_bytes_for_row(row) {
Ok(vbytes) => {
rows_read += 1;
topn.push(self.query.score_doc_bytes(&vbytes), doc);
}
Err(err) => {
read_err = Some(err);
return;
}
}
}
})?;
if let Some(err) = read_err {
return Err(err);
}
stats.exact_rows_read += rows_read;
let segment_ord = self.segment_ord;
Ok(topn
.into_sorted_vec()
.into_iter()
.map(|cd| (cd.sort_key, DocAddress::new(segment_ord, cd.doc)))
.collect())
}
}
/// How the probe loop stopped.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum ProbeTermination {
/// The filter-effective probe budget reached `max_probe_count` — the
/// probe ceiling.
Ceiling,
/// The distance-ratio gate fired with the survivor floor met.
Gate,
/// The ranked centroids were exhausted without hitting either stop.
#[default]
Exhausted,
}
/// Per-segment probe-loop instrumentation: which clusters were probed
/// (in probe order) and a prune breakdown of every doc the inner loop
/// touched. Returned by [`VectorBackend::top_n`] alongside the hits.
/// The flat/exact path fills only `exact_rows_read`; every other field
/// is IVF-probe-only.
#[derive(Debug, Default)]
pub struct ProbeStats {
/// Clusters visited by the probe loop, in probe order. A cluster
/// appears here once we've passed the stop-condition gate for it,
/// regardless of whether its doc-ids slice ends up empty.
pub probed_clusters: Vec<usize>,
/// Docs that passed filter + alive + seen and were scored against the
/// query. This stays the "scored" bucket and equals the final survivor
/// `candidates`, so starvation is just `candidates_scored < min_candidates`.
pub candidates_scored: usize,
/// Every doc-id the inner loop touched, before any gate — the denominator
/// for the prune breakdown.
pub vectors_visited: usize,
/// Touched docs rejected by `filter.contains`.
pub pruned_filter: usize,
/// Touched docs rejected by `is_alive`.
pub pruned_dead: usize,
/// Touched docs rejected by the replica `seen` dedup.
pub pruned_seen: usize,
/// Probed clusters whose surviving rows' posting bytes were fetched —
/// one stride-sized ranged read per surviving row. Counts clusters,
/// not rows.
pub postings_row: usize,
/// Probed clusters that fetched no posting bytes at all: the
/// `filter → alive → seen` pre-pass left zero survivors (fully
/// filtered / dead / already-seen, or the cluster is empty). The two
/// `postings_*` counters partition the probed clusters:
/// `postings_row + postings_skipped == probed_clusters.len()`.
pub postings_skipped: usize,
/// Flat/exact-path stride-sized row reads — one per survivor scored.
/// Filled only by the exact (non-IVF) path.
pub exact_rows_read: usize,
/// Routing cost of ranking the clusters to probe: centroids scored
/// (`routing.visited_count`), plus the centroid-graph beam counters when
/// routing went through the RNG. Ranking is lazy, so this covers only as
/// much routing as the probe loop actually pulled. See
/// [`IvfSearchMetrics`].
pub routing: IvfSearchMetrics,
/// The resolved survivor floor the gate used for this query.
pub min_candidates: usize,
/// How the probe loop terminated. Per-segment; does not sum.
pub termination: ProbeTermination,
}
/// Floor a probed cluster charges the ceiling even when the filter skips
/// all its rows (the gate pre-pass still scans them). A cluster bills
/// `SKIPPED_CLUSTER_COST + (1 - SKIPPED_CLUSTER_COST) * pass_fraction`:
/// 0.05 fully filtered, 1.0 unfiltered. Provisional.
pub(crate) const SKIPPED_CLUSTER_COST: f32 = 0.05;
/// One gate survivor from the pre-pass over a cluster's rows: `row`
/// indexes into the segment-wide dense rows slot.
#[derive(Clone, Copy)]
struct Survivor {
row: usize,
doc: DocId,
}
impl<T: VectorElement> VectorBackend<T> {
/// Top-N by IVF probe. Fills `stats` with this segment's probe-loop
/// counters.
fn probe_top_n(
&self,
index: &IvfIndex,
weight: &dyn Weight,
segment_reader: &SegmentReader,
top_n: usize,
stats: &mut ProbeStats,
) -> crate::Result<Vec<(Score, DocAddress)>> {
if top_n == 0 {
return Ok(Vec::new());
}
let max_doc = segment_reader.max_doc();
if max_doc == 0 {
return Ok(Vec::new());
}
let filter = build_filter_bitset(weight, segment_reader, max_doc)?;
if filter.len() == 0 {
return Ok(Vec::new());
}
let alive = segment_reader.alive_bitset();
let num_centroids = index.num_clusters();
if num_centroids == 0 {
return Ok(Vec::new());
}
let max_probe_count = self.adaptive.resolved_probe_ceiling(num_centroids)?;
// Phase 1: rank the clusters to probe, lazily — the scan below pulls
// ranked clusters on demand, so routing cost is paid only as far as
// probing actually reaches. The filter-effective budget can pull far
// past `max_probe_count` raw clusters on a selective filter (each
// skipped cluster costs ~0), and lazy routing keeps that cheap.
// Routing operates in `f32` (centroid rows are `f32` today), so the
// query is widened losslessly per element.
let query_f32: Vec<f32> = self.query.query().iter().map(|e| e.to_f32()).collect();
let mut routing_ws = Workspace::new();
let mut ranked = index.rank_clusters(&mut routing_ws, &query_f32);
// The best-routed cluster anchors the distance-ratio gate.
let Some(best) = ranked.next() else {
return Ok(Vec::new());
};
let threshold = Similarity::new(adaptive_threshold(
self.query.metric(),
best.sim.score(),
self.adaptive.epsilon,
));
// Without this floor, a selective filter can trip the threshold gate
// immediately and return < K results. Additive margin (not m×top_n)
// so the over-probe cushion stays K-independent — see
// `AdaptiveProbeParams::overfetch_margin`.
let min_candidates = self
.adaptive
.min_candidates
.max(top_n + self.adaptive.overfetch_margin);
stats.min_candidates = min_candidates;
let topn = self.scan_clusters(
index,
std::iter::once(best).chain(&mut ranked),
threshold,
min_candidates,
max_probe_count,
&filter,
max_doc,
alive,
top_n,
stats,
)?;
// The routing cost is only known once the scan stops pulling.
stats.routing = ranked.metrics();
let segment_ord = self.segment_ord;
Ok(topn
.into_sorted_vec()
.into_iter()
.map(|cd| (cd.sort_key, DocAddress::new(segment_ord, cd.doc)))
.collect())
}
/// Phase 2: adaptive probe loop. Each probed cluster is gated first —
/// [`Self::collect_cluster_survivors`] runs `filter → alive → seen`
/// off the pinned id-map with no posting bytes in hand — and only the
/// survivors' bytes are then fetched, one stride-sized read per
/// surviving row. Cluster-order arrival of survivors forbids the
/// ascending-doc shortcut in `push`; use `push_unordered`.
///
/// Note on `NaturalComparator` (vs the `TopNComputer::new` default):
/// vector similarity is "higher = better", so we want top-N *largest*
/// scores. The default `new()` wires `ReverseComparator`, which keeps
/// top-N *smallest* — correct for ascending-distance metrics but inverted
/// for our convention.
///
/// `ranked` is pulled lazily, one cluster per probe: with graph routing,
/// pulling past a converged batch resumes the beam search, so routing
/// work interleaves with (and is bounded by) probing.
///
/// `#[inline(never)]` so it forms its own flamegraph frame carrying its
/// `score_doc_bytes` cost.
#[inline(never)]
#[allow(clippy::too_many_arguments)]
fn scan_clusters(
&self,
index: &IvfIndex,
ranked: impl Iterator<Item = Candidate>,
threshold: Similarity,
min_candidates: usize,
max_probe_count: usize,
filter: &BitSet,
max_doc: DocId,
alive: Option<&AliveBitSet>,
top_n: usize,
stats: &mut ProbeStats,
) -> crate::Result<TopNComputer<Score, DocId, NaturalComparator>> {
let mut topn = TopNComputer::<Score, DocId, NaturalComparator>::new_with_comparator(
top_n,
NaturalComparator,
);
// `candidates` is the cumulative scored count that drives the gate; the
// prune counters accumulate into locals and fold into `ProbeStats` once
// after the loop, keeping the hot per-doc path free of indirection.
let mut candidates = 0usize;
let mut visited = 0usize;
let mut pruned_filter = 0usize;
let mut pruned_dead = 0usize;
let mut pruned_seen = 0usize;
let mut postings_row = 0usize;
let mut postings_skipped = 0usize;
let mut termination = ProbeTermination::Exhausted;
// Replication can place the same doc in several probed clusters; dedup
// by doc id so a vector is scored at most once.
let mut seen = BitSet::with_max_value(max_doc);
// The probed cluster's gate survivors; allocated once, reused
// across clusters.
let mut survivors: Vec<Survivor> = Vec::new();
let mut probe_budget = 0.0f32;
let max_probe_budget = max_probe_count as f32;
for Candidate { sim, node: cluster } in ranked {
// The pull that trips the ceiling proves another ranked cluster
// existed, keeping `Ceiling` distinct from `Exhausted`. The budget
// is filter-effective (see the per-cluster charge below), so a
// selective filter walks far past `max_probe_count` raw clusters.
if probe_budget >= max_probe_budget {
termination = ProbeTermination::Ceiling;
break;
}
if sim < threshold && candidates >= min_candidates {
termination = ProbeTermination::Gate;
break;
}
let cluster = cluster as usize;
// Record the probe before doing any work, so even an empty
// cluster counts as "probed".
stats.probed_clusters.push(cluster);
let rows = index.cluster_range(cluster);
let num_rows = rows.len();
// Pre-pass: run the gate off the pinned id-map alone, BEFORE
// any posting bytes are fetched, so the fetch below can be
// skipped for rows that won't be scored. Gate order, the
// `seen` marking point, and every prune counter are exactly
// the fetch-then-gate scan's; only the byte fetch moved.
let (v, pf, pd, ps) =
self.collect_cluster_survivors(rows, filter, alive, &mut seen, &mut survivors);
visited += v;
pruned_filter += pf;
pruned_dead += pd;
pruned_seen += ps;
// Charge the ceiling by the cluster's filter pass rate: a
// fully-skipped cluster still costs `SKIPPED_CLUSTER_COST` (the
// gate pre-pass scanned it), a fully-unfiltered one costs 1.0.
if num_rows > 0 {
let pass_fraction = (num_rows - pf) as f32 / num_rows as f32;
probe_budget += SKIPPED_CLUSTER_COST + (1.0 - SKIPPED_CLUSTER_COST) * pass_fraction;
}
if survivors.is_empty() {
postings_skipped += 1;
} else {
postings_row += 1;
// One stride-sized read per survivor — the unit the
// pg-backed `Directory` serves zero-copy (see
// `vector_bytes_for_row`).
for &Survivor { row, doc } in &survivors {
let vbytes = self.reader.vector_bytes_for_row(row)?;
topn.push_unordered(self.query.score_doc_bytes(&vbytes), doc);
}
}
candidates += survivors.len();
}
stats.vectors_visited += visited;
stats.pruned_filter += pruned_filter;
stats.pruned_dead += pruned_dead;
stats.pruned_seen += pruned_seen;
stats.postings_row += postings_row;
stats.postings_skipped += postings_skipped;
stats.candidates_scored += candidates;
stats.termination = termination;
Ok(topn)
}
/// Phase 2 pre-pass: run one cluster's rows through the
/// `filter → alive → seen` gate — off the pinned id-map alone, with no
/// posting bytes fetched — collecting into `survivors` (cleared first)
/// the rows to score. `seen` is marked here, at gate-pass time, NOT at
/// scoring time, so replica dedup counts across clusters are identical
/// to the fetch-then-gate scan this pre-pass replaced.
/// Returns `(visited, pruned_filter, pruned_dead, pruned_seen)`.
/// `#[inline(never)]` so per-cluster gate cost forms its own frame,
/// while the per-row loop stays inlined inside it.
#[inline(never)]
fn collect_cluster_survivors(
&self,
rows: Range<usize>,
filter: &BitSet,
alive: Option<&AliveBitSet>,
seen: &mut BitSet,
survivors: &mut Vec<Survivor>,
) -> (usize, usize, usize, usize) {
survivors.clear();
let mut visited = 0usize;
let mut pruned_filter = 0usize;
let mut pruned_dead = 0usize;
let mut pruned_seen = 0usize;
for row in rows {
let doc = self.reader.doc_id_at(row);
visited += 1;
if !filter.contains(doc) {
pruned_filter += 1;
continue;
}
if let Some(bs) = alive {
if !bs.is_alive(doc) {
pruned_dead += 1;
continue;
}
}
if seen.contains(doc) {
pruned_seen += 1;
continue;
}
seen.insert(doc);
survivors.push(Survivor { row, doc });
}
(visited, pruned_filter, pruned_dead, pruned_seen)
}
}
/// Drain the filter `DocSet` into a dense BitSet for O(1) random membership
/// testing per cluster doc. The BitSet allocates `max_doc / 8` bytes regardless
/// of filter selectivity — inherent to IVF needing membership tests on
/// out-of-order doc ids. `#[inline(never)]` so it forms its own flamegraph
/// frame; at low selectivity over a large segment this drain is real cost
/// otherwise hidden in the search entry.
#[inline(never)]
fn build_filter_bitset(
weight: &dyn Weight,
segment_reader: &SegmentReader,
max_doc: DocId,
) -> crate::Result<BitSet> {
let mut filter = BitSet::with_max_value(max_doc);
weight.for_each_no_score(segment_reader, &mut |docs| {
for &doc in docs {
filter.insert(doc);
}
})?;
Ok(filter)
}
/// Per-metric distance-ratio pruning threshold (SPANN eq. 3): a posting
/// list is searched iff `Dist(q, c) <= (1 + epsilon) * Dist(q, c_closest)`,
/// re-expressed on the similarity scale (higher = better) so the probe
/// loop compares scores directly. `best` is the top-ranked centroid's
/// score.
///
/// - **L2:** `score = -d²`, so `threshold = best - epsilon * best.abs()` is `d² > (1 + eps) *
/// d²_min` — SPANN's inequality verbatim (their `Dist` is squared L2).
/// - **Cosine:** `threshold = best - epsilon * (1 - best)` gates on `(1 - score) > (1 + eps) * (1 -
/// best)`. For unit vectors `d² = 2(1 - cos)` and the 2 cancels in the ratio, so this IS SPANN's
/// rule applied to our (write-time-normalized) data.
/// - **Dot:** no natural distance for raw MIPS; a pragmatic linear widening `best - epsilon *
/// best.abs()`. With paper-scale epsilon the gate rarely fires and the ceiling governs. NOTE:
/// with unnormalized dot, the IVF locality assumption itself is heuristic — that's the
/// clusterer's problem, not the threshold's.
///
/// Degenerate scales: L2 with `d_min = 0` and Cosine with `best = 1.0`
/// both give `threshold = best` — the gate arms immediately and only
/// the candidate floor keeps probing. Known property of ratio pruning;
/// do not "fix".
fn adaptive_threshold(metric: Metric, best: f32, epsilon: f32) -> f32 {
match metric {
Metric::L2 | Metric::Dot => best - epsilon * best.abs(),
Metric::Cosine => best - epsilon * (1.0 - best),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn adaptive_threshold_identity_at_zero_epsilon() {
// With epsilon = 0 the threshold is exactly `best` for every
// metric — no ratio slack, no permissiveness.
for &best in &[-10.0_f32, -1.0, 0.0, 0.5, 1.0] {
assert_eq!(adaptive_threshold(Metric::L2, best, 0.0), best);
assert_eq!(adaptive_threshold(Metric::Cosine, best, 0.0), best);
assert_eq!(adaptive_threshold(Metric::Dot, best, 0.0), best);
}
}
#[test]
fn adaptive_threshold_lowers_with_positive_epsilon() {
// "Higher score = closer" convention; ratio slack means the
// threshold is *lower* (more permissive) than `best`.
let eps = 0.1;
// L2 similarity is `-d²`, so `best` is always ≤ 0 and
// `best - eps * |best|` is more negative (= more permissive).
// For best = 0 the threshold is also 0 (d_min = 0 — the
// degenerate ratio scale; the gate arms immediately).
for &best in &[-10.0_f32, -1.0, -0.001] {
let l2 = adaptive_threshold(Metric::L2, best, eps);
assert!(l2 < best, "L2 threshold {l2} should be < best {best}");
}
let cos_best = 0.8;
let cos = adaptive_threshold(Metric::Cosine, cos_best, eps);
assert!(
cos < cos_best,
"Cosine threshold {cos} should be < {cos_best}"
);
// Dot: pinned linear widening. Lower than `best` for positive
// `best`; *also* lower (more negative) for negative `best`,
// because we subtract `eps * |best|`, never add. This is the
// intentional behavior — `best - eps * |best|` is monotonic
// in the "more permissive" direction regardless of sign.
let pos = adaptive_threshold(Metric::Dot, 10.0, eps);
assert!(pos < 10.0, "Dot threshold {pos} should be < 10.0");
let neg = adaptive_threshold(Metric::Dot, -10.0, eps);
assert!(neg < -10.0, "Dot threshold {neg} should be < -10.0");
}
#[test]
fn adaptive_threshold_hand_checked_values() {
// L2: best = -10 (d² = 10), eps = 0.1 ⇒ -10 - 0.1·10 = -11,
// i.e. gate at d² > 1.1 · d²_min.
let l2 = adaptive_threshold(Metric::L2, -10.0, 0.1);
assert!((l2 - -11.0).abs() < 1e-5, "got {l2}");
// Cosine: best = 0.8, eps = 0.1 ⇒ 0.8 - 0.1 · 0.2 = 0.78.
let cos = adaptive_threshold(Metric::Cosine, 0.8, 0.1);
assert!((cos - 0.78).abs() < 1e-5, "got {cos}");
// Cosine at paper-scale epsilon: best = 0.9, eps = 7.0 ⇒
// 0.9 - 7 · 0.1 = 0.2 — the gate CAN fire on realistic angular
// gaps (a |best|-scaled threshold would sit at -5.4 and never
// trip on the cosine range).
let cos_wide = adaptive_threshold(Metric::Cosine, 0.9, 7.0);
assert!((cos_wide - 0.2).abs() < 1e-5, "got {cos_wide}");
// Dot: pinned `best - eps * |best|`.
// best = 10, eps = 0.1 ⇒ 9.0
// best = -10, eps = 0.1 ⇒ -11.0
let dot_pos = adaptive_threshold(Metric::Dot, 10.0, 0.1);
assert!((dot_pos - 9.0).abs() < 1e-5, "got {dot_pos}");
let dot_neg = adaptive_threshold(Metric::Dot, -10.0, 0.1);
assert!((dot_neg - -11.0).abs() < 1e-5, "got {dot_neg}");
// Origin: degenerate (query orthogonal to nearest centroid);
// threshold collapses to 0 because |0| = 0.
let dot_zero = adaptive_threshold(Metric::Dot, 0.0, 0.5);
assert_eq!(dot_zero, 0.0);
}
// ============================================================
// IVF `top_n` test gate.
//
// Built on top of `crate::vector::tests::TestVectorIndex` (the
// shared fixture) where the geometry fits — the 100-doc grid +
// selectivity-based labels covers oracle / filter / delete /
// overflow / zero-K. The handful of tests that need crafted point
// geometry (the trap case + the result-level candidate-floor
// demonstration) build a tiny IVF index inline via `build_inline_ivf`
// and an `InlineClusterer` that's compatible with the batched
// IvfClusterer trait.
// ============================================================
use std::cmp::Ordering;
use crate::collector::TopDocs;
use crate::index::IndexSettings;
use crate::indexer::NoMergePolicy;
use crate::query::{
AllQuery, BitSetDocSet, ConstScorer, EnableScoring, Explanation, Query, Scorer, TermQuery,
};
use crate::schema::{IndexRecordOption, Schema, Term, STORED, STRING};
use crate::vector::tests::{exhaustive_params, TestVectorIndex};
use crate::vector::{
IvfCentroids, IvfClusterer, IvfMatrix, IvfMergeSettings, IvfVectors, VectorClusterStats,
VectorDType, VectorInfo, VectorOptions, VectorStorageFormat,
};
use crate::{Index, IndexWriter, TantivyDocument};
const FIXTURE_NUM_DOCS: usize = 100;
/// Number of centroids the shared fixture uses by default (the
/// 3×3 `grid2d::centroids()` grid). Used by tests that need an
/// "exhaustive" probe ceiling.
const DEFAULT_NUM_CENTROIDS: usize = 9;
/// Run the full collector path with the given filter and adaptive
/// params. Returns the global top-K (already merged across
/// segments) in descending-score / (seg_ord, doc_id) order — the
/// same order `ground_truth::top_k` uses, so equality checks are
/// well-defined.
fn search(
index: &Index,
field: Field,
filter: &dyn Query,
query: Vec<f32>,
k: usize,
params: AdaptiveProbeParams,
) -> crate::Result<Vec<(Score, DocAddress)>> {
let collector = TopDocs::with_limit(k)
.order_by_similarity(field, query)
.with_adaptive_params(params);
Ok(index
.reader()?
.searcher()
.search(filter, &collector)?
.results)
}
/// Probe-stat helper: run `VectorBackend::top_n` against
/// the first segment of `index` and return (hits, stats).
/// The contracts are per-segment, so collecting from segment 0 is
/// what each assertion is talking about.
fn run_top_n(
index: &Index,
embed_field: Field,
query: Vec<f32>,
k: usize,
params: AdaptiveProbeParams,
) -> crate::Result<(Vec<(Score, DocAddress)>, ProbeStats)> {
let searcher = index.reader()?.searcher();
let segment_reader = &searcher.segment_readers()[0];
let weight = AllQuery.weight(EnableScoring::disabled_from_searcher(&searcher))?;
let backend = VectorBackend::<f32>::for_segment(
segment_reader,
0,
embed_field,
Arc::new(query),
params,
)?;
assert!(
segment_reader.vector_index(embed_field)?.index().is_some(),
"expected IVF storage"
);
backend.top_n(weight.as_ref(), segment_reader, k)
}
// ---- Inline IVF builder for crafted-geometry tests ----
//
// The shared fixture's `grid2d::vectors` lays 100 deterministic
// points around a 3×3 grid; it doesn't expose a per-doc-vector
// override. The trap-case and result-level candidate-floor tests
// need points at specific coordinates, so they build a small IVF
// index inline via the helper below.
struct InlineClusterer {
centroids: Vec<[f32; 2]>,
replicas: usize,
}
impl IvfClusterer for InlineClusterer {
fn centroid_ratio(&self) -> f32 {
1.0
}
fn training_samples_per_centroid(&self) -> usize {
2
}
fn merge_settings(&self, _total_target_docs: usize) -> crate::Result<IvfMergeSettings> {
Ok(IvfMergeSettings {
num_centroids: self.centroids.len(),
training_samples_per_centroid: self.training_samples_per_centroid(),
assign_batch_size: self.assign_batch_size(),
replicas: self.replicas,
})
}
fn train(
&self,
options: &VectorOptions,
_vectors: IvfVectors<'_>,
num_centroids: usize,
) -> crate::Result<IvfCentroids> {
assert_eq!(options.dim(), 2);
Ok(IvfCentroids::F32(IvfMatrix {
values: self
.centroids
.iter()
.take(num_centroids)
.flat_map(|c| c.iter().copied())
.collect(),
rows: num_centroids,
dims: 2,
}))
}
fn assign(
&self,
options: &VectorOptions,
vectors: IvfVectors<'_>,
centroids: &IvfCentroids,
) -> crate::Result<Vec<u32>> {
assert_eq!(options.dim(), 2);
let IvfVectors::F32(vectors) = vectors;
let IvfCentroids::F32(centroids) = centroids;
Ok(vectors
.matrix
.values
.chunks_exact(2)
.map(|v| {
let mut best = 0u32;
let mut best_d2 = f32::INFINITY;
for (i, c) in centroids.values.chunks_exact(2).enumerate() {
let dx = v[0] - c[0];
let dy = v[1] - c[1];
let d2 = dx * dx + dy * dy;
if d2 < best_d2 {
best = i as u32;
best_d2 = d2;
}
}
best
})
.collect())
}
}
/// Build a single-IVF-segment index with the supplied centroids and
/// labelled docs. Splits docs across two commits so `merge_ivf`
/// has ≥ 2 source segments to consume. Returns the index plus the
/// `(embedding, label)` field handles.
fn build_inline_ivf(
metric: Metric,
centroids: &[[f32; 2]],
docs: &[(&str, [f32; 2])],
replicas: usize,
) -> crate::Result<(Index, Field, Field)> {
assert!(docs.len() >= 2, "need ≥ 2 docs for ≥ 2 source segments");
let mut sb = Schema::builder();
let embed_field = sb.add_vector_field(
"embedding",
VectorOptions::new(2, metric).with_dtype(VectorDType::F32),
);
let label_field = sb.add_text_field("label", STRING | STORED);
let schema = sb.build();
let settings = IndexSettings {
vector_clustering_threshold: 1,
..IndexSettings::default()
};
let index = Index::builder()
.schema(schema)
.settings(settings)
.ivf_clusterer(Arc::new(InlineClusterer {
centroids: centroids.to_vec(),
replicas,
}))
.create_in_ram()?;
let mut writer: IndexWriter = index.writer_with_num_threads(1, 15_000_000)?;
writer.set_merge_policy(Box::new(NoMergePolicy));
let mid = docs.len() / 2;
for chunk in [&docs[..mid.max(1)], &docs[mid.max(1)..]] {
for (label, v) in chunk {
let mut doc = TantivyDocument::new();
doc.add_text(label_field, label);
doc.add_vector(embed_field, v.as_slice());
writer.add_document(doc)?;
}
writer.commit()?;
}
let segment_ids: Vec<_> = index.searchable_segment_ids()?.into_iter().collect();
writer.merge(&segment_ids).wait()?;
writer.wait_merging_threads()?;
Ok((index, embed_field, label_field))
}
/// Decode a stored little-endian `[f32; 2]` row.
fn decode_2d(bytes: &[u8]) -> [f32; 2] {
[
f32::from_le_bytes(bytes[0..4].try_into().unwrap()),
f32::from_le_bytes(bytes[4..8].try_into().unwrap()),
]
}
/// L2-nearest centroid with first-wins tie-break on strict `<` — the
/// same rule `InlineClusterer::assign` uses for the primary.
fn nearest_centroid(p: [f32; 2], centroids: &[[f32; 2]]) -> usize {
let mut best = 0;
let mut best_d2 = f32::INFINITY;
for (i, c) in centroids.iter().enumerate() {
let dx = p[0] - c[0];
let dy = p[1] - c[1];
let d2 = dx * dx + dy * dy;
if d2 < best_d2 {
best_d2 = d2;
best = i;
}
}
best
}
/// Docs per centroid in the replication fixture.
const REPLICATION_N_PER: usize = 6;
/// Six well-separated centroids (3×2 grid, gap 10) and one label per
/// doc. Docs sit tightly around their centroid (offsets ≤ 0.05
/// against the grid gap of 10 — see [`replication_docs`]) so the
/// primary and the next-nearest replica ranking are unambiguous.
fn replication_fixture() -> (Vec<[f32; 2]>, Vec<String>) {
let centroids = vec![
[0.0f32, 0.0],
[10.0, 0.0],
[20.0, 0.0],
[0.0, 10.0],
[10.0, 10.0],
[20.0, 10.0],
];
let labels = (0..centroids.len() * REPLICATION_N_PER)
.map(|i| format!("d{i}"))
.collect();
(centroids, labels)
}
/// The replication fixture's docs: `REPLICATION_N_PER` per centroid,
/// at offset `(i % REPLICATION_N_PER) * 0.01` along both axes.
fn replication_docs<'a>(
centroids: &[[f32; 2]],
labels: &'a [String],
) -> Vec<(&'a str, [f32; 2])> {
(0..labels.len())
.map(|i| {
let c = centroids[i / REPLICATION_N_PER];
let off = (i % REPLICATION_N_PER) as f32 * 0.01;
(labels[i].as_str(), [c[0] + off, c[1] + off])
})
.collect()
}
/// Fixed-k replication is additive and, at small centroid counts, EXACT:
/// the fixture's 6 centroids sit far below the exact-selection threshold
/// (the search's `ef` budget), so replica cells come from a brute k-NN
/// scan, not the approximate graph selector — every vector is written into exactly
/// `min(replicas, num_centroids)` distinct cells: its primary (once) plus
/// the `replicas - 1` next-nearest centroids. Total posting entries are
/// exactly `replicas × N`. `replicas == 1` is the identity: every doc in
/// exactly its primary cluster, no replica selector constructed at all
/// (`replica_selector` stays `None`) — byte-identical to no replication.
/// Query results never repeat a doc id (the `seen` dedup).
///
/// Every assertion here is deterministic — no envelopes, no retries.
#[test]
fn ivf_fixed_k_replication_is_additive() -> crate::Result<()> {
let (centroids, labels) = replication_fixture();
let docs = replication_docs(¢roids, &labels);
let n = docs.len();
let replicas = 3usize;
assert!(
centroids.len() >= replicas,
"fixture needs >= replicas centroids for full fill"
);
// A replicated build read back through Ming's cluster iteration:
// each doc's cluster memberships plus its primary (recomputed from
// the stored vector).
struct ReplicatedBuild {
index: Index,
embed_field: Field,
memberships: Vec<Vec<usize>>,
primaries: Vec<usize>,
}
let build_and_read = |replicas: usize| -> crate::Result<ReplicatedBuild> {
let (index, embed_field, _label) =
build_inline_ivf(Metric::L2, ¢roids, &docs, replicas)?;
let searcher = index.reader()?.searcher();
assert_eq!(searcher.segment_readers().len(), 1, "one merged segment");
let segment_reader = &searcher.segment_readers()[0];
let vec_reader = segment_reader.vector_index(embed_field)?;
let ivf = vec_reader.index().expect("expected IVF segment");
assert_eq!(ivf.num_clusters(), centroids.len());
let max_doc = segment_reader.max_doc() as usize;
assert_eq!(max_doc, n, "every fixture doc must survive the merge");
let mut memberships: Vec<Vec<usize>> = vec![Vec::new(); max_doc];
for cluster in 0..ivf.num_clusters() {
for doc in vec_reader
.cluster_doc_ids(cluster)
.expect("in-bounds cluster")
{
memberships[doc as usize].push(cluster);
}
}
let primaries: Vec<usize> = (0..max_doc)
.map(|doc| {
let bytes = vec_reader
.vector_bytes(doc as u32)
.expect("readable vector bytes")
.expect("stored vector bytes");
nearest_centroid(decode_2d(&bytes), ¢roids)
})
.collect();
Ok(ReplicatedBuild {
index,
embed_field,
memberships,
primaries,
})
};
// replicas = 3: exact fill. Per doc — ceiling and fill
// (exactly min(replicas, num_centroids) = 3 cells), dedup (cells
// distinct, primary present exactly once). Corpus-wide — total
// memberships exactly replicas × N.
let built3 = build_and_read(replicas)?;
let mut total = 0usize;
for (doc, cells) in built3.memberships.iter().enumerate() {
assert_eq!(
cells.len(),
replicas,
"doc {doc}: expected exactly {replicas} cells, got {cells:?}"
);
let mut distinct = cells.clone();
distinct.sort_unstable();
distinct.dedup();
assert_eq!(
distinct.len(),
replicas,
"doc {doc}: duplicate cells in {cells:?}"
);
assert_eq!(
cells
.iter()
.filter(|&&c| c == built3.primaries[doc])
.count(),
1,
"doc {doc}: primary {} must appear exactly once in {cells:?}",
built3.primaries[doc]
);
total += cells.len();
}
assert_eq!(
total,
replicas * n,
"total memberships must be replicas × N"
);
// Query-time dedup: a doc sits in several probed clusters, but a
// search must return each doc id exactly once — and with exhaustive
// params over an all-alive corpus, all N of them.
let hits = search(
&built3.index,
built3.embed_field,
&AllQuery,
vec![10.0, 10.0],
n,
exhaustive_params(centroids.len()),
)?;
assert_eq!(hits.len(), n, "exhaustive top-N must return every doc");
let mut ids: Vec<_> = hits.iter().map(|(_, addr)| addr.doc_id).collect();
ids.sort_unstable();
ids.dedup();
assert_eq!(ids.len(), n, "search returned duplicate doc ids");