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
7 changes: 7 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 @@ -22,6 +22,7 @@ exclude = ["external/ruqu", "external/rvdna", "examples/OSpipe", "examples/rvf",
# land in iters 92-97.
"crates/ruos-thermal"]
members = [
"crates/ruvector-mrl",
"crates/ruvector-temporal-coherence",
"crates/ruvector-acorn",
"crates/ruvector-acorn-wasm",
Expand Down
17 changes: 17 additions & 0 deletions crates/ruvector-mrl/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
[package]
name = "ruvector-mrl"
version = "0.1.0"
edition = "2021"
description = "Matryoshka Resolution Index — prefix-dimensional two-stage ANN for MRL embeddings"
authors = ["ruvnet", "claude-flow"]
license = "MIT OR Apache-2.0"
repository = "https://github.com/ruvnet/ruvector"
keywords = ["ann", "mrl", "matryoshka", "vector-search", "ruvector"]
categories = ["algorithms", "data-structures"]

[[bin]]
name = "mrl-bench"
path = "src/main.rs"

[dependencies]
rand = { workspace = true }
95 changes: 95 additions & 0 deletions crates/ruvector-mrl/src/flat.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
//! Exact brute-force flat scan — 100 % recall baseline.

use crate::{dot, MrlSearch, SearchResult};

/// Stores vectors in a flat `Vec` and scores every vector on every query.
///
/// O(N·D) per query — the ground-truth reference for recall measurement.
pub struct FlatIndex {
vectors: Vec<Vec<f32>>,
dim: usize,
}

impl FlatIndex {
/// Create an empty flat index for `dim`-dimensional vectors.
pub fn new(dim: usize) -> Self {
FlatIndex {
vectors: Vec::new(),
dim,
}
}
}

impl MrlSearch for FlatIndex {
fn insert(&mut self, id: u32, vector: &[f32]) {
assert_eq!(vector.len(), self.dim, "dimension mismatch on insert");
assert_eq!(
id as usize,
self.vectors.len(),
"ids must be inserted sequentially"
);
self.vectors.push(vector.to_vec());
}

fn search(&self, query: &[f32], k: usize) -> Vec<SearchResult> {
assert!(
query.len() >= self.dim,
"query must have at least {} dims",
self.dim
);
let q = &query[..self.dim];
let mut scores: Vec<(u32, f32)> = self
.vectors
.iter()
.enumerate()
.map(|(i, v)| (i as u32, dot(q, v)))
.collect();
scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
scores.truncate(k);
scores
.into_iter()
.map(|(id, score)| SearchResult { id, score })
.collect()
}

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

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

#[test]
fn test_flat_exact_top1() {
let dim = 4;
let mut idx = FlatIndex::new(dim);
let mut v0 = vec![1.0f32, 0.0, 0.0, 0.0];
let mut v1 = vec![0.0f32, 1.0, 0.0, 0.0];
normalize(&mut v0);
normalize(&mut v1);
idx.insert(0, &v0);
idx.insert(1, &v1);

// query aligned with v0
let results = idx.search(&v0, 1);
assert_eq!(results[0].id, 0);
assert!((results[0].score - 1.0).abs() < 1e-6);
}

#[test]
fn test_flat_returns_k() {
let mut idx = FlatIndex::new(8);
for i in 0..20u32 {
let v: Vec<f32> = (0..8)
.map(|j| if j == i as usize % 8 { 1.0 } else { 0.0 })
.collect();
idx.insert(i, &v);
}
let q = vec![1.0f32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
let results = idx.search(&q, 5);
assert_eq!(results.len(), 5);
}
}
213 changes: 213 additions & 0 deletions crates/ruvector-mrl/src/graph.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
//! Greedy kNN graph operating on the first `d_fast` dimensions.
//!
//! Single-layer graph: each inserted vector connects to its M nearest
//! neighbours among previously inserted vectors, using D_FAST cosine.
//! Beam search then navigates this graph for approximate retrieval,
//! followed by full-dimensional exact rerank.

use std::collections::HashSet;

use crate::{dot, SearchResult};

/// Greedy single-layer kNN graph index.
///
/// Build: O(N² · D_FAST) — practical for N ≤ 10 K.
/// Search: O(ef · M · D_FAST) + O(k_over · D_FULL).
pub struct GreedyGraph {
/// Full-dimensional vectors (used for reranking).
vectors: Vec<Vec<f32>>,
/// Adjacency list: adj[i] = neighbours of node i.
adj: Vec<Vec<u32>>,
/// Number of fast dimensions used for graph navigation.
pub d_fast: usize,
/// Number of full dimensions stored per vector.
pub d_full: usize,
/// Max neighbours per node.
m: usize,
}

impl GreedyGraph {
/// Create an empty graph.
pub fn new(d_fast: usize, d_full: usize, m: usize) -> Self {
assert!(d_fast <= d_full, "d_fast must not exceed d_full");
GreedyGraph {
vectors: Vec::new(),
adj: Vec::new(),
d_fast,
d_full,
m,
}
}

/// Cosine similarity using only the first `d_fast` dimensions.
#[inline]
fn fast_score(&self, id: usize, q: &[f32]) -> f32 {
dot(&self.vectors[id][..self.d_fast], &q[..self.d_fast])
}

/// Cosine similarity using all `d_full` dimensions.
#[inline]
fn full_score(&self, id: usize, q: &[f32]) -> f32 {
dot(&self.vectors[id], &q[..self.d_full])
}

/// Number of indexed vectors.
pub fn len(&self) -> usize {
self.vectors.len()
}

/// Stage-1 insert: just store the vector without building edges.
///
/// Call [`GreedyGraph::build_edges`] after all vectors are inserted to
/// compute the full symmetric kNN adjacency in D_FAST space.
pub fn insert(&mut self, vector: &[f32]) -> u32 {
assert_eq!(vector.len(), self.d_full, "dimension mismatch");
let id = self.vectors.len() as u32;
self.vectors.push(vector.to_vec());
self.adj.push(Vec::new());
id
}

/// Stage-2: build the symmetric kNN graph in D_FAST space.
///
/// For every node `i`, connects it to its M nearest neighbours (by D_FAST
/// cosine) across the full dataset. O(N² · D_FAST) — suited for N ≤ 20 K.
pub fn build_edges(&mut self) {
let n = self.vectors.len();
// Clear any existing edges.
for adj in &mut self.adj {
adj.clear();
}
for i in 0..n {
// Score all other nodes.
let mut scored: Vec<(u32, f32)> = (0..n)
.filter(|&j| j != i)
.map(|j| (j as u32, self.fast_score(j, &self.vectors[i].clone())))
.collect();
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
// Take top-M as outgoing edges for node i.
let take = self.m.min(scored.len());
for (nb_id, _) in &scored[..take] {
if !self.adj[i].contains(nb_id) {
self.adj[i].push(*nb_id);
}
// Symmetric: also add i as a neighbour of nb (cap at m).
let nb = *nb_id as usize;
let i_id = i as u32;
if self.adj[nb].len() < self.m && !self.adj[nb].contains(&i_id) {
self.adj[nb].push(i_id);
}
}
}
}

/// Beam search in D_FAST space (EFANNA-style greedy beam).
///
/// Exploration set `W` drives expansion; candidate set `C` accumulates the
/// top-ef results. Prune when the best unexplored node scores below the
/// worst entry in C (cosine similarity: higher = better).
///
/// Returns up to `k_over` candidates sorted by fast-dim score (desc).
pub fn beam_fast(&self, query: &[f32], k_over: usize, ef: usize) -> Vec<(u32, f32)> {
let n = self.vectors.len();
if n == 0 {
return Vec::new();
}

let mut visited: HashSet<u32> = HashSet::new();

// Use node 0 as entry point and seed both sets.
let entry = 0u32;
let entry_score = self.fast_score(entry as usize, query);
visited.insert(entry);

// W: exploration queue — max-heap by score (best first).
// We simulate with a Vec; sort descending, pop from end = highest.
let mut w: Vec<(f32, u32)> = vec![(entry_score, entry)];

// C: candidate result set, size capped at ef.
// We keep it sorted descending; the last element is the worst.
let mut c: Vec<(f32, u32)> = vec![(entry_score, entry)];

while !w.is_empty() {
// Extract best from W.
w.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
let (best_w_score, best_w_id) = w.pop().unwrap();

// Prune: if the best unexplored is worse than the worst in C, stop.
let worst_c = c.last().map(|(s, _)| *s).unwrap_or(f32::NEG_INFINITY);
if c.len() >= ef && best_w_score < worst_c {
break;
}

// Expand neighbours.
for &nb in &self.adj[best_w_id as usize] {
if visited.insert(nb) {
let s = self.fast_score(nb as usize, query);
let worst_c = c.last().map(|(sc, _)| *sc).unwrap_or(f32::NEG_INFINITY);
if c.len() < ef || s > worst_c {
w.push((s, nb));
c.push((s, nb));
// Keep C sorted descending and trimmed to ef.
c.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
if c.len() > ef {
c.pop(); // remove worst
}
}
}
}
let _ = best_w_score; // used above
}

c.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
c.truncate(k_over);
c.into_iter().map(|(s, id)| (id, s)).collect()
}

/// Rerank `candidates` using full-dimensional cosine, return top-k.
pub fn rerank(&self, candidates: &[(u32, f32)], query: &[f32], k: usize) -> Vec<SearchResult> {
let mut scored: Vec<SearchResult> = candidates
.iter()
.map(|&(id, _)| SearchResult {
id,
score: self.full_score(id as usize, query),
})
.collect();
scored.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
scored.truncate(k);
scored
}
}

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

#[test]
fn test_graph_insert_and_search() {
let (d_fast, d_full, m) = (4, 8, 4);
let mut g = GreedyGraph::new(d_fast, d_full, m);
for i in 0..10u32 {
let mut v: Vec<f32> = (0..d_full)
.map(|j| if j == i as usize % d_full { 1.0 } else { 0.0 })
.collect();
normalize(&mut v);
g.insert(&v);
}
g.build_edges();
assert_eq!(g.len(), 10);

// Query aligned with vector 0.
let q: Vec<f32> = vec![1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
let cands = g.beam_fast(&q, 5, 10);
assert!(!cands.is_empty());
// Rerank should return id=0 first.
let results = g.rerank(&cands, &q, 1);
assert_eq!(results[0].id, 0);
}
}
Loading
Loading