yo_vector/lib.rs
1//! The vector model: RaBitQ codes under partitions that update in place
2//! (`10`).
3//!
4//! A vector index is two decisions, and as of 2026 only one of them is still
5//! open. The quantiser is settled: RaBitQ won, and VectorChord, Lucene, which
6//! calls it BBQ, CockroachDB, turbopuffer and Zvec all landed on it against five
7//! different index structures inside eighteen months. [`Quantizer`] is that,
8//! and it is what makes a ten million vector collection a 1 GB index rather
9//! than a 30 GB one.
10//!
11//! The index is decided here by the update path. A graph index tombstones a
12//! delete, degrades as the tombstones pile up, and gets better again only when
13//! it is rebuilt. Redis shipped HNSW vector sets in 8.0 in May 2025 and they
14//! were still beta three minor releases later, which is the vendor's own
15//! evidence about how hard that path is. So this is partitions with the
16//! centroids resident and the codes in flat postings, which SPFresh showed can
17//! be split, merged and reassigned in place, and there is never a rebuild.
18//!
19//! # What is here so far
20//!
21//! The quantiser and the rotation it needs. [`Quantizer::encode`] writes the
22//! searchable form of a vector against the centroid of the partition it belongs
23//! to, and [`Quantizer::query`] prepares a query once so that measuring it
24//! against the codes in a partition is a scan over contiguous bytes.
25//!
26//! ```
27//! use yo_vector::{Bits, Quantizer};
28//!
29//! let q = Quantizer::new(128, Bits::One, 7);
30//! assert_eq!(q.code_bytes(), 16);
31//! ```
32//!
33//! A code is stored as bit planes rather than with each coordinate's bits next
34//! to each other, and the query is quantised and transposed the same way, so
35//! measuring one against the other is ANDs and popcounts rather than a float
36//! multiply per dimension. That is 20 nanoseconds a vector at 768 dimensions
37//! against a whole search budget of a millisecond, and 35 times what the same
38//! estimator costs with the query left in floats. `benches/rabitq.rs` runs both
39//! so the ratio is measured rather than remembered.
40//!
41//! [`Partitions`] is the index over those codes. A vector belongs to the
42//! partition whose centroid it is nearest, a partition's members are a flat run
43//! of codes, an insert is an append and a delete moves the last member into the
44//! hole. A search ranks the centroids, scans the nearest few postings, and then
45//! measures the best handful properly against the full precision vectors. It
46//! splits, merges and reassigns in bounded steps as it goes, which is SPFresh's
47//! LIRE, and it is why there is never a rebuild.
48//!
49//! The centroids are kept already rotated, which matters more than it sounds
50//! like it should. Preparing a query is mostly the rotation and it happens once
51//! per partition probed, so rotating the centroids once when they are built
52//! turns tens of rotations a search into one.
53//!
54//! They are read in full on every search, which sounds like the obvious thing to
55//! fix on a collection with thousands of them and is not. `src/rank.rs` is the
56//! measurement: coding them the way their members are coded is three to nine
57//! times slower than reading them, because reading them is already going at
58//! memory speed and the estimator that would replace it is not.
59//!
60//! `src/probe.rs` is where that decision gets its context. It splits a query
61//! into ranking the centroids, preparing the query against each partition
62//! probed, and scanning the postings, and it is what says which of the three is
63//! worth working on at a given dimension and collection size.
64//!
65//! [`Collection`] is the piece above that, and it is the one both doors reach.
66//! [`Partitions`] deals in ids and knows nothing about the key a client wrote a
67//! vector under, what metric the collection was opened with, or where the full
68//! precision vectors live, because none of those are the index's business.
69//! `db.vectors()` in a Rust program and `VADD` off a socket both need all three,
70//! so they are answered once here rather than twice above.
71//!
72//! A filter runs inside the posting scan rather than after it. Every member
73//! carries a tag word beside its code, and a scan that can reject a member
74//! before it measures one can keep going into further partitions until it has
75//! enough that pass. That widening is the whole point: a selective filter means
76//! the nearest partitions may hold nothing the caller asked for, and a search
77//! that does not go looking is a recall lottery. [`Signature`] packs arbitrary
78//! attribute values into that one word, and it is allowed to say yes when it
79//! should have said no but never the other way round, so the caller's own
80//! predicate stays the authority. That predicate has a place to live too:
81//! [`Filter::exact`] sees the member's id and runs only on members the tag let
82//! through that are near enough to be ranked, which is what lets an expression
83//! over a JSON string be the real answer without the scan ever reading one.
84//!
85//! [`muvera`] is late interaction retrieval on that same index. A ColBERT style
86//! model gives a document one vector per token and scores a query against it
87//! with Chamfer similarity, which normally means a second index over every
88//! token of every document and a scoring pass on top of it. MUVERA maps a set
89//! of token vectors to one fixed length vector whose dot product approximates
90//! Chamfer, so it costs an encode at write time, the index that is already
91//! here, and [`muvera::chamfer`] as the rerank. There is no second index.
92//!
93//! [`hnsw`] is the compatibility view. Clients pass `M`, `EF_CONSTRUCTION` and
94//! `EF_RUNTIME` and expect them to do something, because against Redis and
95//! valkey they do, and there is no graph here to point them at. So each one is
96//! mapped onto whatever it was actually for: build effort becomes the posting
97//! size, search beam becomes the probe and the rerank width, and `M` is the out
98//! degree of a graph that does not exist, so it is echoed back and changes
99//! nothing. A client that asked for HNSW and meant it can say so and be
100//! refused rather than quietly served something else.
101//!
102//! [`image`] is how any of it survives a restart. A collection is written down
103//! as the format's `10` section 2 says, one chain per partition under a
104//! checkpoint, and read back without a single vector being requantised. The
105//! vectors themselves are not in there, because they are already records of kind
106//! 3 in the log, so a load takes the shape and the codes from the image and the
107//! vectors from whatever the caller points it at.
108
109#![deny(missing_docs)]
110
111pub(crate) mod coarse;
112pub mod collection;
113pub(crate) mod dist;
114pub mod hnsw;
115pub mod image;
116mod miss;
117pub mod muvera;
118mod narrow;
119pub mod partition;
120mod probe;
121pub mod rabitq;
122mod rank;
123pub mod rotate;
124
125pub use collection::{Collection, Match};
126pub use image::{Restored, Stored};
127pub use partition::{Any, Filter, Hit, Partitions, Signature, Tuning, Vectors, Work};
128pub use rabitq::{Bits, Coded, Quantizer, Query};
129pub use rotate::Rotation;