mirror of
https://github.com/ruvnet/RuVector.git
synced 2026-08-14 11:13:46 +00:00
fix bounded RAG flow and input validation
Some checks failed
ruvector-verified CI / check () (push) Has been cancelled
ruvector-verified CI / check (--all-features) (push) Has been cancelled
ruvector-verified CI / check (--features all-proofs) (push) Has been cancelled
ruvector-verified CI / check (--features coherence-proofs) (push) Has been cancelled
ruvector-verified CI / check (--features hnsw-proofs) (push) Has been cancelled
ruvector-verified CI / check (--features rvf-proofs) (push) Has been cancelled
ruvector-verified CI / check (--features serde) (push) Has been cancelled
ruvector-verified CI / check (--features ultra) (push) Has been cancelled
ruvector-verified CI / clippy (push) Has been cancelled
ruvector-verified CI / test (push) Has been cancelled
ruvector-verified CI / bench (push) Has been cancelled
Some checks failed
ruvector-verified CI / check () (push) Has been cancelled
ruvector-verified CI / check (--all-features) (push) Has been cancelled
ruvector-verified CI / check (--features all-proofs) (push) Has been cancelled
ruvector-verified CI / check (--features coherence-proofs) (push) Has been cancelled
ruvector-verified CI / check (--features hnsw-proofs) (push) Has been cancelled
ruvector-verified CI / check (--features rvf-proofs) (push) Has been cancelled
ruvector-verified CI / check (--features serde) (push) Has been cancelled
ruvector-verified CI / check (--features ultra) (push) Has been cancelled
ruvector-verified CI / clippy (push) Has been cancelled
ruvector-verified CI / test (push) Has been cancelled
ruvector-verified CI / bench (push) Has been cancelled
This commit is contained in:
parent
0b85ccc16e
commit
a8d548311f
6 changed files with 222 additions and 68 deletions
17
crates/ruvector-bounded-rag/README.md
Normal file
17
crates/ruvector-bounded-rag/README.md
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# ruvector-bounded-rag
|
||||
|
||||
Research implementations of context-budgeted retrieval for RuVector.
|
||||
|
||||
The crate compares cosine top-k retrieval, priority traversal over a dense
|
||||
similarity graph, and an Edmonds–Karp min-cut partition followed by relevance
|
||||
ranking and budget truncation. The graph-based variants rebuild their pairwise
|
||||
similarity structures per query and are intended as auditable research
|
||||
baselines rather than production-scale indexes.
|
||||
|
||||
```sh
|
||||
cargo test -p ruvector-bounded-rag
|
||||
cargo run --release -p ruvector-bounded-rag --bin benchmark
|
||||
```
|
||||
|
||||
See `docs/adr/ADR-272-bounded-rag-mincut.md` and the associated nightly
|
||||
research report for methodology and limitations.
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
///! Bounded RAG MinCut benchmark
|
||||
///!
|
||||
///! Measures three retrieval variants across two dataset sizes.
|
||||
///! Run: cargo run --release -p ruvector-bounded-rag --bin benchmark
|
||||
//! Bounded RAG MinCut benchmark
|
||||
//!
|
||||
//! Measures three retrieval variants across two dataset sizes.
|
||||
//! Run: cargo run --release -p ruvector-bounded-rag --bin benchmark
|
||||
use ruvector_bounded_rag::{
|
||||
BoundedRetriever, Corpus, GraphBfsRetriever, MinCutRetriever, Query, RetrieverConfig,
|
||||
TopKRetriever,
|
||||
|
|
@ -49,9 +49,7 @@ fn build_corpus(case: &BenchCase, rng: &mut StdRng) -> (Corpus, Vec<Query>) {
|
|||
for x in qv.iter_mut() {
|
||||
*x += normal.sample(rng) * 0.05;
|
||||
}
|
||||
queries.push(
|
||||
Query::new(qv).with_relevant([target_cluster as u32]),
|
||||
);
|
||||
queries.push(Query::new(qv).with_relevant([target_cluster as u32]));
|
||||
}
|
||||
|
||||
(corpus, queries)
|
||||
|
|
@ -66,11 +64,7 @@ struct Stats {
|
|||
mean_budget_util: f64,
|
||||
}
|
||||
|
||||
fn run_variant(
|
||||
retriever: &dyn BoundedRetriever,
|
||||
corpus: &Corpus,
|
||||
queries: &[Query],
|
||||
) -> Stats {
|
||||
fn run_variant(retriever: &dyn BoundedRetriever, corpus: &Corpus, queries: &[Query]) -> Stats {
|
||||
let mut latencies_us: Vec<f64> = Vec::with_capacity(queries.len());
|
||||
let mut precisions: Vec<f64> = Vec::with_capacity(queries.len());
|
||||
let mut budgets: Vec<f64> = Vec::with_capacity(queries.len());
|
||||
|
|
@ -95,7 +89,14 @@ fn run_variant(
|
|||
let mean_precision = precisions.iter().sum::<f64>() / n as f64;
|
||||
let mean_budget_util = budgets.iter().sum::<f64>() / n as f64;
|
||||
|
||||
Stats { mean_us, p50_us, p95_us, throughput_qps, mean_precision, mean_budget_util }
|
||||
Stats {
|
||||
mean_us,
|
||||
p50_us,
|
||||
p95_us,
|
||||
throughput_qps,
|
||||
mean_precision,
|
||||
mean_budget_util,
|
||||
}
|
||||
}
|
||||
|
||||
fn print_header() {
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@
|
|||
//! | Variant | Strategy | Strength |
|
||||
//! |---------|----------|----------|
|
||||
//! | `TopK` | Cosine rank, no graph | Fastest, baseline |
|
||||
//! | `GraphBfs` | BFS expansion with coherence gate | Budget-safe, O(V+E) |
|
||||
//! | `MinCutBounded` | Max-flow/min-cut flow network | Optimal coherent partition |
|
||||
//! | `GraphBfs` | Builds a dense similarity graph, then expands with a coherence gate | Budget-safe heuristic |
|
||||
//! | `MinCutBounded` | Max-flow/min-cut flow network, then top-budget truncation | Coherent-partition heuristic |
|
||||
//!
|
||||
//! ## Quick start
|
||||
//!
|
||||
|
|
@ -66,15 +66,28 @@ pub struct Corpus {
|
|||
impl Corpus {
|
||||
pub fn from_vecs(vecs: Vec<Vec<f32>>) -> Self {
|
||||
let dim = vecs.first().map(|v| v.len()).unwrap_or(0);
|
||||
assert!(
|
||||
vecs.iter().all(|vector| vector.len() == dim),
|
||||
"all corpus vectors must have the same dimension"
|
||||
);
|
||||
let chunks = vecs
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(id, vector)| Chunk { id, vector, label: None })
|
||||
.map(|(id, vector)| Chunk {
|
||||
id,
|
||||
vector,
|
||||
label: None,
|
||||
})
|
||||
.collect();
|
||||
Self { chunks, dim }
|
||||
}
|
||||
|
||||
pub fn with_labels(mut self, labels: Vec<u32>) -> Self {
|
||||
assert_eq!(
|
||||
labels.len(),
|
||||
self.chunks.len(),
|
||||
"label count must match chunk count"
|
||||
);
|
||||
for (chunk, label) in self.chunks.iter_mut().zip(labels) {
|
||||
chunk.label = Some(label);
|
||||
}
|
||||
|
|
@ -98,7 +111,10 @@ pub struct Query {
|
|||
|
||||
impl Query {
|
||||
pub fn new(vector: Vec<f32>) -> Self {
|
||||
Self { vector, relevant_labels: HashSet::new() }
|
||||
Self {
|
||||
vector,
|
||||
relevant_labels: HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_relevant(mut self, labels: impl IntoIterator<Item = u32>) -> Self {
|
||||
|
|
@ -179,7 +195,32 @@ fn normalise(v: &[f32]) -> Vec<f32> {
|
|||
/// Cosine similarity — assumes inputs are already L2-normalised.
|
||||
#[inline]
|
||||
fn cosine(a: &[f32], b: &[f32]) -> f32 {
|
||||
a.iter().zip(b.iter()).map(|(x, y)| x * y).sum::<f32>().clamp(-1.0, 1.0)
|
||||
assert_eq!(a.len(), b.len(), "vector dimension mismatch");
|
||||
a.iter()
|
||||
.zip(b.iter())
|
||||
.map(|(x, y)| x * y)
|
||||
.sum::<f32>()
|
||||
.clamp(-1.0, 1.0)
|
||||
}
|
||||
|
||||
fn validate_config(cfg: &RetrieverConfig) {
|
||||
assert!(cfg.budget > 0, "budget must be greater than zero");
|
||||
assert!(
|
||||
(-1.0..=1.0).contains(&cfg.edge_threshold),
|
||||
"edge_threshold must be in [-1, 1]"
|
||||
);
|
||||
assert!(
|
||||
(-1.0..=1.0).contains(&cfg.seed_threshold),
|
||||
"seed_threshold must be in [-1, 1]"
|
||||
);
|
||||
}
|
||||
|
||||
fn validate_query(corpus: &Corpus, query: &Query) {
|
||||
assert_eq!(
|
||||
query.vector.len(),
|
||||
corpus.dim,
|
||||
"query dimension must match corpus dimension"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Variant 1: TopK (baseline) ─────────────────────────────────────────────────
|
||||
|
|
@ -191,6 +232,7 @@ pub struct TopKRetriever {
|
|||
|
||||
impl TopKRetriever {
|
||||
pub fn new(cfg: RetrieverConfig) -> Self {
|
||||
validate_config(&cfg);
|
||||
Self { cfg }
|
||||
}
|
||||
}
|
||||
|
|
@ -202,8 +244,13 @@ impl BoundedRetriever for TopKRetriever {
|
|||
|
||||
fn retrieve(&self, corpus: &Corpus, query: &Query) -> RetrievalResult {
|
||||
if corpus.is_empty() {
|
||||
return RetrievalResult { chunks: vec![], scores: vec![], budget_utilisation: 0.0 };
|
||||
return RetrievalResult {
|
||||
chunks: vec![],
|
||||
scores: vec![],
|
||||
budget_utilisation: 0.0,
|
||||
};
|
||||
}
|
||||
validate_query(corpus, query);
|
||||
let qn = normalise(&query.vector);
|
||||
|
||||
// Score every chunk
|
||||
|
|
@ -217,7 +264,7 @@ impl BoundedRetriever for TopKRetriever {
|
|||
.collect();
|
||||
|
||||
// Sort descending
|
||||
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
|
||||
scored.sort_by(|a, b| b.1.total_cmp(&a.1));
|
||||
scored.truncate(self.cfg.budget);
|
||||
|
||||
let n = scored.len();
|
||||
|
|
@ -239,13 +286,18 @@ impl BoundedRetriever for TopKRetriever {
|
|||
/// 1. Find seed chunks with cosine(query, chunk) >= seed_threshold.
|
||||
/// 2. BFS: expand to neighbours with edge weight >= edge_threshold.
|
||||
/// 3. Stop when budget is exhausted.
|
||||
///
|
||||
/// Priority queue ensures high-affinity chunks enter first.
|
||||
///
|
||||
/// This research implementation rebuilds a dense similarity graph for every
|
||||
/// query, costing O(n²·d) before the O(V+E) traversal.
|
||||
pub struct GraphBfsRetriever {
|
||||
cfg: RetrieverConfig,
|
||||
}
|
||||
|
||||
impl GraphBfsRetriever {
|
||||
pub fn new(cfg: RetrieverConfig) -> Self {
|
||||
validate_config(&cfg);
|
||||
Self { cfg }
|
||||
}
|
||||
|
||||
|
|
@ -273,8 +325,13 @@ impl BoundedRetriever for GraphBfsRetriever {
|
|||
|
||||
fn retrieve(&self, corpus: &Corpus, query: &Query) -> RetrievalResult {
|
||||
if corpus.is_empty() {
|
||||
return RetrievalResult { chunks: vec![], scores: vec![], budget_utilisation: 0.0 };
|
||||
return RetrievalResult {
|
||||
chunks: vec![],
|
||||
scores: vec![],
|
||||
budget_utilisation: 0.0,
|
||||
};
|
||||
}
|
||||
validate_query(corpus, query);
|
||||
|
||||
let qn = normalise(&query.vector);
|
||||
let normed: Vec<Vec<f32>> = corpus.chunks.iter().map(|c| normalise(&c.vector)).collect();
|
||||
|
|
@ -292,7 +349,7 @@ impl BoundedRetriever for GraphBfsRetriever {
|
|||
}
|
||||
impl Ord for Entry {
|
||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||
self.0.partial_cmp(&other.0).unwrap_or(std::cmp::Ordering::Equal)
|
||||
self.0.total_cmp(&other.0)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -309,7 +366,7 @@ impl BoundedRetriever for GraphBfsRetriever {
|
|||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, nv)| (i, cosine(&qn, nv)))
|
||||
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap())
|
||||
.max_by(|a, b| a.1.total_cmp(&b.1))
|
||||
.unwrap_or((0, 0.0));
|
||||
heap.push(Entry(best_sim, best_id));
|
||||
}
|
||||
|
|
@ -362,7 +419,8 @@ impl BoundedRetriever for GraphBfsRetriever {
|
|||
/// retrieved chunks (capped at budget).
|
||||
///
|
||||
/// The min-cut separates "query-coherent" chunks from noise with minimum weight,
|
||||
/// giving a principled coherence boundary.
|
||||
/// giving a principled coherence boundary. The partition is subsequently
|
||||
/// ranked and truncated, so this is not an exact budget-constrained min-cut.
|
||||
pub struct MinCutRetriever {
|
||||
cfg: RetrieverConfig,
|
||||
/// Scale factor for inter-chunk edge capacities.
|
||||
|
|
@ -371,10 +429,18 @@ pub struct MinCutRetriever {
|
|||
|
||||
impl MinCutRetriever {
|
||||
pub fn new(cfg: RetrieverConfig) -> Self {
|
||||
Self { cfg, edge_scale: 0.5 }
|
||||
validate_config(&cfg);
|
||||
Self {
|
||||
cfg,
|
||||
edge_scale: 0.5,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_edge_scale(mut self, scale: f32) -> Self {
|
||||
assert!(
|
||||
scale.is_finite() && scale >= 0.0,
|
||||
"edge scale must be finite and non-negative"
|
||||
);
|
||||
self.edge_scale = scale;
|
||||
self
|
||||
}
|
||||
|
|
@ -387,8 +453,13 @@ impl BoundedRetriever for MinCutRetriever {
|
|||
|
||||
fn retrieve(&self, corpus: &Corpus, query: &Query) -> RetrievalResult {
|
||||
if corpus.is_empty() {
|
||||
return RetrievalResult { chunks: vec![], scores: vec![], budget_utilisation: 0.0 };
|
||||
return RetrievalResult {
|
||||
chunks: vec![],
|
||||
scores: vec![],
|
||||
budget_utilisation: 0.0,
|
||||
};
|
||||
}
|
||||
validate_query(corpus, query);
|
||||
|
||||
let n = corpus.len();
|
||||
let qn = normalise(&query.vector);
|
||||
|
|
@ -406,6 +477,7 @@ impl BoundedRetriever for MinCutRetriever {
|
|||
for i in 0..n {
|
||||
let sim = cosine(&qn, &normed[i]).max(0.001);
|
||||
cap[source].insert(i, sim);
|
||||
cap[i].entry(source).or_insert(0.0);
|
||||
}
|
||||
|
||||
// Chunk → sink edges
|
||||
|
|
@ -413,6 +485,7 @@ impl BoundedRetriever for MinCutRetriever {
|
|||
let sim = cosine(&qn, &normed[i]);
|
||||
let anti = (1.0 - sim).max(0.001);
|
||||
cap[i].insert(sink, anti);
|
||||
cap[sink].entry(i).or_insert(0.0);
|
||||
}
|
||||
|
||||
// Inter-chunk edges (both directions)
|
||||
|
|
@ -499,7 +572,7 @@ impl BoundedRetriever for MinCutRetriever {
|
|||
.filter(|&i| in_source_set[i])
|
||||
.map(|i| (i, cosine(&qn, &normed[i])))
|
||||
.collect();
|
||||
retrieved.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
|
||||
retrieved.sort_by(|a, b| b.1.total_cmp(&a.1));
|
||||
retrieved.truncate(self.cfg.budget);
|
||||
|
||||
let retrieved_n = retrieved.len();
|
||||
|
|
@ -517,11 +590,7 @@ impl BoundedRetriever for MinCutRetriever {
|
|||
// ── evaluation helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/// Mean average precision across a query set.
|
||||
pub fn mean_precision(
|
||||
retriever: &dyn BoundedRetriever,
|
||||
corpus: &Corpus,
|
||||
queries: &[Query],
|
||||
) -> f32 {
|
||||
pub fn mean_precision(retriever: &dyn BoundedRetriever, corpus: &Corpus, queries: &[Query]) -> f32 {
|
||||
if queries.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
|
|
@ -541,14 +610,14 @@ mod tests {
|
|||
fn toy_corpus() -> Corpus {
|
||||
// Two clusters: cluster 0 near (1,0,0), cluster 1 near (0,0,1)
|
||||
let vecs = vec![
|
||||
vec![1.0_f32, 0.0, 0.0], // 0 – cluster 0
|
||||
vec![0.95, 0.05, 0.0], // 1 – cluster 0
|
||||
vec![0.90, 0.10, 0.0], // 2 – cluster 0
|
||||
vec![0.85, 0.15, 0.05], // 3 – cluster 0
|
||||
vec![0.0, 0.0, 1.0], // 4 – cluster 1
|
||||
vec![0.05, 0.05, 0.95], // 5 – cluster 1
|
||||
vec![0.0, 0.1, 0.9], // 6 – cluster 1
|
||||
vec![0.5, 0.5, 0.5], // 7 – noise
|
||||
vec![1.0_f32, 0.0, 0.0], // 0 – cluster 0
|
||||
vec![0.95, 0.05, 0.0], // 1 – cluster 0
|
||||
vec![0.90, 0.10, 0.0], // 2 – cluster 0
|
||||
vec![0.85, 0.15, 0.05], // 3 – cluster 0
|
||||
vec![0.0, 0.0, 1.0], // 4 – cluster 1
|
||||
vec![0.05, 0.05, 0.95], // 5 – cluster 1
|
||||
vec![0.0, 0.1, 0.9], // 6 – cluster 1
|
||||
vec![0.5, 0.5, 0.5], // 7 – noise
|
||||
];
|
||||
let labels = vec![0, 0, 0, 0, 1, 1, 1, 2];
|
||||
Corpus::from_vecs(vecs).with_labels(labels)
|
||||
|
|
@ -561,7 +630,10 @@ mod tests {
|
|||
#[test]
|
||||
fn topk_returns_budget_chunks() {
|
||||
let corpus = toy_corpus();
|
||||
let cfg = RetrieverConfig { budget: 3, ..Default::default() };
|
||||
let cfg = RetrieverConfig {
|
||||
budget: 3,
|
||||
..Default::default()
|
||||
};
|
||||
let r = TopKRetriever::new(cfg).retrieve(&corpus, &cluster0_query());
|
||||
assert_eq!(r.chunks.len(), 3);
|
||||
}
|
||||
|
|
@ -569,7 +641,10 @@ mod tests {
|
|||
#[test]
|
||||
fn topk_highest_similarity_first() {
|
||||
let corpus = toy_corpus();
|
||||
let cfg = RetrieverConfig { budget: 4, ..Default::default() };
|
||||
let cfg = RetrieverConfig {
|
||||
budget: 4,
|
||||
..Default::default()
|
||||
};
|
||||
let r = TopKRetriever::new(cfg).retrieve(&corpus, &cluster0_query());
|
||||
// Chunk 0 has cosine 1.0 — must be first
|
||||
assert_eq!(r.chunks[0], 0);
|
||||
|
|
@ -582,7 +657,10 @@ mod tests {
|
|||
#[test]
|
||||
fn topk_precision_cluster0() {
|
||||
let corpus = toy_corpus();
|
||||
let cfg = RetrieverConfig { budget: 4, ..Default::default() };
|
||||
let cfg = RetrieverConfig {
|
||||
budget: 4,
|
||||
..Default::default()
|
||||
};
|
||||
let retriever = TopKRetriever::new(cfg);
|
||||
let q = cluster0_query();
|
||||
let r = retriever.retrieve(&corpus, &q);
|
||||
|
|
@ -593,7 +671,11 @@ mod tests {
|
|||
#[test]
|
||||
fn graphbfs_respects_budget() {
|
||||
let corpus = toy_corpus();
|
||||
let cfg = RetrieverConfig { budget: 3, edge_threshold: 0.8, seed_threshold: 0.7 };
|
||||
let cfg = RetrieverConfig {
|
||||
budget: 3,
|
||||
edge_threshold: 0.8,
|
||||
seed_threshold: 0.7,
|
||||
};
|
||||
let r = GraphBfsRetriever::new(cfg).retrieve(&corpus, &cluster0_query());
|
||||
assert!(r.chunks.len() <= 3);
|
||||
}
|
||||
|
|
@ -601,7 +683,11 @@ mod tests {
|
|||
#[test]
|
||||
fn graphbfs_retrieves_cluster0() {
|
||||
let corpus = toy_corpus();
|
||||
let cfg = RetrieverConfig { budget: 5, edge_threshold: 0.75, seed_threshold: 0.60 };
|
||||
let cfg = RetrieverConfig {
|
||||
budget: 5,
|
||||
edge_threshold: 0.75,
|
||||
seed_threshold: 0.60,
|
||||
};
|
||||
let q = cluster0_query();
|
||||
let r = GraphBfsRetriever::new(cfg.clone()).retrieve(&corpus, &q);
|
||||
let p = r.precision(&corpus, &q);
|
||||
|
|
@ -611,7 +697,11 @@ mod tests {
|
|||
#[test]
|
||||
fn mincut_respects_budget() {
|
||||
let corpus = toy_corpus();
|
||||
let cfg = RetrieverConfig { budget: 3, edge_threshold: 0.75, ..Default::default() };
|
||||
let cfg = RetrieverConfig {
|
||||
budget: 3,
|
||||
edge_threshold: 0.75,
|
||||
..Default::default()
|
||||
};
|
||||
let r = MinCutRetriever::new(cfg).retrieve(&corpus, &cluster0_query());
|
||||
assert!(r.chunks.len() <= 3);
|
||||
}
|
||||
|
|
@ -619,7 +709,11 @@ mod tests {
|
|||
#[test]
|
||||
fn mincut_precision_cluster0() {
|
||||
let corpus = toy_corpus();
|
||||
let cfg = RetrieverConfig { budget: 6, edge_threshold: 0.75, seed_threshold: 0.50 };
|
||||
let cfg = RetrieverConfig {
|
||||
budget: 6,
|
||||
edge_threshold: 0.75,
|
||||
seed_threshold: 0.50,
|
||||
};
|
||||
let q = cluster0_query();
|
||||
let r = MinCutRetriever::new(cfg).retrieve(&corpus, &q);
|
||||
let p = r.precision(&corpus, &q);
|
||||
|
|
@ -631,15 +725,16 @@ mod tests {
|
|||
fn mincut_no_cross_cluster_bleed() {
|
||||
// Strongly separated clusters — MinCut should not bleed into cluster 1
|
||||
let corpus = toy_corpus();
|
||||
let cfg = RetrieverConfig { budget: 4, edge_threshold: 0.85, seed_threshold: 0.70 };
|
||||
let cfg = RetrieverConfig {
|
||||
budget: 4,
|
||||
edge_threshold: 0.85,
|
||||
seed_threshold: 0.70,
|
||||
};
|
||||
let q = Query::new(vec![1.0_f32, 0.0, 0.0]);
|
||||
let r = MinCutRetriever::new(cfg).retrieve(&corpus, &q);
|
||||
// Chunk 4,5,6 are cluster 1 — they should NOT appear with tight threshold
|
||||
for &id in &r.chunks {
|
||||
assert!(
|
||||
id < 4 || id == 7,
|
||||
"MinCut bled into cluster 1: chunk={id}"
|
||||
);
|
||||
assert!(id < 4 || id == 7, "MinCut bled into cluster 1: chunk={id}");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -707,4 +802,27 @@ mod tests {
|
|||
assert!(p_bfs >= 0.70, "BFS precision={p_bfs:.3} below threshold");
|
||||
assert!(p_mc >= 0.70, "MinCut precision={p_mc:.3} below threshold");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "budget must be greater than zero")]
|
||||
fn zero_budget_is_rejected() {
|
||||
let _ = TopKRetriever::new(RetrieverConfig {
|
||||
budget: 0,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "all corpus vectors must have the same dimension")]
|
||||
fn inconsistent_corpus_dimensions_are_rejected() {
|
||||
let _ = Corpus::from_vecs(vec![vec![1.0, 0.0], vec![1.0]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "query dimension must match corpus dimension")]
|
||||
fn query_dimension_mismatch_is_rejected() {
|
||||
let corpus = toy_corpus();
|
||||
let query = Query::new(vec![1.0, 0.0]);
|
||||
let _ = TopKRetriever::new(Default::default()).retrieve(&corpus, &query);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,9 +17,14 @@ Standard RAG retrieves the top-k chunks by vector cosine similarity and feeds al
|
|||
|
||||
Agent memory systems have a third failure: accumulated memories from many sessions may share surface-level similarity to a query while being contextually unrelated. Returning them degrades inference quality and increases hallucination risk.
|
||||
|
||||
The insight behind this ADR: the retrieved context should be the **maximally coherent subgraph** that fits within a token budget, not the top-k arbitrary neighbours.
|
||||
The hypothesis behind this ADR is that graph coherence can improve a
|
||||
budget-capped context set compared with top-k retrieval alone.
|
||||
|
||||
Graph min-cut provides a principled mechanism. If chunks are nodes and similarity edges connect them, a min-cut between a query-seeded source partition and a noise sink separates the coherent cluster from noise with minimum edge weight sacrificed — i.e., minimum loss of coherence.
|
||||
Graph min-cut provides one measurable mechanism. If chunks are nodes and
|
||||
similarity edges connect them, a min-cut between a query-seeded source
|
||||
partition and a noise sink separates a candidate cluster from noise. The
|
||||
implementation then ranks and truncates that partition to the budget, so the
|
||||
final set is not the solution to a budget-constrained min-cut objective.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -28,8 +33,8 @@ Graph min-cut provides a principled mechanism. If chunks are nodes and similarit
|
|||
Add `ruvector-bounded-rag` to the workspace as a standalone research crate implementing three retrieval strategies with a shared `BoundedRetriever` trait:
|
||||
|
||||
1. **TopK**: cosine rank, no graph, O(n log n) — baseline.
|
||||
2. **GraphBFS**: priority-queue BFS expansion on a chunk similarity graph, bounded by coherence threshold and budget, O(V+E) — practical at medium scale.
|
||||
3. **MinCutBounded**: Edmonds-Karp max-flow/min-cut on a source-sink flow network built from the chunk graph, O(VE²) — optimal coherence partition, requires pre-filtering at large scale.
|
||||
2. **GraphBFS**: rebuild a dense similarity graph in O(n²·d), then run priority-queue traversal bounded by coherence threshold and budget.
|
||||
3. **MinCutBounded**: Edmonds-Karp max-flow/min-cut on a source-sink flow network built from the chunk graph, followed by relevance ranking and budget truncation; requires pre-filtering at large scale.
|
||||
|
||||
All three share `RetrieverConfig { budget, edge_threshold, seed_threshold }`.
|
||||
|
||||
|
|
@ -46,7 +51,7 @@ The flow network for MinCutBounded:
|
|||
### Positive
|
||||
|
||||
- MinCutBounded provides the tightest coherence guarantee of the three strategies.
|
||||
- GraphBFS is a practical middle ground: O(V+E), no quadratic cost, good precision.
|
||||
- GraphBFS has a simpler traversal than MinCut, but this PoC still pays an O(n²·d) graph-build cost on every query.
|
||||
- All three use the same `BoundedRetriever` trait, making them drop-in swappable.
|
||||
- The budget parameter directly controls context window consumption.
|
||||
- Proof-gated integration is straightforward: add a pre-filter step using `ruvector-proof-gate` to remove chunks the requester lacks access to before running the retriever.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Bounded Context RAG via MinCut Graph Partitioning
|
||||
|
||||
**150-char summary:** MinCut on the chunk similarity graph finds the maximally coherent context window that fits a budget — a principled replacement for fixed-k RAG retrieval.
|
||||
**Summary:** A measured MinCut research baseline partitions a chunk-similarity graph, then relevance-ranks and truncates the candidate partition to a context budget.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -11,8 +11,8 @@ Standard RAG retrieval returns the top-k nearest neighbours of a query vector. T
|
|||
This research implements and benchmarks three retrieval strategies for bounded-context RAG:
|
||||
|
||||
1. **TopK** — cosine rank, no graph reasoning, O(n log n), baseline.
|
||||
2. **GraphBFS** — BFS expansion on a chunk similarity graph with coherence gating, O(V+E).
|
||||
3. **MinCutBounded** — max-flow / min-cut on a source-sink flow network built from chunk similarities, O(VE²), optimal coherence partition.
|
||||
2. **GraphBFS** — O(n²·d) dense graph construction per query followed by coherence-gated priority traversal.
|
||||
3. **MinCutBounded** — max-flow/min-cut on a source-sink network followed by relevance ranking and budget truncation; this is not a budget-optimal cut.
|
||||
|
||||
All three are measured on synthetic clustered corpora with deterministic Rust code. No external services. No placeholder numbers.
|
||||
|
||||
|
|
@ -160,7 +160,9 @@ Green nodes (C1, C2) land in the source partition after min-cut — these are re
|
|||
|
||||
**Edmonds-Karp**: Standard BFS-augmented Ford-Fulkerson. Capacity matrix stored as `Vec<HashMap<usize, f32>>` for sparsity. Flow matrix uses the same structure. Bottleneck found by path replay.
|
||||
|
||||
**Budget cap**: Applied after partition recovery — the source partition may be smaller than budget (correct) or requires a hard cap (applied by sort + truncate).
|
||||
**Budget cap**: Applied after partition recovery. If the source partition
|
||||
exceeds the budget, relevance-based truncation can change the partition's
|
||||
coherence properties.
|
||||
|
||||
**Fallback**: If the seed set is empty (no chunks above seed_threshold), the implementation falls back to the globally highest-similarity chunk as seed, preventing empty results.
|
||||
|
||||
|
|
@ -272,7 +274,7 @@ If budget=2: the min-cut partition still returns {A, B, C} but the final sort+tr
|
|||
|
||||
2. **Edge over-sparsity**: If edge_threshold is too high, the chunk graph is disconnected and GraphBFS returns only seeded chunks. MinCutBounded returns only the seed partition. Mitigation: tune edge_threshold on a representative validation corpus.
|
||||
|
||||
3. **Large coherent clusters exceeding budget**: If the coherent partition has 200 chunks but budget=20, the hard truncation is applied — the bottom 180 chunks by query similarity are dropped. This is correct behaviour but may surprise users expecting a coherence guarantee for all returned chunks.
|
||||
3. **Large candidate partitions exceeding budget**: If the partition has 200 chunks but budget=20, the bottom 180 chunks by query similarity are dropped. This enforces the cap but provides no optimality or coherence guarantee for the truncated set.
|
||||
|
||||
4. **Query similarity ties**: When many chunks have identical cosine similarity to the query, the sort order within the partition is non-deterministic. Mitigation: break ties by chunk ID for reproducibility.
|
||||
|
||||
|
|
@ -290,7 +292,10 @@ If budget=2: the min-cut partition still returns {A, B, C} but the final sort+tr
|
|||
|
||||
## Edge and WASM Implications
|
||||
|
||||
**GraphBFS** can run in WASM without modification. Its memory footprint scales as O(V+E) where E is proportional to sparsity-controlled adjacency. At n=200 chunks with edge_threshold=0.80, typical E ≈ 2n–5n → ~1,000–1,000 edges, ≈ 8–40KB. Viable on Cognitum Seed (256MB RAM envelope).
|
||||
**GraphBFS** can compile for WASM, but this PoC constructs and stores the
|
||||
thresholded graph per query after an O(n²·d) pairwise scan. Edge-device
|
||||
viability therefore needs target-specific measurement and a prebuilt sparse
|
||||
graph.
|
||||
|
||||
**MinCutBounded** requires Edmonds-Karp with a 52-node flow network for the Phase 2 pre-filter path. This is O(52 × 1,225²) ~ 78M f32 operations, well within WASM feasibility.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# ruvector 2026: Bounded Context RAG via MinCut Graph Partitioning for Rust Vector Search
|
||||
|
||||
**SEO summary (150 chars):** MinCut on the chunk similarity graph finds the maximally coherent context window that fits a budget — a principled RAG upgrade for AI agent memory.
|
||||
**Summary:** A measured MinCut baseline partitions a chunk-similarity graph, then relevance-ranks and truncates the partition to a context budget.
|
||||
|
||||
**Value proposition**: Replace fixed-k retrieval with a graph min-cut boundary that enforces semantic coherence while respecting a context budget — implemented in pure Rust, zero external services.
|
||||
|
||||
|
|
@ -18,7 +18,11 @@ The fundamental problem is that top-k retrieval models chunk-query relationships
|
|||
|
||||
Current workarounds are insufficient. MMR (Maximal Marginal Relevance) reduces redundancy but does not maximise coherence — a diverse-but-incoherent set is worse than a redundant-but-coherent one for agent memory applications. RAPTOR builds hierarchical summaries offline and cannot track streaming agent memory. GraphRAG requires LLM-powered entity extraction — expensive to maintain on continuously growing agent memory.
|
||||
|
||||
This research implements min-cut bounded retrieval: model chunk relationships as a similarity graph, attach source and sink nodes weighted by query affinity, run max-flow (Edmonds-Karp), and return the source-side partition. This is the maximally coherent chunk set that fits within a budget. It is a graph-theoretic answer to the question: "which chunks should I retrieve together?"
|
||||
This research implements min-cut bounded retrieval: model chunk relationships
|
||||
as a similarity graph, attach source and sink nodes weighted by query affinity,
|
||||
run max-flow (Edmonds-Karp), then relevance-rank and truncate the source-side
|
||||
partition. It is an auditable graph heuristic, not an exact
|
||||
budget-constrained min-cut.
|
||||
|
||||
For RuVector — a Rust-native cognition substrate for AI agents, graph memory, and MCP tooling — this is a natural capability. `ruvector-mincut` already implements subpolynomial dynamic min-cut. `ruvector-agent-memory` provides the chunk store. `ruvector-proof-gate` provides access-controlled pre-filtering. This crate (`ruvector-bounded-rag`) connects them into a coherence-aware retrieval layer.
|
||||
|
||||
|
|
@ -32,8 +36,8 @@ The practical constraint today is compute: O(n²) pairwise similarity matrix dom
|
|||
|---------|-------------|----------------|--------|
|
||||
| `BoundedRetriever` trait | Shared API for all retrieval strategies | Swappable backends in production | Implemented in PoC |
|
||||
| `TopKRetriever` | Cosine rank, no graph, O(n log n) | Fastest baseline | Implemented & measured |
|
||||
| `GraphBfsRetriever` | Priority-queue BFS on similarity graph | Budget-safe, O(V+E) | Implemented & measured |
|
||||
| `MinCutRetriever` | Edmonds-Karp max-flow/min-cut | Optimal coherent partition | Implemented & measured |
|
||||
| `GraphBfsRetriever` | O(n²·d) graph build + priority traversal | Budget-safe heuristic | Implemented & measured |
|
||||
| `MinCutRetriever` | Edmonds-Karp cut + relevance truncation | Coherent-partition heuristic | Implemented & measured |
|
||||
| `RetrieverConfig` | Shared budget + threshold config | Single config controls all three | Implemented in PoC |
|
||||
| Precision scoring | Recall metric using chunk labels | Honest quality measurement | Implemented & measured |
|
||||
| Phase 2 k-NN graph | Pre-built approximate graph | Reduces O(n²) to O(k log n) | Research direction |
|
||||
|
|
@ -218,7 +222,11 @@ Note: no direct comparison benchmarks are included. All measurements in this doc
|
|||
|
||||
## Deep Research Notes
|
||||
|
||||
The core insight is that max-flow/min-cut provides a **globally optimal** coherence boundary for a given query, while BFS-based methods only provide locally greedy boundaries. On well-separated synthetic corpora both achieve precision=1.000. The difference emerges on ambiguous corpora where clusters partially overlap — MinCut will find the lower-weight edge set to cut, while BFS may expand into the wrong cluster.
|
||||
Max-flow/min-cut exactly optimises the capacities of the constructed flow
|
||||
network. That does not establish that the capacities model contextual
|
||||
coherence, and the subsequent budget truncation is outside that optimum. On
|
||||
the well-separated synthetic corpus both graph variants achieve
|
||||
precision=1.000; ambiguous real-corpus behaviour remains to be validated.
|
||||
|
||||
The SOTA in RAG retrieval (as of July 2026) has not published min-cut bounded retrieval. The closest adjacent work is GraphRAG (Microsoft, 2024) which uses community detection on entity graphs. Community detection and min-cut are related (min-cut is equivalent to finding the minimum-weight balanced partition) but GraphRAG operates on structured entity-relation graphs extracted by LLMs, while this work operates on raw vector similarity graphs.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue