Skip to main content

mnemo/
index.rs

1//! IVF + PQ approximate-nearest-neighbour index (Phase 2 of the build plan).
2//!
3//! Exact search is `O(n)`. This module makes retrieval sub-linear:
4//!
5//! 1. **IVF** (inverted file): vectors are grouped into roughly `√n`
6//!    partitions by k-means. A query scans only the `n_probe` partitions
7//!    whose centroids are nearest to it.
8//! 2. **PQ** (product quantization): each vector is cut into `m` contiguous
9//!    subspaces; every subspace is quantized to one of up to 256 learned
10//!    codewords, so a whole vector collapses to `m` bytes. Candidate
11//!    distances are summed from precomputed per-subspace lookup tables — the
12//!    scan never touches a full float vector.
13//! 3. **Rerank**: the `n_rerank` closest candidates by PQ distance are
14//!    returned to the store, which loads their exact vectors for final,
15//!    precise ranking.
16//!
17//! All distances *inside* the index are squared-L2. The index only has to
18//! produce a good candidate *set*; the store reranks with the caller's actual
19//! metric (cosine / dot / L2). For embedding vectors — which tend to have
20//! similar norms — L2-nearest and cosine-nearest largely agree, and a
21//! generous `n_rerank` absorbs the rest.
22
23use std::collections::HashMap;
24
25use rand::rngs::StdRng;
26use rand::seq::SliceRandom;
27use rand::{Rng, SeedableRng};
28use serde::{Deserialize, Serialize};
29
30use crate::error::{MnemoError, Result};
31
32/// Default RNG seed — fixed so index builds are reproducible.
33const DEFAULT_SEED: u64 = 0x4d_4e_45_4d_4f_49_44_58; // "MNEMOIDX"
34/// Training-set cap: k-means trains on at most this many sampled vectors.
35const TRAIN_CAP: usize = 50_000;
36/// Codewords per PQ subspace.
37const PQ_CODEWORDS: usize = 256;
38
39/// Tuning knobs for building and querying an [`IvfPqIndex`].
40#[derive(Clone, Copy, Debug)]
41pub struct IndexConfig {
42    /// Number of IVF partitions. `0` means auto (`ceil(√n)`).
43    pub n_partitions: usize,
44    /// Number of PQ subspaces. `0` means auto (≈ 8 dims per subspace).
45    pub pq_subspaces: usize,
46    /// Partitions scanned per query (the IVF accuracy/speed dial).
47    pub n_probe: usize,
48    /// Candidates handed back for exact reranking (the PQ accuracy dial).
49    pub n_rerank: usize,
50    /// Lloyd-iteration count for every k-means run.
51    pub kmeans_iters: usize,
52    /// RNG seed for reproducible builds.
53    pub seed: u64,
54}
55
56impl Default for IndexConfig {
57    fn default() -> Self {
58        Self {
59            n_partitions: 0,
60            pq_subspaces: 0,
61            n_probe: 8,
62            n_rerank: 64,
63            kmeans_iters: 25,
64            seed: DEFAULT_SEED,
65        }
66    }
67}
68
69/// A read-only snapshot of an index's shape, surfaced via `Mnemo::stats`.
70#[derive(Clone, Copy, Debug)]
71pub struct IndexInfo {
72    /// Vectors currently held in the index.
73    pub vectors: usize,
74    /// IVF partition count.
75    pub partitions: usize,
76    /// PQ subspace count.
77    pub subspaces: usize,
78    /// Default partitions probed per query.
79    pub n_probe: usize,
80    /// Default candidates reranked per query.
81    pub n_rerank: usize,
82}
83
84/// Squared Euclidean distance between two equal-length vectors.
85fn squared_l2(a: &[f32], b: &[f32]) -> f32 {
86    let mut s = 0.0f32;
87    for i in 0..a.len() {
88        let d = a[i] - b[i];
89        s += d * d;
90    }
91    s
92}
93
94/// Index of the nearest centroid (flat layout, `stride` floats each).
95fn nearest(centroids: &[f32], stride: usize, v: &[f32]) -> usize {
96    let mut best = 0usize;
97    let mut best_d = f32::INFINITY;
98    let count = centroids.len() / stride;
99    for i in 0..count {
100        let d = squared_l2(&centroids[i * stride..(i + 1) * stride], v);
101        if d < best_d {
102            best_d = d;
103            best = i;
104        }
105    }
106    best
107}
108
109/// k-means (k-means++ seeding, Lloyd iterations). Returns a flat
110/// `k * dim` centroid buffer; `k` may shrink to `points.len()`.
111fn kmeans(points: &[&[f32]], k: usize, iters: usize, rng: &mut StdRng) -> Vec<f32> {
112    let n = points.len();
113    let dim = points[0].len();
114    let k = k.clamp(1, n);
115
116    // --- k-means++ seeding ---
117    let mut centroids: Vec<f32> = Vec::with_capacity(k * dim);
118    let first = rng.gen_range(0..n);
119    centroids.extend_from_slice(points[first]);
120    let mut d2: Vec<f32> = points
121        .iter()
122        .map(|p| squared_l2(p, &centroids[0..dim]))
123        .collect();
124
125    while centroids.len() / dim < k {
126        let sum: f32 = d2.iter().sum();
127        let pick = if sum <= 0.0 {
128            rng.gen_range(0..n)
129        } else {
130            let mut t = rng.gen::<f32>() * sum;
131            let mut chosen = n - 1;
132            for (i, &w) in d2.iter().enumerate() {
133                t -= w;
134                if t <= 0.0 {
135                    chosen = i;
136                    break;
137                }
138            }
139            chosen
140        };
141        let base = centroids.len();
142        centroids.extend_from_slice(points[pick]);
143        let new = &centroids[base..base + dim];
144        for (i, p) in points.iter().enumerate() {
145            let nd = squared_l2(p, new);
146            if nd < d2[i] {
147                d2[i] = nd;
148            }
149        }
150    }
151
152    // --- Lloyd iterations ---
153    let kk = centroids.len() / dim;
154    for _ in 0..iters {
155        let mut sums = vec![0.0f32; kk * dim];
156        let mut counts = vec![0usize; kk];
157        for p in points {
158            let a = nearest(&centroids, dim, p);
159            counts[a] += 1;
160            for d in 0..dim {
161                sums[a * dim + d] += p[d];
162            }
163        }
164        for c in 0..kk {
165            if counts[c] == 0 {
166                // Reseed an empty cluster onto a random point.
167                let r = rng.gen_range(0..n);
168                centroids[c * dim..(c + 1) * dim].copy_from_slice(points[r]);
169            } else {
170                let inv = 1.0 / counts[c] as f32;
171                for d in 0..dim {
172                    centroids[c * dim + d] = sums[c * dim + d] * inv;
173                }
174            }
175        }
176    }
177    centroids
178}
179
180/// Product-quantization codebook: one learned set of codewords per subspace.
181#[derive(Serialize, Deserialize, Clone, Debug)]
182struct PqCodebook {
183    /// Subspace boundaries; subspace `s` spans `[sub_offsets[s], sub_offsets[s+1])`.
184    sub_offsets: Vec<usize>,
185    /// Per subspace: a flat `ks[s] * subdim` codeword buffer.
186    centroids: Vec<Vec<f32>>,
187    /// Codeword count per subspace (`≤ 256`, so a code fits in a `u8`).
188    ks: Vec<usize>,
189}
190
191impl PqCodebook {
192    fn m(&self) -> usize {
193        self.sub_offsets.len() - 1
194    }
195
196    /// Train one codebook per subspace on the sampled training vectors.
197    fn train(sub_offsets: &[usize], train: &[&[f32]], iters: usize, rng: &mut StdRng) -> PqCodebook {
198        let m = sub_offsets.len() - 1;
199        let mut centroids = Vec::with_capacity(m);
200        let mut ks = Vec::with_capacity(m);
201        for s in 0..m {
202            let (lo, hi) = (sub_offsets[s], sub_offsets[s + 1]);
203            let subs: Vec<Vec<f32>> = train.iter().map(|v| v[lo..hi].to_vec()).collect();
204            let refs: Vec<&[f32]> = subs.iter().map(|x| x.as_slice()).collect();
205            let k = PQ_CODEWORDS.min(refs.len()).max(1);
206            let cs = kmeans(&refs, k, iters, rng);
207            ks.push(cs.len() / (hi - lo));
208            centroids.push(cs);
209        }
210        PqCodebook { sub_offsets: sub_offsets.to_vec(), centroids, ks }
211    }
212
213    /// Encode a full vector into `m` codeword indices.
214    fn encode(&self, v: &[f32]) -> Vec<u8> {
215        let m = self.m();
216        let mut code = vec![0u8; m];
217        for (s, slot) in code.iter_mut().enumerate().take(m) {
218            let (lo, hi) = (self.sub_offsets[s], self.sub_offsets[s + 1]);
219            *slot = nearest(&self.centroids[s], hi - lo, &v[lo..hi]) as u8;
220        }
221        code
222    }
223
224    /// Precompute, for a query, the distance from each query subspace to every
225    /// codeword. Asymmetric distance = sum of one lookup per subspace.
226    fn distance_table(&self, q: &[f32]) -> Vec<Vec<f32>> {
227        let m = self.m();
228        let mut table = Vec::with_capacity(m);
229        for s in 0..m {
230            let (lo, hi) = (self.sub_offsets[s], self.sub_offsets[s + 1]);
231            let sd = hi - lo;
232            let qs = &q[lo..hi];
233            let cs = &self.centroids[s];
234            let mut row = vec![0.0f32; self.ks[s]];
235            for (c, slot) in row.iter_mut().enumerate() {
236                *slot = squared_l2(qs, &cs[c * sd..(c + 1) * sd]);
237            }
238            table.push(row);
239        }
240        table
241    }
242}
243
244/// One IVF partition: parallel arrays of memory IDs and their PQ codes.
245#[derive(Serialize, Deserialize, Clone, Debug, Default)]
246struct Posting {
247    ids: Vec<u128>,
248    /// Flat `ids.len() * m` byte buffer of PQ codes.
249    codes: Vec<u8>,
250}
251
252/// An IVF + PQ approximate-nearest-neighbour index over the database's vectors.
253#[derive(Serialize, Deserialize, Clone, Debug)]
254pub struct IvfPqIndex {
255    dims: usize,
256    n_partitions: usize,
257    n_probe: usize,
258    n_rerank: usize,
259    /// Flat `n_partitions * dims` IVF centroid buffer.
260    centroids: Vec<f32>,
261    pq: PqCodebook,
262    partitions: Vec<Posting>,
263    /// `id -> partition`. Reconstructed from `partitions` after load, never
264    /// serialized.
265    #[serde(skip)]
266    assignment: HashMap<u128, usize>,
267}
268
269impl IvfPqIndex {
270    /// Build an index over `items` (`(id, vector)` pairs).
271    pub fn build(dims: usize, items: &[(u128, &[f32])], cfg: IndexConfig) -> Result<IvfPqIndex> {
272        if items.is_empty() {
273            return Err(MnemoError::Invalid(
274                "cannot build an index over an empty database".into(),
275            ));
276        }
277        let n = items.len();
278        let mut rng = StdRng::seed_from_u64(cfg.seed);
279
280        // Training sample (all vectors when small enough).
281        let mut order: Vec<usize> = (0..n).collect();
282        let train_n = TRAIN_CAP.min(n);
283        order.partial_shuffle(&mut rng, train_n);
284        let train: Vec<&[f32]> = order[..train_n].iter().map(|&i| items[i].1).collect();
285
286        // IVF centroids.
287        let n_partitions = if cfg.n_partitions > 0 {
288            cfg.n_partitions
289        } else {
290            (n as f64).sqrt().ceil() as usize
291        }
292        .clamp(1, n);
293        let centroids = kmeans(&train, n_partitions, cfg.kmeans_iters, &mut rng);
294        let n_partitions = centroids.len() / dims;
295
296        // PQ subspaces — split `dims` into `m` near-equal contiguous spans.
297        let m = if cfg.pq_subspaces > 0 {
298            cfg.pq_subspaces
299        } else {
300            (dims / 8).max(1)
301        }
302        .clamp(1, dims);
303        let mut sub_offsets = Vec::with_capacity(m + 1);
304        for s in 0..=m {
305            sub_offsets.push(s * dims / m);
306        }
307        let pq = PqCodebook::train(&sub_offsets, &train, cfg.kmeans_iters, &mut rng);
308
309        let mut index = IvfPqIndex {
310            dims,
311            n_partitions,
312            n_probe: cfg.n_probe.max(1),
313            n_rerank: cfg.n_rerank.max(1),
314            centroids,
315            pq,
316            partitions: vec![Posting::default(); n_partitions],
317            assignment: HashMap::with_capacity(n),
318        };
319        for &(id, v) in items {
320            index.add(id, v);
321        }
322        Ok(index)
323    }
324
325    /// Embedding dimensionality the index was built for.
326    pub fn dims(&self) -> usize {
327        self.dims
328    }
329
330    /// Total vectors currently indexed.
331    pub fn len(&self) -> usize {
332        self.partitions.iter().map(|p| p.ids.len()).sum()
333    }
334
335    /// Default partitions probed per query.
336    pub fn n_probe(&self) -> usize {
337        self.n_probe
338    }
339
340    /// Default candidates reranked per query.
341    pub fn n_rerank(&self) -> usize {
342        self.n_rerank
343    }
344
345    /// Shape snapshot for statistics.
346    pub fn info(&self) -> IndexInfo {
347        IndexInfo {
348            vectors: self.len(),
349            partitions: self.n_partitions,
350            subspaces: self.pq.m(),
351            n_probe: self.n_probe,
352            n_rerank: self.n_rerank,
353        }
354    }
355
356    /// Rebuild the `id -> partition` map from the posting lists. Called once
357    /// after the index is deserialized (the map itself is not stored).
358    pub fn rebuild_assignment(&mut self) {
359        self.assignment.clear();
360        for (pi, p) in self.partitions.iter().enumerate() {
361            for &id in &p.ids {
362                self.assignment.insert(id, pi);
363            }
364        }
365    }
366
367    fn nearest_centroid(&self, v: &[f32]) -> usize {
368        nearest(&self.centroids, self.dims, v)
369    }
370
371    /// Insert or overwrite a vector. The PQ codebook and IVF centroids are
372    /// fixed at build time; only posting lists grow. This keeps the index
373    /// complete across inserts without a full rebuild — cluster drift is the
374    /// price, repaid by `Mnemo::rebuild_index` / `compact`.
375    pub fn add(&mut self, id: u128, vector: &[f32]) {
376        self.remove(id);
377        let part = self.nearest_centroid(vector);
378        let code = self.pq.encode(vector);
379        let p = &mut self.partitions[part];
380        p.ids.push(id);
381        p.codes.extend_from_slice(&code);
382        self.assignment.insert(id, part);
383    }
384
385    /// Remove a vector if present (no-op otherwise).
386    pub fn remove(&mut self, id: u128) {
387        if let Some(part) = self.assignment.remove(&id) {
388            let m = self.pq.m();
389            let p = &mut self.partitions[part];
390            if let Some(pos) = p.ids.iter().position(|&x| x == id) {
391                p.ids.remove(pos);
392                p.codes.drain(pos * m..(pos + 1) * m);
393            }
394        }
395    }
396
397    /// Run the tiered query: IVF probe → PQ scan → top-`n_rerank` candidates.
398    /// Returns candidate IDs (nearest first by PQ distance) for the store to
399    /// rerank exactly. `n_probe`/`n_rerank` of `None` use the build defaults.
400    pub fn query(
401        &self,
402        q: &[f32],
403        n_probe: Option<usize>,
404        n_rerank: Option<usize>,
405    ) -> Vec<u128> {
406        let n_probe = n_probe.unwrap_or(self.n_probe).clamp(1, self.n_partitions);
407        let n_rerank = n_rerank.unwrap_or(self.n_rerank).max(1);
408
409        // Stage 1 — pick the nearest partitions.
410        let mut parts: Vec<(usize, f32)> = (0..self.n_partitions)
411            .map(|i| {
412                let c = &self.centroids[i * self.dims..(i + 1) * self.dims];
413                (i, squared_l2(q, c))
414            })
415            .collect();
416        parts.sort_by(|a, b| a.1.total_cmp(&b.1));
417        parts.truncate(n_probe);
418
419        // Stage 2 — scan PQ codes in the selected partitions.
420        let table = self.pq.distance_table(q);
421        let m = self.pq.m();
422        let mut cands: Vec<(u128, f32)> = Vec::new();
423        for (pi, _) in parts {
424            let p = &self.partitions[pi];
425            for (j, &id) in p.ids.iter().enumerate() {
426                let code = &p.codes[j * m..(j + 1) * m];
427                let mut d = 0.0f32;
428                for (s, row) in table.iter().enumerate() {
429                    d += row[code[s] as usize];
430                }
431                cands.push((id, d));
432            }
433        }
434
435        // Stage 3 — keep the closest candidates for exact reranking.
436        cands.sort_by(|a, b| a.1.total_cmp(&b.1));
437        cands.truncate(n_rerank);
438        cands.into_iter().map(|(id, _)| id).collect()
439    }
440}