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
16 changes: 16 additions & 0 deletions Cargo.lock

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

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,8 @@ members = [
"crates/ruvllm_sparse_attention",
# Generic retrieval LM + masked discrete diffusion built on the kernel
"crates/ruvllm_retrieval_diffusion",
# RAIRS IVF: Redundant Assignment + Amplified Inverse Residual (ADR-193)
"crates/ruvector-rairs",
]
resolver = "2"

Expand Down
25 changes: 25 additions & 0 deletions crates/ruvector-rairs/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
[package]
name = "ruvector-rairs"
version = "0.1.0"
edition = "2021"
description = "RAIRS IVF: Redundant Assignment with Amplified Inverse Residual — ruvector's first IVF index family"
authors = ["ruvnet", "claude-flow"]
license = "MIT OR Apache-2.0"
repository = "https://github.com/ruvnet/ruvector"
keywords = ["ann", "ivf", "vector-search", "approximate-nearest-neighbor", "ruvector"]
categories = ["algorithms", "data-structures"]

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

[dependencies]
rand = "0.8"
serde = { version = "1", features = ["derive"] }

[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }

[[bench]]
name = "rairs_bench"
harness = false
62 changes: 62 additions & 0 deletions crates/ruvector-rairs/benches/rairs_bench.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
//! Criterion micro-benchmarks for RAIRS IVF kernels.

use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
use rand::{Rng, SeedableRng};
use rand::rngs::StdRng;
use ruvector_rairs::{AnnIndex, IvfFlat, RairsStrict, RairsSeil};

const DIM: usize = 128;
const N: usize = 2_000;
const NCLUSTERS: usize = 32;
const SEED: u64 = 99;

fn corpus(n: usize, seed: u64) -> Vec<Vec<f32>> {
let mut rng = StdRng::seed_from_u64(seed);
(0..n).map(|_| (0..DIM).map(|_| rng.gen::<f32>()).collect()).collect()
}

fn bench_search(c: &mut Criterion) {
let vecs = corpus(N, SEED);
let query: Vec<f32> = vecs[0].clone();

let mut ivf = IvfFlat::new(DIM, NCLUSTERS, 20, SEED);
ivf.train(&vecs).unwrap();
ivf.add(&vecs).unwrap();

let mut strict = RairsStrict::new(DIM, NCLUSTERS, 20, SEED, 1.0);
strict.train(&vecs).unwrap();
strict.add(&vecs).unwrap();

let mut seil = RairsSeil::new(DIM, NCLUSTERS, 20, SEED, 1.0);
seil.train(&vecs).unwrap();
seil.add(&vecs).unwrap();

let mut g = c.benchmark_group("search_nprobe16");
g.throughput(Throughput::Elements(1));

g.bench_function("ivf_flat", |b| {
b.iter(|| ivf.search(&query, 10, 16).unwrap())
});
g.bench_function("rairs_strict", |b| {
b.iter(|| strict.search(&query, 10, 16).unwrap())
});
g.bench_function("rairs_seil", |b| {
b.iter(|| seil.search(&query, 10, 16).unwrap())
});
g.finish();

let mut g2 = c.benchmark_group("search_nprobe_sweep");
g2.throughput(Throughput::Elements(1));
for &np in &[1usize, 4, 16, 32] {
g2.bench_with_input(BenchmarkId::new("ivf_flat", np), &np, |b, &np| {
b.iter(|| ivf.search(&query, 10, np).unwrap())
});
g2.bench_with_input(BenchmarkId::new("rairs_seil", np), &np, |b, &np| {
b.iter(|| seil.search(&query, 10, np).unwrap())
});
}
g2.finish();
}

criterion_group!(benches, bench_search);
criterion_main!(benches);
39 changes: 39 additions & 0 deletions crates/ruvector-rairs/src/error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
//! Error types for ruvector-rairs.

use std::fmt;

