ADR-003: Graph RAG Hybrid Memory (In-RAM State, Out-of-Core Edges)¶
Status: Implemented — G1 (CSR-backed community detection), G2 (default DataFusion memory limit), G3 (bounded Graph RAG materialization), and G4 (independent knobs) have all shipped. Promoted from
plans/graph_rag_memory_architecture.md; see the per-item notes below.Related:
src/core/memory.rs,src/core/resources.rs,src/core/cache.rs,src/core/sql/session.rs,src/core/sql/graph_udf/louvain_communities.rs,src/core/sql/graph_udf/leiden_communities.rs,src/core/index/csr_graph.rs
Principle¶
Move the O(E) working set out of RAM; keep only O(V) state resident. The CSR is already the out-of-core adjacency (mmap’d, MADV_RANDOM). The graph algorithms should read from it instead of buffering the edge set. For a 6M-node / 383M-edge Wikipedia graph, O(V) state is ~72 MB while O(E) is ~7.7 GB — a 100x difference.
flowchart LR
subgraph RAM["In-RAM (O(V))"]
C[community: Vec u32]
D[degree: Vec f32]
T[comm_tot: Vec f32]
end
subgraph OOC["Out-of-core (O(E), mmap)"]
CSR[CSR offsets/edges/dict]
end
CSR -->|get_neighbors_raw| C
C --> D --> T
G1 — CSR-backed community detection (biggest risk)¶
Status: implemented. src/core/algorithms/communities.rs provides csr_louvain / csr_leiden (and their _seeded variants) reading adjacency from the mmap’d CSR with O(V) resident state; Table.communities() routes to the CSR path when a graph index is present.
Problem. LouvainAccumulator and LeidenAccumulator hold sources/targets/weights as Vecs — ~20 bytes/edge, ~7.7 GB at 383M edges.
Design. Add src/core/algorithms/communities.rs with louvain_csr(forward, resolution) and leiden_csr(forward, resolution) that read adjacency from the mmap’d CSR via get_neighbors_raw and keep only O(V) state:
community: Vec<u32>(4 B/node)degree: Vec<f32>(4 B/node)comm_tot: Vec<f32>(4 B/node)
The local-move and refinement phases are identical to the UDF versions; only the adjacency source changes. Table.communities() routes to the CSR path when has_graph_index("source"), else falls back to the UDF accumulator (small graphs / SQL convenience). For tables without a CSR, build one on the fly by reusing MmapCsrGraph::build_from_file into a temp dir.
Acceptance. Community detection on the wiki demo runs in bounded RSS (state only), and results match the UDF path on small graphs.
G2 — Default DataFusion memory limit¶
Status: implemented. default_datafusion_memory_bytes derives the limit from DATAFUSION_MEMORY_FRACTION (0.5) of the effective memory, overridable with BSDB_DATAFUSION_MEMORY_GB, and it is applied to both Session and Table.execute_sql.
Problem. BenoStreamSession::new(None) sets no limit, and that is what bsdb.rs:122 and graph.rs:58 use — so SQL sorts/joins/aggregations never spill.
Design. When memory_limit_bytes is None, default it to effective_memory_bytes() * 0.5 (leaving headroom for the ingest buffer and index builds). Add BSDB_DATAFUSION_MEMORY_GB to override. This turns on DataFusion’s disk spilling for the SQL path.
Acceptance. A large ORDER BY/join spills to disk instead of OOMing.
G3 — Avoid full materialization in Graph RAG¶
Status: implemented. subgraph_nodes() (visited set only, no edge join) is exposed on the Python Table and used for neighborhood_nodes; the final edge materialization is capped by max_nodes.
Problem. graph_rag_search materializes subgraph edges into pandas just to compute neighborhood_nodes; summarize_communities does SELECT * FROM t over the whole doc table.
Design.
Add
subgraph_nodes()(visited set only, no edge join) and use it forneighborhood_nodes; materialize edges only for the final result, capped bymax_nodes.In
summarize_communities, replaceSELECT * FROM twith a projection (id,content,title) and aWHERE id IN (top_entities)per community, or stream in batches.
Acceptance. Graph RAG peak RSS scales with max_nodes, not with the graph size.
G4 — Individual knobs, not a unified budget (decided)¶
Decision. A single coordinated MemoryBudget with a fixed percentage split
was prototyped and rejected: operators prefer independent knobs so each
subsystem can be tuned to the actual workload. The individual overrides stand:
Subsystem |
Override |
|---|---|
Ingest high-water mark |
|
Heap-trim budget |
|
DataFusion query limit |
|
Index-build fan-out |
|
What was kept. The DataFusion limit is bounded and configurable: it
defaults to DATAFUSION_MEMORY_FRACTION (50%) of the effective memory, and
Table.execute_sql — the path the graph UDFs run on — now applies it too (it
previously used an unbounded SessionContext::new()).
Priority (completed)¶
✅ G1 — biggest risk, clear win, reuses the existing CSR.
✅ G2 — bounded + configurable DataFusion limit, enables spilling.
✅ G3 — bounded materialization in the Graph RAG path.
✅ G4 — resolved: independent knobs, no fixed split.