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
9 changes: 9 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 @@ -49,6 +49,7 @@ members = [
"crates/ruvector-gnn",
"crates/ruvector-gnn-node",
"crates/ruvector-gnn-wasm",
"crates/ruvector-gnn-rerank",
"crates/ruvector-attention",
"crates/ruvector-attention-wasm",
"crates/ruvector-attention-node",
Expand Down
29 changes: 29 additions & 0 deletions crates/ruvector-gnn-rerank/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
[package]
name = "ruvector-gnn-rerank"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
description = "GNN-enhanced candidate reranking for approximate ANN search in ruvector"
keywords = ["vector-search", "ann", "gnn", "reranking", "rag"]
categories = ["algorithms", "data-structures"]

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

[[bin]]
name = "benchmark"
path = "src/main.rs"

[lib]
name = "ruvector_gnn_rerank"
crate-type = ["rlib"]

[lints.rust]
dead_code = "allow"
unused_variables = "allow"
unused_imports = "allow"
11 changes: 11 additions & 0 deletions crates/ruvector-gnn-rerank/src/error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
use thiserror::Error;

#[derive(Debug, Error)]
pub enum RerankerError {
#[error("empty candidate set")]
Empty,
#[error("k={k} exceeds candidate count={n}")]
KTooLarge { k: usize, n: usize },
#[error("dimension mismatch: query has {query} dims, candidate has {candidate} dims")]
DimMismatch { query: usize, candidate: usize },
}
117 changes: 117 additions & 0 deletions crates/ruvector-gnn-rerank/src/graph.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
//! Candidate k-NN subgraph for GNN score diffusion.
//!
//! Given a small candidate set (typically 50–200 vectors returned by an
//! approximate first-stage retriever), this module builds a k-nearest-neighbour
//! graph over the candidates using cosine similarity between their full-precision
//! vectors. The resulting graph is the propagation medium for score diffusion in
//! `GnnDiffusionReranker` and `GnnMincutReranker`.
//!
//! **Complexity:** O(n² × dim) — acceptable for n ≤ 200 and dim ≤ 2048.
//! At n=80, dim=128: ~820K multiply-adds, sub-millisecond on modern hardware.

use crate::reranker::Candidate;

/// k-NN graph over a set of ANN candidates.
///
/// `edges[i]` is a sorted list of `(neighbour_index, cosine_similarity)` for
/// candidate `i`, ordered by descending similarity.
pub struct CandidateGraph {
pub edges: Vec<Vec<(usize, f32)>>,
}

impl CandidateGraph {
/// Build a k-NN graph over `candidates` using cosine similarity.
///
/// `k_graph` is the maximum degree per node. Edges are undirected but
/// stored as a directed adjacency list (each endpoint stores its own
/// neighbourhood independently).
pub fn build(candidates: &[Candidate], k_graph: usize) -> Self {
let n = candidates.len();
let k = k_graph.min(n.saturating_sub(1));
let mut edges = vec![Vec::<(usize, f32)>::new(); n];

// Pre-compute L2 norms to avoid recomputing in the inner loop.
let norms: Vec<f32> = candidates.iter().map(|c| l2_norm(&c.vector)).collect();

for i in 0..n {
let mut sims: Vec<(usize, f32)> = (0..n)
.filter(|&j| j != i)
.map(|j| {
let dot: f32 = candidates[i]
.vector
.iter()
.zip(candidates[j].vector.iter())
.map(|(a, b)| a * b)
.sum();
let denom = norms[i] * norms[j];
let sim = if denom < 1e-9 { 0.0 } else { dot / denom };
(j, sim)
})
.collect();

// Sort descending by similarity; keep top-k.
sims.sort_unstable_by(|a, b| {
b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)
});
sims.truncate(k);
edges[i] = sims;
}

Self { edges }
}
}

fn l2_norm(v: &[f32]) -> f32 {
v.iter().map(|x| x * x).sum::<f32>().sqrt().max(1e-9)
}

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

fn unit_candidate(id: u32, v: Vec<f32>) -> Candidate {
Candidate {
id,
vector: v,
noisy_score: 0.5,
}
}

#[test]
fn self_not_in_neighbours() {
let cands = vec![
unit_candidate(0, vec![1.0, 0.0]),
unit_candidate(1, vec![0.0, 1.0]),
unit_candidate(2, vec![-1.0, 0.0]),
];
let g = CandidateGraph::build(&cands, 2);
for (i, nbrs) in g.edges.iter().enumerate() {
assert!(!nbrs.iter().any(|(j, _)| *j == i), "node {i} found itself");
}
}

#[test]
fn degree_does_not_exceed_k_graph() {
let cands: Vec<Candidate> = (0..15)
.map(|i| unit_candidate(i, vec![(i as f32).sin(), (i as f32).cos()]))
.collect();
let g = CandidateGraph::build(&cands, 4);
for nbrs in &g.edges {
assert!(nbrs.len() <= 4);
}
}

