use std::collections::HashMap;
use rand::rngs::StdRng;
use rand::seq::SliceRandom;
use rand::{Rng, SeedableRng};
use serde::{Deserialize, Serialize};
use crate::error::{MnemoError, Result};
const DEFAULT_SEED: u64 = 0x4d_4e_45_4d_4f_49_44_58; const TRAIN_CAP: usize = 50_000;
const PQ_CODEWORDS: usize = 256;
#[derive(Clone, Copy, Debug)]
pub struct IndexConfig {
pub n_partitions: usize,
pub pq_subspaces: usize,
pub n_probe: usize,
pub n_rerank: usize,
pub kmeans_iters: usize,
pub seed: u64,
}
pub(crate) fn effective_auto_n_probe(partitions: usize) -> usize {
let half = partitions.div_ceil(2);
let full_up_to_100 = partitions.min(100);
half.max(full_up_to_100).max(1)
}
pub(crate) fn effective_auto_n_rerank(n_vectors: usize) -> usize {
(n_vectors / 200).clamp(100, 1000)
}
impl Default for IndexConfig {
fn default() -> Self {
Self {
n_partitions: 0,
pq_subspaces: 0,
n_probe: 0,
n_rerank: 0,
kmeans_iters: 25,
seed: DEFAULT_SEED,
}
}
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
pub struct IndexInfo {
pub vectors: usize,
pub partitions: usize,
pub subspaces: usize,
pub n_probe: usize,
pub n_rerank: usize,
}
fn squared_l2(a: &[f32], b: &[f32]) -> f32 {
let mut s = 0.0f32;
for i in 0..a.len() {
let d = a[i] - b[i];
s += d * d;
}
s
}
fn nearest(centroids: &[f32], stride: usize, v: &[f32]) -> usize {
let mut best = 0usize;
let mut best_d = f32::INFINITY;
let count = centroids.len() / stride;
for i in 0..count {
let d = squared_l2(¢roids[i * stride..(i + 1) * stride], v);
if d < best_d {
best_d = d;
best = i;
}
}
best
}
fn kmeans(points: &[&[f32]], k: usize, iters: usize, rng: &mut StdRng) -> Vec<f32> {
let n = points.len();
let dim = points[0].len();
let k = k.clamp(1, n);
let mut centroids: Vec<f32> = Vec::with_capacity(k * dim);
let first = rng.gen_range(0..n);
centroids.extend_from_slice(points[first]);
let mut d2: Vec<f32> = points
.iter()
.map(|p| squared_l2(p, ¢roids[0..dim]))
.collect();
while centroids.len() / dim < k {
let sum: f32 = d2.iter().sum();
let pick = if sum <= 0.0 {
rng.gen_range(0..n)
} else {
let mut t = rng.gen::<f32>() * sum;
let mut chosen = n - 1;
for (i, &w) in d2.iter().enumerate() {
t -= w;
if t <= 0.0 {
chosen = i;
break;
}
}
chosen
};
let base = centroids.len();
centroids.extend_from_slice(points[pick]);
let new = ¢roids[base..base + dim];
for (i, p) in points.iter().enumerate() {
let nd = squared_l2(p, new);
if nd < d2[i] {
d2[i] = nd;
}
}
}
let kk = centroids.len() / dim;
for _ in 0..iters {
let mut sums = vec![0.0f32; kk * dim];
let mut counts = vec![0usize; kk];
for p in points {
let a = nearest(¢roids, dim, p);
counts[a] += 1;
for d in 0..dim {
sums[a * dim + d] += p[d];
}
}
for c in 0..kk {
if counts[c] == 0 {
let r = rng.gen_range(0..n);
centroids[c * dim..(c + 1) * dim].copy_from_slice(points[r]);
} else {
let inv = 1.0 / counts[c] as f32;
for d in 0..dim {
centroids[c * dim + d] = sums[c * dim + d] * inv;
}
}
}
}
centroids
}
#[derive(Serialize, Deserialize, Clone, Debug)]
struct PqCodebook {
sub_offsets: Vec<usize>,
centroids: Vec<Vec<f32>>,
ks: Vec<usize>,
}
impl PqCodebook {
fn m(&self) -> usize {
self.sub_offsets.len() - 1
}
fn train(
sub_offsets: &[usize],
train: &[&[f32]],
iters: usize,
rng: &mut StdRng,
) -> PqCodebook {
let m = sub_offsets.len() - 1;
let mut centroids = Vec::with_capacity(m);
let mut ks = Vec::with_capacity(m);
for s in 0..m {
let (lo, hi) = (sub_offsets[s], sub_offsets[s + 1]);
let subs: Vec<Vec<f32>> = train.iter().map(|v| v[lo..hi].to_vec()).collect();
let refs: Vec<&[f32]> = subs.iter().map(|x| x.as_slice()).collect();
let k = PQ_CODEWORDS.min(refs.len()).max(1);
let cs = kmeans(&refs, k, iters, rng);
ks.push(cs.len() / (hi - lo));
centroids.push(cs);
}
PqCodebook {
sub_offsets: sub_offsets.to_vec(),
centroids,
ks,
}
}
fn encode(&self, v: &[f32]) -> Vec<u8> {
let m = self.m();
let mut code = vec![0u8; m];
for (s, slot) in code.iter_mut().enumerate().take(m) {
let (lo, hi) = (self.sub_offsets[s], self.sub_offsets[s + 1]);
*slot = nearest(&self.centroids[s], hi - lo, &v[lo..hi]) as u8;
}
code
}
fn distance_table(&self, q: &[f32]) -> Vec<Vec<f32>> {
let m = self.m();
let mut table = Vec::with_capacity(m);
for s in 0..m {
let (lo, hi) = (self.sub_offsets[s], self.sub_offsets[s + 1]);
let sd = hi - lo;
let qs = &q[lo..hi];
let cs = &self.centroids[s];
let mut row = vec![0.0f32; self.ks[s]];
for (c, slot) in row.iter_mut().enumerate() {
*slot = squared_l2(qs, &cs[c * sd..(c + 1) * sd]);
}
table.push(row);
}
table
}
}
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
struct Posting {
ids: Vec<u128>,
codes: Vec<u8>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct IvfPqIndex {
dims: usize,
n_partitions: usize,
n_probe: usize,
n_rerank: usize,
centroids: Vec<f32>,
pq: PqCodebook,
partitions: Vec<Posting>,
#[serde(skip)]
assignment: HashMap<u128, usize>,
}
impl IvfPqIndex {
pub fn build(dims: usize, items: &[(u128, &[f32])], cfg: IndexConfig) -> Result<IvfPqIndex> {
if items.is_empty() {
return Err(MnemoError::Invalid(
"cannot build an index over an empty database".into(),
));
}
let n = items.len();
let mut rng = StdRng::seed_from_u64(cfg.seed);
let mut order: Vec<usize> = (0..n).collect();
let train_n = TRAIN_CAP.min(n);
order.partial_shuffle(&mut rng, train_n);
let train: Vec<&[f32]> = order[..train_n].iter().map(|&i| items[i].1).collect();
let n_partitions = if cfg.n_partitions > 0 {
cfg.n_partitions
} else {
(n as f64).sqrt().ceil() as usize
}
.clamp(1, n);
let centroids = kmeans(&train, n_partitions, cfg.kmeans_iters, &mut rng);
let n_partitions = centroids.len() / dims;
let m = if cfg.pq_subspaces > 0 {
cfg.pq_subspaces
} else {
(dims / 4).max(1)
}
.clamp(1, dims);
let mut sub_offsets = Vec::with_capacity(m + 1);
for s in 0..=m {
sub_offsets.push(s * dims / m);
}
let pq = PqCodebook::train(&sub_offsets, &train, cfg.kmeans_iters, &mut rng);
let n_probe = if cfg.n_probe > 0 {
cfg.n_probe
} else {
effective_auto_n_probe(n_partitions)
};
let n_rerank = if cfg.n_rerank > 0 {
cfg.n_rerank
} else {
effective_auto_n_rerank(items.len())
};
let mut index = IvfPqIndex {
dims,
n_partitions,
n_probe: n_probe.max(1),
n_rerank: n_rerank.max(1),
centroids,
pq,
partitions: vec![Posting::default(); n_partitions],
assignment: HashMap::with_capacity(n),
};
for &(id, v) in items {
index.add(id, v);
}
Ok(index)
}
pub(crate) fn effective_defaults(&self) -> (usize, usize) {
if self.n_probe == 8 && self.n_rerank == 64 {
(
effective_auto_n_probe(self.n_partitions),
effective_auto_n_rerank(self.len()),
)
} else {
(self.n_probe, self.n_rerank)
}
}
pub fn dims(&self) -> usize {
self.dims
}
pub fn len(&self) -> usize {
self.partitions.iter().map(|p| p.ids.len()).sum()
}
pub fn info(&self) -> IndexInfo {
let (eff_probe, eff_rerank) = self.effective_defaults();
IndexInfo {
vectors: self.len(),
partitions: self.n_partitions,
subspaces: self.pq.m(),
n_probe: eff_probe,
n_rerank: eff_rerank,
}
}
pub fn rebuild_assignment(&mut self) {
self.assignment.clear();
for (pi, p) in self.partitions.iter().enumerate() {
for &id in &p.ids {
self.assignment.insert(id, pi);
}
}
}
fn nearest_centroid(&self, v: &[f32]) -> usize {
nearest(&self.centroids, self.dims, v)
}
pub fn add(&mut self, id: u128, vector: &[f32]) {
self.remove(id);
let part = self.nearest_centroid(vector);
let code = self.pq.encode(vector);
let p = &mut self.partitions[part];
p.ids.push(id);
p.codes.extend_from_slice(&code);
self.assignment.insert(id, part);
}
pub fn remove(&mut self, id: u128) {
if let Some(part) = self.assignment.remove(&id) {
let m = self.pq.m();
let p = &mut self.partitions[part];
if let Some(pos) = p.ids.iter().position(|&x| x == id) {
p.ids.remove(pos);
p.codes.drain(pos * m..(pos + 1) * m);
}
}
}
pub fn query(&self, q: &[f32], n_probe: Option<usize>, n_rerank: Option<usize>) -> Vec<u128> {
let (auto_probe, auto_rerank) = self.effective_defaults();
let n_probe = n_probe.unwrap_or(auto_probe).clamp(1, self.n_partitions);
let n_rerank = n_rerank.unwrap_or(auto_rerank).max(1);
let mut parts: Vec<(usize, f32)> = (0..self.n_partitions)
.map(|i| {
let c = &self.centroids[i * self.dims..(i + 1) * self.dims];
(i, squared_l2(q, c))
})
.collect();
parts.sort_by(|a, b| a.1.total_cmp(&b.1));
parts.truncate(n_probe);
let table = self.pq.distance_table(q);
let m = self.pq.m();
let mut cands: Vec<(u128, f32)> = Vec::new();
for (pi, _) in parts {
let p = &self.partitions[pi];
for (j, &id) in p.ids.iter().enumerate() {
let code = &p.codes[j * m..(j + 1) * m];
let mut d = 0.0f32;
for (s, row) in table.iter().enumerate() {
d += row[code[s] as usize];
}
cands.push((id, d));
}
}
if n_rerank < cands.len() {
cands.select_nth_unstable_by(n_rerank - 1, |a, b| a.1.total_cmp(&b.1));
cands.truncate(n_rerank);
}
cands.sort_by(|a, b| a.1.total_cmp(&b.1));
cands.into_iter().map(|(id, _)| id).collect()
}
}
#[cfg(test)]
mod ann_1_1_tests {
use super::*;
use rand::rngs::StdRng;
use rand::SeedableRng;
#[derive(serde::Serialize, serde::Deserialize)]
struct V052IvfPqIndex {
dims: usize,
n_partitions: usize,
n_probe: usize,
n_rerank: usize,
centroids: Vec<f32>,
pq: PqCodebook,
partitions: Vec<Posting>,
}
fn tiny_pq(dims: usize) -> PqCodebook {
let sub_offsets = vec![0, dims];
let mut rng = StdRng::seed_from_u64(0);
let train: Vec<Vec<f32>> = (0..4).map(|i| vec![i as f32; dims]).collect();
let refs: Vec<&[f32]> = train.iter().map(|v| v.as_slice()).collect();
PqCodebook::train(&sub_offsets, &refs, 2, &mut rng)
}
#[test]
fn v0_5_2_index_blob_decodes_unchanged() {
let dims = 4;
let legacy = V052IvfPqIndex {
dims,
n_partitions: 2,
n_probe: 8,
n_rerank: 64,
centroids: vec![1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0],
pq: tiny_pq(dims),
partitions: vec![Posting::default(); 2],
};
let bytes = rmp_serde::to_vec(&legacy).expect("serialize v0.5.2 layout");
let decoded: IvfPqIndex =
rmp_serde::from_slice(&bytes).expect("v0.5.2 bytes must decode under current schema");
assert_eq!(decoded.dims, 4);
assert_eq!(decoded.n_partitions, 2);
assert_eq!(decoded.n_probe, 8);
assert_eq!(decoded.n_rerank, 64);
}
#[test]
fn current_build_serializes_with_v0_5_2_field_count() {
let dims = 4;
let items: Vec<(u128, Vec<f32>)> = (0..32)
.map(|i| (i as u128, vec![i as f32, i as f32 + 1.0, 0.0, 0.0]))
.collect();
let refs: Vec<(u128, &[f32])> = items.iter().map(|(id, v)| (*id, v.as_slice())).collect();
let idx = IvfPqIndex::build(dims, &refs, IndexConfig::default()).unwrap();
let bytes = rmp_serde::to_vec(&idx).expect("serialize current layout");
let as_v052: V052IvfPqIndex = rmp_serde::from_slice(&bytes)
.expect("current index must decode under the v0.5.2 shape");
assert_eq!(as_v052.dims, dims);
}
#[test]
fn legacy_8_64_triggers_auto_formulas_at_query_time() {
let dims = 4;
let legacy = V052IvfPqIndex {
dims,
n_partitions: 10,
n_probe: 8,
n_rerank: 64,
centroids: vec![0.0; 10 * dims],
pq: tiny_pq(dims),
partitions: (0..10)
.map(|_| Posting {
ids: vec![0u128, 1u128],
codes: vec![0u8; 2],
})
.collect(),
};
let bytes = rmp_serde::to_vec(&legacy).unwrap();
let decoded: IvfPqIndex = rmp_serde::from_slice(&bytes).unwrap();
let (auto_probe, auto_rerank) = decoded.effective_defaults();
assert_eq!(
auto_probe, 10,
"auto n_probe at <=100 partitions is full probe"
);
assert_eq!(auto_rerank, 100, "auto n_rerank at tiny N clamps up to 100");
}
#[test]
fn new_build_stores_resolved_auto_values() {
let dims = 4;
let items: Vec<(u128, Vec<f32>)> = (0..64)
.map(|i| {
let f = i as f32;
(i as u128, vec![f, f + 1.0, f + 2.0, f + 3.0])
})
.collect();
let refs: Vec<(u128, &[f32])> = items.iter().map(|(id, v)| (*id, v.as_slice())).collect();
let idx = IvfPqIndex::build(dims, &refs, IndexConfig::default()).unwrap();
assert!(
idx.n_probe > 0 && idx.n_rerank > 0,
"resolved values must be concrete (never 0)"
);
let expected_rerank = (64 / 200usize).clamp(100, 1000);
assert_eq!(idx.n_rerank, expected_rerank);
}
#[test]
fn explicit_build_values_persist_verbatim() {
let dims = 4;
let items: Vec<(u128, Vec<f32>)> =
(0..64).map(|i| (i as u128, vec![i as f32; dims])).collect();
let refs: Vec<(u128, &[f32])> = items.iter().map(|(id, v)| (*id, v.as_slice())).collect();
let cfg = IndexConfig {
n_probe: 3,
n_rerank: 17,
..Default::default()
};
let idx = IvfPqIndex::build(dims, &refs, cfg).unwrap();
assert_eq!(idx.n_probe, 3);
assert_eq!(idx.n_rerank, 17);
let (p, r) = idx.effective_defaults();
assert_eq!(p, 3);
assert_eq!(r, 17);
}
}