DeepConcepts

Graph RAG / indexing / graph rag / clustering

GraphRAG Community Detection

The misconception

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.

16 min

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.

planted groups recovered
communities at level 0
levels in the tree
community reports to write
modularity at γ=1
largest community
entities with no community
Detected community × planted group — one row per level-0 community

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

Level-0 community sizes — magenta bars get re-partitioned

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.

Why this concept is on the site

Topics are chosen from places engineers visibly get stuck, and the sources are kept with the lesson so the claim is checkable.