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///
71/// `Serialize`/`Deserialize` derived so the MCP `stats` tool (and any
72/// other JSON-shaped consumer) can emit / round-trip it without a
73/// per-field object build. Plain-old-data type — no invariants to
74/// preserve across (de)serialization.
75#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
76pub struct IndexInfo {
77    /// Vectors currently held in the index.
78    pub vectors: usize,
79    /// IVF partition count.
80    pub partitions: usize,
81    /// PQ subspace count.
82    pub subspaces: usize,
83    /// Default partitions probed per query.
84    pub n_probe: usize,
85    /// Default candidates reranked per query.
86    pub n_rerank: usize,
87}
88
89/// Squared Euclidean distance between two equal-length vectors.
90fn squared_l2(a: &[f32], b: &[f32]) -> f32 {
91    let mut s = 0.0f32;
92    for i in 0..a.len() {
93        let d = a[i] - b[i];
94        s += d * d;
95    }
96    s
97}
98
99/// Index of the nearest centroid (flat layout, `stride` floats each).
100fn nearest(centroids: &[f32], stride: usize, v: &[f32]) -> usize {
101    let mut best = 0usize;
102    let mut best_d = f32::INFINITY;
103    let count = centroids.len() / stride;
104    for i in 0..count {
105        let d = squared_l2(&centroids[i * stride..(i + 1) * stride], v);
106        if d < best_d {
107            best_d = d;
108            best = i;
109        }
110    }
111    best
112}
113
114/// k-means (k-means++ seeding, Lloyd iterations). Returns a flat
115/// `k * dim` centroid buffer; `k` may shrink to `points.len()`.
116fn kmeans(points: &[&[f32]], k: usize, iters: usize, rng: &mut StdRng) -> Vec<f32> {
117    let n = points.len();
118    let dim = points[0].len();
119    let k = k.clamp(1, n);
120
121    // --- k-means++ seeding ---
122    let mut centroids: Vec<f32> = Vec::with_capacity(k * dim);
123    let first = rng.gen_range(0..n);
124    centroids.extend_from_slice(points[first]);
125    let mut d2: Vec<f32> = points
126        .iter()
127        .map(|p| squared_l2(p, &centroids[0..dim]))
128        .collect();
129
130    while centroids.len() / dim < k {
131        let sum: f32 = d2.iter().sum();
132        let pick = if sum <= 0.0 {
133            rng.gen_range(0..n)
134        } else {
135            let mut t = rng.gen::<f32>() * sum;
136            let mut chosen = n - 1;
137            for (i, &w) in d2.iter().enumerate() {
138                t -= w;
139                if t <= 0.0 {
140                    chosen = i;
141                    break;
142                }
143            }
144            chosen
145        };
146        let base = centroids.len();
147        centroids.extend_from_slice(points[pick]);
148        let new = &centroids[base..base + dim];
149        for (i, p) in points.iter().enumerate() {
150            let nd = squared_l2(p, new);
151            if nd < d2[i] {
152                d2[i] = nd;
153            }
154        }
155    }
156
157    // --- Lloyd iterations ---
158    let kk = centroids.len() / dim;
159    for _ in 0..iters {
160        let mut sums = vec![0.0f32; kk * dim];
161        let mut counts = vec![0usize; kk];
162        for p in points {
163            let a = nearest(&centroids, dim, p);
164            counts[a] += 1;
165            for d in 0..dim {
166                sums[a * dim + d] += p[d];
167            }
168        }
169        for c in 0..kk {
170            if counts[c] == 0 {
171                // Reseed an empty cluster onto a random point.
172                let r = rng.gen_range(0..n);
173                centroids[c * dim..(c + 1) * dim].copy_from_slice(points[r]);
174            } else {
175                let inv = 1.0 / counts[c] as f32;
176                for d in 0..dim {
177                    centroids[c * dim + d] = sums[c * dim + d] * inv;
178                }
179            }
180        }
181    }
182    centroids
183}
184
185/// Product-quantization codebook: one learned set of codewords per subspace.
186#[derive(Serialize, Deserialize, Clone, Debug)]
187struct PqCodebook {
188    /// Subspace boundaries; subspace `s` spans `[sub_offsets[s], sub_offsets[s+1])`.
189    sub_offsets: Vec<usize>,
190    /// Per subspace: a flat `ks[s] * subdim` codeword buffer.
191    centroids: Vec<Vec<f32>>,
192    /// Codeword count per subspace (`≤ 256`, so a code fits in a `u8`).
193    ks: Vec<usize>,
194}
195
196impl PqCodebook {
197    fn m(&self) -> usize {
198        self.sub_offsets.len() - 1
199    }
200
201    /// Train one codebook per subspace on the sampled training vectors.
202    fn train(
203        sub_offsets: &[usize],
204        train: &[&[f32]],
205        iters: usize,
206        rng: &mut StdRng,
207    ) -> PqCodebook {
208        let m = sub_offsets.len() - 1;
209        let mut centroids = Vec::with_capacity(m);
210        let mut ks = Vec::with_capacity(m);
211        for s in 0..m {
212            let (lo, hi) = (sub_offsets[s], sub_offsets[s + 1]);
213            let subs: Vec<Vec<f32>> = train.iter().map(|v| v[lo..hi].to_vec()).collect();
214            let refs: Vec<&[f32]> = subs.iter().map(|x| x.as_slice()).collect();
215            let k = PQ_CODEWORDS.min(refs.len()).max(1);
216            let cs = kmeans(&refs, k, iters, rng);
217            ks.push(cs.len() / (hi - lo));
218            centroids.push(cs);
219        }
220        PqCodebook {
221            sub_offsets: sub_offsets.to_vec(),
222            centroids,
223            ks,
224        }
225    }
226
227    /// Encode a full vector into `m` codeword indices.
228    fn encode(&self, v: &[f32]) -> Vec<u8> {
229        let m = self.m();
230        let mut code = vec![0u8; m];
231        for (s, slot) in code.iter_mut().enumerate().take(m) {
232            let (lo, hi) = (self.sub_offsets[s], self.sub_offsets[s + 1]);
233            *slot = nearest(&self.centroids[s], hi - lo, &v[lo..hi]) as u8;
234        }
235        code
236    }
237
238    /// Precompute, for a query, the distance from each query subspace to every
239    /// codeword. Asymmetric distance = sum of one lookup per subspace.
240    fn distance_table(&self, q: &[f32]) -> Vec<Vec<f32>> {
241        let m = self.m();
242        let mut table = Vec::with_capacity(m);
243        for s in 0..m {
244            let (lo, hi) = (self.sub_offsets[s], self.sub_offsets[s + 1]);
245            let sd = hi - lo;
246            let qs = &q[lo..hi];
247            let cs = &self.centroids[s];
248            let mut row = vec![0.0f32; self.ks[s]];
249            for (c, slot) in row.iter_mut().enumerate() {
250                *slot = squared_l2(qs, &cs[c * sd..(c + 1) * sd]);
251            }
252            table.push(row);
253        }
254        table
255    }
256}
257
258/// One IVF partition: parallel arrays of memory IDs and their PQ codes.
259#[derive(Serialize, Deserialize, Clone, Debug, Default)]
260struct Posting {
261    ids: Vec<u128>,
262    /// Flat `ids.len() * m` byte buffer of PQ codes.
263    codes: Vec<u8>,
264}
265
266/// An IVF + PQ approximate-nearest-neighbour index over the database's vectors.
267#[derive(Serialize, Deserialize, Clone, Debug)]
268pub struct IvfPqIndex {
269    dims: usize,
270    n_partitions: usize,
271    n_probe: usize,
272    n_rerank: usize,
273    /// Flat `n_partitions * dims` IVF centroid buffer.
274    centroids: Vec<f32>,
275    pq: PqCodebook,
276    partitions: Vec<Posting>,
277    /// `id -> partition`. Reconstructed from `partitions` after load, never
278    /// serialized.
279    #[serde(skip)]
280    assignment: HashMap<u128, usize>,
281}
282
283impl IvfPqIndex {
284    /// Build an index over `items` (`(id, vector)` pairs).
285    pub fn build(dims: usize, items: &[(u128, &[f32])], cfg: IndexConfig) -> Result<IvfPqIndex> {
286        if items.is_empty() {
287            return Err(MnemoError::Invalid(
288                "cannot build an index over an empty database".into(),
289            ));
290        }
291        let n = items.len();
292        let mut rng = StdRng::seed_from_u64(cfg.seed);
293
294        // Training sample (all vectors when small enough).
295        let mut order: Vec<usize> = (0..n).collect();
296        let train_n = TRAIN_CAP.min(n);
297        order.partial_shuffle(&mut rng, train_n);
298        let train: Vec<&[f32]> = order[..train_n].iter().map(|&i| items[i].1).collect();
299
300        // IVF centroids.
301        let n_partitions = if cfg.n_partitions > 0 {
302            cfg.n_partitions
303        } else {
304            (n as f64).sqrt().ceil() as usize
305        }
306        .clamp(1, n);
307        let centroids = kmeans(&train, n_partitions, cfg.kmeans_iters, &mut rng);
308        let n_partitions = centroids.len() / dims;
309
310        // PQ subspaces — split `dims` into `m` near-equal contiguous spans.
311        let m = if cfg.pq_subspaces > 0 {
312            cfg.pq_subspaces
313        } else {
314            (dims / 8).max(1)
315        }
316        .clamp(1, dims);
317        let mut sub_offsets = Vec::with_capacity(m + 1);
318        for s in 0..=m {
319            sub_offsets.push(s * dims / m);
320        }
321        let pq = PqCodebook::train(&sub_offsets, &train, cfg.kmeans_iters, &mut rng);
322
323        let mut index = IvfPqIndex {
324            dims,
325            n_partitions,
326            n_probe: cfg.n_probe.max(1),
327            n_rerank: cfg.n_rerank.max(1),
328            centroids,
329            pq,
330            partitions: vec![Posting::default(); n_partitions],
331            assignment: HashMap::with_capacity(n),
332        };
333        for &(id, v) in items {
334            index.add(id, v);
335        }
336        Ok(index)
337    }
338
339    /// Embedding dimensionality the index was built for.
340    pub fn dims(&self) -> usize {
341        self.dims
342    }
343
344    /// Total vectors currently indexed.
345    pub fn len(&self) -> usize {
346        self.partitions.iter().map(|p| p.ids.len()).sum()
347    }
348
349    /// Default partitions probed per query.
350    pub fn n_probe(&self) -> usize {
351        self.n_probe
352    }
353
354    /// Default candidates reranked per query.
355    pub fn n_rerank(&self) -> usize {
356        self.n_rerank
357    }
358
359    /// Shape snapshot for statistics.
360    pub fn info(&self) -> IndexInfo {
361        IndexInfo {
362            vectors: self.len(),
363            partitions: self.n_partitions,
364            subspaces: self.pq.m(),
365            n_probe: self.n_probe,
366            n_rerank: self.n_rerank,
367        }
368    }
369
370    /// Rebuild the `id -> partition` map from the posting lists. Called once
371    /// after the index is deserialized (the map itself is not stored).
372    pub fn rebuild_assignment(&mut self) {
373        self.assignment.clear();
374        for (pi, p) in self.partitions.iter().enumerate() {
375            for &id in &p.ids {
376                self.assignment.insert(id, pi);
377            }
378        }
379    }
380
381    fn nearest_centroid(&self, v: &[f32]) -> usize {
382        nearest(&self.centroids, self.dims, v)
383    }
384
385    /// Insert or overwrite a vector. The PQ codebook and IVF centroids are
386    /// fixed at build time; only posting lists grow. This keeps the index
387    /// complete across inserts without a full rebuild — cluster drift is the
388    /// price, repaid by `Mnemo::rebuild_index` / `compact`.
389    pub fn add(&mut self, id: u128, vector: &[f32]) {
390        self.remove(id);
391        let part = self.nearest_centroid(vector);
392        let code = self.pq.encode(vector);
393        let p = &mut self.partitions[part];
394        p.ids.push(id);
395        p.codes.extend_from_slice(&code);
396        self.assignment.insert(id, part);
397    }
398
399    /// Remove a vector if present (no-op otherwise).
400    pub fn remove(&mut self, id: u128) {
401        if let Some(part) = self.assignment.remove(&id) {
402            let m = self.pq.m();
403            let p = &mut self.partitions[part];
404            if let Some(pos) = p.ids.iter().position(|&x| x == id) {
405                p.ids.remove(pos);
406                p.codes.drain(pos * m..(pos + 1) * m);
407            }
408        }
409    }
410
411    /// Run the tiered query: IVF probe → PQ scan → top-`n_rerank` candidates.
412    /// Returns candidate IDs (nearest first by PQ distance) for the store to
413    /// rerank exactly. `n_probe`/`n_rerank` of `None` use the build defaults.
414    pub fn query(&self, q: &[f32], n_probe: Option<usize>, n_rerank: Option<usize>) -> Vec<u128> {
415        let n_probe = n_probe.unwrap_or(self.n_probe).clamp(1, self.n_partitions);
416        let n_rerank = n_rerank.unwrap_or(self.n_rerank).max(1);
417
418        // Stage 1 — pick the nearest partitions.
419        let mut parts: Vec<(usize, f32)> = (0..self.n_partitions)
420            .map(|i| {
421                let c = &self.centroids[i * self.dims..(i + 1) * self.dims];
422                (i, squared_l2(q, c))
423            })
424            .collect();
425        parts.sort_by(|a, b| a.1.total_cmp(&b.1));
426        parts.truncate(n_probe);
427
428        // Stage 2 — scan PQ codes in the selected partitions.
429        let table = self.pq.distance_table(q);
430        let m = self.pq.m();
431        let mut cands: Vec<(u128, f32)> = Vec::new();
432        for (pi, _) in parts {
433            let p = &self.partitions[pi];
434            for (j, &id) in p.ids.iter().enumerate() {
435                let code = &p.codes[j * m..(j + 1) * m];
436                let mut d = 0.0f32;
437                for (s, row) in table.iter().enumerate() {
438                    d += row[code[s] as usize];
439                }
440                cands.push((id, d));
441            }
442        }
443
444        // Stage 3 — keep the closest candidates for exact reranking.
445        cands.sort_by(|a, b| a.1.total_cmp(&b.1));
446        cands.truncate(n_rerank);
447        cands.into_iter().map(|(id, _)| id).collect()
448    }
449}