yo_vector/partition.rs
1//! The partition index, and the in place update protocol that means it never
2//! has to be rebuilt (`10` sections 2, 4 and 5).
3//!
4//! A vector index is two decisions and the quantiser was the easy one. This is
5//! the other: what holds the codes, and what happens to it when the collection
6//! changes.
7//!
8//! The answer is not a graph. Redis shipped HNSW vector sets in 8.0 in May 2025
9//! and they were still beta three minor releases later, which is the vendor's
10//! own evidence about how that goes. The reason is the update path: a graph
11//! index tombstones a delete, degrades as the tombstones pile up, and only gets
12//! better again when somebody rebuilds it, which on a collection anyone cares
13//! about is an outage with a nicer name.
14//!
15//! So this is partitions. Every vector belongs to the partition whose centroid
16//! it is nearest, the centroids are resident, and a partition's members are a
17//! flat run of codes that the scan walks end to end. An insert is an append. A
18//! delete takes a member out and moves the last one into the hole. Neither one
19//! touches anything else.
20//!
21//! # Search
22//!
23//! Rank the centroids, take the nearest `probe` of them, scan those partitions
24//! with the estimator, keep the best `rerank` candidates, and then look up the
25//! full precision vectors for exactly those and measure them properly. Rerank
26//! costs nothing structurally here, because the vector is already in the record
27//! log at an address the id resolves to. Every other system that quantises has
28//! to keep the raw vectors somewhere on purpose.
29//!
30//! The scan is the shape hardware likes, a linear walk over contiguous bytes,
31//! and that is why an index of this family beats a graph on a modern core even
32//! though it looks at more candidates.
33//!
34//! # It never rebuilds
35//!
36//! This is SPFresh's LIRE, and it is four bounded operations rather than a
37//! background rebuild.
38//!
39//! A posting that grows past twice its target splits, by two means over its own
40//! members. A posting that falls under a quarter of its target merges, by
41//! handing its members to whichever centroid is nearest now. Both are bounded
42//! work on one partition.
43//!
44//! The third is the one that matters and it is what LIRE actually contributes.
45//! After a split, the members of the partitions *around* the one that split may
46//! now be nearer one of the two new centroids than the one they are filed
47//! under. Nobody told them, and a plain partitioned index just lets that drift,
48//! which is why a plain partitioned index measures beautifully on a freshly
49//! built corpus and badly after a week of writes. So a split is followed by a
50//! sweep of the neighbouring partitions, and anything whose nearest centroid
51//! has changed is moved. The test for this writes a stream and checks recall at
52//! the end of it rather than on a fresh build, because a fresh build is exactly
53//! the measurement that hides the problem.
54//!
55//! # Everything here is in rotated space
56//!
57//! The rotation is linear, so `rotate(v - c)` is `rotate(v) - rotate(c)`, and
58//! distances and angles come through it unchanged. That means a centroid can be
59//! stored already rotated and a query can be rotated once, and then meeting a
60//! partition is a subtraction rather than another rotation. The rotation is the
61//! expensive half of preparing a query, so on a search that probes tens of
62//! partitions this is most of what preparation costs.
63//!
64//! # What it costs to build
65//!
66//! `examples/ingest.rs` measures the rate at every doubling and splits it
67//! between the insert and the maintenance, because a rate that falls as the
68//! collection grows and a rate that is just low need different work and a single
69//! number cannot tell them apart. On 128 dimensional vectors on one core of an
70//! M-series Mac:
71//!
72//! ```text
73//! at partitions a second insert maintain touched
74//! 12500 36 115952 20.4% 79.6% 5.3
75//! 50000 132 72013 39.4% 60.6% 6.9
76//! 200000 595 46917 58.7% 41.3% 5.5
77//! 800000 2141 42459 60.9% 39.1% 4.4
78//! ```
79//!
80//! `touched` is how many vectors maintenance moved or looked at per vector
81//! inserted. It is flat, and that is the number which says the update protocol
82//! is doing bounded work rather than quietly turning into a rebuild.
83//!
84//! Both halves of that took a fix to get there and they were different fixes.
85//! Maintenance was 80 percent of the time and most of it was `sweep` measuring
86//! every member it looked at against every centroid in the collection, which is
87//! not what LIRE says and is several full scans per vector inserted. The insert
88//! was the other half and it was a scan over every centroid by definition, which
89//! is why the coarse layer in `src/coarse.rs` is there, and that file is where
90//! the reasoning about it lives. Before either fix, the rate halved on every
91//! doubling and was 13563 a second by 800 thousand.
92//!
93//! # What is not here yet
94//!
95//! A `.yo` file. None of this is written down yet, and the format freezes at the
96//! end of M6, so that is the next thing.
97//!
98//! A search that fits in a millisecond. SIFT1M on a 13900K gets recall 0.9598 at
99//! probe 64 rerank 16, which clears the gate, with p50 at 1.1 ms and p99 at 1.5
100//! ms, which does not. `examples/search.rs` is the breakdown of where that time
101//! goes and the answer is that two thirds of it is the estimator meeting one
102//! code at a time.
103//!
104//! The commands that put all of this on the wire are the rest of M6.
105
106use std::collections::HashMap;
107
108use crate::coarse::Coarse;
109use crate::rabitq::{Bits, Coded, Quantizer};
110
111/// Where the full precision vectors live.
112///
113/// A real collection answers this out of the record log, which already holds
114/// the vector at an address the id resolves to. A test answers it out of a map.
115/// Either way the index itself never stores a raw vector, which is the whole
116/// point of quantising one.
117pub trait Vectors {
118 /// Write the vector `id` stands for into `into` and say so, or say that the
119 /// id is gone.
120 ///
121 /// An id that is gone is dropped from the index the next time maintenance
122 /// walks over it, so a collection that deletes from the log without telling
123 /// the index heals rather than lying.
124 fn get(&self, id: u64, into: &mut [f32]) -> bool;
125}
126
127/// The knobs, all of which have a defensible default and none of which anybody
128/// should have to touch.
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub struct Tuning {
131 /// How many members a partition wants. It splits past twice this and merges
132 /// under a quarter of it.
133 ///
134 /// This is what sets how many partitions a collection ends up with, and so
135 /// it trades the cost of ranking centroids against the cost of scanning a
136 /// posting. A few hundred is where those two are near enough even.
137 pub posting: usize,
138 /// How many partitions a search scans.
139 pub probe: usize,
140 /// How many candidates are reranked per answer asked for.
141 ///
142 /// Four is the number the recall table was measured at: one bit codes put
143 /// the true ten inside the best forty better than 98 times in a hundred.
144 pub rerank: usize,
145 /// How many neighbouring partitions a split sweeps for members that should
146 /// move.
147 ///
148 /// This is the cost of never drifting. Zero would make a split free and
149 /// would make recall fall off over a long write stream, which is the thing
150 /// this index exists to not do.
151 pub sweep: usize,
152 /// How much further than `probe` a filtered search will go looking when the
153 /// filter is selective enough that the nearest partitions do not hold `k`
154 /// members that pass, as a multiple of `probe`.
155 ///
156 /// This is the only knob here with a genuinely hard trade behind it. Too
157 /// small and a filter matching one document in a thousand returns nothing
158 /// while the answer sat two partitions further out. Too large and the same
159 /// filter reads the whole collection to prove there is nothing there.
160 pub widen: usize,
161}
162
163impl Default for Tuning {
164 fn default() -> Tuning {
165 Tuning {
166 posting: 256,
167 probe: 8,
168 rerank: 4,
169 sweep: 4,
170 widen: 8,
171 }
172 }
173}
174
175/// The fewest candidates a search will rerank, whatever `k` and `rerank`
176/// multiply out to.
177///
178/// Four times `k` is the right ratio and it is the wrong number when `k` is
179/// small: asking for one answer and reranking four candidates puts the whole
180/// weight of the answer on the estimator getting its top four right, which is
181/// not what the estimator is for. Reranking a few dozen costs a few dozen
182/// squared distances, which is nothing next to the scan that produced them.
183const FLOOR: usize = 32;
184
185/// What decides whether the scan bothers with a member.
186///
187/// A filtered vector search is a recall lottery when the filter runs after the
188/// search: ask for ten English passages, get the best forty by vector, find
189/// three of them are English, and the other seven English passages that were
190/// nearer never had a chance. The fix is to filter inside the scan, so that
191/// only members that can be answers are ranked at all, and that means the thing
192/// the filter reads has to sit next to the codes rather than behind a lookup
193/// into somebody else's table.
194///
195/// So every member carries a `u64` tag, given at insert, and a filter is a
196/// predicate on that tag. What the tag means is the caller's business. A
197/// handful of low cardinality attributes pack into it exactly, one field each,
198/// and the filter is then exact. Anything wider goes through [`Signature`],
199/// which is exact in the direction that matters: it never rejects a member that
200/// should have matched, so the caller's real predicate over the answers still
201/// decides.
202pub trait Filter {
203 /// Whether a member with this tag is worth ranking.
204 fn allows(&self, tag: u64) -> bool;
205}
206
207/// The filter that lets everything through, which is what an unfiltered search
208/// runs.
209#[derive(Debug, Clone, Copy, Default)]
210pub struct Any;
211
212impl Filter for Any {
213 fn allows(&self, _tag: u64) -> bool {
214 true
215 }
216}
217
218impl<F: Fn(u64) -> bool> Filter for F {
219 fn allows(&self, tag: u64) -> bool {
220 self(tag)
221 }
222}
223
224/// A tag built by setting one bit per attribute value, so that a conjunction of
225/// required values is a subset test.
226///
227/// Superimposed coding, which is old and still the right answer when the test
228/// has to be one instruction on a value that is already in a register. Each
229/// attribute and value pair hashes to one of 64 bits. A member's tag is the
230/// bits for the values it has. A query's tag is the bits for the values it
231/// requires. The member is worth ranking when it has all of the query's bits.
232///
233/// Two different values can land on the same bit, so a member can pass a filter
234/// it does not really match. It can never fail one it does match, which is the
235/// direction that matters: the answers are a superset of the truth and the
236/// caller's own predicate cuts them down, where the other way round would lose
237/// answers silently.
238///
239/// ```
240/// use yo_vector::Signature;
241///
242/// // What a document is tagged with, and what a query asks for.
243/// let doc = Signature::of(&[("lang", "en".as_bytes()), ("topic", "finance".as_bytes())]);
244/// let english = Signature::of(&[("lang", "en".as_bytes())]);
245///
246/// assert!(doc.covers(english));
247/// // The other way round only holds if the two bits happened to collide.
248/// assert!(!english.covers(doc) || english.bits() == doc.bits());
249/// ```
250#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
251pub struct Signature(u64);
252
253impl Signature {
254 /// The signature of a set of attribute and value pairs.
255 #[must_use]
256 pub fn of(values: &[(&str, &[u8])]) -> Signature {
257 let mut bits = 0u64;
258 for (attribute, value) in values {
259 bits |= 1u64 << (hash(attribute.as_bytes(), value) % 64);
260 }
261 Signature(bits)
262 }
263
264 /// The signature as the tag to hand to [`Partitions::insert_tagged`].
265 #[must_use]
266 pub fn bits(self) -> u64 {
267 self.0
268 }
269
270 /// The signature of a tag that came back out of the index.
271 #[must_use]
272 pub fn from_bits(bits: u64) -> Signature {
273 Signature(bits)
274 }
275
276 /// Whether this has every bit `want` has, which is the test the scan runs.
277 #[must_use]
278 pub fn covers(self, want: Signature) -> bool {
279 self.0 & want.0 == want.0
280 }
281}
282
283impl Filter for Signature {
284 fn allows(&self, tag: u64) -> bool {
285 Signature(tag).covers(*self)
286 }
287}
288
289/// FNV over the attribute and then the value, which is small, has no state and
290/// spreads a short value over the whole word well enough to pick a bit.
291fn hash(attribute: &[u8], value: &[u8]) -> u64 {
292 let mut h = 0xcbf2_9ce4_8422_2325u64;
293 for byte in attribute.iter().chain(b":").chain(value) {
294 h ^= u64::from(*byte);
295 h = h.wrapping_mul(0x1000_0000_01b3);
296 }
297 h
298}
299
300/// An answer: a document id and how far it really is, not how far it was
301/// estimated to be.
302#[derive(Debug, Clone, Copy, PartialEq)]
303pub struct Hit {
304 /// The id that was inserted.
305 pub id: u64,
306 /// The exact squared distance, measured against the full precision vector.
307 pub distance: f32,
308}
309
310/// Where a member sits.
311#[derive(Debug, Clone, Copy)]
312struct Slot {
313 partition: u32,
314 slot: u32,
315}
316
317/// One partition's members: the ids, their codes end to end, and what each code
318/// needs beside it.
319#[derive(Default)]
320struct Posting {
321 ids: Vec<u64>,
322 /// One tag per member, in the same order, which is what a filter meets.
323 ///
324 /// Beside the ids rather than behind a pointer, because the whole point is
325 /// that the scan can skip a member without touching anything that is not
326 /// already in cache.
327 tags: Vec<u64>,
328 codes: Vec<u8>,
329 meta: Vec<Coded>,
330 /// The size at which a split was tried and found there was no cut, which
331 /// happens when every member is the same vector. It is not tried again
332 /// until the posting has grown past it.
333 stuck: usize,
334}
335
336impl Posting {
337 fn len(&self) -> usize {
338 self.ids.len()
339 }
340}
341
342/// A collection of vectors, quantised, partitioned, and updated in place.
343pub struct Partitions {
344 quant: Quantizer,
345 tuning: Tuning,
346 /// The centroids, already rotated, `dim` floats each end to end.
347 centroids: Vec<f32>,
348 postings: Vec<Posting>,
349 /// Which partition and which slot every id is in, which is what makes a
350 /// delete a constant time operation rather than a search.
351 at: HashMap<u64, Slot>,
352 /// The index over the centroids. See [`crate::coarse`].
353 coarse: Coarse,
354 /// The shortlist a placement fills in, kept here so that placing a vector
355 /// does not allocate.
356 scratch: Vec<u32>,
357}
358
359impl Partitions {
360 /// An empty collection of `dim` dimensional vectors.
361 ///
362 /// The first vector inserted becomes the first centroid, and the index
363 /// grows by splitting from there, so there is no build step and no moment
364 /// where the shape of the collection has to be known in advance.
365 ///
366 /// # Panics
367 ///
368 /// If `dim` is zero.
369 #[must_use]
370 pub fn new(dim: usize, bits: Bits, seed: u64, tuning: Tuning) -> Partitions {
371 Partitions {
372 quant: Quantizer::new(dim, bits, seed),
373 tuning,
374 centroids: Vec::new(),
375 postings: Vec::new(),
376 at: HashMap::new(),
377 coarse: Coarse::default(),
378 scratch: Vec::new(),
379 }
380 }
381
382 /// How many coordinates a vector here has.
383 #[must_use]
384 pub fn dim(&self) -> usize {
385 self.quant.dim()
386 }
387
388 /// How many vectors are in the collection.
389 #[must_use]
390 pub fn len(&self) -> usize {
391 self.at.len()
392 }
393
394 /// Whether there are none.
395 #[must_use]
396 pub fn is_empty(&self) -> bool {
397 self.at.is_empty()
398 }
399
400 /// How many partitions the collection has grown to.
401 #[must_use]
402 pub fn partitions(&self) -> usize {
403 self.postings.len()
404 }
405
406 /// The knobs.
407 #[must_use]
408 pub fn tuning(&self) -> Tuning {
409 self.tuning
410 }
411
412 /// Change the knobs on a collection that already has vectors in it.
413 ///
414 /// [`Tuning::probe`], [`Tuning::rerank`] and [`Tuning::widen`] are read by
415 /// each search, so they take effect on the next one. That is what makes a
416 /// recall against latency curve measurable on one built index rather than on
417 /// one built per row, and it is what `EF_RUNTIME` means to a client that
418 /// thinks it is talking to a graph.
419 ///
420 /// [`Tuning::posting`] and [`Tuning::sweep`] are what maintenance aims at,
421 /// so lowering `posting` does not split anything by itself. The partitions
422 /// move towards the new size as [`Partitions::maintain`] gets called, which
423 /// is the same way they got to the old one.
424 pub fn retune(&mut self, tuning: Tuning) {
425 self.tuning = tuning;
426 }
427
428 /// The quantiser, whose seed and width a catalogue entry has to record.
429 #[must_use]
430 pub fn quantizer(&self) -> &Quantizer {
431 &self.quant
432 }
433
434 /// How many bytes the codes take, which is the searchable size of the
435 /// collection and the number the 32x claim is about.
436 #[must_use]
437 pub fn code_bytes(&self) -> usize {
438 self.postings.iter().map(|p| p.codes.len()).sum()
439 }
440
441 /// Put a vector in, replacing whatever was under `id`.
442 ///
443 /// Two appends and no locks: the code goes on the end of the nearest
444 /// partition's posting, and the caller puts the full precision vector in the
445 /// log. Nothing else in the index is touched, which is the difference
446 /// between this and a graph.
447 ///
448 /// # Panics
449 ///
450 /// If `v` is not [`Partitions::dim`] long.
451 pub fn insert(&mut self, id: u64, v: &[f32]) {
452 self.insert_tagged(id, v, 0);
453 }
454
455 /// The same, with the tag a filter will meet in the scan.
456 ///
457 /// See [`Filter`] for what a tag is and [`Signature`] for the encoding to
458 /// reach for when the attributes do not fit in one exactly.
459 ///
460 /// # Panics
461 ///
462 /// If `v` is not [`Partitions::dim`] long.
463 pub fn insert_tagged(&mut self, id: u64, v: &[f32], tag: u64) {
464 assert_eq!(
465 v.len(),
466 self.dim(),
467 "this collection holds {} dimensional vectors and was handed {}",
468 self.dim(),
469 v.len()
470 );
471 self.remove(id);
472 let x = self.quant.rotate(v);
473 let p = if self.postings.is_empty() {
474 // The first vector is the first centroid. There is nothing to
475 // average it with yet, and the first split is what starts the
476 // centroids being means rather than members.
477 self.add_partition(&x)
478 } else {
479 let mut short = core::mem::take(&mut self.scratch);
480 let p = self.roughly_nearest(&x, &mut short);
481 self.scratch = short;
482 p
483 };
484 self.place(p, id, tag, &x);
485 }
486
487 /// The tag `id` was inserted with, if it is still here.
488 #[must_use]
489 pub fn tag(&self, id: u64) -> Option<u64> {
490 let at = self.at.get(&id)?;
491 Some(self.postings[at.partition as usize].tags[at.slot as usize])
492 }
493
494 /// Take a vector out, saying whether it was there.
495 ///
496 /// The last member of the posting moves into the hole. There is no
497 /// tombstone, so there is nothing to accumulate and nothing to compact.
498 pub fn remove(&mut self, id: u64) -> bool {
499 let Some(Slot { partition, slot }) = self.at.remove(&id) else {
500 return false;
501 };
502 let moved = self.pull(partition as usize, slot as usize);
503 if let Some(other) = moved {
504 self.at.insert(other, Slot { partition, slot });
505 }
506 true
507 }
508
509 /// Whether `id` is in the collection.
510 #[must_use]
511 pub fn contains(&self, id: u64) -> bool {
512 self.at.contains_key(&id)
513 }
514
515 /// The `k` nearest vectors to `q`, measured exactly.
516 ///
517 /// The codes pick the candidates and the log settles the order, so the
518 /// answer is as exact as brute force whenever the candidates contained the
519 /// truth, and the recall table is about how often they do.
520 ///
521 /// # Panics
522 ///
523 /// If `q` is not [`Partitions::dim`] long.
524 #[must_use]
525 pub fn search(&self, q: &[f32], k: usize, vectors: &impl Vectors) -> Vec<Hit> {
526 self.search_where(q, k, &Any, vectors)
527 }
528
529 /// The `k` nearest vectors to `q` that a filter allows.
530 ///
531 /// The filter runs inside the scan, on the tag that sits next to the code,
532 /// so a member the filter rejects is never ranked and never takes a place
533 /// that an answer should have had. Filtering afterwards instead is what
534 /// makes a filtered vector search a lottery, and the more selective the
535 /// filter the worse a lottery it is.
536 ///
537 /// A selective filter also means the nearest few partitions may not hold `k`
538 /// members that pass, so the scan keeps going into further partitions until
539 /// it has enough or until it has spent [`Tuning::widen`]. A filter that
540 /// matches almost nothing returns fewer answers rather than reading the
541 /// whole collection, which is the trade every engine makes here and is worth
542 /// saying out loud.
543 ///
544 /// # Panics
545 ///
546 /// If `q` is not [`Partitions::dim`] long.
547 #[must_use]
548 pub fn search_where(
549 &self,
550 q: &[f32],
551 k: usize,
552 filter: &impl Filter,
553 vectors: &impl Vectors,
554 ) -> Vec<Hit> {
555 if k == 0 {
556 return Vec::new();
557 }
558 let candidates = self.candidates_where(q, (k * self.tuning.rerank).max(FLOOR), filter);
559 let mut buf = vec![0.0f32; self.dim()];
560 let mut hits = Vec::with_capacity(candidates.len());
561 for (id, _) in candidates {
562 if vectors.get(id, &mut buf) {
563 hits.push(Hit {
564 id,
565 distance: sqdist(q, &buf),
566 });
567 }
568 }
569 hits.sort_by(|a, b| a.distance.total_cmp(&b.distance));
570 hits.truncate(k);
571 hits
572 }
573
574 /// The `want` best candidates by the estimator, without rerank.
575 ///
576 /// This is what a filter will eventually push into, and it is what the
577 /// recall of the codes alone is measured on.
578 ///
579 /// # Panics
580 ///
581 /// If `q` is not [`Partitions::dim`] long.
582 #[must_use]
583 pub fn candidates(&self, q: &[f32], want: usize) -> Vec<(u64, f32)> {
584 self.candidates_where(q, want, &Any)
585 }
586
587 /// The same, with the filter run inside the scan.
588 ///
589 /// # Panics
590 ///
591 /// If `q` is not [`Partitions::dim`] long.
592 #[must_use]
593 pub fn candidates_where(
594 &self,
595 q: &[f32],
596 want: usize,
597 filter: &impl Filter,
598 ) -> Vec<(u64, f32)> {
599 assert_eq!(
600 q.len(),
601 self.dim(),
602 "this collection holds {} dimensional vectors and was handed {}",
603 self.dim(),
604 q.len()
605 );
606 if want == 0 || self.postings.is_empty() {
607 return Vec::new();
608 }
609 // Rotated once here and never again, which is what lets a search probe
610 // tens of partitions without paying for tens of rotations.
611 let u = self.quant.rotate(q);
612 let mut best = Bounded::new(want);
613 // One buffer for the whole search rather than one per partition, and
614 // grown rather than cleared, because every partition after the first
615 // wants the same room the one before it did.
616 let mut scores: Vec<f32> = Vec::new();
617 let reach = self.tuning.probe.saturating_mul(self.tuning.widen.max(1));
618 for (n, p) in self.near_partitions(&u, reach).into_iter().enumerate() {
619 // Past the partitions an unfiltered search would have read, keep
620 // going only while there is still not enough to answer with. An
621 // unfiltered search never gets here, because the first `probe`
622 // partitions of a collection worth probing hold more than `want`.
623 if n >= self.tuning.probe && best.full() {
624 break;
625 }
626 let prepared = self.quant.query_rotated(&u, self.centroid(p));
627 let posting = &self.postings[p];
628 let held = posting.ids.len();
629 if scores.len() < held {
630 scores.resize(held, 0.0);
631 }
632 // The whole posting at once, so the estimator's inner loops know
633 // how wide a code is. Then a second pass, which for most members is
634 // one comparison against the worst answer so far and no more, and
635 // which does not read the id or the tag of a member that lost.
636 prepared.scan(&posting.codes, &posting.meta, &mut scores[..held]);
637 for (i, &at) in scores[..held].iter().enumerate() {
638 if !best.wants(at) {
639 continue;
640 }
641 if !filter.allows(posting.tags[i]) {
642 continue;
643 }
644 best.put(posting.ids[i], at);
645 }
646 }
647 best.sorted()
648 }
649
650 /// Whether there is a split or a merge waiting.
651 #[must_use]
652 pub fn needs_maintenance(&self) -> bool {
653 self.job().is_some()
654 }
655
656 /// Do bounded maintenance, and say how many vectors it looked at.
657 ///
658 /// `budget` is in vectors touched rather than in time, because time is not
659 /// something a storage engine gets to measure cheaply and a vector is the
660 /// unit all of this work is actually made of. Call it from a maintenance
661 /// slice until it returns less than the budget, which means there was
662 /// nothing left to do.
663 pub fn maintain(&mut self, vectors: &impl Vectors, budget: usize) -> usize {
664 let mut done = 0;
665 while done < budget {
666 let Some(job) = self.job() else { break };
667 done += match job {
668 Job::Split(p) => self.split(p, vectors),
669 Job::Merge(p) => self.merge(p, vectors),
670 };
671 }
672 done
673 }
674
675 /// The next thing worth doing, biggest problem first.
676 fn job(&self) -> Option<Job> {
677 let big = (0..self.postings.len())
678 .filter(|&p| self.postings[p].len() > self.postings[p].stuck)
679 .max_by_key(|&p| self.postings[p].len());
680 if let Some(big) = big
681 && self.postings[big].len() > self.tuning.posting * 2
682 {
683 return Some(Job::Split(big));
684 }
685 if self.postings.len() > 1 {
686 let small = (0..self.postings.len()).min_by_key(|&p| self.postings[p].len())?;
687 if self.postings[small].len() * 4 < self.tuning.posting {
688 return Some(Job::Merge(small));
689 }
690 }
691 None
692 }
693
694 /// Cut a partition in two by two means over its own members, then sweep the
695 /// neighbours for anything that should have come along.
696 fn split(&mut self, p: usize, vectors: &impl Vectors) -> usize {
697 let (members, xs) = self.take(p, vectors);
698 let dim = self.dim();
699 if members.len() < 2 {
700 for (i, m) in members.iter().enumerate() {
701 self.place(p, m.id, m.tag, &xs[i * dim..(i + 1) * dim]);
702 }
703 return members.len();
704 }
705 let (a, b) = two_means(&xs, dim);
706 let sides: Vec<bool> = (0..members.len())
707 .map(|i| {
708 sqdist(&xs[i * dim..(i + 1) * dim], &a) <= sqdist(&xs[i * dim..(i + 1) * dim], &b)
709 })
710 .collect();
711 // A thousand copies of the same vector is one point as far as two means
712 // is concerned, and there is no cut that divides it. Put them back, and
713 // do not come back until the posting has doubled, so that a collection
714 // that really is all one vector costs a re-encode of it a logarithmic
715 // number of times rather than once per insert.
716 if sides.iter().all(|&s| s) || sides.iter().all(|&s| !s) {
717 for (i, m) in members.iter().enumerate() {
718 self.place(p, m.id, m.tag, &xs[i * dim..(i + 1) * dim]);
719 }
720 self.postings[p].stuck = members.len() * 2;
721 return members.len();
722 }
723 self.centroids[p * dim..(p + 1) * dim].copy_from_slice(&a);
724 self.coarse.moved(p, &a, dim);
725 let q = self.add_partition(&b);
726 for (i, m) in members.iter().enumerate() {
727 let to = if sides[i] { p } else { q };
728 self.place(to, m.id, m.tag, &xs[i * dim..(i + 1) * dim]);
729 }
730 members.len() + self.sweep(&[p, q], vectors)
731 }
732
733 /// Hand a partition's members to whoever is nearest now, and drop it.
734 fn merge(&mut self, p: usize, vectors: &impl Vectors) -> usize {
735 let (members, xs) = self.take(p, vectors);
736 let dim = self.dim();
737 self.drop_partition(p);
738 for (i, m) in members.iter().enumerate() {
739 let x = &xs[i * dim..(i + 1) * dim];
740 let to = self.nearest(x);
741 self.place(to, m.id, m.tag, x);
742 }
743 members.len()
744 }
745
746 /// LIRE: after the centroids move, anything nearby that is now filed under
747 /// the wrong one gets moved.
748 ///
749 /// Only the partitions near the ones that just changed are looked at,
750 /// because those are the only ones whose members can have a new nearest
751 /// centroid, and looking at all of them would be the rebuild this index
752 /// exists to avoid.
753 ///
754 /// # Why a member is only measured against what changed
755 ///
756 /// Every member is already filed under the centroid it was nearest to, and a
757 /// split moves one centroid and adds one. Nothing else moved, so for a member
758 /// of some other partition the nearest of all the centroids that did not
759 /// change is still the one it is already under, and the only way it can have
760 /// a new answer is if one of the two new centroids beats that. That is a
761 /// comparison against two, not a search over all of them.
762 ///
763 /// This is not a shortcut, it is what LIRE says, and getting it wrong is
764 /// expensive in a way that is easy to miss. A sweep after a split walks about
765 /// four partitions' worth of members, and a split happens every posting's
766 /// worth of inserts, so a full centroid scan per member works out at several
767 /// scans of every centroid in the collection per vector inserted. That is the
768 /// whole ingest cost at any size worth talking about: measured on 128
769 /// dimensional vectors it was 74 thousand a second at twelve thousand vectors
770 /// and 13 thousand at two hundred thousand, with maintenance three quarters
771 /// of it, and `examples/ingest.rs` is the harness that says so.
772 fn sweep(&mut self, changed: &[usize], vectors: &impl Vectors) -> usize {
773 let dim = self.dim();
774 let mut look: Vec<usize> = Vec::new();
775 for &p in changed {
776 let centre = self.centroid(p).to_vec();
777 for q in self.near_partitions(¢re, self.tuning.sweep) {
778 if !changed.contains(&q) && !look.contains(&q) {
779 look.push(q);
780 }
781 }
782 }
783 // Copied out because placing a member borrows the index, and safe to
784 // copy because nothing below here moves a centroid: `place` appends a
785 // code to a posting and leaves the centroids alone.
786 let fresh: Vec<(usize, Vec<f32>)> = changed
787 .iter()
788 .map(|&p| (p, self.centroid(p).to_vec()))
789 .collect();
790 let mut seen = 0;
791 let mut buf = vec![0.0f32; dim];
792 for p in look {
793 let here = self.centroid(p).to_vec();
794 // Backwards, because taking a member out moves the last one into
795 // its slot and a backwards walk never steps over the one that moved.
796 for i in (0..self.postings[p].len()).rev() {
797 seen += 1;
798 let id = self.postings[p].ids[i];
799 let tag = self.postings[p].tags[i];
800 if !vectors.get(id, &mut buf) {
801 self.pull_and_forget(p, i);
802 continue;
803 }
804 let x = self.quant.rotate(&buf);
805 let mut best = (p, sqdist(&x, &here));
806 for (q, centre) in &fresh {
807 let d = sqdist(&x, centre);
808 if d < best.1 {
809 best = (*q, d);
810 }
811 }
812 if best.0 != p {
813 self.pull_and_forget(p, i);
814 self.place(best.0, id, tag, &x);
815 }
816 }
817 }
818 seen
819 }
820
821 /// Empty a partition out, handing back its members and their rotated
822 /// vectors. Ids the source has forgotten are dropped.
823 fn take(&mut self, p: usize, vectors: &impl Vectors) -> (Vec<Member>, Vec<f32>) {
824 let dim = self.dim();
825 let ids = std::mem::take(&mut self.postings[p].ids);
826 let tags = std::mem::take(&mut self.postings[p].tags);
827 self.postings[p].codes.clear();
828 self.postings[p].meta.clear();
829 let mut kept = Vec::with_capacity(ids.len());
830 let mut xs = Vec::with_capacity(ids.len() * dim);
831 let mut buf = vec![0.0f32; dim];
832 for (id, tag) in ids.into_iter().zip(tags) {
833 self.at.remove(&id);
834 if vectors.get(id, &mut buf) {
835 xs.extend_from_slice(&self.quant.rotate(&buf));
836 kept.push(Member { id, tag });
837 }
838 }
839 (kept, xs)
840 }
841
842 /// The `n` partitions whose centroids are nearest `x`, nearest first.
843 fn near_partitions(&self, x: &[f32], n: usize) -> Vec<usize> {
844 let mut by: Vec<(usize, f32)> = (0..self.postings.len())
845 .map(|p| (p, sqdist(x, self.centroid(p))))
846 .collect();
847 let n = n.min(by.len());
848 by.select_nth_unstable_by(n.saturating_sub(1), |a, b| a.1.total_cmp(&b.1));
849 by.truncate(n);
850 by.sort_by(|a, b| a.1.total_cmp(&b.1));
851 by.into_iter().map(|(p, _)| p).collect()
852 }
853
854 /// The partition `x` belongs to, as far as the coarse layer can tell.
855 fn roughly_nearest(&self, x: &[f32], short: &mut Vec<u32>) -> usize {
856 if !self.coarse.ready() {
857 return self.nearest(x);
858 }
859 self.coarse.shortlist(x, self.dim(), short);
860 short
861 .iter()
862 .map(|&p| (p as usize, sqdist(x, self.centroid(p as usize))))
863 .min_by(|a, b| a.1.total_cmp(&b.1))
864 .map_or(0, |(p, _)| p)
865 }
866
867 /// The partition `x` belongs to.
868 fn nearest(&self, x: &[f32]) -> usize {
869 (0..self.postings.len())
870 .map(|p| (p, sqdist(x, self.centroid(p))))
871 .min_by(|a, b| a.1.total_cmp(&b.1))
872 .map_or(0, |(p, _)| p)
873 }
874
875 fn centroid(&self, p: usize) -> &[f32] {
876 let dim = self.dim();
877 &self.centroids[p * dim..(p + 1) * dim]
878 }
879
880 /// A new empty partition around `centroid`, which is already rotated.
881 fn add_partition(&mut self, centroid: &[f32]) -> usize {
882 let dim = self.quant.dim();
883 self.centroids.extend_from_slice(centroid);
884 self.postings.push(Posting::default());
885 let p = self.postings.len() - 1;
886 self.coarse.added(p, centroid, dim);
887 self.refresh_coarse();
888 p
889 }
890
891 /// Rebuild the coarse layer if the partition count has moved far enough
892 /// since the anchors were last chosen.
893 fn refresh_coarse(&mut self) {
894 let n = self.postings.len();
895 if self.coarse.stale(n) {
896 let dim = self.quant.dim();
897 self.coarse.rebuild(&self.centroids, dim, n);
898 }
899 }
900
901 /// Drop an empty partition, moving the last one into its place.
902 fn drop_partition(&mut self, p: usize) {
903 debug_assert_eq!(self.postings[p].len(), 0, "a partition is emptied first");
904 let dim = self.dim();
905 let last = self.postings.len() - 1;
906 self.coarse.dropped(p);
907 self.postings.swap_remove(p);
908 for i in 0..dim {
909 self.centroids[p * dim + i] = self.centroids[last * dim + i];
910 }
911 self.centroids.truncate(last * dim);
912 if p != last {
913 // The partition that used to be last is at `p` now, so everything
914 // filed under it has to be told.
915 for &id in &self.postings[p].ids {
916 if let Some(slot) = self.at.get_mut(&id) {
917 slot.partition = p as u32;
918 }
919 }
920 }
921 self.refresh_coarse();
922 }
923
924 /// Append a member to a partition. `x` is rotated.
925 fn place(&mut self, p: usize, id: u64, tag: u64, x: &[f32]) {
926 let dim = self.dim();
927 let width = self.quant.code_bytes();
928 let slot = self.postings[p].len();
929 self.postings[p].codes.resize((slot + 1) * width, 0);
930 let centroid = &self.centroids[p * dim..(p + 1) * dim];
931 let coded = self.quant.encode_rotated(
932 x,
933 centroid,
934 &mut self.postings[p].codes[slot * width..(slot + 1) * width],
935 );
936 self.postings[p].ids.push(id);
937 self.postings[p].tags.push(tag);
938 self.postings[p].meta.push(coded);
939 self.at.insert(
940 id,
941 Slot {
942 partition: p as u32,
943 slot: slot as u32,
944 },
945 );
946 }
947
948 /// Take slot `s` out of partition `p`, returning the id that moved into it.
949 fn pull(&mut self, p: usize, s: usize) -> Option<u64> {
950 let width = self.quant.code_bytes();
951 let posting = &mut self.postings[p];
952 let last = posting.len() - 1;
953 posting.ids.swap_remove(s);
954 posting.tags.swap_remove(s);
955 posting.meta.swap_remove(s);
956 if s != last {
957 let (head, tail) = posting.codes.split_at_mut(last * width);
958 head[s * width..(s + 1) * width].copy_from_slice(&tail[..width]);
959 }
960 posting.codes.truncate(last * width);
961 (s != last).then(|| posting.ids[s])
962 }
963
964 /// The same, keeping the map straight, for the paths that are about to put
965 /// the member somewhere else.
966 fn pull_and_forget(&mut self, p: usize, s: usize) {
967 let id = self.postings[p].ids[s];
968 self.at.remove(&id);
969 if let Some(moved) = self.pull(p, s) {
970 self.at.insert(
971 moved,
972 Slot {
973 partition: p as u32,
974 slot: s as u32,
975 },
976 );
977 }
978 }
979}
980
981/// A member on its way from one partition to another, which is the only time
982/// its id and its tag travel together without a posting around them.
983#[derive(Clone, Copy)]
984struct Member {
985 id: u64,
986 tag: u64,
987}
988
989enum Job {
990 Split(usize),
991 Merge(usize),
992}
993
994/// Two means over a set of vectors laid out end to end.
995///
996/// The seeds are the member furthest from the middle and then the member
997/// furthest from that one, which is deterministic, needs no generator, and
998/// starts on the axis the cloud is actually longest along. Eight rounds is
999/// past where this stops moving on anything shaped like an embedding.
1000fn two_means(xs: &[f32], dim: usize) -> (Vec<f32>, Vec<f32>) {
1001 let n = xs.len() / dim;
1002 let mut middle = vec![0.0f32; dim];
1003 for i in 0..n {
1004 for (m, c) in middle.iter_mut().zip(&xs[i * dim..(i + 1) * dim]) {
1005 *m += c;
1006 }
1007 }
1008 for m in &mut middle {
1009 *m /= n as f32;
1010 }
1011 let far = |from: &[f32]| {
1012 (0..n)
1013 .max_by(|&i, &j| {
1014 sqdist(from, &xs[i * dim..(i + 1) * dim])
1015 .total_cmp(&sqdist(from, &xs[j * dim..(j + 1) * dim]))
1016 })
1017 .unwrap_or(0)
1018 };
1019 let i = far(&middle);
1020 let mut a = xs[i * dim..(i + 1) * dim].to_vec();
1021 let j = far(&a);
1022 let mut b = xs[j * dim..(j + 1) * dim].to_vec();
1023
1024 for _ in 0..8 {
1025 let mut sums = (vec![0.0f32; dim], vec![0.0f32; dim]);
1026 let mut counts = (0usize, 0usize);
1027 for i in 0..n {
1028 let x = &xs[i * dim..(i + 1) * dim];
1029 if sqdist(x, &a) <= sqdist(x, &b) {
1030 for (s, c) in sums.0.iter_mut().zip(x) {
1031 *s += c;
1032 }
1033 counts.0 += 1;
1034 } else {
1035 for (s, c) in sums.1.iter_mut().zip(x) {
1036 *s += c;
1037 }
1038 counts.1 += 1;
1039 }
1040 }
1041 // A side that ended up with nothing keeps the seed it had, because a
1042 // mean of no points is not a place and the next round would put every
1043 // member on the other side for ever.
1044 if counts.0 > 0 {
1045 for (m, s) in a.iter_mut().zip(&sums.0) {
1046 *m = s / counts.0 as f32;
1047 }
1048 }
1049 if counts.1 > 0 {
1050 for (m, s) in b.iter_mut().zip(&sums.1) {
1051 *m = s / counts.1 as f32;
1052 }
1053 }
1054 }
1055 (a, b)
1056}
1057
1058/// The squared distance, because the square root is monotone and nothing here
1059/// needs it.
1060///
1061/// Written with eight running totals rather than one, and that is not a
1062/// flourish. Adding floats is not associative, so a compiler is not allowed to
1063/// turn a single accumulator into a vector of them, and the obvious one line
1064/// version is a chain of dependent adds four cycles apart. It is the hottest
1065/// loop in the file, because every insert measures a query against every
1066/// centroid and every search does it twice over, and writing it this way was
1067/// worth two thirds of what an insert cost at 768 dimensions.
1068///
1069/// The eight totals are summed in a fixed order at the end, so the answer is
1070/// deterministic. It is a different answer from the one line version, by the
1071/// last bit or so, in the same way that any two orderings of a float sum are.
1072fn sqdist(a: &[f32], b: &[f32]) -> f32 {
1073 let mut totals = [0.0f32; 8];
1074 let mut i = 0;
1075 while i + 8 <= a.len() {
1076 for (k, total) in totals.iter_mut().enumerate() {
1077 let d = a[i + k] - b[i + k];
1078 *total += d * d;
1079 }
1080 i += 8;
1081 }
1082 let mut sum = 0.0f32;
1083 for total in totals {
1084 sum += total;
1085 }
1086 while i < a.len() {
1087 let d = a[i] - b[i];
1088 sum += d * d;
1089 i += 1;
1090 }
1091 sum
1092}
1093
1094/// One candidate, ordered by its estimated distance.
1095///
1096/// The tie break on the id is not decoration. Two members of the same partition
1097/// can get the same estimate out of codes that are 16 bytes wide, and without a
1098/// tie break which of them survives depends on the order the heap happened to
1099/// be in, which makes a search answer depend on the insertion history of the
1100/// collection rather than on the collection.
1101#[derive(PartialEq)]
1102struct Ranked {
1103 at: f32,
1104 id: u64,
1105}
1106
1107impl Eq for Ranked {}
1108
1109impl Ord for Ranked {
1110 fn cmp(&self, other: &Ranked) -> std::cmp::Ordering {
1111 self.at.total_cmp(&other.at).then(self.id.cmp(&other.id))
1112 }
1113}
1114
1115impl PartialOrd for Ranked {
1116 fn partial_cmp(&self, other: &Ranked) -> Option<std::cmp::Ordering> {
1117 Some(self.cmp(other))
1118 }
1119}
1120
1121/// The best `want` candidates seen so far, and nothing else.
1122///
1123/// The scan used to push every member of every partition it read into one
1124/// vector and then select from it, which at probe 64 is 24 thousand entries
1125/// pushed and 24 thousand selected over to keep 160. That is 384 kilobytes of
1126/// writes per search and it was a tenth of the search's time.
1127///
1128/// A bounded heap makes the common case one comparison. Once `want` candidates
1129/// are in, a member is only touched further if it beats the worst of them,
1130/// which after the first partition or two is a small fraction of them, and a
1131/// member that loses never has its id or its tag read at all.
1132struct Bounded {
1133 want: usize,
1134 heap: std::collections::BinaryHeap<Ranked>,
1135}
1136
1137impl Bounded {
1138 fn new(want: usize) -> Bounded {
1139 Bounded {
1140 want,
1141 heap: std::collections::BinaryHeap::with_capacity(want + 1),
1142 }
1143 }
1144
1145 /// Whether there are already `want` answers, which is what says a search
1146 /// that was widening for a filter can stop widening.
1147 fn full(&self) -> bool {
1148 self.heap.len() >= self.want
1149 }
1150
1151 /// Whether `at` could still be one of the answers.
1152 #[inline]
1153 fn wants(&self, at: f32) -> bool {
1154 match self.heap.peek() {
1155 Some(worst) if self.heap.len() >= self.want => at < worst.at,
1156 _ => true,
1157 }
1158 }
1159
1160 fn put(&mut self, id: u64, at: f32) {
1161 if self.heap.len() >= self.want {
1162 self.heap.pop();
1163 }
1164 self.heap.push(Ranked { at, id });
1165 }
1166
1167 /// The answers, nearest first.
1168 fn sorted(self) -> Vec<(u64, f32)> {
1169 self.heap
1170 .into_sorted_vec()
1171 .into_iter()
1172 .map(|r| (r.id, r.at))
1173 .collect()
1174 }
1175}
1176
1177#[cfg(test)]
1178mod tests {
1179 use super::*;
1180 use yo_common::Rng;
1181
1182 /// The record log, for a test: every vector by id, where the id is where it
1183 /// sits.
1184 struct Store(Vec<Vec<f32>>);
1185
1186 impl Vectors for Store {
1187 fn get(&self, id: u64, into: &mut [f32]) -> bool {
1188 match self.0.get(id as usize) {
1189 Some(v) => {
1190 into.copy_from_slice(v);
1191 true
1192 }
1193 None => false,
1194 }
1195 }
1196 }
1197
1198 /// A store with a hole in it, for the case where the log forgot something
1199 /// the index still thinks it has.
1200 struct Holey(Vec<Vec<f32>>, u64);
1201
1202 impl Vectors for Holey {
1203 fn get(&self, id: u64, into: &mut [f32]) -> bool {
1204 if id == self.1 {
1205 return false;
1206 }
1207 match self.0.get(id as usize) {
1208 Some(v) => {
1209 into.copy_from_slice(v);
1210 true
1211 }
1212 None => false,
1213 }
1214 }
1215 }
1216
1217 /// Vectors with the two things real embeddings have and uniform noise does
1218 /// not: a few coordinates carrying most of the energy, so that two vectors
1219 /// are genuinely near each other rather than all being equally far apart,
1220 /// and clusters, so that the partitioning has something to get right.
1221 ///
1222 /// A corpus without the first of those is not a hard test, it is an
1223 /// impossible one. The nearest ten of three hundred uniform points are
1224 /// arbitrary, no quantiser can pick them out, and the recall it measures
1225 /// says nothing about the index.
1226 fn corpus(dim: usize, n: usize, clusters: usize, seed: u64) -> Store {
1227 let mut rng = Rng::new(seed);
1228 let centres: Vec<Vec<f32>> = (0..clusters).map(|_| draw(dim, &mut rng)).collect();
1229 Store(
1230 (0..n)
1231 .map(|i| {
1232 let off = draw(dim, &mut rng);
1233 let mut v: Vec<f32> = centres[i % clusters]
1234 .iter()
1235 .zip(&off)
1236 .map(|(c, o)| c + o * 0.7)
1237 .collect();
1238 unit(&mut v);
1239 v
1240 })
1241 .collect(),
1242 )
1243 }
1244
1245 /// One vector of the shape above, unit length.
1246 fn draw(dim: usize, rng: &mut Rng) -> Vec<f32> {
1247 let mut v: Vec<f32> = (0..dim)
1248 .map(|i| {
1249 let u = (rng.next_u64() >> 40) as f32 / (1u32 << 24) as f32;
1250 let heavy = if i < dim / 16 { 6.0 } else { 1.0 };
1251 (u * 2.0 - 1.0) * heavy
1252 })
1253 .collect();
1254 unit(&mut v);
1255 v
1256 }
1257
1258 fn unit(v: &mut [f32]) {
1259 let len = v.iter().map(|c| c * c).sum::<f32>().sqrt();
1260 for c in v {
1261 *c /= len;
1262 }
1263 }
1264
1265 fn truth(store: &Store, q: &[f32], k: usize) -> Vec<u64> {
1266 let mut all: Vec<(u64, f32)> = store
1267 .0
1268 .iter()
1269 .enumerate()
1270 .map(|(i, v)| (i as u64, sqdist(q, v)))
1271 .collect();
1272 all.sort_by(|a, b| a.1.total_cmp(&b.1));
1273 all.truncate(k);
1274 all.into_iter().map(|(i, _)| i).collect()
1275 }
1276
1277 /// Build an index over a whole store, running maintenance as it goes the way
1278 /// a maintenance slice would.
1279 fn build(store: &Store, dim: usize, tuning: Tuning) -> Partitions {
1280 let mut ix = Partitions::new(dim, Bits::One, 7, tuning);
1281 for (i, v) in store.0.iter().enumerate() {
1282 ix.insert(i as u64, v);
1283 if i % 64 == 0 {
1284 ix.maintain(store, 4096);
1285 }
1286 }
1287 ix.maintain(store, 1 << 20);
1288 ix
1289 }
1290
1291 /// How often the true `k` nearest come back.
1292 fn recall(ix: &Partitions, store: &Store, k: usize, queries: usize) -> f32 {
1293 let mut hits = 0usize;
1294 for i in 0..queries {
1295 // A query near a real vector rather than anywhere at all, because
1296 // that is what a search looks like.
1297 let q = &store.0[i * 7 % store.0.len()];
1298 let want = truth(store, q, k);
1299 let got: Vec<u64> = ix.search(q, k, store).into_iter().map(|h| h.id).collect();
1300 hits += want.iter().filter(|id| got.contains(id)).count();
1301 }
1302 hits as f32 / (queries * k) as f32
1303 }
1304
1305 /// Everything the index believes about itself, checked.
1306 fn consistent(ix: &Partitions) {
1307 assert_eq!(ix.centroids.len(), ix.postings.len() * ix.dim());
1308 let width = ix.quant.code_bytes();
1309 let mut seen = 0usize;
1310 for (p, posting) in ix.postings.iter().enumerate() {
1311 assert_eq!(posting.codes.len(), posting.len() * width, "partition {p}");
1312 assert_eq!(posting.meta.len(), posting.len(), "partition {p}");
1313 assert_eq!(posting.tags.len(), posting.len(), "partition {p}");
1314 for (s, id) in posting.ids.iter().enumerate() {
1315 let at = ix.at.get(id).expect("every member is in the map");
1316 assert_eq!(at.partition as usize, p, "id {id}");
1317 assert_eq!(at.slot as usize, s, "id {id}");
1318 seen += 1;
1319 }
1320 }
1321 assert_eq!(seen, ix.at.len(), "the map has entries with no member");
1322 }
1323
1324 /// Build an index where every vector carries a tag, so the filter has
1325 /// something to meet.
1326 fn build_tagged(
1327 store: &Store,
1328 dim: usize,
1329 tuning: Tuning,
1330 tag: impl Fn(u64) -> u64,
1331 ) -> Partitions {
1332 let mut ix = Partitions::new(dim, Bits::One, 7, tuning);
1333 for (i, v) in store.0.iter().enumerate() {
1334 ix.insert_tagged(i as u64, v, tag(i as u64));
1335 if i % 64 == 0 {
1336 ix.maintain(store, 4096);
1337 }
1338 }
1339 ix.maintain(store, 1 << 20);
1340 ix
1341 }
1342
1343 /// The whole point of pushing a filter into the scan, measured against the
1344 /// thing it replaces.
1345 ///
1346 /// One document in fifty carries the tag. Filtering inside the scan finds
1347 /// the true ten of those. Taking the best forty by vector and then throwing
1348 /// away the ones that do not match, which is what a search that cannot push
1349 /// a filter down has to do, finds almost none of them, and the ones it
1350 /// misses were nearer than the ones it kept.
1351 #[test]
1352 fn a_filter_in_the_scan_finds_what_a_filter_after_it_cannot() {
1353 let dim = 96;
1354 let store = corpus(dim, 3000, 12, 47);
1355 let tuning = Tuning {
1356 posting: 64,
1357 ..Tuning::default()
1358 };
1359 let wanted = |id: u64| id.is_multiple_of(50);
1360 let ix = build_tagged(&store, dim, tuning, |id| u64::from(wanted(id)));
1361
1362 let (mut pushed, mut after) = (0usize, 0usize);
1363 let k = 10;
1364 for i in 0..40 {
1365 let q = &store.0[i * 71 % store.0.len()];
1366
1367 // What the answer is: brute force over the members that match.
1368 let mut all: Vec<(u64, f32)> = store
1369 .0
1370 .iter()
1371 .enumerate()
1372 .filter(|(id, _)| wanted(*id as u64))
1373 .map(|(id, v)| (id as u64, sqdist(q, v)))
1374 .collect();
1375 all.sort_by(|a, b| a.1.total_cmp(&b.1));
1376 let want: Vec<u64> = all[..k].iter().map(|(id, _)| *id).collect();
1377
1378 let got: Vec<u64> = ix
1379 .search_where(q, k, &|tag: u64| tag == 1, &store)
1380 .into_iter()
1381 .map(|h| h.id)
1382 .collect();
1383 pushed += want.iter().filter(|id| got.contains(id)).count();
1384
1385 let late: Vec<u64> = ix
1386 .search(q, k * tuning.rerank, &store)
1387 .into_iter()
1388 .map(|h| h.id)
1389 .filter(|id| wanted(*id))
1390 .take(k)
1391 .collect();
1392 after += want.iter().filter(|id| late.contains(id)).count();
1393 }
1394 let (pushed, after) = (pushed as f32 / 400.0, after as f32 / 400.0);
1395 assert!(pushed >= 0.95, "pushing the filter down gave {pushed}");
1396 assert!(
1397 after < pushed / 2.0,
1398 "filtering afterwards gave {after} against {pushed}, which is not the point being made"
1399 );
1400 }
1401
1402 #[test]
1403 fn a_filter_that_matches_nothing_answers_nothing() {
1404 let dim = 64;
1405 let store = corpus(dim, 500, 4, 53);
1406 let ix = build_tagged(&store, dim, Tuning::default(), |_| 1);
1407 assert!(
1408 ix.search_where(&store.0[0], 10, &|tag: u64| tag == 2, &store)
1409 .is_empty()
1410 );
1411 // And the same filter matching everything is the unfiltered answer.
1412 let all = ix.search_where(&store.0[0], 10, &Any, &store);
1413 assert_eq!(all, ix.search(&store.0[0], 10, &store));
1414 }
1415
1416 #[test]
1417 fn a_tag_survives_a_split_and_a_merge() {
1418 let dim = 64;
1419 let store = corpus(dim, 800, 6, 59);
1420 let tuning = Tuning {
1421 posting: 24,
1422 ..Tuning::default()
1423 };
1424 let mut ix = build_tagged(&store, dim, tuning, |id| id * 7 + 1);
1425 assert!(ix.partitions() > 4, "it never split");
1426 for id in 0..800u64 {
1427 assert_eq!(ix.tag(id), Some(id * 7 + 1), "id {id} after the splits");
1428 }
1429
1430 // Now shrink it until partitions merge, and the survivors keep theirs.
1431 for id in 0..760u64 {
1432 ix.remove(id);
1433 }
1434 ix.maintain(&store, 1 << 20);
1435 consistent(&ix);
1436 for id in 760..800u64 {
1437 assert_eq!(ix.tag(id), Some(id * 7 + 1), "id {id} after the merges");
1438 }
1439 assert_eq!(ix.tag(0), None);
1440 }
1441
1442 /// A selective filter means the answers are not in the nearest partitions,
1443 /// and a search that will not look further returns fewer than it should.
1444 #[test]
1445 fn a_selective_filter_makes_the_search_look_further() {
1446 let dim = 64;
1447 let store = corpus(dim, 2000, 10, 61);
1448 let tuning = Tuning {
1449 posting: 32,
1450 ..Tuning::default()
1451 };
1452 let tag = |id: u64| u64::from(id.is_multiple_of(100));
1453 let ix = build_tagged(&store, dim, tuning, tag);
1454 let narrow = build_tagged(&store, dim, Tuning { widen: 1, ..tuning }, tag);
1455
1456 let mut wide_found = 0usize;
1457 let mut narrow_found = 0usize;
1458 for i in 0..20 {
1459 let q = &store.0[i * 91 % store.0.len()];
1460 wide_found += ix.search_where(q, 10, &|t: u64| t == 1, &store).len();
1461 narrow_found += narrow.search_where(q, 10, &|t: u64| t == 1, &store).len();
1462 }
1463 assert_eq!(
1464 wide_found, 200,
1465 "one in a hundred of two thousand is twenty"
1466 );
1467 assert!(
1468 narrow_found < wide_found,
1469 "not widening found {narrow_found} of {wide_found}"
1470 );
1471 }
1472
1473 #[test]
1474 fn a_signature_never_rejects_something_it_should_have_matched() {
1475 let english = Signature::of(&[("lang", b"en")]);
1476 let doc = Signature::of(&[("lang", b"en"), ("topic", b"finance"), ("year", b"2026")]);
1477 assert!(doc.covers(english));
1478 assert!(english.allows(doc.bits()));
1479 assert_eq!(Signature::from_bits(doc.bits()), doc);
1480
1481 // And over a lot of values, nothing that matches is ever turned away.
1482 for i in 0..500u32 {
1483 let value = i.to_string();
1484 let one = Signature::of(&[("id", value.as_bytes())]);
1485 let with = Signature::of(&[("id", value.as_bytes()), ("kind", b"page")]);
1486 assert!(with.covers(one), "value {value}");
1487 }
1488 }
1489
1490 #[test]
1491 fn an_empty_index_answers_nothing() {
1492 let ix = Partitions::new(32, Bits::One, 1, Tuning::default());
1493 let store = Store(Vec::new());
1494 assert!(ix.is_empty());
1495 assert_eq!(ix.partitions(), 0);
1496 assert!(ix.search(&[0.0; 32], 10, &store).is_empty());
1497 assert!(!ix.needs_maintenance());
1498 }
1499
1500 #[test]
1501 fn the_first_vector_is_the_first_partition() {
1502 let store = corpus(32, 1, 1, 3);
1503 let mut ix = Partitions::new(32, Bits::One, 1, Tuning::default());
1504 ix.insert(0, &store.0[0]);
1505 assert_eq!(ix.partitions(), 1);
1506 assert_eq!(ix.len(), 1);
1507 let hits = ix.search(&store.0[0], 5, &store);
1508 assert_eq!(hits.len(), 1);
1509 assert_eq!(hits[0].id, 0);
1510 assert!(hits[0].distance < 1e-6, "{}", hits[0].distance);
1511 consistent(&ix);
1512 }
1513
1514 #[test]
1515 fn a_search_finds_what_brute_force_finds() {
1516 let dim = 128;
1517 let store = corpus(dim, 2000, 12, 5);
1518 let ix = build(&store, dim, Tuning::default());
1519 assert!(ix.partitions() > 1, "it never split");
1520 consistent(&ix);
1521 let r = recall(&ix, &store, 10, 50);
1522 assert!(r >= 0.95, "recall at 10 was {r}");
1523 }
1524
1525 #[test]
1526 fn a_posting_that_grows_too_big_splits() {
1527 let dim = 64;
1528 let tuning = Tuning {
1529 posting: 32,
1530 ..Tuning::default()
1531 };
1532 let store = corpus(dim, 600, 6, 9);
1533 let ix = build(&store, dim, tuning);
1534 assert!(
1535 ix.partitions() >= 600 / (32 * 2),
1536 "600 vectors in {} partitions",
1537 ix.partitions()
1538 );
1539 for posting in &ix.postings {
1540 assert!(
1541 posting.len() <= 32 * 2,
1542 "a posting is {} long",
1543 posting.len()
1544 );
1545 }
1546 consistent(&ix);
1547 }
1548
1549 #[test]
1550 fn a_posting_that_shrinks_merges() {
1551 let dim = 64;
1552 let tuning = Tuning {
1553 posting: 32,
1554 ..Tuning::default()
1555 };
1556 let store = corpus(dim, 600, 6, 9);
1557 let mut ix = build(&store, dim, tuning);
1558 let grown = ix.partitions();
1559 assert!(grown > 4);
1560
1561 // Take away almost everything and let maintenance settle.
1562 for id in 0..570u64 {
1563 assert!(ix.remove(id));
1564 }
1565 ix.maintain(&store, 1 << 20);
1566 consistent(&ix);
1567 assert_eq!(ix.len(), 30);
1568 assert!(
1569 ix.partitions() < grown,
1570 "{} partitions for 30 vectors, was {grown}",
1571 ix.partitions()
1572 );
1573 // And it still answers.
1574 let hits = ix.search(&store.0[599], 1, &store);
1575 assert_eq!(hits[0].id, 599);
1576 }
1577
1578 #[test]
1579 fn a_removed_vector_stops_coming_back() {
1580 let dim = 64;
1581 let store = corpus(dim, 400, 4, 11);
1582 let mut ix = build(&store, dim, Tuning::default());
1583 let q = store.0[7].clone();
1584 assert_eq!(ix.search(&q, 1, &store)[0].id, 7);
1585
1586 assert!(ix.remove(7));
1587 assert!(!ix.remove(7), "removing it twice should say so");
1588 assert!(!ix.contains(7));
1589 assert_eq!(ix.len(), 399);
1590 consistent(&ix);
1591 assert!(ix.search(&q, 5, &store).iter().all(|h| h.id != 7));
1592 }
1593
1594 #[test]
1595 fn inserting_the_same_id_twice_replaces_it() {
1596 let dim = 64;
1597 let store = corpus(dim, 200, 2, 13);
1598 let mut ix = build(&store, dim, Tuning::default());
1599 let before = ix.len();
1600 ix.insert(3, &store.0[3]);
1601 assert_eq!(ix.len(), before);
1602 consistent(&ix);
1603 assert_eq!(ix.search(&store.0[3], 1, &store)[0].id, 3);
1604 }
1605
1606 /// A collection of copies of one vector has no cut in it, and maintenance
1607 /// has to notice that rather than try the same split for ever.
1608 ///
1609 /// This is not a corner case anybody has to go looking for. It is what a
1610 /// collection looks like when a pipeline embeds the same document a thousand
1611 /// times, and getting it wrong is a hang rather than a wrong answer.
1612 #[test]
1613 fn a_thousand_copies_of_one_vector_do_not_spin() {
1614 let dim = 32;
1615 let one = corpus(dim, 1, 1, 41).0.pop().expect("one vector");
1616 let store = Store(vec![one; 1000]);
1617 let tuning = Tuning {
1618 posting: 16,
1619 ..Tuning::default()
1620 };
1621 let mut ix = Partitions::new(dim, Bits::One, 7, tuning);
1622 for (i, v) in store.0.iter().enumerate() {
1623 ix.insert(i as u64, v);
1624 ix.maintain(&store, 4096);
1625 }
1626 ix.maintain(&store, 1 << 20);
1627 consistent(&ix);
1628 assert_eq!(ix.len(), 1000);
1629 assert!(!ix.needs_maintenance(), "it still thinks there is work");
1630 // And it still answers, with the exact distance rather than an estimate.
1631 let hits = ix.search(&store.0[0], 5, &store);
1632 assert_eq!(hits.len(), 5);
1633 assert!(hits.iter().all(|h| h.distance < 1e-6));
1634 }
1635
1636 /// G13's actual claim. Recall is measured at the end of a long stream of
1637 /// writes and deletes rather than on a fresh build, because a fresh build is
1638 /// the measurement that hides drift.
1639 #[test]
1640 fn recall_holds_over_a_write_stream_with_no_rebuild() {
1641 let dim = 96;
1642 let store = corpus(dim, 3000, 15, 17);
1643 let tuning = Tuning {
1644 posting: 64,
1645 ..Tuning::default()
1646 };
1647 let mut ix = Partitions::new(dim, Bits::One, 7, tuning);
1648
1649 // Write everything, and churn a tenth of it as we go, which is what
1650 // moves the centroids around under the members that are already filed.
1651 let mut rng = Rng::new(23);
1652 for (i, v) in store.0.iter().enumerate() {
1653 ix.insert(i as u64, v);
1654 if i > 100 && i % 10 == 0 {
1655 let victim = rng.below(i) as u64;
1656 ix.remove(victim);
1657 ix.insert(victim, &store.0[victim as usize]);
1658 }
1659 ix.maintain(&store, 512);
1660 }
1661 ix.maintain(&store, 1 << 20);
1662 consistent(&ix);
1663 assert_eq!(ix.len(), store.0.len());
1664
1665 let r = recall(&ix, &store, 10, 60);
1666 assert!(r >= 0.95, "recall at 10 after the stream was {r}");
1667 }
1668
1669 /// What the sweep is for, measured as the thing it actually fixes rather
1670 /// than through recall.
1671 ///
1672 /// Drift is members filed under a partition that is no longer their nearest,
1673 /// which is what a split leaves behind in the partitions around it. It shows
1674 /// up in recall eventually, but recall is a blunt instrument here and moves
1675 /// by a percent for reasons that have nothing to do with this, so the
1676 /// straight count is the honest measurement.
1677 #[test]
1678 fn the_sweep_is_what_keeps_members_under_their_nearest_centroid() {
1679 let dim = 96;
1680 let store = corpus(dim, 2000, 10, 29);
1681 let tuning = Tuning {
1682 posting: 48,
1683 ..Tuning::default()
1684 };
1685 let with = misfiled(&build(&store, dim, tuning), &store);
1686 let without = misfiled(&build(&store, dim, Tuning { sweep: 0, ..tuning }), &store);
1687 assert!(
1688 with * 4 < without,
1689 "sweeping left {with} members drifted and not sweeping left {without}"
1690 );
1691 }
1692
1693 /// How many members are filed under something that is not their nearest
1694 /// centroid.
1695 fn misfiled(ix: &Partitions, store: &Store) -> usize {
1696 let mut buf = vec![0.0f32; ix.dim()];
1697 let mut wrong = 0;
1698 for (p, posting) in ix.postings.iter().enumerate() {
1699 for &id in &posting.ids {
1700 assert!(store.get(id, &mut buf));
1701 if ix.nearest(&ix.quant.rotate(&buf)) != p {
1702 wrong += 1;
1703 }
1704 }
1705 }
1706 wrong
1707 }
1708
1709 #[test]
1710 fn a_vector_the_log_forgot_is_dropped_rather_than_returned() {
1711 let dim = 64;
1712 let store = corpus(dim, 400, 4, 31);
1713 let tuning = Tuning {
1714 posting: 24,
1715 ..Tuning::default()
1716 };
1717 let mut ix = build(&store, dim, tuning);
1718 assert!(ix.contains(11));
1719
1720 // The log loses one without telling the index, which is the state a
1721 // crash between two appends leaves behind.
1722 let holey = Holey(store.0.clone(), 11);
1723 assert!(
1724 ix.search(&store.0[11], 5, &holey)
1725 .iter()
1726 .all(|h| h.id != 11)
1727 );
1728
1729 // And maintenance walking over it takes it out for good.
1730 for id in 0..300u64 {
1731 ix.remove(id);
1732 }
1733 ix.maintain(&holey, 1 << 20);
1734 consistent(&ix);
1735 assert!(!ix.contains(11));
1736 }
1737
1738 #[test]
1739 fn rotating_first_is_the_same_as_rotating_inside() {
1740 // The whole index rests on the rotation being linear, so this is the
1741 // property, not an implementation detail.
1742 let dim = 128;
1743 let q = Quantizer::new(dim, Bits::One, 5);
1744 let store = corpus(dim, 2, 1, 37);
1745 let (v, c) = (&store.0[0], &store.0[1]);
1746
1747 let mut a = vec![0u8; q.code_bytes()];
1748 let one = q.encode(v, c, &mut a);
1749 let mut b = vec![0u8; q.code_bytes()];
1750 let two = q.encode_rotated(&q.rotate(v), &q.rotate(c), &mut b);
1751
1752 assert_eq!(a, b, "the two ways round should write the same code");
1753 assert!((one.norm - two.norm).abs() < 1e-4);
1754 assert!((one.scale - two.scale).abs() < 1e-4);
1755 }
1756
1757 #[test]
1758 fn two_means_splits_two_clouds_apart() {
1759 let dim = 8;
1760 let mut xs = Vec::new();
1761 for i in 0..40 {
1762 let far = if i % 2 == 0 { 0.0 } else { 10.0 };
1763 for d in 0..dim {
1764 xs.push(far + (i as f32 + d as f32) * 0.01);
1765 }
1766 }
1767 let (a, b) = two_means(&xs, dim);
1768 let (near, away) = if a[0] < b[0] { (a, b) } else { (b, a) };
1769 assert!(near[0] < 1.0, "{near:?}");
1770 assert!(away[0] > 9.0, "{away:?}");
1771 }
1772}