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 #[cfg_attr(
671 miri,
672 ignore = "the count is the claim: how often the top ten holds the right document, over a corpus"
673 )]
674 fn the_encoding_finds_the_document_a_query_came_from() {
675 let got = found(48, Shape::default(), 10, 4242);
676 assert!(
677 got >= 0.9,
678 "the encoding's top ten held it {got} of the time"
679 );
680 }
681
682 /// The paper's claim about which knob matters, and the one that tells you
683 /// what to turn when recall is short. Whether a query token and its best
684 /// match land in the same bucket is a coin toss, and repetitions are what
685 /// average it out.
686 #[test]
687 #[cfg_attr(
688 miri,
689 ignore = "the count is the claim: whether a query token and its match land in the same bucket is a coin toss, and this is the average of it"
690 )]
691 fn more_repetitions_find_it_more_often() {
692 let one = found(
693 48,
694 Shape {
695 reps: 1,
696 ..Shape::default()
697 },
698 1,
699 4242,
700 );
701 let many = found(
702 48,
703 Shape {
704 reps: 16,
705 ..Shape::default()
706 },
707 1,
708 4242,
709 );
710 assert!(
711 many > one + 0.1,
712 "sixteen repetitions found it {many} of the time against one repetition's {one}"
713 );
714 }
715
716 /// The record log, holding the encodings, so the index can rerank.
717 struct Fdes(Vec<Vec<f32>>);
718
719 impl Vectors for Fdes {
720 fn get(&self, id: u64, into: &mut [f32]) -> bool {
721 match self.0.get(id as usize) {
722 Some(v) => {
723 into.copy_from_slice(v);
724 true
725 }
726 None => false,
727 }
728 }
729 }
730
731 /// End to end, which is the thing that has to work: encodings in the
732 /// ordinary partition index, searched with an encoded query, and the
733 /// candidates reranked with exact Chamfer.
734 ///
735 /// No second index, no postings over every token of every document, and the
736 /// exact score runs on the forty documents the index handed back rather
737 /// than on all three hundred.
738 #[test]
739 #[cfg_attr(
740 miri,
741 ignore = "the count is the claim: three hundred documents, forty queries and a shortlist, measured end to end"
742 )]
743 fn retrieval_then_a_chamfer_rerank_finds_the_right_document() {
744 let dim = 48;
745 let enc = Encoder::new(dim, Shape::default(), 909);
746 let docs = corpus(dim, 300, 24, 64, 909);
747 let qs = queries(&docs, dim, 40, 6, 0xbeef);
748 let fdes = Fdes(docs.iter().map(|d| enc.document(d)).collect());
749
750 let mut ix = Partitions::new(
751 enc.fde_dim(),
752 Bits::One,
753 7,
754 Tuning {
755 posting: 48,
756 ..Tuning::default()
757 },
758 );
759 for (id, f) in fdes.0.iter().enumerate() {
760 ix.insert(id as u64, f);
761 }
762 ix.maintain(&fdes, 1 << 20);
763
764 let mut first = 0usize;
765 for (from, q) in &qs {
766 let best = ix
767 .search(&enc.query(q), 40, &fdes)
768 .into_iter()
769 .map(|h| (h.id as usize, chamfer(q, &docs[h.id as usize], dim)))
770 .max_by(|a, b| a.1.total_cmp(&b.1));
771 first += usize::from(best.map(|(id, _)| id) == Some(*from));
772 }
773 let got = first as f32 / qs.len() as f32;
774 assert!(
775 got >= 0.9,
776 "the right document came first {got} of the time"
777 );
778 }
779
780 /// What the whole thing is standing in for, priced.
781 ///
782 /// A Chamfer scan over the collection is every query token against every
783 /// token of every document. The encoding turns that into one dot product a
784 /// document, and then one Chamfer against the few the index kept.
785 #[test]
786 #[cfg_attr(
787 miri,
788 ignore = "the count is the claim: how much of a full chamfer scan the rerank got away with not doing"
789 )]
790 fn the_rerank_is_the_only_chamfer_anyone_pays_for() {
791 let dim = 32;
792 let enc = Encoder::new(dim, Shape::default(), 5);
793 let docs = corpus(dim, 200, 24, 32, 5);
794 let (from, q) = queries(&docs, dim, 1, 6, 17).remove(0);
795
796 let scanned: usize = docs.iter().map(|d| d.len() / dim).sum::<usize>() * (q.len() / dim);
797 let fdes: Vec<Vec<f32>> = docs.iter().map(|d| enc.document(d)).collect();
798 let f = enc.query(&q);
799 let mut by: Vec<(usize, f32)> = fdes
800 .iter()
801 .enumerate()
802 .map(|(i, d)| (i, dot(&f, d)))
803 .collect();
804 by.sort_by(|a, b| b.1.total_cmp(&a.1));
805 let kept: Vec<usize> = by[..20].iter().map(|(i, _)| *i).collect();
806 let reranked: usize =
807 kept.iter().map(|i| docs[*i].len() / dim).sum::<usize>() * (q.len() / dim);
808
809 assert!(
810 kept.contains(&from),
811 "the document it came from was dropped"
812 );
813 assert!(
814 reranked * 8 < scanned,
815 "reranking {reranked} token pairs against a full scan's {scanned} is not a saving"
816 );
817 }
818}