pub mod dataset;
pub mod hnsw;
use hnsw::{l2_sq_prefix, HnswConfig, HnswGraph};
#[derive(Clone, Debug)]
pub struct MatryoshkaConfig {
pub full_dim: usize,
pub coarse_dim: usize,
pub mid_dim: usize,
pub m: usize,
pub ef_construction: usize,
pub two_stage_candidates: usize,
pub three_stage_coarse_candidates: usize,
pub three_stage_mid_candidates: usize,
}
impl MatryoshkaConfig {
pub fn default_128() -> Self {
Self {
full_dim: 128,
coarse_dim: 32,
mid_dim: 64,
m: 16,
ef_construction: 100,
two_stage_candidates: 100,
three_stage_coarse_candidates: 150,
three_stage_mid_candidates: 50,
}
}
}
#[inline(always)]
pub fn l2_sq(a: &[f32], b: &[f32]) -> f32 {
l2_sq_prefix(a, b, a.len().min(b.len()))
}
pub fn l2_normalize(v: &mut [f32]) {
let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt();
if norm > 1e-10 {
let inv = 1.0 / norm;
v.iter_mut().for_each(|x| *x *= inv);
}
}
pub fn prefix_project(v: &[f32], dim: usize) -> Vec<f32> {
let mut out: Vec<f32> = v[..dim.min(v.len())].to_vec();
l2_normalize(&mut out);
out
}
pub trait Searcher {
fn build(config: &MatryoshkaConfig, vectors: &[Vec<f32>]) -> Self
where
Self: Sized;
fn search(&self, query: &[f32], k: usize, ef: usize) -> Vec<usize>;
fn name(&self) -> &'static str;
}
pub struct FullDimIndex {
graph: HnswGraph,
}
impl Searcher for FullDimIndex {
fn build(config: &MatryoshkaConfig, vectors: &[Vec<f32>]) -> Self {
let hcfg = HnswConfig::new(config.full_dim, config.m, config.ef_construction);
let mut graph = HnswGraph::new(hcfg);
for v in vectors {
let projected = prefix_project(v, config.full_dim);
graph.insert(projected);
}
Self { graph }
}
fn search(&self, query: &[f32], k: usize, ef: usize) -> Vec<usize> {
let q_proj = prefix_project(query, self.graph.config.dim);
self.graph
.search(&q_proj, k, ef)
.into_iter()
.map(|id| id as usize)
.collect()
}
fn name(&self) -> &'static str {
"FullDimHNSW"
}
}
pub struct TwoStageIndex {
config: MatryoshkaConfig,
coarse_graph: HnswGraph,
full_vecs: Vec<Vec<f32>>,
}
impl Searcher for TwoStageIndex {
fn build(config: &MatryoshkaConfig, vectors: &[Vec<f32>]) -> Self {
let hcfg = HnswConfig::new(config.coarse_dim, config.m, config.ef_construction);
let mut coarse_graph = HnswGraph::new(hcfg);
let mut full_vecs = Vec::with_capacity(vectors.len());
for v in vectors {
let coarse = prefix_project(v, config.coarse_dim);
coarse_graph.insert(coarse);
full_vecs.push(prefix_project(v, config.full_dim));
}
Self {
config: config.clone(),
coarse_graph,
full_vecs,
}
}
fn search(&self, query: &[f32], k: usize, ef: usize) -> Vec<usize> {
let candidates = self.config.two_stage_candidates.max(k);
let q_coarse = prefix_project(query, self.config.coarse_dim);
let coarse_ids = self
.coarse_graph
.search(&q_coarse, candidates, ef.max(candidates));
let q_full = prefix_project(query, self.config.full_dim);
let mut scored: Vec<(f32, usize)> = coarse_ids
.iter()
.map(|&id| (l2_sq(&q_full, &self.full_vecs[id as usize]), id as usize))
.collect();
scored.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
scored.into_iter().take(k).map(|(_, id)| id).collect()
}
fn name(&self) -> &'static str {
"TwoStage"
}
}
pub struct ThreeStageIndex {
config: MatryoshkaConfig,
coarse_graph: HnswGraph,
mid_vecs: Vec<Vec<f32>>,
full_vecs: Vec<Vec<f32>>,
}
impl Searcher for ThreeStageIndex {
fn build(config: &MatryoshkaConfig, vectors: &[Vec<f32>]) -> Self {
let hcfg = HnswConfig::new(config.coarse_dim, config.m, config.ef_construction);
let mut coarse_graph = HnswGraph::new(hcfg);
let mut mid_vecs = Vec::with_capacity(vectors.len());
let mut full_vecs = Vec::with_capacity(vectors.len());
for v in vectors {
let coarse = prefix_project(v, config.coarse_dim);
coarse_graph.insert(coarse);
mid_vecs.push(prefix_project(v, config.mid_dim));
full_vecs.push(prefix_project(v, config.full_dim));
}
Self {
config: config.clone(),
coarse_graph,
mid_vecs,
full_vecs,
}
}
fn search(&self, query: &[f32], k: usize, ef: usize) -> Vec<usize> {
let coarse_n = self.config.three_stage_coarse_candidates.max(k);
let mid_n = self.config.three_stage_mid_candidates.max(k);
let q_coarse = prefix_project(query, self.config.coarse_dim);
let coarse_ids = self
.coarse_graph
.search(&q_coarse, coarse_n, ef.max(coarse_n));
let q_mid = prefix_project(query, self.config.mid_dim);
let mut mid_scored: Vec<(f32, u32)> = coarse_ids
.iter()
.map(|&id| (l2_sq(&q_mid, &self.mid_vecs[id as usize]), id))
.collect();
mid_scored.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
let mid_ids: Vec<u32> = mid_scored
.into_iter()
.take(mid_n)
.map(|(_, id)| id)
.collect();
let q_full = prefix_project(query, self.config.full_dim);
let mut full_scored: Vec<(f32, usize)> = mid_ids
.iter()
.map(|&id| (l2_sq(&q_full, &self.full_vecs[id as usize]), id as usize))
.collect();
full_scored.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
full_scored.into_iter().take(k).map(|(_, id)| id).collect()
}
fn name(&self) -> &'static str {
"ThreeStage"
}
}
pub fn brute_force_knn(vectors: &[Vec<f32>], query: &[f32], k: usize, dim: usize) -> Vec<usize> {
let mut dists: Vec<(f32, usize)> = vectors
.iter()
.enumerate()
.map(|(i, v)| (l2_sq_prefix(query, v, dim), i))
.collect();
dists.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
dists.into_iter().take(k).map(|(_, i)| i).collect()
}
pub fn recall_at_k(results: &[usize], ground_truth: &[usize]) -> f32 {
if ground_truth.is_empty() {
return 1.0;
}
let hits: usize = results
.iter()
.filter(|&&r| ground_truth.contains(&r))
.count();
hits as f32 / ground_truth.len() as f32
}
#[cfg(test)]
mod tests {
use super::*;
use dataset::generate_matryoshka_dataset;
const N: usize = 500;
const K: usize = 10;
const EF: usize = 50;
const N_QUERIES: usize = 20;
const SEED: u64 = 0xDEAD_BEEF;
fn build_and_recall<S: Searcher>(seed: u64) -> f32 {
let cfg = MatryoshkaConfig::default_128();
let (vectors, queries) =
generate_matryoshka_dataset(N, N_QUERIES, cfg.full_dim, cfg.coarse_dim, seed);
let idx = S::build(&cfg, &vectors);
let mut total = 0.0f32;
for q in &queries {
let gt = brute_force_knn(&vectors, q, K, cfg.full_dim);
let res = idx.search(q, K, EF);
total += recall_at_k(&res, >);
}
total / N_QUERIES as f32
}
#[test]
fn full_dim_recall_passes_threshold() {
let recall = build_and_recall::<FullDimIndex>(SEED);
assert!(
recall >= 0.75,
"FullDimHNSW recall@10 = {:.3} < 0.75",
recall
);
}
#[test]
fn two_stage_recall_passes_threshold() {
let recall = build_and_recall::<TwoStageIndex>(SEED);
assert!(recall >= 0.65, "TwoStage recall@10 = {:.3} < 0.65", recall);
}
#[test]
fn three_stage_recall_passes_threshold() {
let recall = build_and_recall::<ThreeStageIndex>(SEED);
assert!(
recall >= 0.58,
"ThreeStage recall@10 = {:.3} < 0.58",
recall
);
}
#[test]
fn brute_force_is_perfect() {
let cfg = MatryoshkaConfig::default_128();
let (vectors, queries) =
generate_matryoshka_dataset(200, 10, cfg.full_dim, cfg.coarse_dim, 42);
for q in &queries {
let gt = brute_force_knn(&vectors, q, K, cfg.full_dim);
let recall = recall_at_k(>, >);
assert!((recall - 1.0).abs() < 1e-6, "brute force must be perfect");
}
}
}