Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ exclude = ["crates/micro-hnsw-wasm", "crates/ruvector-hyperbolic-hnsw", "crates/
# land in iters 92-97.
"crates/ruos-thermal"]
members = [
"crates/ruvector-multivec",
"crates/ruvector-acorn",
"crates/ruvector-acorn-wasm",
"crates/ruvector-rabitq",
Expand Down
30 changes: 30 additions & 0 deletions crates/ruvector-multivec/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
[package]
name = "ruvector-multivec"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
description = "Multi-vector late-interaction search: MaxSim, Chamfer, and MUVERA-FDE approximate scoring for ColBERT-style token-level retrieval"

[[bin]]
name = "multivec-demo"
path = "src/main.rs"

[[bench]]
name = "multivec_bench"
harness = false

[dependencies]
rand = { workspace = true }
rand_distr = { workspace = true }
thiserror = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }

[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rayon = { workspace = true }

[dev-dependencies]
criterion = { workspace = true }
127 changes: 127 additions & 0 deletions crates/ruvector-multivec/benches/multivec_bench.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
//! Criterion benchmarks for ruvector-multivec.
//!
//! Two groups:
//!
//! `scoring_kernels` — per-query cost of centroid dot, MaxSim, Chamfer,
//! and FDE encode+dot at dim ∈ {64, 128, 256} with
//! T ∈ {8, 32} tokens per document.
//!
//! `index_search` — end-to-end search at n ∈ {1K, 5K, 10K} for all
//! three index variants.
//!
//! Run: cargo bench -p ruvector-multivec --bench multivec_bench

use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion};
use rand::SeedableRng;
use rand_distr::{Distribution, Normal};
use ruvector_multivec::{
index::{CentroidIndex, MaxSimIndex, MultiVecIndex, MuveraFdeIndex},
scoring::{centroid_dot, chamfer_score, dot, l2_normalize, maxsim_exact, FdeEncoder},
};

fn make_tokens(count: usize, dim: usize, seed: u64) -> Vec<Vec<f32>> {
let mut rng = rand::rngs::StdRng::seed_from_u64(seed);
let normal = Normal::new(0.0f64, 1.0).unwrap();
(0..count)
.map(|_| {
let mut v: Vec<f32> = (0..dim).map(|_| normal.sample(&mut rng) as f32).collect();
l2_normalize(&mut v);
v
})
.collect()
}

fn make_corpus(n_docs: usize, t: usize, dim: usize, seed: u64) -> Vec<(usize, Vec<Vec<f32>>)> {
(0..n_docs)
.map(|id| {
let tokens = make_tokens(t, dim, seed.wrapping_add(id as u64));
(id, tokens)
})
.collect()
}

// ---------------------------------------------------------------------------
// Scoring kernel benchmarks
// ---------------------------------------------------------------------------

fn bench_scoring_kernels(c: &mut Criterion) {
let mut g = c.benchmark_group("scoring_kernels");

for (dim, t) in [(64usize, 8usize), (128, 8), (128, 32), (256, 32)] {
let qt = make_tokens(8, dim, 1);
let dt = make_tokens(t, dim, 2);
let label = format!("D{dim}_T{t}");

g.bench_with_input(BenchmarkId::new("centroid_dot", &label), &(), |b, _| {
b.iter(|| black_box(centroid_dot(black_box(&qt), black_box(&dt))))
});

g.bench_with_input(BenchmarkId::new("maxsim_exact", &label), &(), |b, _| {
b.iter(|| black_box(maxsim_exact(black_box(&qt), black_box(&dt))))
});

g.bench_with_input(BenchmarkId::new("chamfer_score", &label), &(), |b, _| {
b.iter(|| black_box(chamfer_score(black_box(&qt), black_box(&dt))))
});

// FDE encode + dot.
let m = if dim >= 128 { 8 } else { 4 };
let enc = FdeEncoder::new(dim, m, 2, 42);
g.bench_with_input(BenchmarkId::new("fde_encode_dot", &label), &(), |b, _| {
b.iter(|| {
let qfde = enc.encode(black_box(&qt));
let dfde = enc.encode(black_box(&dt));
black_box(dot(&qfde, &dfde))
})
});
}
g.finish();
}

// ---------------------------------------------------------------------------
// End-to-end index search benchmarks
// ---------------------------------------------------------------------------

fn bench_index_search(c: &mut Criterion) {
let mut g = c.benchmark_group("index_search");
let dim = 128;
let t = 16;
let k = 10;

for n in [1_000usize, 5_000, 10_000] {
let corpus = make_corpus(n, t, dim, 77);
let query = make_tokens(8, dim, 999);
let label = format!("n{n}_D{dim}_T{t}");

// CentroidIndex
let mut cidx = CentroidIndex::new(dim);
for (id, toks) in &corpus {
cidx.add(*id, toks.clone()).unwrap();
}
g.bench_with_input(BenchmarkId::new("centroid", &label), &(), |b, _| {
b.iter(|| black_box(cidx.search(black_box(&query), k).unwrap()))
});

// MaxSimIndex
let mut midx = MaxSimIndex::new(dim);
for (id, toks) in &corpus {
midx.add(*id, toks.clone()).unwrap();
}
g.bench_with_input(BenchmarkId::new("maxsim", &label), &(), |b, _| {
b.iter(|| black_box(midx.search(black_box(&query), k).unwrap()))
});

// MuveraFdeIndex
let mut fidx = MuveraFdeIndex::new(dim, 8, 4, 42).unwrap();
for (id, toks) in &corpus {
fidx.add(*id, toks.clone()).unwrap();
}
g.bench_with_input(BenchmarkId::new("muvera_fde", &label), &(), |b, _| {
b.iter(|| black_box(fidx.search(black_box(&query), k).unwrap()))
});
}
g.finish();
}

criterion_group!(benches, bench_scoring_kernels, bench_index_search);
criterion_main!(benches);
25 changes: 25 additions & 0 deletions crates/ruvector-multivec/src/error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
use thiserror::Error;

#[derive(Debug, Error)]
pub enum MultivecError {
#[error("empty corpus")]
EmptyCorpus,

#[error("document {id} has no token vectors")]
EmptyDocument { id: usize },

#[error("dimension mismatch: expected {expected}, got {actual}")]
DimMismatch { expected: usize, actual: usize },

#[error("k ({k}) exceeds corpus size ({n})")]
KTooLarge { k: usize, n: usize },

#[error("FDE subspaces {m} must divide dimension {d}")]
FdeSubspaceMismatch { m: usize, d: usize },

#[error("MUVERA repetitions R must be ≥ 1")]
InvalidRepetitions,

#[error("index not yet built — call build() first")]
NotBuilt,
}
Loading
Loading