#[test]
fn two_nodes_are_each_others_only_neighbour() {
let cands = vec![
unit_candidate(0, vec![1.0, 0.0]),
unit_candidate(1, vec![0.5, 0.5]),
];
let g = CandidateGraph::build(&cands, 5);
assert_eq!(g.edges[0].len(), 1);
assert_eq!(g.edges[0][0].0, 1);
assert_eq!(g.edges[1].len(), 1);
assert_eq!(g.edges[1][0].0, 0);
}
}
139 changes: 139 additions & 0 deletions crates/ruvector-gnn-rerank/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
//! # ruvector-gnn-rerank
//!
//! GNN-enhanced candidate reranking for approximate ANN search.
//!
//! After a first-stage approximate retriever (HNSW, DiskANN, IVF) returns a
//! candidate set, this crate applies graph neural score diffusion over the
//! candidate k-NN subgraph to recover recall lost to quantisation noise.
//!
//! ## Variant summary
//!
//! | Variant | Algorithm | Design rationale |
//! |---------|-----------|-----------------|
//! | `NoisyScoreReranker` | passthrough | baseline — sorts by approximate score |
//! | `GnnDiffusionReranker` | 1-hop score propagation | cancels i.i.d. noise by averaging cluster neighbours |
//! | `GnnMincutReranker` | coherence-gated propagation | blocks cross-cluster pollution (mincut-inspired) |
//! | `ExactL2Reranker` | exact Euclidean sort | oracle upper bound |
//!
//! All four implement [`CandidateReranker`].
//!
//! ## Research context
//!
//! Nightly research 2026-05-21. Design rationale in `docs/adr/ADR-194-gnn-rerank.md`.
//! Companion papers: GNRR (arXiv 2406.11720), Maniscope (arXiv 2602.15860),
//! AQR-HNSW (arXiv 2602.21600).

#![forbid(unsafe_code)]

pub mod error;
pub mod graph;
pub mod reranker;

pub use error::RerankerError;
pub use graph::CandidateGraph;
pub use reranker::{
Candidate, CandidateReranker, ExactL2Reranker, GnnDiffusionReranker, GnnMincutReranker,
NoisyScoreReranker, RankedResult,
};

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

fn make_candidates(n: usize, dim: usize, seed: u64) -> Vec<Candidate> {
use rand::{rngs::StdRng, Rng, SeedableRng};
let mut rng = StdRng::seed_from_u64(seed);
(0..n)
.map(|i| Candidate {
id: i as u32,
vector: (0..dim).map(|_| rng.gen_range(-1.0_f32..1.0)).collect(),
noisy_score: rng.gen_range(0.1_f32..1.0),
})
.collect()
}

fn make_query(dim: usize) -> Vec<f32> {
vec![0.0_f32; dim]
}

#[test]
fn noisy_reranker_returns_k_results() {
let cands = make_candidates(20, 8, 1);
let query = make_query(8);
let r = NoisyScoreReranker.rerank(&query, &cands, 5).unwrap();
assert_eq!(r.len(), 5);
}

#[test]
fn gnn_diffusion_returns_k_results() {
let cands = make_candidates(20, 8, 2);
let query = make_query(8);
let r = GnnDiffusionReranker::default()
.rerank(&query, &cands, 5)
.unwrap();
assert_eq!(r.len(), 5);
}

#[test]
fn gnn_mincut_returns_k_results() {
let cands = make_candidates(20, 8, 3);
let query = make_query(8);
let r = GnnMincutReranker::default()
.rerank(&query, &cands, 5)
.unwrap();
assert_eq!(r.len(), 5);
}

#[test]
fn exact_l2_returns_closest_to_origin() {
let mut cands: Vec<Candidate> = (0..10)
.map(|i| Candidate {
id: i as u32,
// id=0 is origin (closest), others are progressively farther
vector: vec![(i as f32) * 0.5; 4],
noisy_score: 0.5,
})
.collect();
// Shuffle scores so noisy ordering would fail
cands[0].noisy_score = 0.1; // lowest noisy score but closest
cands[9].noisy_score = 0.9; // highest noisy score but farthest

let query = vec![0.0_f32; 4];
let r = ExactL2Reranker.rerank(&query, &cands, 3).unwrap();
// Should pick id=0 (L2=0), id=1 (L2=0.5×sqrt(4)=1.0), id=2 first
assert_eq!(
r[0].id, 0,
"ExactL2 must pick the true nearest neighbour first"
);
}

#[test]
fn k_too_large_returns_error() {
let cands = make_candidates(5, 4, 4);
let query = make_query(4);
assert!(matches!(
NoisyScoreReranker.rerank(&query, &cands, 10),
Err(RerankerError::KTooLarge { .. })
));
}

#[test]
fn empty_candidates_returns_error() {
let cands: Vec<Candidate> = vec![];
let query = make_query(4);
assert!(matches!(
NoisyScoreReranker.rerank(&query, &cands, 1),
Err(RerankerError::Empty)
));
}

#[test]
fn candidate_graph_has_correct_degree() {
let cands = make_candidates(20, 8, 5);
let k_graph = 4;
let g = CandidateGraph::build(&cands, k_graph);
for neighbours in &g.edges {
assert!(neighbours.len() <= k_graph);
}
}
}
Loading
Loading