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::partition`]'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/// The totals are summed in a fixed order at the end, so the answer is
433/// deterministic, and it is a different answer from the one line version by the
434/// last bit or so in the way any two orderings of a float sum are.
435fn dot(a: &[f32], b: &[f32]) -> f32 {
436    let mut totals = [0.0f32; 8];
437    let mut i = 0;
438    while i + 8 <= a.len() {
439        for (k, total) in totals.iter_mut().enumerate() {
440            *total += a[i + k] * b[i + k];
441        }
442        i += 8;
443    }
444    let mut sum = 0.0f32;
445    for total in totals {
446        sum += total;
447    }
448    while i < a.len() {
449        sum += a[i] * b[i];
450        i += 1;
451    }
452    sum
453}
454
455/// A standard normal, by Box Muller. Only ever called while an encoder is being
456/// built, so throwing half of each pair away costs nothing worth saving.
457fn gauss(rng: &mut Rng) -> f32 {
458    let u1 = (uniform(rng)).max(f32::MIN_POSITIVE);
459    let u2 = uniform(rng);
460    (-2.0 * u1.ln()).sqrt() * (core::f32::consts::TAU * u2).cos()
461}
462
463fn uniform(rng: &mut Rng) -> f32 {
464    (rng.next_u64() >> 40) as f32 / (1u32 << 24) as f32
465}
466
467fn unit(v: &mut [f32]) {
468    let len = v.iter().map(|c| c * c).sum::<f32>().sqrt();
469    if len > 0.0 {
470        for c in v {
471            *c /= len;
472        }
473    }
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479    use crate::{Bits, Partitions, Tuning, Vectors};
480
481    /// A collection of token sets with the shape a late interaction model
482    /// produces: a document is about a few things out of a much larger pool,
483    /// and its tokens point near the directions of those things rather than
484    /// anywhere at all.
485    fn corpus(dim: usize, docs: usize, tokens: usize, topics: usize, seed: u64) -> Vec<Vec<f32>> {
486        let mut rng = Rng::new(seed);
487        let concepts: Vec<Vec<f32>> = (0..topics).map(|_| draw(dim, &mut rng)).collect();
488        (0..docs)
489            .map(|_| {
490                let about: Vec<usize> = (0..4).map(|_| rng.below(topics)).collect();
491                let mut out = Vec::with_capacity(tokens * dim);
492                for t in 0..tokens {
493                    let base = &concepts[about[t % about.len()]];
494                    out.extend_from_slice(&near(base, 0.5, &mut rng));
495                }
496                out
497            })
498            .collect()
499    }
500
501    /// Queries, each one taken from a particular document.
502    ///
503    /// This is the part that took a diagnostic to get right, and it is worth
504    /// writing down. The obvious test corpus is unrelated documents and
505    /// unrelated queries, and it measures nothing at all: over three hundred
506    /// documents of that kind the best Chamfer score is 0.331 and the thirtieth
507    /// best is 0.261, so which ten are the top ten is decided by noise in the
508    /// third decimal place and no approximation of any quality can recover
509    /// them. A recall number against that ground truth looks like a verdict on
510    /// the encoder and is a verdict on the random number generator.
511    ///
512    /// A real query set is not like that. A query came from somewhere, and the
513    /// passage it came from is the answer, which is exactly how MS MARCO is
514    /// built and what recall at k means there. So a query here is a handful of
515    /// one document's own tokens with noise on them, and the test is whether
516    /// the document it came from comes back.
517    fn queries(
518        docs: &[Vec<f32>],
519        dim: usize,
520        n: usize,
521        len: usize,
522        seed: u64,
523    ) -> Vec<(usize, Vec<f32>)> {
524        let mut rng = Rng::new(seed);
525        (0..n)
526            .map(|_| {
527                let from = rng.below(docs.len());
528                let doc = &docs[from];
529                let have = doc.len() / dim;
530                let mut q = Vec::with_capacity(len * dim);
531                for _ in 0..len {
532                    let t = rng.below(have);
533                    let token = &doc[t * dim..(t + 1) * dim];
534                    q.extend_from_slice(&near(token, 0.35, &mut rng));
535                }
536                (from, q)
537            })
538            .collect()
539    }
540
541    /// A unit vector near `base`, off by `how much`.
542    fn near(base: &[f32], off: f32, rng: &mut Rng) -> Vec<f32> {
543        let noise = draw(base.len(), rng);
544        let mut v: Vec<f32> = base.iter().zip(&noise).map(|(c, o)| c + o * off).collect();
545        unit(&mut v);
546        v
547    }
548
549    fn draw(dim: usize, rng: &mut Rng) -> Vec<f32> {
550        let mut v: Vec<f32> = (0..dim).map(|_| gauss(rng)).collect();
551        unit(&mut v);
552        v
553    }
554
555    fn dot(a: &[f32], b: &[f32]) -> f32 {
556        a.iter().zip(b).map(|(x, y)| x * y).sum()
557    }
558
559    /// How often the document a query came from is in the encoding's own top
560    /// `keep` out of the whole collection.
561    fn found(dim: usize, shape: Shape, keep: usize, seed: u64) -> f32 {
562        let enc = Encoder::new(dim, shape, seed);
563        let docs = corpus(dim, 300, 24, 64, seed);
564        let qs = queries(&docs, dim, 40, 6, seed ^ 0x5eed);
565        let fdes: Vec<Vec<f32>> = docs.iter().map(|d| enc.document(d)).collect();
566
567        let mut hits = 0usize;
568        for (from, q) in &qs {
569            let f = enc.query(q);
570            let mut by: Vec<(usize, f32)> = fdes
571                .iter()
572                .enumerate()
573                .map(|(i, d)| (i, dot(&f, d)))
574                .collect();
575            by.select_nth_unstable_by(keep, |a, b| b.1.total_cmp(&a.1));
576            hits += usize::from(by[..keep].iter().any(|(i, _)| i == from));
577        }
578        hits as f32 / qs.len() as f32
579    }
580
581    #[test]
582    fn an_encoding_is_the_length_it_says_it_is() {
583        let shape = Shape {
584            ksim: 3,
585            dproj: 8,
586            reps: 4,
587        };
588        let enc = Encoder::new(32, shape, 1);
589        assert_eq!(enc.fde_dim(), 4 * 8 * 8);
590        assert_eq!(enc.dim(), 32);
591
592        // Whatever the token count is, and that is the whole point of it.
593        for tokens in [1usize, 2, 40] {
594            let set = corpus(32, 1, tokens, 4, 9).remove(0);
595            assert_eq!(enc.document(&set).len(), enc.fde_dim());
596            assert_eq!(enc.query(&set).len(), enc.fde_dim());
597        }
598    }
599
600    #[test]
601    fn the_same_seed_is_the_same_encoder() {
602        let set = corpus(24, 1, 9, 4, 3).remove(0);
603        let a = Encoder::new(24, Shape::default(), 77).document(&set);
604        let b = Encoder::new(24, Shape::default(), 77).document(&set);
605        let other = Encoder::new(24, Shape::default(), 78).document(&set);
606        assert_eq!(a, b);
607        assert_ne!(a, other);
608    }
609
610    #[test]
611    fn chamfer_is_the_best_match_for_each_query_token() {
612        let dim = 4;
613        // Two document tokens: one along x, one along y.
614        let doc = [1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0];
615        // Two query tokens: one along y, one halfway between x and z.
616        let half = core::f32::consts::FRAC_1_SQRT_2;
617        let query = [0.0, 1.0, 0.0, 0.0, half, 0.0, half, 0.0];
618        // The first matches y exactly, the second does 1/sqrt(2) against x.
619        assert!((chamfer(&query, &doc, dim) - (1.0 + half) / 2.0).abs() < 1e-6);
620
621        // It is not symmetric, and it is not supposed to be: a query of one
622        // token that the document has exactly is a perfect score, and the
623        // document scored against that query is not, because its other token
624        // has nothing to match.
625        let one = [1.0f32, 0.0, 0.0, 0.0];
626        assert!((chamfer(&one, &doc, dim) - 1.0).abs() < 1e-6);
627        assert!((chamfer(&doc, &one, dim) - 0.5).abs() < 1e-6);
628        assert_eq!(chamfer(&[], &doc, dim), 0.0);
629    }
630
631    /// The fill for empty buckets, checked by its cleanest consequence: a
632    /// document with one token has that token in every bucket, because it is
633    /// the best match for anything a query can ask.
634    #[test]
635    fn a_one_token_document_fills_every_bucket() {
636        let dim = 16;
637        let shape = Shape {
638            ksim: 3,
639            dproj: 8,
640            reps: 2,
641        };
642        let enc = Encoder::new(dim, shape, 11);
643        let one = corpus(dim, 1, 1, 4, 5).remove(0);
644        let fde = enc.document(&one);
645
646        // Every block of every repetition is the same projected token, so no
647        // block is empty and they all agree.
648        for r in 0..shape.reps {
649            let first = &fde[r * 8 * shape.dproj..][..shape.dproj];
650            assert!(first.iter().any(|c| c.abs() > 1e-6), "rep {r} is empty");
651            for k in 1..8 {
652                let block = &fde[(r * 8 + k) * shape.dproj..][..shape.dproj];
653                for (a, b) in first.iter().zip(block) {
654                    assert!((a - b).abs() < 1e-6, "rep {r} bucket {k} differs");
655                }
656            }
657        }
658    }
659
660    #[test]
661    fn the_encoding_finds_the_document_a_query_came_from() {
662        let got = found(48, Shape::default(), 10, 4242);
663        assert!(
664            got >= 0.9,
665            "the encoding's top ten held it {got} of the time"
666        );
667    }
668
669    /// The paper's claim about which knob matters, and the one that tells you
670    /// what to turn when recall is short. Whether a query token and its best
671    /// match land in the same bucket is a coin toss, and repetitions are what
672    /// average it out.
673    #[test]
674    fn more_repetitions_find_it_more_often() {
675        let one = found(
676            48,
677            Shape {
678                reps: 1,
679                ..Shape::default()
680            },
681            1,
682            4242,
683        );
684        let many = found(
685            48,
686            Shape {
687                reps: 16,
688                ..Shape::default()
689            },
690            1,
691            4242,
692        );
693        assert!(
694            many > one + 0.1,
695            "sixteen repetitions found it {many} of the time against one repetition's {one}"
696        );
697    }
698
699    /// The record log, holding the encodings, so the index can rerank.
700    struct Fdes(Vec<Vec<f32>>);
701
702    impl Vectors for Fdes {
703        fn get(&self, id: u64, into: &mut [f32]) -> bool {
704            match self.0.get(id as usize) {
705                Some(v) => {
706                    into.copy_from_slice(v);
707                    true
708                }
709                None => false,
710            }
711        }
712    }
713
714    /// End to end, which is the thing that has to work: encodings in the
715    /// ordinary partition index, searched with an encoded query, and the
716    /// candidates reranked with exact Chamfer.
717    ///
718    /// No second index, no postings over every token of every document, and the
719    /// exact score runs on the forty documents the index handed back rather
720    /// than on all three hundred.
721    #[test]
722    fn retrieval_then_a_chamfer_rerank_finds_the_right_document() {
723        let dim = 48;
724        let enc = Encoder::new(dim, Shape::default(), 909);
725        let docs = corpus(dim, 300, 24, 64, 909);
726        let qs = queries(&docs, dim, 40, 6, 0xbeef);
727        let fdes = Fdes(docs.iter().map(|d| enc.document(d)).collect());
728
729        let mut ix = Partitions::new(
730            enc.fde_dim(),
731            Bits::One,
732            7,
733            Tuning {
734                posting: 48,
735                ..Tuning::default()
736            },
737        );
738        for (id, f) in fdes.0.iter().enumerate() {
739            ix.insert(id as u64, f);
740        }
741        ix.maintain(&fdes, 1 << 20);
742
743        let mut first = 0usize;
744        for (from, q) in &qs {
745            let best = ix
746                .search(&enc.query(q), 40, &fdes)
747                .into_iter()
748                .map(|h| (h.id as usize, chamfer(q, &docs[h.id as usize], dim)))
749                .max_by(|a, b| a.1.total_cmp(&b.1));
750            first += usize::from(best.map(|(id, _)| id) == Some(*from));
751        }
752        let got = first as f32 / qs.len() as f32;
753        assert!(
754            got >= 0.9,
755            "the right document came first {got} of the time"
756        );
757    }
758
759    /// What the whole thing is standing in for, priced.
760    ///
761    /// A Chamfer scan over the collection is every query token against every
762    /// token of every document. The encoding turns that into one dot product a
763    /// document, and then one Chamfer against the few the index kept.
764    #[test]
765    fn the_rerank_is_the_only_chamfer_anyone_pays_for() {
766        let dim = 32;
767        let enc = Encoder::new(dim, Shape::default(), 5);
768        let docs = corpus(dim, 200, 24, 32, 5);
769        let (from, q) = queries(&docs, dim, 1, 6, 17).remove(0);
770
771        let scanned: usize = docs.iter().map(|d| d.len() / dim).sum::<usize>() * (q.len() / dim);
772        let fdes: Vec<Vec<f32>> = docs.iter().map(|d| enc.document(d)).collect();
773        let f = enc.query(&q);
774        let mut by: Vec<(usize, f32)> = fdes
775            .iter()
776            .enumerate()
777            .map(|(i, d)| (i, dot(&f, d)))
778            .collect();
779        by.sort_by(|a, b| b.1.total_cmp(&a.1));
780        let kept: Vec<usize> = by[..20].iter().map(|(i, _)| *i).collect();
781        let reranked: usize =
782            kept.iter().map(|i| docs[*i].len() / dim).sum::<usize>() * (q.len() / dim);
783
784        assert!(
785            kept.contains(&from),
786            "the document it came from was dropped"
787        );
788        assert!(
789            reranked * 8 < scanned,
790            "reranking {reranked} token pairs against a full scan's {scanned} is not a saving"
791        );
792    }
793}