/// Errors returned by RAIRS index operations.
#[derive(Debug, Clone, PartialEq)]
pub enum RairsError {
/// Input vectors have inconsistent dimensionality.
DimMismatch { expected: usize, got: usize },
/// Index must be trained before search.
NotTrained,
/// Empty corpus passed to train.
EmptyCorpus,
/// k > n in top-k search.
KTooLarge { k: usize, n: usize },
/// nprobe exceeds number of clusters.
NprobeTooLarge { nprobe: usize, nclusters: usize },
/// Invalid parameter value.
InvalidParam(String),
}

impl fmt::Display for RairsError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::DimMismatch { expected, got } => {
write!(f, "dimension mismatch: expected {expected}, got {got}")
}
Self::NotTrained => write!(f, "index not trained"),
Self::EmptyCorpus => write!(f, "corpus is empty"),
Self::KTooLarge { k, n } => write!(f, "k={k} > n={n}"),
Self::NprobeTooLarge { nprobe, nclusters } => {
write!(f, "nprobe={nprobe} > nclusters={nclusters}")
}
Self::InvalidParam(msg) => write!(f, "invalid parameter: {msg}"),
}
}
}

impl std::error::Error for RairsError {}
54 changes: 54 additions & 0 deletions crates/ruvector-rairs/src/index.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
//! Shared ANN index trait and search result type.

use crate::error::RairsError;

/// A nearest-neighbor result from any index variant.
#[derive(Debug, Clone, PartialEq)]
pub struct SearchResult {
/// Original vector ID (0-based insertion order).
pub id: usize,
/// Approximate L2 distance to the query.
pub distance: f32,
}

/// Common interface for all three RAIRS index variants.
pub trait AnnIndex {
/// Add a slice of f32 vectors to the index.
fn add(&mut self, vectors: &[Vec<f32>]) -> Result<(), RairsError>;

/// Search for the `k` approximate nearest neighbors of `query`.
/// `nprobe` controls how many inverted lists are visited.
fn search(
&self,
query: &[f32],
k: usize,
nprobe: usize,
) -> Result<Vec<SearchResult>, RairsError>;

/// Return the number of indexed vectors.
fn len(&self) -> usize;

/// Return true if the index is empty.
fn is_empty(&self) -> bool {
self.len() == 0
}

/// Return the number of inverted lists (clusters).
fn num_lists(&self) -> usize;
}

// ─── shared distance helpers ─────────────────────────────────────────────────

/// Squared Euclidean distance between two equal-length f32 slices.
#[inline(always)]
pub fn l2sq(a: &[f32], b: &[f32]) -> f32 {
debug_assert_eq!(a.len(), b.len());
a.iter().zip(b.iter()).map(|(x, y)| (x - y) * (x - y)).sum()
}

/// Dot product of two equal-length f32 slices.
#[inline(always)]
pub fn dot(a: &[f32], b: &[f32]) -> f32 {
debug_assert_eq!(a.len(), b.len());
a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
}
172 changes: 172 additions & 0 deletions crates/ruvector-rairs/src/ivf.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
//! Variant 1 — IvfFlat: classic single-assignment IVF with flat list scan.
//!
//! Each vector is assigned to exactly one centroid. Search probes the
//! `nprobe` closest centroids and linearly scans each list.

use crate::error::RairsError;
use crate::index::{AnnIndex, SearchResult, l2sq};
use crate::kmeans;

/// IVF baseline: one list per vector, flat scan.
#[derive(Debug, Clone)]
pub struct IvfFlat {
dim: usize,
nclusters: usize,
max_iter: usize,
seed: u64,
/// Trained centroids (nclusters × dim).
centroids: Vec<Vec<f32>>,
/// Per-cluster: list of (vector_id, raw_vector).
lists: Vec<Vec<(usize, Vec<f32>)>>,
total: usize,
}

