Skip to main content

yo_vector/
muvera.rs

1//! MUVERA: a set of token vectors as one vector, so late interaction retrieval
2//! runs on the index that is already here (`10` section 6).
3//!
4//! Late interaction is where retrieval quality went. A ColBERT style model does
5//! not give a document one embedding, it gives every token one, and it scores a
6//! query against a document with Chamfer similarity: for each query token, the
7//! best match anywhere in the document, averaged over the query. That is a much
8//! better score than one vector against one vector, and it is much more
9//! expensive, because there is no single vector to put in an index and the
10//! score is a loop over two sets rather than a dot product.
11//!
12//! The usual answer is a second index over every token of every document, so a
13//! collection of a million documents at a hundred tokens each becomes a hundred
14//! million vector index, plus a gather and a scoring pass on top. That is a
15//! whole second system with its own memory, its own tuning and its own failure
16//! modes.
17//!
18//! MUVERA (NeurIPS 2024, arXiv:2405.19504) does away with it. It maps a set of
19//! token vectors to one fixed length vector, a Fixed Dimensional Encoding, such
20//! that the dot product of a query's encoding with a document's approximates the
21//! Chamfer similarity between the two sets, with a proven bound on the error.
22//! So multi vector retrieval costs an encode at write time, the index that is
23//! already here, and a different rerank function. No second index.
24//!
25//! # How it works
26//!
27//! The trick is to cut the vector space into buckets, with random hyperplanes,
28//! and then compare a query's tokens only against the document tokens that
29//! landed in the same bucket.
30//!
31//! A document's encoding holds, for each bucket, the average of the document
32//! tokens that fell in it. A query's encoding holds, for each bucket, the sum of
33//! the query tokens that fell in it. Take the dot product of the two and the
34//! bucket contributes, for each query token in it, that token against the
35//! average of the document tokens near it. If the hyperplanes did their job then
36//! the document token that maximises the dot product is in the same bucket, and
37//! the bucket average is close enough to it, so the sum over buckets is close to
38//! the sum of maxima that Chamfer wanted.
39//!
40//! Two details make the difference between that being an argument and it being
41//! true.
42//!
43//! An empty bucket in a document is filled with the document tokens whose own
44//! bucket is nearest in Hamming distance. Without it, a query token in a bucket
45//! the document did not reach contributes nothing, when the truth is that it
46//! still has a best match somewhere in the document, and short documents lose
47//! badly. With it, a one token document has that token in every bucket, which is
48//! exactly right, because that token is the best match for every query token.
49//!
50//! The whole construction is repeated with independent hyperplanes and the
51//! results are laid end to end. One repetition is a coin toss on whether a query
52//! token and its true best match landed together. Several repetitions average
53//! that away, and this is the knob that actually buys accuracy.
54//!
55//! # What the repetitions buy
56//!
57//! Three hundred documents of twenty four tokens each, forty queries, where a
58//! query is six of one document's own tokens with noise on them and the answer
59//! is the document it came from. How often the encoding alone, with no rerank,
60//! puts that document first:
61//!
62//! ```text
63//! repetitions      1      2      4      8     16     32
64//! ranked first  .825   .900   .950   .950  1.000  1.000
65//! ```
66//!
67//! That is the shape to expect. Nothing else moves it nearly as much: buckets
68//! and block width change how long the encoding is far more than how good it
69//! is, which is why the default puts eight repetitions on sixteen buckets
70//! rather than the other way round.
71//!
72//! # What is different here
73//!
74//! The paper ends with an optional random projection of the whole encoding down
75//! to a smaller dimension, to make it cheap to store and search. There is no
76//! point doing that here. [`crate::Quantizer`] already turns a vector into one
77//! bit a dimension with an error bound that a random projection does not have,
78//! so projecting first would throw away accuracy to save space that RaBitQ was
79//! going to save anyway, and better. The per bucket projection that shrinks each
80//! block from the token dimension down to [`Shape::dproj`] is still here,
81//! because that one is what keeps the encoding from being buckets times token
82//! dimension long.
83//!
84//! The encodings come out unit length. The index ranks by squared distance, and
85//! for vectors of equal length that ordering is exactly the dot product ordering
86//! the approximation is stated in, so normalising is what makes the two agree.
87//! What it costs is the length of a document's raw encoding, which mostly says
88//! how many tokens crowded into each bucket rather than anything about whether
89//! the document is a good answer, and [`chamfer`] on the candidates puts back
90//! any ordering that lost.
91//!
92//! ```
93//! use yo_vector::muvera::{Encoder, Shape, chamfer};
94//!
95//! let dim = 16;
96//! let enc = Encoder::new(dim, Shape::default(), 7);
97//!
98//! // Two tokens for the document, one for the query, laid out end to end.
99//! let doc: Vec<f32> = (0..2 * dim).map(|i| if i % dim == i / dim { 1.0 } else { 0.0 }).collect();
100//! let query: Vec<f32> = (0..dim).map(|i| f32::from(u8::from(i == 1))).collect();
101//!
102//! // The query token is the second document token, so Chamfer is 1.
103//! assert!((chamfer(&query, &doc, dim) - 1.0).abs() < 1e-6);
104//!
105//! // And both sides encode to one vector of the same fixed length.
106//! assert_eq!(enc.document(&doc).len(), enc.fde_dim());
107//! assert_eq!(enc.query(&query).len(), enc.fde_dim());
108//! ```
109
110use yo_common::Rng;
111
112/// How big an encoding is and how much accuracy it buys.
113///
114/// The three numbers trade the same way in every experiment in the paper: more
115/// buckets and more repetitions track Chamfer more closely and cost a longer
116/// encoding, and the encoding's length is the product of all three.
117#[derive(Debug, Clone, Copy)]
118pub struct Shape {
119    /// How many random hyperplanes cut the space, so there are `2^ksim`
120    /// buckets.
121    ///
122    /// This is the one to think about against the number of tokens a document
123    /// has. Buckets well past the token count means most of them are empty and
124    /// filled from a neighbour, which is not wrong but is not buying anything
125    /// either.
126    pub ksim: usize,
127    /// How many numbers each bucket's block is squeezed down to.
128    ///
129    /// Without this a block would be the token dimension long and the encoding
130    /// would be buckets times that, which at 128 dimensional tokens and sixteen
131    /// buckets is two thousand numbers for one repetition.
132    pub dproj: usize,
133    /// How many times the whole thing is done again with fresh hyperplanes.
134    ///
135    /// Whether a query token and its true best match land in the same bucket is
136    /// a coin toss that this averages out, and it is the knob that buys
137    /// accuracy rather than just length.
138    pub reps: usize,
139}
140
141impl Default for Shape {
142    /// Sixteen buckets, sixteen numbers a block, eight repetitions, which is
143    /// two thousand numbers whatever the token dimension is.
144    ///
145    /// That is the middle of the range the paper measures, and it is a
146    /// reasonable place to start for the hundred or so tokens a passage has. At
147    /// one bit a dimension it is a 256 byte code, against the 100 token by 128
148    /// dimension set it stands in for, which would be 12 kilobytes of floats.
149    fn default() -> Shape {
150        Shape {
151            ksim: 4,
152            dproj: 16,
153            reps: 8,
154        }
155    }
156}
157
158/// Turns a set of token vectors into one vector.
159///
160/// Built from a token dimension, a [`Shape`] and a seed, and nothing else, so
161/// two processes that were told the same three things build the same encoder
162/// and the hyperplanes never have to be written down. Same rule as
163/// [`crate::Rotation`], for the same reason.
164pub struct Encoder {
165    dim: usize,
166    shape: Shape,
167    /// `reps * ksim` hyperplane normals, `dim` long each, in that order.
168    planes: Vec<f32>,
169    /// `reps` projection matrices of `dproj` rows by `dim`, already scaled.
170    proj: Vec<f32>,
171}
172
173impl Encoder {
174    /// An encoder for `dim` dimensional tokens.
175    ///
176    /// # Panics
177    ///
178    /// If `dim` is zero, if `ksim` is not between 1 and 16, or if `dproj` or
179    /// `reps` is zero.
180    #[must_use]
181    pub fn new(dim: usize, shape: Shape, seed: u64) -> Encoder {
182        assert!(dim > 0, "a token has to have a dimension");
183        assert!(
184            (1..=16).contains(&shape.ksim),
185            "ksim is {}, and a bucket index is built by shifting, so it has to \
186             stay somewhere a machine can count to",
187            shape.ksim
188        );
189        assert!(shape.dproj > 0, "a block has to have a width");
190        assert!(shape.reps > 0, "there has to be at least one repetition");
191        let mut rng = Rng::new(seed);
192        let planes = (0..shape.reps * shape.ksim * dim)
193            .map(|_| gauss(&mut rng))
194            .collect();
195        // Signs over the square root of the width, which is the sketch that
196        // preserves a dot product in expectation. It is baked into the matrix
197        // so the encode is a plain multiply.
198        let scale = 1.0 / (shape.dproj as f32).sqrt();
199        let proj = (0..shape.reps * shape.dproj * dim)
200            .map(|_| {
201                if rng.next_u64() & 1 == 0 {
202                    scale
203                } else {
204                    -scale
205                }
206            })
207            .collect();
208        Encoder {
209            dim,
210            shape,
211            planes,
212            proj,
213        }
214    }
215
216    /// The token dimension this was built for.
217    #[must_use]
218    pub fn dim(&self) -> usize {
219        self.dim
220    }
221
222    /// The shape this was built with.
223    #[must_use]
224    pub fn shape(&self) -> Shape {
225        self.shape
226    }
227
228    /// How long an encoding is, which is what to build the index at.
229    #[must_use]
230    pub fn fde_dim(&self) -> usize {
231        self.shape.reps * self.buckets() * self.shape.dproj
232    }
233
234    /// Encode a document's tokens, laid out end to end.
235    ///
236    /// A bucket holds the average of the tokens that fell in it, and a bucket
237    /// no token reached is filled from the tokens whose own bucket is nearest,
238    /// so that every bucket has something to say. That filling is what lets a
239    /// short document compete: a query token in a bucket the document never
240    /// reached still has a best match in the document, and without the fill it
241    /// would contribute nothing at all.
242    ///
243    /// # Panics
244    ///
245    /// If `tokens` is empty or is not a whole number of [`Encoder::dim`]
246    /// vectors.
247    #[must_use]
248    pub fn document(&self, tokens: &[f32]) -> Vec<f32> {
249        self.encode(tokens, true)
250    }
251
252    /// Encode a query's tokens, laid out end to end.
253    ///
254    /// A bucket holds the sum of the tokens that fell in it, not the average,
255    /// because every query token is supposed to contribute its own best match
256    /// to the score rather than share one. A bucket no query token reached is
257    /// left at zero, because there is nothing there to ask for.
258    ///
259    /// # Panics
260    ///
261    /// If `tokens` is empty or is not a whole number of [`Encoder::dim`]
262    /// vectors.
263    #[must_use]
264    pub fn query(&self, tokens: &[f32]) -> Vec<f32> {
265        self.encode(tokens, false)
266    }
267
268    fn buckets(&self) -> usize {
269        1usize << self.shape.ksim
270    }
271
272    fn encode(&self, tokens: &[f32], document: bool) -> Vec<f32> {
273        let dim = self.dim;
274        assert!(!tokens.is_empty(), "there is nothing to encode");
275        assert_eq!(
276            tokens.len() % dim,
277            0,
278            "the tokens are {} numbers, which is not a whole number of {dim} \
279             dimensional vectors",
280            tokens.len()
281        );
282        let n = tokens.len() / dim;
283        let buckets = self.buckets();
284        let mut out = vec![0.0f32; self.fde_dim()];
285        let mut codes = vec![0u32; n];
286        let mut totals = vec![0.0f32; buckets * dim];
287        let mut counts = vec![0u32; buckets];
288        let mut fill = vec![0.0f32; dim];
289        for r in 0..self.shape.reps {
290            totals.fill(0.0);
291            counts.fill(0);
292            for (t, code) in codes.iter_mut().enumerate() {
293                let x = &tokens[t * dim..(t + 1) * dim];
294                let k = self.bucket(r, x);
295                *code = k as u32;
296                counts[k] += 1;
297                for (into, c) in totals[k * dim..(k + 1) * dim].iter_mut().zip(x) {
298                    *into += c;
299                }
300            }
301            for k in 0..buckets {
302                let at = (r * buckets + k) * self.shape.dproj;
303                if counts[k] > 0 {
304                    // The document wants the average of what landed here, the
305                    // query wants the sum, and both are linear so it makes no
306                    // difference whether the scaling happens before the
307                    // projection or after.
308                    let scale = if document {
309                        1.0 / counts[k] as f32
310                    } else {
311                        1.0
312                    };
313                    self.project(r, &totals[k * dim..(k + 1) * dim], scale, at, &mut out);
314                } else if document {
315                    let hits = nearest_by_hamming(tokens, dim, &codes, k as u32, &mut fill);
316                    self.project(r, &fill, 1.0 / hits as f32, at, &mut out);
317                }
318            }
319        }
320        // Both sides come out unit length, and that is what makes the index's
321        // squared distance order the same as the dot product order the
322        // approximation is written in. The constant the paper carries, one over
323        // the number of query tokens and one over the number of repetitions,
324        // goes with it: it is one scale over the whole vector, so it moves the
325        // estimate's value and not the order of anything, and the order is all
326        // the index reads.
327        unit(&mut out);
328        out
329    }
330
331    /// Which bucket a token falls in: one bit per hyperplane, which side of it.
332    fn bucket(&self, rep: usize, x: &[f32]) -> usize {
333        let planes = &self.planes[rep * self.shape.ksim * self.dim..];
334        let mut code = 0usize;
335        for b in 0..self.shape.ksim {
336            let plane = &planes[b * self.dim..(b + 1) * self.dim];
337            code |= usize::from(dot(plane, x) > 0.0) << b;
338        }
339        code
340    }
341
342    /// Squeeze one bucket's `dim` numbers down to `dproj` of them, into `out`
343    /// at `at`.
344    fn project(&self, rep: usize, x: &[f32], scale: f32, at: usize, out: &mut [f32]) {
345        let m = &self.proj[rep * self.shape.dproj * self.dim..];
346        for j in 0..self.shape.dproj {
347            out[at + j] = dot(&m[j * self.dim..(j + 1) * self.dim], x) * scale;
348        }
349    }
350}
351
352/// The sum of the tokens whose bucket is nearest `want` in Hamming distance,
353/// into `fill`, and how many there were.
354///
355/// Ties are summed rather than broken, because there is no ordering of the
356/// tokens that means anything and a rule that picks one of them would be an
357/// arbitrary one dressed up as a decision.
358fn nearest_by_hamming(
359    tokens: &[f32],
360    dim: usize,
361    codes: &[u32],
362    want: u32,
363    fill: &mut [f32],
364) -> u32 {
365    let mut best = u32::MAX;
366    let mut hits = 0u32;
367    for (t, code) in codes.iter().enumerate() {
368        let apart = (code ^ want).count_ones();
369        if apart > best {
370            continue;
371        }
372        if apart < best {
373            best = apart;
374            hits = 0;
375            fill.fill(0.0);
376        }
377        hits += 1;
378        for (into, c) in fill.iter_mut().zip(&tokens[t * dim..(t + 1) * dim]) {
379            *into += c;
380        }
381    }
382    hits
383}
384
385/// The Chamfer similarity of a query's tokens against a document's: for each
386/// query token, the best it does against any document token, averaged over the
387/// query.
388///
389/// This is the score the encoding approximates, and it is what to rerank the
390/// candidates with once the index has narrowed the collection down, the same
391/// way [`crate::Partitions::search`] reranks estimates against the full
392/// precision vectors. It is quadratic in the token counts, which is why it runs
393/// on the handful the index handed back and not on the collection.
394///
395/// # Panics
396///
397/// If `dim` is zero, or if either side is not a whole number of `dim`
398/// dimensional vectors.
399#[must_use]
400pub fn chamfer(query: &[f32], doc: &[f32], dim: usize) -> f32 {
401    assert!(dim > 0, "a token has to have a dimension");
402    assert_eq!(query.len() % dim, 0, "the query is not whole tokens");
403    assert_eq!(doc.len() % dim, 0, "the document is not whole tokens");
404    if query.is_empty() || doc.is_empty() {
405        return 0.0;
406    }
407    let mut total = 0.0f32;
408    for q in query.chunks_exact(dim) {
409        let mut best = f32::NEG_INFINITY;
410        for p in doc.chunks_exact(dim) {
411            let d = dot(q, p);
412            if d > best {
413                best = d;
414            }
415        }
416        total += best;
417    }
418    total / (query.len() / dim) as f32
419}
420
421/// A dot product, with eight running totals rather than one.
422///
423/// The same reason as [`crate::dist`]'s squared distance, and it is worth
424/// repeating because it is not obvious: adding floats is not associative, so a
425/// compiler is not allowed to turn one accumulator into a vector of them, and
426/// the one line version is a chain of dependent adds four cycles apart. This is
427/// the whole of an encode. Projecting one bucket is a row of the matrix against
428/// it, there are buckets times repetitions of those, and at the default shape
429/// and 128 dimensional tokens that is a quarter of a million multiply adds for
430/// one document, all of them here.
431///
432/// Eight totals on their own are not enough. Walking two slices by a counter
433/// bounded by one of the two lengths leaves the compiler unable to prove the
434/// other index is in range, so it emits a bounds check per element, and a
435/// branch in the middle of a loop body is one the vectoriser will not cross.
436/// [`slice::as_chunks`] hands back fixed size arrays instead, and indexing an
437/// eight element array by a constant needs no check at all.
438///
439/// The totals are summed in a fixed order at the end, so the answer is
440/// deterministic, and it is a different answer from the one line version by the
441/// last bit or so in the way any two orderings of a float sum are.
442fn dot(a: &[f32], b: &[f32]) -> f32 {
443    let n = a.len().min(b.len());
444    let (xs, x_tail) = a[..n].as_chunks::<8>();
445    let (ys, _) = b[..n].as_chunks::<8>();
446
447    let mut totals = [0.0f32; 8];
448    for (x, y) in xs.iter().zip(ys) {
449        for k in 0..8 {
450            totals[k] += x[k] * y[k];
451        }
452    }
453
454    let mut sum = 0.0f32;
455    for total in totals {
456        sum += total;
457    }
458    for (x, y) in x_tail.iter().zip(&b[n - x_tail.len()..]) {
459        sum += x * y;
460    }
461    sum
462}
463
464/// A standard normal, by Box Muller. Only ever called while an encoder is being
465/// built, so throwing half of each pair away costs nothing worth saving.
466fn gauss(rng: &mut Rng) -> f32 {
467    let u1 = (uniform(rng)).max(f32::MIN_POSITIVE);
468    let u2 = uniform(rng);
469    (-2.0 * u1.ln()).sqrt() * (core::f32::consts::TAU * u2).cos()
470}
471
472fn uniform(rng: &mut Rng) -> f32 {
473    (rng.next_u64() >> 40) as f32 / (1u32 << 24) as f32
474}
475
476fn unit(v: &mut [f32]) {
477    let len = v.iter().map(|c| c * c).sum::<f32>().sqrt();
478    if len > 0.0 {
479        for c in v {
480            *c /= len;
481        }
482    }
483}
484
485#[cfg(test)]
486mod tests {
487    use super::*;
488    use crate::{Bits, Partitions, Tuning, Vectors};
489
490    /// A collection of token sets with the shape a late interaction model
491    /// produces: a document is about a few things out of a much larger pool,
492    /// and its tokens point near the directions of those things rather than
493    /// anywhere at all.
494    fn corpus(dim: usize, docs: usize, tokens: usize, topics: usize, seed: u64) -> Vec<Vec<f32>> {
495        let mut rng = Rng::new(seed);
496        let concepts: Vec<Vec<f32>> = (0..topics).map(|_| draw(dim, &mut rng)).collect();
497        (0..docs)
498            .map(|_| {
499                let about: Vec<usize> = (0..4).map(|_| rng.below(topics)).collect();
500                let mut out = Vec::with_capacity(tokens * dim);
501                for t in 0..tokens {
502                    let base = &concepts[about[t % about.len()]];
503                    out.extend_from_slice(&near(base, 0.5, &mut rng));
504                }
505                out
506            })
507            .collect()
508    }
509
510    /// Queries, each one taken from a particular document.
511    ///
512    /// This is the part that took a diagnostic to get right, and it is worth
513    /// writing down. The obvious test corpus is unrelated documents and
514    /// unrelated queries, and it measures nothing at all: over three hundred
515    /// documents of that kind the best Chamfer score is 0.331 and the thirtieth
516    /// best is 0.261, so which ten are the top ten is decided by noise in the
517    /// third decimal place and no approximation of any quality can recover
518    /// them. A recall number against that ground truth looks like a verdict on
519    /// the encoder and is a verdict on the random number generator.
520    ///
521    /// A real query set is not like that. A query came from somewhere, and the
522    /// passage it came from is the answer, which is exactly how MS MARCO is
523    /// built and what recall at k means there. So a query here is a handful of
524    /// one document's own tokens with noise on them, and the test is whether
525    /// the document it came from comes back.
526    fn queries(
527        docs: &[Vec<f32>],
528        dim: usize,
529        n: usize,
530        len: usize,
531        seed: u64,
532    ) -> Vec<(usize, Vec<f32>)> {
533        let mut rng = Rng::new(seed);
534        (0..n)
535            .map(|_| {
536                let from = rng.below(docs.len());
537                let doc = &docs[from];
538                let have = doc.len() / dim;
539                let mut q = Vec::with_capacity(len * dim);
540                for _ in 0..len {
541                    let t = rng.below(have);
542                    let token = &doc[t * dim..(t + 1) * dim];
543                    q.extend_from_slice(&near(token, 0.35, &mut rng));
544                }
545                (from, q)
546            })
547            .collect()
548    }
549
550    /// A unit vector near `base`, off by `how much`.
551    fn near(base: &[f32], off: f32, rng: &mut Rng) -> Vec<f32> {
552        let noise = draw(base.len(), rng);
553        let mut v: Vec<f32> = base.iter().zip(&noise).map(|(c, o)| c + o * off).collect();
554        unit(&mut v);
555        v
556    }
557
558    fn draw(dim: usize, rng: &mut Rng) -> Vec<f32> {
559        let mut v: Vec<f32> = (0..dim).map(|_| gauss(rng)).collect();
560        unit(&mut v);
561        v
562    }
563
564    fn dot(a: &[f32], b: &[f32]) -> f32 {
565        a.iter().zip(b).map(|(x, y)| x * y).sum()
566    }
567
568    /// How often the document a query came from is in the encoding's own top
569    /// `keep` out of the whole collection.
570    fn found(dim: usize, shape: Shape, keep: usize, seed: u64) -> f32 {
571        let enc = Encoder::new(dim, shape, seed);
572        let docs = corpus(dim, 300, 24, 64, seed);
573        let qs = queries(&docs, dim, 40, 6, seed ^ 0x5eed);
574        let fdes: Vec<Vec<f32>> = docs.iter().map(|d| enc.document(d)).collect();
575
576        let mut hits = 0usize;
577        for (from, q) in &qs {
578            let f = enc.query(q);
579            let mut by: Vec<(usize, f32)> = fdes
580                .iter()
581                .enumerate()
582                .map(|(i, d)| (i, dot(&f, d)))
583                .collect();
584            by.select_nth_unstable_by(keep, |a, b| b.1.total_cmp(&a.1));
585            hits += usize::from(by[..keep].iter().any(|(i, _)| i == from));
586        }
587        hits as f32 / qs.len() as f32
588    }
589
590    #[test]
591    fn an_encoding_is_the_length_it_says_it_is() {
592        let shape = Shape {
593            ksim: 3,
594            dproj: 8,
595            reps: 4,
596        };
597        let enc = Encoder::new(32, shape, 1);
598        assert_eq!(enc.fde_dim(), 4 * 8 * 8);
599        assert_eq!(enc.dim(), 32);
600
601        // Whatever the token count is, and that is the whole point of it.
602        for tokens in [1usize, 2, 40] {
603            let set = corpus(32, 1, tokens, 4, 9).remove(0);
604            assert_eq!(enc.document(&set).len(), enc.fde_dim());
605            assert_eq!(enc.query(&set).len(), enc.fde_dim());
606        }
607    }
608
609    #[test]
610    fn the_same_seed_is_the_same_encoder() {
611        let set = corpus(24, 1, 9, 4, 3).remove(0);
612        let a = Encoder::new(24, Shape::default(), 77).document(&set);
613        let b = Encoder::new(24, Shape::default(), 77).document(&set);
614        let other = Encoder::new(24, Shape::default(), 78).document(&set);
615        assert_eq!(a, b);
616        assert_ne!(a, other);
617    }
618
619    #[test]
620    fn chamfer_is_the_best_match_for_each_query_token() {
621        let dim = 4;
622        // Two document tokens: one along x, one along y.
623        let doc = [1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0];
624        // Two query tokens: one along y, one halfway between x and z.
625        let half = core::f32::consts::FRAC_1_SQRT_2;
626        let query = [0.0, 1.0, 0.0, 0.0, half, 0.0, half, 0.0];
627        // The first matches y exactly, the second does 1/sqrt(2) against x.
628        assert!((chamfer(&query, &doc, dim) - (1.0 + half) / 2.0).abs() < 1e-6);
629
630        // It is not symmetric, and it is not supposed to be: a query of one
631        // token that the document has exactly is a perfect score, and the
632        // document scored against that query is not, because its other token
633        // has nothing to match.
634        let one = [1.0f32, 0.0, 0.0, 0.0];
635        assert!((chamfer(&one, &doc, dim) - 1.0).abs() < 1e-6);
636        assert!((chamfer(&doc, &one, dim) - 0.5).abs() < 1e-6);
637        assert_eq!(chamfer(&[], &doc, dim), 0.0);
638    }
639
640    /// The fill for empty buckets, checked by its cleanest consequence: a
641    /// document with one token has that token in every bucket, because it is
642    /// the best match for anything a query can ask.
643    #[test]
644    fn a_one_token_document_fills_every_bucket() {
645        let dim = 16;
646        let shape = Shape {
647            ksim: 3,
648            dproj: 8,
649            reps: 2,
650        };
651        let enc = Encoder::new(dim, shape, 11);
652        let one = corpus(dim, 1, 1, 4, 5).remove(0);
653        let fde = enc.document(&one);
654
655        // Every block of every repetition is the same projected token, so no
656        // block is empty and they all agree.
657        for r in 0..shape.reps {
658            let first = &fde[r * 8 * shape.dproj..][..shape.dproj];
659            assert!(first.iter().any(|c| c.abs() > 1e-6), "rep {r} is empty");
660            for k in 1..8 {
661                let block = &fde[(r * 8 + k) * shape.dproj..][..shape.dproj];
662                for (a, b) in first.iter().zip(block) {
663                    assert!((a - b).abs() < 1e-6, "rep {r} bucket {k} differs");
664                }
665            }
666        }
667    }
668
669    #[test]
670    fn the_encoding_finds_the_document_a_query_came_from() {
671        let got = found(48, Shape::default(), 10, 4242);
672        assert!(
673            got >= 0.9,
674            "the encoding's top ten held it {got} of the time"
675        );
676    }
677
678    /// The paper's claim about which knob matters, and the one that tells you
679    /// what to turn when recall is short. Whether a query token and its best
680    /// match land in the same bucket is a coin toss, and repetitions are what
681    /// average it out.
682    #[test]
683    fn more_repetitions_find_it_more_often() {
684        let one = found(
685            48,
686            Shape {
687                reps: 1,
688                ..Shape::default()
689            },
690            1,
691            4242,
692        );
693        let many = found(
694            48,
695            Shape {
696                reps: 16,
697                ..Shape::default()
698            },
699            1,
700            4242,
701        );
702        assert!(
703            many > one + 0.1,
704            "sixteen repetitions found it {many} of the time against one repetition's {one}"
705        );
706    }
707
708    /// The record log, holding the encodings, so the index can rerank.
709    struct Fdes(Vec<Vec<f32>>);
710
711    impl Vectors for Fdes {
712        fn get(&self, id: u64, into: &mut [f32]) -> bool {
713            match self.0.get(id as usize) {
714                Some(v) => {
715                    into.copy_from_slice(v);
716                    true
717                }
718                None => false,
719            }
720        }
721    }
722
723    /// End to end, which is the thing that has to work: encodings in the
724    /// ordinary partition index, searched with an encoded query, and the
725    /// candidates reranked with exact Chamfer.
726    ///
727    /// No second index, no postings over every token of every document, and the
728    /// exact score runs on the forty documents the index handed back rather
729    /// than on all three hundred.
730    #[test]
731    fn retrieval_then_a_chamfer_rerank_finds_the_right_document() {
732        let dim = 48;
733        let enc = Encoder::new(dim, Shape::default(), 909);
734        let docs = corpus(dim, 300, 24, 64, 909);
735        let qs = queries(&docs, dim, 40, 6, 0xbeef);
736        let fdes = Fdes(docs.iter().map(|d| enc.document(d)).collect());
737
738        let mut ix = Partitions::new(
739            enc.fde_dim(),
740            Bits::One,
741            7,
742            Tuning {
743                posting: 48,
744                ..Tuning::default()
745            },
746        );
747        for (id, f) in fdes.0.iter().enumerate() {
748            ix.insert(id as u64, f);
749        }
750        ix.maintain(&fdes, 1 << 20);
751
752        let mut first = 0usize;
753        for (from, q) in &qs {
754            let best = ix
755                .search(&enc.query(q), 40, &fdes)
756                .into_iter()
757                .map(|h| (h.id as usize, chamfer(q, &docs[h.id as usize], dim)))
758                .max_by(|a, b| a.1.total_cmp(&b.1));
759            first += usize::from(best.map(|(id, _)| id) == Some(*from));
760        }
761        let got = first as f32 / qs.len() as f32;
762        assert!(
763            got >= 0.9,
764            "the right document came first {got} of the time"
765        );
766    }
767
768    /// What the whole thing is standing in for, priced.
769    ///
770    /// A Chamfer scan over the collection is every query token against every
771    /// token of every document. The encoding turns that into one dot product a
772    /// document, and then one Chamfer against the few the index kept.
773    #[test]
774    fn the_rerank_is_the_only_chamfer_anyone_pays_for() {
775        let dim = 32;
776        let enc = Encoder::new(dim, Shape::default(), 5);
777        let docs = corpus(dim, 200, 24, 32, 5);
778        let (from, q) = queries(&docs, dim, 1, 6, 17).remove(0);
779
780        let scanned: usize = docs.iter().map(|d| d.len() / dim).sum::<usize>() * (q.len() / dim);
781        let fdes: Vec<Vec<f32>> = docs.iter().map(|d| enc.document(d)).collect();
782        let f = enc.query(&q);
783        let mut by: Vec<(usize, f32)> = fdes
784            .iter()
785            .enumerate()
786            .map(|(i, d)| (i, dot(&f, d)))
787            .collect();
788        by.sort_by(|a, b| b.1.total_cmp(&a.1));
789        let kept: Vec<usize> = by[..20].iter().map(|(i, _)| *i).collect();
790        let reranked: usize =
791            kept.iter().map(|i| docs[*i].len() / dim).sum::<usize>() * (q.len() / dim);
792
793        assert!(
794            kept.contains(&from),
795            "the document it came from was dropped"
796        );
797        assert!(
798            reranked * 8 < scanned,
799            "reranking {reranked} token pairs against a full scan's {scanned} is not a saving"
800        );
801    }
802}