rag-graphrag implements the three primitives from Edge et al. 2024,
"From Local to Global: A Graph RAG Approach to Query-Focused
Summarization":
- Community detection — partition the knowledge graph into densely connected sub-graphs.
- Per-community summarisation — produce one LLM-generated "community report" per community, used at retrieval time for query-against-summary scoring.
- Graph-aware retrieval — at query time, score the query against community summaries, then expand from the top communities' key entities into the graph.
The package depends only on rag-core and networkx. No new SPIs
were added in Step 2.9 — community detection runs over the existing
GraphRetrievalBackend SPI (via the NetworkXGraphStore.graph
escape hatch for the in-process case), LLM calls run through the
existing rag_core.spi.llm.LLM SPI, and the retriever's output is a
plain list[ChunkRef] that drops into
HybridRetriever.
Hybrid RRF retrieval (Step 2.5) is great when the answer is "a few chunks that locally mention the query." It struggles with two recurring patterns:
| Query pattern | Why hybrid retrieval misses |
|---|---|
| "What's the relationship between X and Y?" | Vector + keyword surface chunks about X or Y, not the connection. The connection lives in the graph edge, not the chunk text. |
| "Summarise everything we know about topic Z" | The relevant chunks are spread across documents; the topical glue is structural, not textual. |
GraphRAG addresses both by making the community — a learned sub-graph cluster — a first-class retrieval anchor. Community summaries serve as topical "table of contents" entries; the graph edges then carry the locality.
| Algorithm | Pro | Con | Default? |
|---|---|---|---|
| Louvain (Blondel et al. 2008) | Ships with NetworkX 3.x; no extra; deterministic with a seed. | Modularity-optimising but does not guarantee well-connected communities. | Yes — pure-Python, zero install cost. |
| Leiden (Traag et al. 2019) | Strictly higher modularity, well-connected communities guaranteed. | Pulls python-igraph (C extension) + leidenalg. |
Behind [leiden] extra. |
For most real-world graphs the gap is small; for production-scale graphs (> 100 K nodes) Leiden is noticeably faster and better, so the extra is worth installing. We picked Louvain as default because the demo / laptop / CI install footprint matters more than a few percent of modularity quality at our current scale.
We deliberately do not ship spectral clustering, label propagation, or stochastic block models in V1 — they're either much slower or much weaker than Louvain on the graph shapes we expect (entity-relation graphs with heavy-tailed degree distributions).
Leiden supports hierarchical community detection by recursively
applying the algorithm to the reduced graph. V1 returns flat
partitions only (level=0, parent_id=None) because:
- The retriever's community scoring + seed-collection logic is much simpler at one level.
- Hierarchical communities are most valuable for summary-of-summaries workflows, which Phase 3's gateway will own.
- We can ship hierarchy later without breaking the schema —
levelandparent_idare already onCommunity.
Like RerankPipeline (Step 2.7) and ContextPacker (Step 2.8), every
stage that fails raises GraphRAGError rather than silently degrading.
The one narrow exception: when a node has no chunk_ids property, the
adapter silently skips it — that's a legitimate "isolated entity"
state, not an error.
Each entity node carries properties["chunk_ids"] — a list of the
chunks it appears in. The graph-indexer (typically an offline NER /
relation-extraction pass) is responsible for stamping this. We don't
introduce a separate node_chunks table because:
- The graph backends we ship (Neo4j, Memgraph, NetworkX) all support list-valued properties natively.
- A separate table would mean a second backend RPC at retrieval time.
- Storing the join in the graph means it travels with the node — no consistency issues during graph migration.
The current scorer is set-overlap between query tokens and (summary, key_entities) tokens, with a 2× weight on key-entity matches. This is deliberately simple:
- Pure-Python, deterministic, fast.
- Requires no per-tenant embedder config —
rag.yamlcorpus routing doesn't land until Step 3.5. - Gives us a working pipeline today; the cosine-similarity path arrives with Phase 3 alongside the rest of the per-tenant embedding plumbing.
Phase 3 will swap this for an Embedder-backed cosine scorer behind a
constructor argument.
The system prompt demands strict JSON to keep parsing deterministic. Real LLMs (GPT-4 class) reliably obey; weaker LLMs may not. Our parser:
- Strips
```fences if present. - Tries
json.loads. - On parse failure, falls back to using the raw text as the summary
and the first three node IDs as key entities — this is what lets
smoke tests work with
NoopLLM. - On JSON parse success but schema violation, raises
GraphRAGError.
The fallback is documented as "best effort for smoke flows" — real deployments using non-instruction-following LLMs should validate summary quality offline.
Three OTel spans, one per stage:
| Span | Key attributes |
|---|---|
graphrag.detect |
algorithm, nodes_in, edges_in, resolution, communities_out, elapsed_ms |
graphrag.summarize |
community_id, nodes_n, edges_n, model, input_tokens, output_tokens, elapsed_ms |
graphrag.retrieve |
query_length, community_top_k, hops, communities_matched, seeds_n, neighbors_n, chunks_out, elapsed_ms |
All attributes carry the rag. prefix and follow the same naming
conventions as Step 2.7 (rerank) and Step 2.8 (pack) for dashboard
consistency.
- Detection: callers pass a
nx.Graphalready filtered to one tenant (usetenant_subgraph()helper). The resultingCommunity.tenant_idis set fromctx.tenant_id. - Summarisation: cross-tenant calls raise
GraphRAGError. - Store: every read/write checks
ctx.tenant_idmatches the community's declared tenant; cross-tenant lookups returnNonerather than leaking.
rag_graphrag does not call PolicyEngine directly:
CommunityDetectorruns over already-policed data (the caller has filtered the graph to ACL-visible nodes before invoking detection).CommunitySummarizerissues LLM calls at index-build time over already-policed graph nodes — the Phase 3 gateway is the canonical PolicyEngine consumer for LLM-call governance. The summariser is on thetests/policy/coverage.pyallowlist with a justifying comment.GraphRAGRetrieverreturnsChunkReflists; the downstreamHybridRetrieveris the canonicalread_chunkPolicyEngine consumer.
GraphRAGRetriever returns list[ChunkRef] — exactly the shape that
HybridRetriever's graph_seeds + graph_adapter path emits. Two
integration modes:
- Adapter-only mode — pass
graphrag_adapteras thegraph_adaptertoHybridRetrieverand letHybridRetrieverdriveexpand()directly. Best for chunk-graph projections where node_id ≈ chunk_id. - Pre-fan-out mode — call
GraphRAGRetriever.retrieve()separately, feed the result toHybridRetrieveras a pre-built ranking (forthcoming Phase 3 API). Best for entity-graph projections where community scoring is the primary signal.
V1 supports mode 1. Mode 2 lands with the Phase 3 corpus router (Step 3.5) which will own per-query backend selection.
- Hierarchical communities + summary-of-summaries — Phase 3 gateway.
- Persistent community store (Postgres / Redis) — Step 3.5 corpus router will own backend wiring.
- Embedder-cosine community scoring — Phase 3 (needs per-tenant embedder config).
- Drift monitoring for community quality — Step 5.5 drift monitors.
/v1/communitiesREST endpoint + admin UI — Phase 3.- LLM contradiction reconciliation across communities — Step 5.x hallucination guard.
- Edge weights in community detection — V1 treats every edge
uniformly; weighted Louvain via the
weightparameter is a one-line change once the indexer starts emitting edge weights.
When changing rag-graphrag or its consumers:
- Did you preserve the frozen-Pydantic invariant on
Community/CommunitySummary? These cross package boundaries and downstream consumers may rely on immutability. - If you added a new pipeline stage, does it open an OTel span with
attributes namespaced under
rag.graphrag.*? - If the detector / summariser changed, are the tenant isolation checks still in the path (subgraph filter for detection; tenant-mismatch raise for summarisation + store)?
- If the retriever's seed-collection logic changed, do top-K communities still dedupe shared entities across communities?
- If you added a new optional dependency, is it behind an extra in
pyproject.tomland lazy-imported inside the function that needs it (soimport rag_graphragstill succeeds without the extra)?