yo_vector/rabitq.rs
1//! RaBitQ, the quantiser the searchable form of a vector is written in
2//! (`10` section 3).
3//!
4//! A 768 dimensional embedding is 3072 bytes of `f32`, and ten million of them
5//! is 30 GB. RaBitQ writes the same vector as one bit per dimension, which is 96
6//! bytes and a 32x reduction, and that is the difference between an index that
7//! sits in memory and one that does not.
8//!
9//! Binary quantisation on its own is old and it is not good enough: keeping only
10//! the sign of each coordinate throws away how far the point is from the
11//! boundary, and the distances that come back are biased in a way that no amount
12//! of rerank hides. RaBitQ's contribution is the estimator. It stores one extra
13//! number per vector, the cosine between the vector and the corner of the cube
14//! it was rounded to, and dividing by that turns a biased guess into an unbiased
15//! one with an error bound that shrinks as `1/sqrt(D)`. The ordering that comes
16//! out of the codes alone is then good enough that rerank only has to look at a
17//! small multiple of `k` real vectors.
18//!
19//! ```
20//! use yo_vector::{Bits, Quantizer};
21//!
22//! let q = Quantizer::new(64, Bits::One, 7);
23//! let centroid = vec![0.0f32; 64];
24//! let mut code = vec![0u8; q.code_bytes()];
25//!
26//! let v: Vec<f32> = (0..64).map(|i| (i as f32 * 0.37).sin()).collect();
27//! let coded = q.encode(&v, ¢roid, &mut code);
28//!
29//! // The query is prepared once and then measured against many codes.
30//! let query = q.query(&v, ¢roid);
31//! let guess = query.distance(&code, &coded);
32//! // A vector against itself, so the answer should be near zero.
33//! assert!(guess < 0.2, "{guess}");
34//! ```
35//!
36//! # What is stored
37//!
38//! Per vector: the code, and two `f32`. The first is the length of the residual,
39//! which is the vector minus its partition's centroid, and it is what turns an
40//! angle in the unit sphere back into a distance. The second is the correction,
41//! which is the estimator, and it is kept as its reciprocal because the
42//! estimator only ever divides by it. `10` section 3's table says one `f32` and
43//! it is two,
44//! because both are needed and neither can be recovered from the other. Eight
45//! bytes on top of 96 is still a 30x reduction rather than 32x.
46//!
47//! # One bit and four
48//!
49//! [`Bits::One`] rounds each coordinate to a sign and [`Bits::Four`] rounds it
50//! to one of sixteen levels between the smallest and the largest coordinate the
51//! vector has. Four bits is four times the index and roughly a quarter of the
52//! error, and which one a collection wants is a question about the embedding
53//! family rather than about the engine, which is why both are here and the
54//! choice is per collection.
55//!
56//! The two share one code path. Reconstructing a code is `lo + level * delta`
57//! either way, with `lo` and `delta` fixed by the dimension for one bit and
58//! measured per vector for four, so the estimator is written once and the only
59//! thing that changes is how many bits a level takes.
60//!
61//! # A code is bit planes, and that is what makes the scan fast
62//!
63//! The obvious layout writes a coordinate's level in the bits next to it, and
64//! then measuring a code against a query is a multiply per dimension. That is
65//! 768 multiplies per candidate and it does not fit inside a millisecond search.
66//!
67//! So a code is stored transposed. Plane `b` holds bit `b` of every
68//! coordinate's level, one bit per coordinate, `dim` bits rounded up to whole
69//! 64 bit words, and the planes run least significant first. A one bit code is
70//! one plane and a four bit code is four, and the byte count is the same either
71//! way.
72//!
73//! The query is quantised to [`Bits::query_bits`] and transposed the same way.
74//! Then the sum of the code's levels times the query's levels is
75//!
76//! ```text
77//! sum over a, b of 2^(a+b) * popcount(code plane a AND query plane b)
78//! ```
79//!
80//! which is four ANDs and four popcounts per word for a one bit code, against
81//! 64 float multiplies for the same 64 coordinates. The sums are exact
82//! integers, so the arithmetic is also better behaved than the float version it
83//! replaces, and nothing is left to round until the end.
84//!
85//! # The query is quantised finer than the code
86//!
87//! Quantising the query costs accuracy, and how much was measured rather than
88//! assumed. At one bit the query at four bits is off by about a third of what
89//! the code itself is off by, which is lost in the quadrature and does not
90//! matter. At four bits the code is ten times more accurate and the same four
91//! bit query is off by four times as much as the code, which throws away the
92//! entire reason anyone would pay for four bit codes.
93//!
94//! So the query width follows the code width: four bits against a one bit code
95//! and eight against a four bit one. That puts the query's error back at about
96//! a third of the code's in both cases, and [`Query::cosine`] against
97//! [`Query::cosine_exact`] is the test that holds it there.
98
99use crate::rotate::Rotation;
100
101/// How many 64 bit words one plane of a `dim` dimensional code takes.
102fn words_of(dim: usize) -> usize {
103 dim.div_ceil(64)
104}
105
106/// How many bits a coordinate is rounded to.
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub enum Bits {
109 /// The sign, which is 96 bytes for a 768 dimensional vector.
110 One,
111 /// Sixteen levels, which is 384 bytes for the same vector and about a
112 /// quarter of the error.
113 Four,
114}
115
116impl Bits {
117 /// How many bits one coordinate takes, which is also how many planes a code
118 /// is written in.
119 #[must_use]
120 pub fn count(self) -> usize {
121 match self {
122 Bits::One => 1,
123 Bits::Four => 4,
124 }
125 }
126
127 /// How many bits a query is quantised to before it is scanned against codes
128 /// this wide.
129 ///
130 /// Four bits either side is what RaBitQ specifies, and it is right for a one
131 /// bit code and wrong for a four bit one, because a four bit code is ten
132 /// times more accurate and a four bit query is not. Eight is what puts the
133 /// query's error back under the code's, and the cost is four more ANDs and
134 /// four more popcounts per word on the path that was already paying for four
135 /// times the bytes.
136 #[must_use]
137 pub fn query_bits(self) -> usize {
138 match self {
139 Bits::One => 4,
140 Bits::Four => 8,
141 }
142 }
143
144 /// The largest level a coordinate this wide can round to.
145 fn top(self) -> u64 {
146 (1 << self.count()) - 1
147 }
148}
149
150/// What a code needs alongside it to be measured against a query.
151///
152/// `lo` and `delta` are only worth storing for [`Bits::Four`]. At one bit they
153/// are the same two numbers for every vector in the collection, so a store that
154/// keeps them per vector is keeping the dimension eight bytes at a time.
155#[derive(Debug, Clone, Copy, PartialEq)]
156pub struct Coded {
157 /// The length of the vector minus its centroid.
158 pub norm: f32,
159 /// One over the cosine between the vector and what its code reconstructs
160 /// to. That cosine is the estimator's correction and the whole of RaBitQ
161 /// over plain binary quantisation.
162 ///
163 /// Stored the wrong way up on purpose, because the estimator divides by it
164 /// exactly once per member and a float division was 44 percent of what a
165 /// member cost: 3.56 nanoseconds became 1.98 at 128 dimensions when the
166 /// division became a multiplication. Nothing else here reads it, so nothing
167 /// else pays for the inversion.
168 pub scale: f32,
169 /// The value level zero reconstructs to, over the length of the
170 /// reconstruction.
171 pub lo: f32,
172 /// What one level is worth, over the length of the reconstruction.
173 pub delta: f32,
174}
175
176/// The quantiser for one collection: its rotation and its width.
177pub struct Quantizer {
178 rot: Rotation,
179 bits: Bits,
180}
181
182impl Quantizer {
183 /// The quantiser for `dim` dimensional vectors at `bits`, with `seed`
184 /// choosing the rotation.
185 ///
186 /// # Panics
187 ///
188 /// If `dim` is zero.
189 #[must_use]
190 pub fn new(dim: usize, bits: Bits, seed: u64) -> Quantizer {
191 Quantizer {
192 rot: Rotation::new(dim, seed),
193 bits,
194 }
195 }
196
197 /// How many coordinates a vector has.
198 #[must_use]
199 pub fn dim(&self) -> usize {
200 self.rot.dim()
201 }
202
203 /// How wide a coordinate is written.
204 #[must_use]
205 pub fn bits(&self) -> Bits {
206 self.bits
207 }
208
209 /// The seed the rotation was built from, which is what a catalogue stores.
210 #[must_use]
211 pub fn seed(&self) -> u64 {
212 self.rot.seed()
213 }
214
215 /// How many bytes one code takes.
216 ///
217 /// A plane is whole 64 bit words because the scan reads words, so a
218 /// dimension that is not a multiple of 64 pays for the rest of its last
219 /// word. At the dimensions embedding families actually use there is nothing
220 /// to pay.
221 #[must_use]
222 pub fn code_bytes(&self) -> usize {
223 words_of(self.dim()) * 8 * self.bits.count()
224 }
225
226 /// A vector in the frame everything else here works in.
227 ///
228 /// The rotation is linear, so `rotate(v - c)` is `rotate(v) - rotate(c)`,
229 /// and an index that keeps its centroids already rotated never has to
230 /// rotate one again. That is what [`Quantizer::encode_rotated`] and
231 /// [`Quantizer::query_rotated`] are for, and the rotation is the expensive
232 /// half of both of the two calls above them.
233 ///
234 /// # Panics
235 ///
236 /// If `v` is not [`Quantizer::dim`] long.
237 #[must_use]
238 pub fn rotate(&self, v: &[f32]) -> Vec<f32> {
239 let mut x = v.to_vec();
240 self.rot.apply(&mut x);
241 x
242 }
243
244 /// Write `v`'s code against the centroid of the partition it is going into.
245 ///
246 /// # Panics
247 ///
248 /// If `v` or `centroid` is not [`Quantizer::dim`] long, or `code` is not
249 /// [`Quantizer::code_bytes`] long.
250 pub fn encode(&self, v: &[f32], centroid: &[f32], code: &mut [u8]) -> Coded {
251 let mut x = self.residual(v, centroid);
252 let norm = length(&x);
253 if norm > 0.0 {
254 let by = 1.0 / norm;
255 for c in &mut x {
256 *c *= by;
257 }
258 }
259 self.rot.apply(&mut x);
260 self.write(&x, norm, code)
261 }
262
263 /// The same as [`Quantizer::encode`] with both sides already rotated.
264 ///
265 /// # Panics
266 ///
267 /// If `x` or `centroid` is not [`Quantizer::dim`] long, or `code` is not
268 /// [`Quantizer::code_bytes`] long.
269 pub fn encode_rotated(&self, x: &[f32], centroid: &[f32], code: &mut [u8]) -> Coded {
270 let mut r = self.residual(x, centroid);
271 let norm = length(&r);
272 if norm > 0.0 {
273 let by = 1.0 / norm;
274 for c in &mut r {
275 *c *= by;
276 }
277 }
278 self.write(&r, norm, code)
279 }
280
281 /// The code of a rotated unit residual whose original length was `norm`.
282 fn write(&self, x: &[f32], norm: f32, code: &mut [u8]) -> Coded {
283 assert_eq!(
284 code.len(),
285 self.code_bytes(),
286 "a code here is {} bytes and the buffer is {}",
287 self.code_bytes(),
288 code.len()
289 );
290 code.fill(0);
291 if norm == 0.0 {
292 // The vector is the centroid. There is no direction to write down,
293 // and a scale of one keeps the estimator from producing an infinity
294 // if anyone measures against it anyway.
295 return Coded {
296 norm: 0.0,
297 scale: 1.0,
298 lo: 0.0,
299 delta: 0.0,
300 };
301 }
302 let mut coded = match self.bits {
303 Bits::One => sign_code(x, code),
304 Bits::Four => level_code(x, code),
305 };
306 coded.norm = norm;
307 coded
308 }
309
310 /// Prepare a query against the centroid of a partition being scanned.
311 ///
312 /// This is the per partition half of a search and it happens once, where
313 /// the estimate against a code happens once per vector in the partition.
314 ///
315 /// # Panics
316 ///
317 /// If `q` or `centroid` is not [`Quantizer::dim`] long.
318 #[must_use]
319 pub fn query(&self, q: &[f32], centroid: &[f32]) -> Query {
320 let mut x = self.residual(q, centroid);
321 let norm = length(&x);
322 if norm > 0.0 {
323 let by = 1.0 / norm;
324 for c in &mut x {
325 *c *= by;
326 }
327 }
328 self.rot.apply(&mut x);
329 self.prepare(x, norm)
330 }
331
332 /// The same as [`Quantizer::query`] with both sides already rotated.
333 ///
334 /// A search rotates its query once and then meets every partition it probes
335 /// through this, so the rotation is paid for once rather than once per
336 /// partition.
337 ///
338 /// # Panics
339 ///
340 /// If `q` or `centroid` is not [`Quantizer::dim`] long.
341 #[must_use]
342 pub fn query_rotated(&self, q: &[f32], centroid: &[f32]) -> Query {
343 let mut x = self.residual(q, centroid);
344 let norm = length(&x);
345 if norm > 0.0 {
346 let by = 1.0 / norm;
347 for c in &mut x {
348 *c *= by;
349 }
350 }
351 self.prepare(x, norm)
352 }
353
354 /// Quantise and transpose a rotated unit residual into the form the scan
355 /// meets a code with.
356 fn prepare(&self, x: Vec<f32>, norm: f32) -> Query {
357 // The sum is taken from the unquantised coordinates because it is one
358 // number computed once, so there is nothing to gain by approximating
359 // it and it is half of what the estimator adds up.
360 let sum = x.iter().sum();
361 let words = words_of(self.dim());
362 let wide = self.bits.query_bits();
363 let top = (1u64 << wide) - 1;
364 let (lo, hi) = span(&x);
365 let delta = step(lo, hi, top);
366 let by = 1.0 / delta;
367 let mut planes = vec![0u64; wide * words];
368 for (i, &c) in x.iter().enumerate() {
369 let level = level_of(c, lo, by, top);
370 for (b, plane) in planes.chunks_exact_mut(words).enumerate() {
371 plane[i / 64] |= ((level >> b) & 1) << (i % 64);
372 }
373 }
374 Query {
375 bits: self.bits,
376 words,
377 rotated: x,
378 planes,
379 lo,
380 delta,
381 sum,
382 norm,
383 }
384 }
385
386 /// `v - centroid`.
387 fn residual(&self, v: &[f32], centroid: &[f32]) -> Vec<f32> {
388 assert_eq!(
389 v.len(),
390 self.dim(),
391 "this collection holds {} dimensional vectors and was handed {}",
392 self.dim(),
393 v.len()
394 );
395 assert_eq!(
396 centroid.len(),
397 self.dim(),
398 "the centroid is {} dimensional and the collection is {}",
399 centroid.len(),
400 self.dim()
401 );
402 v.iter().zip(centroid).map(|(a, b)| a - b).collect()
403 }
404}
405
406/// A query, rotated and quantised once and then measured against every code in
407/// a partition.
408pub struct Query {
409 /// The width of the codes this is measured against, so the scan knows how
410 /// many planes each one has.
411 bits: Bits,
412 /// The words one plane takes, for both this query and those codes.
413 words: usize,
414 /// The query's residual, unit length and rotated, which is only what
415 /// [`Query::cosine_exact`] reads.
416 rotated: Vec<f32>,
417 /// The same coordinates at [`Bits::query_bits`], transposed into planes.
418 planes: Vec<u64>,
419 /// What level zero of the query reconstructs to.
420 lo: f32,
421 /// What one level of the query is worth.
422 delta: f32,
423 /// The sum of the rotated coordinates, which the estimator needs and which
424 /// does not depend on the code it is being compared against.
425 sum: f32,
426 /// The length of the query's residual.
427 norm: f32,
428}
429
430impl Query {
431 /// The estimated squared distance from this query to every code in a
432 /// posting, written into `out`.
433 ///
434 /// This is the loop a search spends most of its life in, and it takes a
435 /// whole posting rather than one code because of what that costs. A code is
436 /// `dim / 64` words wide, and `dim` is not a compile time constant, so a
437 /// scan written one code at a time meets a loop whose trip count the
438 /// compiler cannot see and cannot unroll. Measured at 128 dimensions, the
439 /// same popcount arithmetic is 1.98 nanoseconds a member with the word
440 /// count known and 6.31 with it unknown, which is more than three times.
441 /// Deciding the width once for a posting of a few hundred rather than once
442 /// per member is what buys that back.
443 ///
444 /// # Panics
445 ///
446 /// If `out` is not as long as `meta`, or if `codes` is not `meta.len()`
447 /// codes long.
448 pub fn scan(&self, codes: &[u8], meta: &[Coded], out: &mut [f32]) {
449 assert_eq!(
450 meta.len(),
451 out.len(),
452 "{} codes and room for {} answers",
453 meta.len(),
454 out.len()
455 );
456 let stride = self.bits.count() * self.words * 8;
457 assert_eq!(
458 codes.len(),
459 meta.len() * stride,
460 "{} members of {stride} bytes is {} and there are {}",
461 meta.len(),
462 meta.len() * stride,
463 codes.len()
464 );
465 // One arm per width worth having a copy of the loop for: 64, 128, 256,
466 // 384, 512, 768, 1024, 1536 and 3072 dimensions, which is every
467 // embedding family anybody ships. Anything else takes the general loop
468 // and is correct and slower, which is the right way round.
469 match self.words {
470 1 => self.scan_at::<1>(codes, meta, out),
471 2 => self.scan_at::<2>(codes, meta, out),
472 4 => self.scan_at::<4>(codes, meta, out),
473 6 => self.scan_at::<6>(codes, meta, out),
474 8 => self.scan_at::<8>(codes, meta, out),
475 12 => self.scan_at::<12>(codes, meta, out),
476 16 => self.scan_at::<16>(codes, meta, out),
477 24 => self.scan_at::<24>(codes, meta, out),
478 48 => self.scan_at::<48>(codes, meta, out),
479 _ => {
480 for (i, (code, coded)) in codes.chunks_exact(stride).zip(meta).enumerate() {
481 let (total, cross) =
482 packed_dot(code, self.bits.count(), self.words, &self.planes);
483 out[i] = self.settle(total, cross, coded);
484 }
485 }
486 }
487 }
488
489 /// The same with the word count known, so the inner loops unroll.
490 fn scan_at<const W: usize>(&self, codes: &[u8], meta: &[Coded], out: &mut [f32]) {
491 let planes = self.bits.count();
492 let wide = self.bits.query_bits();
493 for (i, (code, coded)) in codes.chunks_exact(planes * W * 8).zip(meta).enumerate() {
494 let (total, cross) = fixed_dot::<W>(code, planes, wide, &self.planes);
495 out[i] = self.settle(total, cross, coded);
496 }
497 }
498
499 /// Turn the two popcount sums into a squared distance.
500 ///
501 /// There is no branch here for a zero length residual, on either side,
502 /// which the one code at a time version used to have. If the code's
503 /// residual is zero then both of its terms below are zero and the answer is
504 /// the query's own length, and if the query's residual is zero then both of
505 /// the query's terms are zero and the answer is the code's. Those are the
506 /// right answers, so the branch was only ever buying a wrong reason to skip
507 /// arithmetic that costs less than the branch.
508 #[inline]
509 fn settle(&self, total: u32, cross: u32, coded: &Coded) -> f32 {
510 // Undo the query's quantisation: every level was `lo + delta * level`,
511 // so the sum over the code's levels needs the `lo` part weighted by how
512 // much level the code is carrying and the `delta` part by the cross
513 // term the popcounts just measured.
514 let levels = self.lo * total as f32 + self.delta * cross as f32;
515 let cos = (coded.lo * self.sum + coded.delta * levels) * coded.scale;
516 // The law of cosines on the triangle the centroid makes with the two
517 // points, which is why the residual lengths had to be kept.
518 (self.norm * self.norm + coded.norm * coded.norm - 2.0 * self.norm * coded.norm * cos)
519 .max(0.0)
520 }
521
522 /// The estimated squared distance between the query and one coded vector.
523 ///
524 /// Squared rather than the distance itself because the square root is
525 /// monotone, so it changes no ordering and nothing above this needs it.
526 ///
527 /// This is the convenient form and not the one a search calls.
528 /// [`Query::scan`] is that one.
529 ///
530 /// # Panics
531 ///
532 /// If `code` is not as long as the codes this query's quantiser writes.
533 #[must_use]
534 pub fn distance(&self, code: &[u8], coded: &Coded) -> f32 {
535 let mut out = [0.0f32];
536 self.scan(code, std::slice::from_ref(coded), &mut out);
537 out[0]
538 }
539
540 /// The estimated cosine between the query's residual and the coded one.
541 ///
542 /// This is RaBitQ's estimator: the inner product against what the code
543 /// reconstructs to, divided by the cosine between that reconstruction and
544 /// the vector it came from. The division is the part that makes it
545 /// unbiased. The inner product is the popcount scan.
546 ///
547 /// # Panics
548 ///
549 /// If `code` is not as long as the codes this query's quantiser writes.
550 #[must_use]
551 pub fn cosine(&self, code: &[u8], coded: &Coded) -> f32 {
552 if coded.norm == 0.0 || self.norm == 0.0 {
553 return 0.0;
554 }
555 let (total, cross) = packed_dot(code, self.bits.count(), self.words, &self.planes);
556 // Undo the query's quantisation: every level was `lo + delta * level`,
557 // so the sum over the code's levels needs the `lo` part weighted by how
558 // much level the code is carrying and the `delta` part by the cross
559 // term the popcounts just measured.
560 let levels = self.lo * total as f32 + self.delta * cross as f32;
561 (coded.lo * self.sum + coded.delta * levels) * coded.scale
562 }
563
564 /// The same estimate with the query left at full precision.
565 ///
566 /// This is the reference the popcount scan is checked against, and it is
567 /// public because a divergence between the two is worth being able to
568 /// measure from outside. It reads one coordinate at a time and it is not
569 /// what a search should call.
570 ///
571 /// # Panics
572 ///
573 /// If `code` is not as long as the codes this query's quantiser writes.
574 #[must_use]
575 pub fn cosine_exact(&self, code: &[u8], coded: &Coded) -> f32 {
576 if coded.norm == 0.0 || self.norm == 0.0 {
577 return 0.0;
578 }
579 let levels = exact_dot(code, self.bits.count(), self.words, &self.rotated);
580 (coded.lo * self.sum + coded.delta * levels) * coded.scale
581 }
582}
583
584/// The smallest and the largest coordinate.
585fn span(x: &[f32]) -> (f32, f32) {
586 let mut lo = f32::INFINITY;
587 let mut hi = f32::NEG_INFINITY;
588 for &c in x {
589 lo = lo.min(c);
590 hi = hi.max(c);
591 }
592 (lo, hi)
593}
594
595/// What one level is worth when `lo` to `hi` is cut into `top` of them.
596///
597/// A vector whose coordinates are all the same value has no range to divide up.
598/// It cannot happen after a rotation of a non zero residual, and a division by
599/// zero here would be a silent NaN rather than a loud one.
600fn step(lo: f32, hi: f32, top: u64) -> f32 {
601 if hi > lo { (hi - lo) / top as f32 } else { 1.0 }
602}
603
604/// Which level a coordinate rounds to.
605///
606/// `by` is one over the step rather than the step, because this runs once per
607/// coordinate on both the encode path and the query path and a float division
608/// per coordinate is not worth paying twice for one number.
609fn level_of(c: f32, lo: f32, by: f32, top: u64) -> u64 {
610 (((c - lo) * by).round() as i64).clamp(0, top as i64) as u64
611}
612
613/// Write one word of one plane.
614fn put(code: &mut [u8], words: usize, plane: usize, w: usize, v: u64) {
615 let at = (plane * words + w) * 8;
616 code[at..at + 8].copy_from_slice(&v.to_le_bytes());
617}
618
619/// The sign of each coordinate, written into the one plane a one bit code has.
620///
621/// The reconstruction is `(2 * bit - 1) / sqrt(D)`, which is a unit vector
622/// pointing at one corner of the cube, so `lo` and `delta` fall out of the
623/// dimension and the correction is the sum of the absolute coordinates over
624/// `sqrt(D)`.
625fn sign_code(x: &[f32], code: &mut [u8]) -> Coded {
626 let words = words_of(x.len());
627 let mut abs = 0.0f32;
628 for (w, chunk) in x.chunks(64).enumerate() {
629 let mut bits = 0u64;
630 for (k, &c) in chunk.iter().enumerate() {
631 abs += c.abs();
632 if c >= 0.0 {
633 bits |= 1 << k;
634 }
635 }
636 put(code, words, 0, w, bits);
637 }
638 let root = (x.len() as f32).sqrt();
639 Coded {
640 norm: 0.0,
641 scale: recip(abs / root),
642 lo: -1.0 / root,
643 delta: 2.0 / root,
644 }
645}
646
647/// Sixteen levels between the smallest and the largest coordinate, written
648/// across the four planes a four bit code has.
649fn level_code(x: &[f32], code: &mut [u8]) -> Coded {
650 let words = words_of(x.len());
651 let top = Bits::Four.top();
652 let (lo, hi) = span(x);
653 let delta = step(lo, hi, top);
654 let by = 1.0 / delta;
655 let mut recon = 0.0f32;
656 let mut dot = 0.0f32;
657 for (w, chunk) in x.chunks(64).enumerate() {
658 let mut planes = [0u64; 4];
659 for (k, &c) in chunk.iter().enumerate() {
660 let level = level_of(c, lo, by, top);
661 for (b, plane) in planes.iter_mut().enumerate() {
662 *plane |= ((level >> b) & 1) << k;
663 }
664 let back = lo + level as f32 * delta;
665 recon += back * back;
666 dot += back * c;
667 }
668 for (b, &plane) in planes.iter().enumerate() {
669 put(code, words, b, w, plane);
670 }
671 }
672 let len = recon.sqrt();
673 Coded {
674 norm: 0.0,
675 scale: recip(dot / len),
676 lo: lo / len,
677 delta: delta / len,
678 }
679}
680
681/// The scan: the sum of the code's levels, and the sum of the code's levels
682/// times the query's, both exact.
683///
684/// This is the loop the whole search spends its time in. Every plane of the
685/// code is met by every plane of the query, and a meeting is an AND and a
686/// popcount over 64 coordinates at a time.
687// Clippy wants `as_chunks::<8>()` here and it is 21 percent slower: 17.99
688// microseconds against 14.89 for `scan/one/768`, and 118.58 against 96.96 for
689// `scan/four/768`, both the minimum per iteration out of the same pair of runs.
690// Walking two byte slices side by side is what vectorises, and walking a slice
691// of eight byte arrays against a slice of words is what does not.
692#[allow(clippy::chunks_exact_to_as_chunks)]
693fn packed_dot(code: &[u8], planes: usize, words: usize, query: &[u64]) -> (u32, u32) {
694 assert_eq!(
695 code.len(),
696 planes * words * 8,
697 "a code here is {} bytes and this one is {}",
698 planes * words * 8,
699 code.len()
700 );
701 let mut total = 0u32;
702 let mut cross = 0u32;
703 for (a, plane) in code.chunks_exact(words * 8).enumerate() {
704 let mut ones = 0u32;
705 for chunk in plane.chunks_exact(8) {
706 ones += word(chunk).count_ones();
707 }
708 total += ones << a;
709 for (b, qp) in query.chunks_exact(words).enumerate() {
710 let mut acc = 0u32;
711 for (chunk, &qw) in plane.chunks_exact(8).zip(qp) {
712 acc += (word(chunk) & qw).count_ones();
713 }
714 cross += acc << (a + b);
715 }
716 }
717 (total, cross)
718}
719
720/// The same with the words per plane known at compile time.
721///
722/// Which is the whole point of it. `packed_dot` above walks three nested
723/// `chunks_exact` whose lengths are all runtime values, so the compiler emits
724/// loops where it could have emitted a handful of ANDs and popcounts. Handing
725/// it `W` turns the inner two into straight line code and the arithmetic goes
726/// from 6.31 nanoseconds a member to 1.98 at 128 dimensions.
727///
728/// `planes` and `wide` stay runtime, because there are only two combinations of
729/// them and neither loop is the one that was hurting.
730#[inline]
731fn fixed_dot<const W: usize>(code: &[u8], planes: usize, wide: usize, query: &[u64]) -> (u32, u32) {
732 let mut total = 0u32;
733 let mut cross = 0u32;
734 for a in 0..planes {
735 // Read the plane into words once. Every query plane below meets the
736 // same bytes, and reading them back out of the slice each time is a
737 // load and a bounds check that the register already had the answer to.
738 let mut plane = [0u64; W];
739 let at = a * W * 8;
740 let mut ones = 0u32;
741 for (w, slot) in plane.iter_mut().enumerate() {
742 *slot = word(&code[at + w * 8..at + w * 8 + 8]);
743 ones += slot.count_ones();
744 }
745 total += ones << a;
746 for b in 0..wide {
747 let qp = &query[b * W..(b + 1) * W];
748 let mut acc = 0u32;
749 for (w, &c) in plane.iter().enumerate() {
750 acc += (c & qp[w]).count_ones();
751 }
752 cross += acc << (a + b);
753 }
754 }
755 (total, cross)
756}
757
758/// One over `x`, or zero if there is no such thing.
759///
760/// A correction of zero cannot come out of a non zero residual, because it is
761/// the cosine between a vector and the corner of the cube it rounded to and
762/// that is bounded below by `1/sqrt(D)`. This is here so that if it ever does,
763/// the estimator returns a finite wrong answer for that one member rather than
764/// an infinity that poisons the ordering of everything it is sorted against.
765fn recip(x: f32) -> f32 {
766 if x == 0.0 { 0.0 } else { 1.0 / x }
767}
768
769/// The same dot product against a query that was never quantised, one
770/// coordinate at a time. The reference, not the scan.
771fn exact_dot(code: &[u8], planes: usize, words: usize, query: &[f32]) -> f32 {
772 assert_eq!(
773 code.len(),
774 planes * words * 8,
775 "a code here is {} bytes and this one is {}",
776 planes * words * 8,
777 code.len()
778 );
779 let mut sum = 0.0f32;
780 for (i, &qi) in query.iter().enumerate() {
781 let mut level = 0u32;
782 for b in 0..planes {
783 let at = (b * words + i / 64) * 8;
784 level |= (((word(&code[at..at + 8]) >> (i % 64)) & 1) as u32) << b;
785 }
786 sum += level as f32 * qi;
787 }
788 sum
789}
790
791/// Eight bytes as a word, the way a code stores one.
792fn word(bytes: &[u8]) -> u64 {
793 u64::from_le_bytes(bytes.try_into().expect("eight bytes"))
794}
795
796fn length(v: &[f32]) -> f32 {
797 v.iter().map(|c| c * c).sum::<f32>().sqrt()
798}
799
800#[cfg(test)]
801mod tests {
802 use super::*;
803 use yo_common::Rng;
804
805 /// Vectors that look a little like embeddings: not uniform, with a few
806 /// coordinates carrying more than their share, which is the case a rotation
807 /// is there to handle.
808 fn corpus(dim: usize, n: usize, seed: u64) -> Vec<Vec<f32>> {
809 let mut rng = Rng::new(seed);
810 (0..n)
811 .map(|_| {
812 let mut v: Vec<f32> = (0..dim)
813 .map(|i| {
814 let u = (rng.next_u64() >> 40) as f32 / (1u32 << 24) as f32;
815 let heavy = if i < dim / 16 { 6.0 } else { 1.0 };
816 (u * 2.0 - 1.0) * heavy
817 })
818 .collect();
819 let len = length(&v);
820 for c in &mut v {
821 *c /= len;
822 }
823 v
824 })
825 .collect()
826 }
827
828 fn mean(vs: &[Vec<f32>]) -> Vec<f32> {
829 let dim = vs[0].len();
830 let mut c = vec![0.0f32; dim];
831 for v in vs {
832 for (a, b) in c.iter_mut().zip(v) {
833 *a += b;
834 }
835 }
836 for a in &mut c {
837 *a /= vs.len() as f32;
838 }
839 c
840 }
841
842 fn exact(a: &[f32], b: &[f32]) -> f32 {
843 a.iter().zip(b).map(|(x, y)| (x - y) * (x - y)).sum()
844 }
845
846 fn residual(v: &[f32], c: &[f32]) -> Vec<f32> {
847 v.iter().zip(c).map(|(a, b)| a - b).collect()
848 }
849
850 fn dot(a: &[f32], b: &[f32]) -> f32 {
851 a.iter().zip(b).map(|(x, y)| x * y).sum()
852 }
853
854 /// Encode a corpus and return the codes with what goes beside them.
855 fn encode_all(q: &Quantizer, vs: &[Vec<f32>], c: &[f32]) -> (Vec<u8>, Vec<Coded>) {
856 let width = q.code_bytes();
857 let mut codes = vec![0u8; width * vs.len()];
858 let mut meta = Vec::with_capacity(vs.len());
859 for (i, v) in vs.iter().enumerate() {
860 meta.push(q.encode(v, c, &mut codes[i * width..(i + 1) * width]));
861 }
862 (codes, meta)
863 }
864
865 /// How often the true ten nearest are inside the `keep` best the codes
866 /// picked, which is the number that decides whether rerank has anything to
867 /// work with.
868 fn recall(bits: Bits, dim: usize, keep: usize) -> f32 {
869 let vs = corpus(dim, 800, 1);
870 let qs = corpus(dim, 40, 2);
871 let c = mean(&vs);
872 let q = Quantizer::new(dim, bits, 99);
873 let (codes, meta) = encode_all(&q, &vs, &c);
874 let width = q.code_bytes();
875
876 let mut hits = 0usize;
877 for query in &qs {
878 let mut truth: Vec<(usize, f32)> = vs
879 .iter()
880 .enumerate()
881 .map(|(i, v)| (i, exact(query, v)))
882 .collect();
883 truth.sort_by(|a, b| a.1.total_cmp(&b.1));
884 let want: Vec<usize> = truth[..10].iter().map(|(i, _)| *i).collect();
885
886 let prepared = q.query(query, &c);
887 let mut guess: Vec<(usize, f32)> = (0..vs.len())
888 .map(|i| {
889 let code = &codes[i * width..(i + 1) * width];
890 (i, prepared.distance(code, &meta[i]))
891 })
892 .collect();
893 guess.sort_by(|a, b| a.1.total_cmp(&b.1));
894 let got: Vec<usize> = guess[..keep].iter().map(|(i, _)| *i).collect();
895 hits += want.iter().filter(|i| got.contains(i)).count();
896 }
897 hits as f32 / (qs.len() * 10) as f32
898 }
899
900 /// Not a test, a table. Run it with
901 /// `cargo test -p yo-vector --release -- --ignored --nocapture recall_table`
902 /// when the estimator changes, because the two recall tests below only
903 /// assert a floor and this is where the floor came from.
904 #[test]
905 #[ignore = "prints a table rather than asserting anything"]
906 fn recall_table() {
907 for (bits, name) in [(Bits::One, "1 bit"), (Bits::Four, "4 bit")] {
908 for dim in [128usize, 256, 768] {
909 for keep in [10usize, 20, 40, 100] {
910 println!(
911 "{name} dim {dim} keep {keep}: {:.3}",
912 recall(bits, dim, keep)
913 );
914 }
915 }
916 }
917 }
918
919 #[test]
920 fn a_code_is_the_width_it_says_it_is() {
921 assert_eq!(Quantizer::new(768, Bits::One, 1).code_bytes(), 96);
922 assert_eq!(Quantizer::new(768, Bits::Four, 1).code_bytes(), 384);
923 // A plane is whole words, so a dimension that is not a multiple of 64
924 // pays for the rest of its last one. A hundred coordinates is two
925 // words either way.
926 assert_eq!(Quantizer::new(100, Bits::One, 1).code_bytes(), 16);
927 assert_eq!(Quantizer::new(128, Bits::One, 1).code_bytes(), 16);
928 }
929
930 #[test]
931 fn one_bit_finds_the_true_neighbours_inside_a_short_rerank() {
932 // Forty candidates for ten answers, which is the 4k rerank the search
933 // path defaults to.
934 let r = recall(Bits::One, 256, 40);
935 assert!(r >= 0.95, "recall at 10 was {r}");
936 }
937
938 #[test]
939 fn four_bits_is_better_than_one() {
940 // Twenty candidates for ten answers, which one bit does not manage and
941 // four does, so this measures the difference rather than asserting it.
942 let one = recall(Bits::One, 128, 20);
943 let four = recall(Bits::Four, 128, 20);
944 assert!(four > one, "one bit got {one} and four bits got {four}");
945 assert!(four >= 0.95, "four bit recall at 10 was {four}");
946 }
947
948 #[test]
949 fn the_estimate_is_close_to_the_truth_rather_than_merely_ordered() {
950 let dim = 256;
951 let vs = corpus(dim, 200, 5);
952 let qs = corpus(dim, 20, 6);
953 let c = mean(&vs);
954 let q = Quantizer::new(dim, Bits::One, 3);
955 let (codes, meta) = encode_all(&q, &vs, &c);
956 let width = q.code_bytes();
957
958 let mut worst = 0.0f32;
959 let mut bias = 0.0f32;
960 let mut n = 0usize;
961 for query in &qs {
962 let prepared = q.query(query, &c);
963 for (i, v) in vs.iter().enumerate() {
964 let truth = exact(query, v);
965 let guess = prepared.distance(&codes[i * width..(i + 1) * width], &meta[i]);
966 let err = (guess - truth) / truth;
967 worst = worst.max(err.abs());
968 bias += err;
969 n += 1;
970 }
971 }
972 let bias = bias / n as f32;
973 // Unbiased is the claim, so the average error should sit near zero
974 // rather than merely being small in absolute value.
975 assert!(
976 bias.abs() < 0.02,
977 "the estimate is off by {bias} on average"
978 );
979 assert!(worst < 0.5, "the worst estimate was off by {worst}");
980 }
981
982 /// What quantising the query costs, measured against what the code itself
983 /// costs, because that is the only comparison that means anything.
984 ///
985 /// The query's error and the code's are independent, so they add in
986 /// quadrature, and a query error a third of the code's makes the whole
987 /// estimate five percent worse. That is the bar. An absolute threshold here
988 /// would pass at four bit codes while the query was throwing away the whole
989 /// reason to pay for them, which is exactly what the first cut did.
990 #[test]
991 fn the_query_is_quantised_finer_than_the_code_it_is_measured_against() {
992 for (bits, dim) in [
993 (Bits::One, 128),
994 (Bits::One, 256),
995 (Bits::One, 768),
996 (Bits::Four, 256),
997 (Bits::Four, 768),
998 ] {
999 let vs = corpus(dim, 200, 5);
1000 let qs = corpus(dim, 20, 6);
1001 let c = mean(&vs);
1002 let q = Quantizer::new(dim, bits, 3);
1003 let (codes, meta) = encode_all(&q, &vs, &c);
1004 let width = q.code_bytes();
1005
1006 // How far the scan is from the same estimate on an unquantised
1007 // query, and how far that estimate is from the truth.
1008 let mut from_query = 0.0f32;
1009 let mut from_code = 0.0f32;
1010 for query in &qs {
1011 let prepared = q.query(query, &c);
1012 let qr = residual(query, &c);
1013 let qn = length(&qr);
1014 for (i, v) in vs.iter().enumerate() {
1015 let code = &codes[i * width..(i + 1) * width];
1016 let fast = prepared.cosine(code, &meta[i]);
1017 let slow = prepared.cosine_exact(code, &meta[i]);
1018 let vr = residual(v, &c);
1019 let truth = dot(&qr, &vr) / (qn * length(&vr));
1020 from_query += (fast - slow).abs();
1021 from_code += (slow - truth).abs();
1022 }
1023 }
1024 let ratio = from_query / from_code;
1025 assert!(
1026 ratio < 0.5,
1027 "{dim} at {bits:?}: the query costs {ratio} of what the code costs"
1028 );
1029 }
1030 }
1031
1032 #[test]
1033 fn a_vector_sitting_on_its_centroid_is_not_a_division_by_zero() {
1034 let q = Quantizer::new(16, Bits::One, 1);
1035 let c = vec![0.5f32; 16];
1036 let mut code = vec![0u8; q.code_bytes()];
1037 let coded = q.encode(&c, &c, &mut code);
1038 assert_eq!(coded.norm, 0.0);
1039 assert!(code.iter().all(|b| *b == 0));
1040
1041 let query = q.query(&[1.0f32; 16], &c);
1042 let d = query.distance(&code, &coded);
1043 assert!(d.is_finite(), "{d}");
1044 // The centroid is where it says it is, so the distance is the query's
1045 // own residual and nothing else.
1046 let want: f32 = (0..16).map(|_| 0.25f32).sum();
1047 assert!((d - want).abs() < 1e-3, "{d} against {want}");
1048 }
1049
1050 #[test]
1051 fn a_query_sitting_on_the_centroid_is_not_a_division_by_zero() {
1052 let q = Quantizer::new(16, Bits::One, 1);
1053 let c = vec![0.5f32; 16];
1054 let mut code = vec![0u8; q.code_bytes()];
1055 let v: Vec<f32> = (0..16).map(|i| i as f32 * 0.1).collect();
1056 let coded = q.encode(&v, &c, &mut code);
1057 let d = q.query(&c, &c).distance(&code, &coded);
1058 assert!(d.is_finite(), "{d}");
1059 }
1060
1061 #[test]
1062 fn a_code_is_written_over_whatever_was_in_the_buffer() {
1063 let q = Quantizer::new(32, Bits::One, 1);
1064 let c = vec![0.0f32; 32];
1065 let v: Vec<f32> = (0..32).map(|i| (i as f32).sin()).collect();
1066 let mut fresh = vec![0u8; q.code_bytes()];
1067 let mut dirty = vec![0xffu8; q.code_bytes()];
1068 let a = q.encode(&v, &c, &mut fresh);
1069 let b = q.encode(&v, &c, &mut dirty);
1070 assert_eq!(fresh, dirty);
1071 assert_eq!(a, b);
1072 // And the coordinates that are not there are not set either, because
1073 // the scan popcounts whole words and would count them.
1074 assert!(fresh[4..].iter().all(|b| *b == 0), "{fresh:?}");
1075 }
1076
1077 #[test]
1078 fn the_same_seed_is_the_same_code() {
1079 let v: Vec<f32> = (0..64).map(|i| (i as f32 * 0.3).cos()).collect();
1080 let c = vec![0.0f32; 64];
1081 let mut a = vec![0u8; 8];
1082 let mut b = vec![0u8; 8];
1083 Quantizer::new(64, Bits::One, 12).encode(&v, &c, &mut a);
1084 Quantizer::new(64, Bits::One, 12).encode(&v, &c, &mut b);
1085 assert_eq!(a, b);
1086 let mut d = vec![0u8; 8];
1087 Quantizer::new(64, Bits::One, 13).encode(&v, &c, &mut d);
1088 assert_ne!(a, d, "two seeds should not be one code");
1089 }
1090
1091 /// The planes are the layout the file will hold, so a code has to read back
1092 /// as the levels that went into it.
1093 #[test]
1094 fn a_code_reads_back_as_the_levels_it_was_written_from() {
1095 let dim = 200;
1096 let q = Quantizer::new(dim, Bits::Four, 4);
1097 let c = vec![0.0f32; dim];
1098 let v: Vec<f32> = (0..dim).map(|i| (i as f32 * 0.11).sin()).collect();
1099 let mut code = vec![0u8; q.code_bytes()];
1100 let coded = q.encode(&v, &c, &mut code);
1101
1102 // Pull every level back out of the planes and rebuild the vector the
1103 // code stands for. It should point the same way the original does.
1104 let words = words_of(dim);
1105 let mut back = vec![0.0f32; dim];
1106 for (i, b) in back.iter_mut().enumerate() {
1107 let mut level = 0u32;
1108 for p in 0..4 {
1109 let at = (p * words + i / 64) * 8;
1110 level |= (((word(&code[at..at + 8]) >> (i % 64)) & 1) as u32) << p;
1111 }
1112 *b = coded.lo + level as f32 * coded.delta;
1113 }
1114 let mut spun: Vec<f32> = v.iter().map(|c| c / length(&v)).collect();
1115 crate::Rotation::new(dim, q.seed()).apply(&mut spun);
1116 let cos = back
1117 .iter()
1118 .zip(&spun)
1119 .map(|(a, b)| a * b)
1120 .sum::<f32>()
1121 .abs()
1122 / length(&back);
1123 assert!(cos > 0.95, "the code points somewhere else: {cos}");
1124 }
1125}