impl IvfFlat {
/// Create a new untrained IvfFlat index.
///
/// * `dim` — vector dimensionality
/// * `nclusters` — number of Voronoi cells (Voronoi = k-means clusters)
/// * `max_iter` — k-means max iterations
/// * `seed` — RNG seed for reproducibility
pub fn new(dim: usize, nclusters: usize, max_iter: usize, seed: u64) -> Self {
Self {
dim,
nclusters,
max_iter,
seed,
centroids: Vec::new(),
lists: Vec::new(),
total: 0,
}
}

/// Train centroids on the given corpus. Must be called before `add`.
pub fn train(&mut self, corpus: &[Vec<f32>]) -> Result<(), RairsError> {
if corpus.is_empty() {
return Err(RairsError::EmptyCorpus);
}
if corpus[0].len() != self.dim {
return Err(RairsError::DimMismatch {
expected: self.dim,
got: corpus[0].len(),
});
}
let k = self.nclusters.min(corpus.len());
let (centroids, _) = kmeans::train(corpus, k, self.max_iter, self.seed);
self.centroids = centroids;
self.lists = vec![Vec::new(); k];
Ok(())
}
}

impl AnnIndex for IvfFlat {
fn add(&mut self, vectors: &[Vec<f32>]) -> Result<(), RairsError> {
if self.centroids.is_empty() {
return Err(RairsError::NotTrained);
}
for v in vectors {
if v.len() != self.dim {
return Err(RairsError::DimMismatch {
expected: self.dim,
got: v.len(),
});
}
let c = kmeans::nearest_centroid(v, &self.centroids);
self.lists[c].push((self.total, v.clone()));
self.total += 1;
}
Ok(())
}

fn search(
&self,
query: &[f32],
k: usize,
nprobe: usize,
) -> Result<Vec<SearchResult>, RairsError> {
if self.centroids.is_empty() {
return Err(RairsError::NotTrained);
}
if query.len() != self.dim {
return Err(RairsError::DimMismatch {
expected: self.dim,
got: query.len(),
});
}
let nc = self.centroids.len();
let nprobe = nprobe.min(nc);

// Rank centroids by distance to query
let mut centroid_dists: Vec<(usize, f32)> = self
.centroids
.iter()
.enumerate()
.map(|(i, c)| (i, l2sq(query, c)))
.collect();
centroid_dists.sort_unstable_by(|a, b| a.1.partial_cmp(&b.1).unwrap());

// Collect candidates from top-nprobe lists
let mut heap: Vec<SearchResult> = Vec::new();
for &(ci, _) in centroid_dists.iter().take(nprobe) {
for (id, vec) in &self.lists[ci] {
let dist = l2sq(query, vec).sqrt();
heap.push(SearchResult { id: *id, distance: dist });
}
}

// Partial-sort to find top-k
heap.sort_unstable_by(|a, b| a.distance.partial_cmp(&b.distance).unwrap());
heap.truncate(k);
Ok(heap)
}

fn len(&self) -> usize {
self.total
}

fn num_lists(&self) -> usize {
self.centroids.len()
}
}

// ─── tests ───────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
use super::*;

fn corpus(n: usize, dim: usize, seed: u64) -> Vec<Vec<f32>> {
use rand::{Rng, SeedableRng};
let mut rng = rand::rngs::StdRng::seed_from_u64(seed);
(0..n).map(|_| (0..dim).map(|_| rng.gen::<f32>()).collect()).collect()
}

#[test]
fn basic_search_returns_k_results() {
let n = 200;
let dim = 16;
let vecs = corpus(n, dim, 1);
let mut idx = IvfFlat::new(dim, 8, 20, 42);
idx.train(&vecs).unwrap();
idx.add(&vecs).unwrap();
assert_eq!(idx.len(), n);
let results = idx.search(&vecs[0], 5, 4).unwrap();
assert!(results.len() <= 5);
// Exact self-match must be first (distance ≈ 0)
assert_eq!(results[0].id, 0);
assert!(results[0].distance < 1e-5);
}

#[test]
fn full_probe_gives_exact_results() {
let n = 100;
let dim = 8;
let vecs = corpus(n, dim, 7);
let mut idx = IvfFlat::new(dim, 4, 20, 42);
idx.train(&vecs).unwrap();
idx.add(&vecs).unwrap();
// With nprobe = nclusters, should get exact top-1
let results = idx.search(&vecs[42], 1, idx.num_lists()).unwrap();
assert_eq!(results[0].id, 42);
}
}
Loading
Loading