Graph RAG / indexing / graph rag / clustering
GraphRAG Community Detection
That a community is a topic and that max_cluster_size is the knob for how fine-grained your topics are. A community is whatever maximises modularity on the extracted edge list — the clustering never reads your text — and max_cluster_size does not change the level-0 partition at all; it only decides which already-formed communities get split into a deeper level. The parameter that really decides how many communities exist, Leiden's resolution, is hard-coded to 1.0 in the source and is not a configuration key.
A GraphRAG community is not a topic. It is a set of entities that are more densely connected to each other than a random graph with the same degrees would be — and that is the whole definition. The clustering step never reads your documents. It receives an edge list of entity names and relationship weights, and it returns a partition.
The algorithm is hierarchical Leiden: run one community detection
pass over the whole graph, then take any community bigger than
max_cluster_size and run the same pass again inside it, which
creates a deeper level. Repeat until nothing is too big. Community detection
here means partitioning the graph so as to maximise modularity — a
score that rewards edges falling inside a community and penalises
communities for being large. Every community produced at every level then
gets its own summary written by a model.
One number decides how many communities exist: Leiden's
resolution, which sets how hard modularity penalises size.
In GraphRAG it is written as resolution=1.0 in the body of
hierarchical_leiden.py. There is no configuration key for it,
no command-line flag, and no mention of it in the documentation. The
parameter people do reach for, max_cluster_size, does something
completely different.
The panel below builds a small entity graph, runs a real modularity partition on it, and then performs the real size-based split. Move resolution first and watch the communities merge and shatter. Then move max_cluster_size across its whole range and watch the top row of the matrix not move at all.
The corpus is synthetic: 65 entities planted in 5 topics of 14, 12, 10, 9
and 7, plus two 3-entity groups that are internally complete but attached
to the rest by a single relationship, 3 hub entities linked across topics,
and 2 detached pairs. Real mechanism: the modularity objective with a
resolution parameter, local moving with aggregation, splitting communities
into connected pieces, the recursive re-partition of anything over
max_cluster_size, the largest-connected-component filter, and
relationship weights used as edge weights. Modelled: the corpus itself and
the 1-10 relationship strengths, which a real run gets from the extraction
model. "Groups recovered" is a score for this simulation only — a real
index has no ground truth to check against.
Each row is a community the algorithm found; each column is a group that was actually planted in the corpus. A clean partition puts one filled cell in every row and every column. group recovered as its own community · group split across communities · community that blends two groups — its report will describe both as one thing
max_cluster_size is applied after this partition
exists. It cannot change the bars; it only decides which of them are
broken into a deeper level.
The communities at level 0 readout is the one to watch while you drag
max_cluster_size from 3 to 20. It does not move. Neither does
the matrix, nor groups recovered, nor modularity. What moves is
community reports to write, which goes from about 35 down to 7 — a
five-fold change in
what the index costs to build and in what
global search has to read, produced by a
parameter that most people believe controls granularity.
What modularity actually maximises
Modularity compares your graph to a coin flip. For a candidate partition it computes, for every community, the fraction of the graph's edge weight that falls inside that community, and subtracts the fraction you would expect if the same nodes kept their degrees but were wired at random. Sum that over communities and you have the score. A partition scores well when its communities are surprisingly dense relative to that random baseline.
Written out for one community, the quantity being maximised is
(internal weight / total weight) - resolution × (community degree
/ 2 × total weight)². The first term rewards edges landing
inside. The second is a size penalty: a community's total degree enters
squared, so a community twice as large is penalised four times as much.
resolution is the multiplier on that penalty and nothing else.
Set it to 0.5 and large communities become cheap, so the algorithm merges.
Set it to 2.0 and they become expensive, so it splits. That single number is
the difference between "five topics" and "thirteen fragments" on the same
graph, and you saw both above without touching the corpus.
Two consequences follow immediately, and both matter more than the choice of algorithm.
The first is that modularity has a smallest visible scale. Because
the baseline is the whole graph, a small group has to beat the odds of being
connected by chance across the entire corpus. Fortunato and
Barthélemy proved the consequence in 2007: modularity optimisation may fail
to separate modules whose internal link count is around
sqrt(2L) or smaller, where L is the total number of
links, even when the modules are unambiguous. Their example is two
cliques joined by a single edge, which modularity merges. In the simulation
the audit trio is exactly that construction: three entities, three
relationships between them, one relationship out. At 105 relationships the
bound is about 14, and 3 is far below it, so the trio disappears into a
larger community and never gets a report of its own. On a real corpus with
200,000 relationships the bound is around 630 internal links — which means
every genuinely small, tightly-knit group in your data is invisible to the
clustering at every level of the hierarchy.
The second is that the resolution that suits your corpus is not the
resolution you get. Sweep the slider in the simulation and the recovered
count peaks below 1.0 for this graph; a different corpus would peak above
it. GraphRAG calls gn.hierarchical_leiden(...) with
resolution=1.0 written inline, alongside
randomness=0.001, use_modularity=True and
iterations=1. None of these are exposed through
settings.yaml. If you need a different resolution you are
editing the package or calling the clustering yourself and writing the
communities table by hand.
The level is a splitting cascade, not a zoom control
Level 0 is the partition of the whole graph. Level 1 exists only inside
communities that were bigger than max_cluster_size, because
those get re-partitioned. Level 2 exists only inside the level-1 communities
that are still too big. Nothing else creates a level.
That has an awkward implication for anyone choosing a
--community-level: the tree is not the same depth everywhere.
A dense region of your graph may go four levels down while a sparse region
stops at level 0. Asking for level 2 does not mean "everything at medium
granularity" — it means "each entity's deepest community that is at level 2
or shallower", which mixes a level-2 fragment of your densest topic with a
level-0 community that was never split at all. That is why the same flag
behaves so differently on two corpora, and it is the question behind issue
1532: "How do we choose a community level given a query? Is this something
that we need to experiment with manually?" There is no principled answer
because the levels do not mean the same thing across the graph.
The other half is the cost. Reports are generated for every
community at every level, not just the level you intend to query.
The indexer explodes the community table so each entity appears once per
level it belongs to, and generates one report per community per level, from
the deepest level upward. In the simulation, dropping
max_cluster_size from 20 to 3 leaves the level-0 partition
untouched and takes the report count from 7 to about 35. You paid five times
as much for a hierarchy whose top row is character-for-character identical.
So the real trade the parameter controls is: how much of the graph gets
summarised at fine grain. Small max_cluster_size means many
small communities deep in the tree, each with a report that names specific
entities. Large means few coarse communities whose reports compress dozens
of entities into the same 2,000-token cap set by
community_reports.max_length. Neither changes what the
top-level communities are.
What Leiden guarantees, and three things it does not
Leiden exists because Louvain — the algorithm everyone used before it — has a defect that went unnoticed for a decade. Traag, Waltman and van Eck showed in 2019 that Louvain "may yield arbitrarily badly connected communities", up to and including communities that are internally disconnected: two groups of entities with no path between them, reported as one community and therefore summarised into one report. In their experiments up to 25% of communities were badly connected and up to 16% were disconnected. Leiden adds a refinement phase and proves the result cannot happen. That guarantee is the entire reason GraphRAG uses it, and it is worth having.
It is also the only guarantee you get. Three things it does not give you:
It is not deterministic across seeds. Leiden visits nodes in a
randomised order and accepts moves with a small randomness parameter, so two
runs over the identical graph produce different partitions. Move the
random_seed slider in the simulation: the graph does not
change by one edge, and the community count and the recovered count both
move. GraphRAG pins seed = 0xDEADBEEF for this reason. What it
buys you is reproducibility, not correctness — and note what it means for
incremental indexing. Add documents, re-cluster, and community 7 is
now a different set of entities than it was yesterday. Community ids are not
stable identities.
It does not know what your entities mean. The input is
(source, target, weight). Entity descriptions, types and
embeddings are not consulted. Two entities that any reader would file
together end up in different communities if the extraction step never
emitted a relationship between them, and that is a common outcome —
see what happens when one entity became four
nodes, which is upstream of everything here. Clustering cannot repair a
graph; it can only partition the one it is given.
It silently discards whatever is not in the main component.
use_lcc defaults to true, and it means exactly what it says:
build the largest connected component and throw the rest away. A ten-page
document whose entities never got linked to the main body of the corpus is
not clustered, not summarised, and not retrievable through any community
path. Turn the toggle off in the simulation and the two detached pairs
reappear as their own communities, each costing a full report.
There is a related failure that is a genuine bug rather than a design
choice. The query-side level filter compares level <= n, and
an entity with no community assignment has a missing level, which fails that
comparison silently. Issue 2348 reports the result: 10 of 151 entities
surviving into the query context. Four separate pull requests are open
against it. If your answers are thin in a way that looks like retrieval, count
the rows in your entities table and compare with what the query path actually
loaded.
Checking it yourself
Everything above is visible in the output parquet files before you spend a penny on reports or queries. Three checks, in order of how often they find something.
The size distribution. Load communities.parquet and
group by level, then look at the size of each community. Two
shapes are bad news. One giant community holding most of the graph at level
0 means the resolution is too low for your corpus, and since you cannot
raise it, your real lever is the graph: prune spurious relationships, or
raise the extraction quality so the edges you do have are the ones that
matter. A long tail of 2- and 3-entity communities at level 0 means the
opposite — the graph is fragmented and the connected structure Leiden needs
is not there.
The report count against the level you query.
len(communities) is your report bill; the subset at or below
your --community-level is what a query reads. If those two
numbers are far apart you are paying to summarise levels you never look at.
Raise max_cluster_size until the tree stops growing levels you
do not query.
The entities that vanished. Compare
len(entities) with the number of distinct entity titles
appearing across all communities. The difference is what
use_lcc removed, and it is usually larger than people expect on
a corpus of unrelated documents. If it is more than a few percent, your
corpus is not one graph; consider indexing the disconnected parts
separately rather than letting them be deleted.
For a direct read on partition quality, GraphRAG ships modularity
calculations in graphrag/graphs/modularity.py — root, leaf,
whole-graph and largest-component variants. A modularity near 0 means the
partition is no better than chance and community reports are summarising
noise. Values around 0.4 to 0.7 are the normal range for a graph with real
structure, which is the band the simulation sits in at default settings.
Your index has 900 communities at level 0 and you think that is too many —
the reports are fragmentary. You change max_cluster_size from
10 to 40 and re-index. What happens to the level-0 communities?
Next: what this tree costs to build, one model call per community per level on top of the extraction bill, in the indexing cost lesson; and what happens when a query walks the graph directly instead of reading its summaries, in multi-hop traversal. If the communities look wrong and you suspect the graph rather than the clustering, the problem is usually one entity that became four nodes — start at entity resolution.