From a8d548311fe622ee43b4e28a9d5d5fd863bfc76e Mon Sep 17 00:00:00 2001 From: ruvnet Date: Mon, 27 Jul 2026 12:45:06 -0400 Subject: [PATCH] fix bounded RAG flow and input validation --- crates/ruvector-bounded-rag/README.md | 17 ++ crates/ruvector-bounded-rag/src/benchmark.rs | 27 +-- crates/ruvector-bounded-rag/src/lib.rs | 196 ++++++++++++++---- docs/adr/ADR-272-bounded-rag-mincut.md | 15 +- .../2026-07-25-bounded-rag-mincut/README.md | 17 +- .../2026-07-25-bounded-rag-mincut/gist.md | 18 +- 6 files changed, 222 insertions(+), 68 deletions(-) create mode 100644 crates/ruvector-bounded-rag/README.md diff --git a/crates/ruvector-bounded-rag/README.md b/crates/ruvector-bounded-rag/README.md new file mode 100644 index 000000000..577cba595 --- /dev/null +++ b/crates/ruvector-bounded-rag/README.md @@ -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. diff --git a/crates/ruvector-bounded-rag/src/benchmark.rs b/crates/ruvector-bounded-rag/src/benchmark.rs index 8fef43b7b..18c35f9ca 100644 --- a/crates/ruvector-bounded-rag/src/benchmark.rs +++ b/crates/ruvector-bounded-rag/src/benchmark.rs @@ -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) { 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 = Vec::with_capacity(queries.len()); let mut precisions: Vec = Vec::with_capacity(queries.len()); let mut budgets: Vec = Vec::with_capacity(queries.len()); @@ -95,7 +89,14 @@ fn run_variant( let mean_precision = precisions.iter().sum::() / n as f64; let mean_budget_util = budgets.iter().sum::() / 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() { diff --git a/crates/ruvector-bounded-rag/src/lib.rs b/crates/ruvector-bounded-rag/src/lib.rs index 789653ce8..267cf55a8 100644 --- a/crates/ruvector-bounded-rag/src/lib.rs +++ b/crates/ruvector-bounded-rag/src/lib.rs @@ -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>) -> 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) -> 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) -> Self { - Self { vector, relevant_labels: HashSet::new() } + Self { + vector, + relevant_labels: HashSet::new(), + } } pub fn with_relevant(mut self, labels: impl IntoIterator) -> Self { @@ -179,7 +195,32 @@ fn normalise(v: &[f32]) -> Vec { /// 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::().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::() + .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> = 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); + } } diff --git a/docs/adr/ADR-272-bounded-rag-mincut.md b/docs/adr/ADR-272-bounded-rag-mincut.md index fe1ec10ad..4945f03f1 100644 --- a/docs/adr/ADR-272-bounded-rag-mincut.md +++ b/docs/adr/ADR-272-bounded-rag-mincut.md @@ -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. diff --git a/docs/research/nightly/2026-07-25-bounded-rag-mincut/README.md b/docs/research/nightly/2026-07-25-bounded-rag-mincut/README.md index 039a2553a..9e31f905c 100644 --- a/docs/research/nightly/2026-07-25-bounded-rag-mincut/README.md +++ b/docs/research/nightly/2026-07-25-bounded-rag-mincut/README.md @@ -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>` 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. diff --git a/docs/research/nightly/2026-07-25-bounded-rag-mincut/gist.md b/docs/research/nightly/2026-07-25-bounded-rag-mincut/gist.md index acc8e4def..bf32acd39 100644 --- a/docs/research/nightly/2026-07-25-bounded-rag-mincut/gist.md +++ b/docs/research/nightly/2026-07-25-bounded-rag-mincut/gist.md @@ -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.