Graph RAG / retrieval / graph rag / query
More Hops Is Not More Answer
That hop depth is a recall knob: if the answer was not found at one hop, two or three hops will find it. Reachability is not retrieval. The neighbourhood grows multiplicatively with depth, the context window does not grow at all, and the rows that survive packing are chosen by degree — so the specific low-degree edge that carries the answer ranks last in a pool of thousands, while the corpus's biggest hubs fill the prompt. Past depth two GraphRAG's local context contains no relationship rows at all, because the entity table is packed first and consumes the whole budget.
A hop is a step along one edge of the knowledge graph, from an entity to a neighbour it has a relationship with. Walking two hops instead of one does not double what the model gets to read. The prompt is the same size it was before, so the only thing that changed is how many candidates are competing for it — and in GraphRAG the row that wins is the one whose two endpoints have the most relationships between them, which is a statement about the corpus and not about your question.
The intuition to break is that hop depth is a recall knob. It reads like one: the fact you wanted was three edges away, you only looked one edge out, so look further. That reasoning is correct right up to the point where the graph hands its candidates to the packer. Reachability and retrieval are different events, and between them sits a fixed token budget and a ranking function.
The panel below builds an entity graph, runs the seed lookup that GraphRAG's
local search actually runs, expands the neighbourhood to the depth you ask
for, and then packs the result into the local context exactly the way
_build_local_context does — entity table first, relationship
table into whatever is left. Move hop depth from 1 to 4 and watch the
two density numbers. Then look at what happened to the relationship rows.
Real defaults from the repository: top_k_entities 10,
top_k_relationships 10, max_context_tokens
12,000, text_unit_prop 0.5, community_prop 0.15
— so the entity and relationship tables share the remaining 35% of the
window. relationship_ranking_attribute defaults to
rank, which the loader fills from the
combined_degree column. max_node_degree_std is
None by default, so no hub pruning happens unless you ask.
Modelled, not measured: the graph itself (preferential attachment, which
produces the heavy-tailed degree distribution real extraction produces),
the size of the subgraph that genuinely answers one question, and 95
tokens per entity row and 70 per relationship row. Treat the shapes as
real and the row widths as illustration.
entity in the subgraph that answers the question · everything else this ring dragged in. Each ring is the set of entities at exactly that hop distance from the seeds. The subgraph that answers the question has a fixed size; the ring does not.
on topic · a relationship row that is off topic · an entity row for one of the corpus's hubs. Entity rows are packed first with the whole local budget, then relationship rows fill what is left. That order is not configurable.
At depth 1 — which is what GraphRAG's local search actually does — the
prompt holds ten entity rows and around forty-six relationship rows. Move to
depth 2 and the relationship rows go to zero. Not fewer: none. The
neighbours you just traversed to have been promoted into the entity table,
the entity table is packed first with the entire local budget, and
build_relationship_context is then asked to fit into what
remains, which is nothing. The structure you expanded the graph to obtain is
the first thing evicted. This is the same fixed-window arithmetic that
governs how much a model can attend to at once, arriving
from a direction nobody expects.
GraphRAG walks one hop, on purpose
There is no hop-depth setting in GraphRAG. Issue 2039 on the repository asked for one in September 2025 — "support multi-hop reasoning and manually specifying the desired number of hops" — and was closed without an implementation. The absence is the design.
What local search actually does is narrower than the phrase "graph
retrieval" suggests, and it is worth stating precisely, because most of the
confusion is people reasoning about a system that does not exist.
map_query_to_entities embeds your question and runs a
similarity search over the entity descriptions, returning
top_k_entities of them — ten by default, oversampled to twenty
before filtering. That set is selected_entities, and nothing
later adds to it. The seeds come from
an ordinary vector index; the graph has not been
consulted yet.
Then _filter_relationships looks at every relationship touching
those ten entities and splits them in two.
In-network relationships have both endpoints in the selected set —
edges between your seeds. Out-network relationships have one
endpoint in the set and one outside. In-network comes first and is
not budgeted at all; out-network is capped at
top_k_relationships * len(selected_entities), which with the
defaults is 10 × 10 = 100. Within out-network, the sort key is the number of
distinct selected entities that outside neighbour touches, and then the
ranking attribute.
That "number of distinct selected entities" heuristic is a small piece of real cleverness. If three of your ten seeds all point at the same outside entity, that entity is probably the thing your question is circling, and its edges get promoted. It works because the seed set is small and topically coherent. Expanding the neighbourhood is precisely the operation that destroys the precondition: once the selected set is four hundred entities, every hub in the corpus touches dozens of them, and the heuristic that was detecting topical convergence is now detecting popularity. Turn hop depth up in the panel and watch the hub rows arrive.
The out-network entities never become selected entities. Their names appear
inside relationship rows — id|source|target|description — and
that is all. GraphRAG shows you the edge, not the node at the far end of it.
One hop of structure, and then it stops and hands the rest of the job to
community reports, which is what
hierarchical Leiden precomputed for exactly
this purpose.
combined_degree is not a relevance score
Set relationship_ranking_attribute aside for a moment and look
at what its default means. The loader builds relationships with
rank_col="combined_degree", and
compute_edge_combined_degree defines that column as
source_degree + target_degree — the number of relationships the
source entity has, plus the number the target entity has. Nothing else. Not
the query. Not the edge's own description. Not how often the relationship
was observed.
So the row that wins a place in your prompt is the one joining the two
best-connected entities in the candidate pool. In a graph extracted from
real documents those are the entities you would never search for: the
country, the year, the industry, the word "company". A relationship like
Ada Okafor | Meridian Labs | served as principal investigator on the
2019 filing joins two entities with two relationships each. Combined
degree: four. In a pool of eleven thousand candidates it ranks eleven
thousandth.
Move the panel to depth 3 with the defaults and read the answer line in the log. The edge is reachable — it is in the candidate pool, the traversal did its job — and it is in last place. This is the sentence worth carrying away: the ranking function put the one row that answers the question at the bottom of the list, and by its own definition it did so correctly. Nothing is broken. The system is ranking by connectedness because connectedness is the only signal the relationship table carries, and connectedness is anti-correlated with specificity.
Now tick relationship_ranking_attribute = weight. This is a
real option — sort_relationships_by_rank accepts
rank, weight, or any attribute column. Edge weight
is what extraction assigned for the strength of the relationship, summed
across occurrences, so it rewards relationships that documents kept
restating rather than entities that appear everywhere. At depth 1 the share
of prompt rows on topic jumps from about 14% to about 43% — a threefold
improvement from a one-line config change, on a knob almost nobody touches.
At depth 3 the same change moves the answer edge from eleven-thousandth to around twenty-eighth, and it still does not reach the model. There are zero relationship rows at that depth. Ranking is only the whole system when there is something for it to rank into. This is the general shape of a reranking problem and it has the general failure: a better score cannot help you if the budget it feeds is empty.
A bigger context window makes it worse
The reflex when a prompt cannot hold the evidence is to enlarge the prompt.
Set hop depth to 3 and move max_context_tokens across its
range. At 4,000 tokens the local budget is 1,400, fourteen entity rows fit,
and roughly 53% of the prompt is about your question. At 12,000 the budget
is 4,200, forty-four rows fit, and it is down to 18%. At 32,000 you get 117
rows and about 7%.
Every extra token of budget is spent on the next-highest-degree entity in the ball, and the next-highest-degree entity is by construction less specific than the one before it. The window is not a container you are filling with evidence. It is a cursor moving down a list sorted by the wrong key, and lengthening it moves the cursor further into the noise. The small window looked better only because it truncated to the seeds, which came from a query-aware ranking.
There is a second cost that this simulation does not model and you should price separately. Even if the answer row is in the prompt, its recall from a long context is not free. The "context rot" measurements that circulated widely in 2025 are the empirical version of this, and they point the same way: more input tokens, worse retrieval of any given fact from them. So the two effects compound. Deeper traversal lowers the probability that the row is present, and raises the number of tokens the model has to find it among if it is.
Set the window back to 12,000, put hop depth at 1, and walk
top_k_entities up from 2. At three seeds the candidate pool is
46.7% on topic. At four it is 2.8%. The fourth thing vector
search returned is the hub with 236 relationships, and adding it multiplied
the pool by fifteen while adding nothing the question needed. One bad seed
dominates every hop downstream of it, because branching is a property of the
entities you started from and hubs are the entities a description-similarity
search is most likely to hand you.
Keep raising it and the prompt density climbs again — at twenty seeds it is back to 29% — while the pool density stays under 3%. Those two numbers disagreeing is worth sitting with. The prompt looks better because more topical seeds are being copied into the entity table; the retrieval underneath is not better at all, and the moment you go past depth 1 the entity table is all you have.
The knob that works is at index time
Tick prune_graph.max_node_degree_std = 2. Every number moves at
once, and further than any query-side setting moved them. At depth 1 the
candidate pool's on-topic share goes from about 3.4% to about 27%. At depth
4 the entities reached fall from around 7,000 to around 800.
The reason is that branching factor is not a property of hop depth; it is a property of the degree distribution, and a preferential-attachment graph — which is what entity extraction produces, because common entities get mentioned in more documents and therefore acquire more edges — has a heavy tail. One seed with 236 relationships contributes 236 entities to the first ring on its own. Remove the handful of nodes above two standard deviations and every ring downstream of them shrinks.
GraphRAG ships this. prune_graph exposes
min_node_freq (default 2), max_node_freq_std,
min_node_degree (default 1), max_node_degree_std,
min_edge_weight_pct (default 40.0) and
remove_ego_nodes (default true). The two standard-deviation
caps both default to None, which means the hubs stay unless you
say otherwise. That is a defensible default — pruning is lossy, and the node
you remove may be the one a different question needed — but it is a decision
you are making by not making it. Pruning also changes community membership,
so every community report has to be regenerated;
that is a rebuild, not a config reload.
The other real fix is not to deepen the walk but to split the question.
GraphRAG's DRIFT search does this: n_depth defaults to 3 and
drift_k_followups to 20, but the depth is three rounds of
follow-up questions, each one re-running a one-hop local search
with a new query embedding, not three rounds of graph expansion. Answering
"who signed it" and then "what did that person also sign" as two one-hop
searches costs two model calls and keeps both neighbourhoods small. Asking
it as one three-hop search costs one call and returns eleven thousand
candidates. The graph is doing the same work in both cases; only one of them
lets a ranking function succeed.
A note on when the walk is genuinely the right tool: if the answer entity's description does not read as topical — which is the case for the chain in the simulation, and is the case for most bridging facts — then no vector search will ever return it and the graph is the only route to it. That is the real argument for graph retrieval, and it survives everything above. The argument that does not survive is that you get there by walking further.
Checking it yourself
Run a local search with return_candidate_context=True. The
result's context_records then contains every candidate entity
and relationship with an in_context boolean column, rather than
only the rows that made it. Two lines tell you almost everything:
len(records["relationships"])versusrecords["relationships"]["in_context"].sum()— the pool size against the admitted count. If the ratio is worse than about one in fifty, your ranking function is the system, not your traversal.records["relationships"].query("in_context")["combined_degree"].describe()— the degree profile of what got in. If the minimum admitted combined degree is above about 20, nothing specific reached the model.
In the logs, the line to grep for is
Reached token limit - reverting to previous context state. It
is emitted at WARNING from mixed_context.py the
moment the entity table plus the relationship table exceeds
max_context_tokens. If it fires on the first iteration you have
a prompt with no relationship rows in it, and the response will still read
fluently, because the entity descriptions alone are enough for the model to
write something plausible.
For the degree distribution itself, load
relationships.parquet and look at
combined_degree.quantile([0.5, 0.99]). A median near 6 and a
99th percentile in the hundreds is the shape that makes degree ranking
useless, and it is the normal shape. Then check
entities.parquet for the top ten by degree: if they are
"United States", "the Company", "2023" and similar, set
max_node_degree_std and re-index.
One symptom that looks like a traversal problem and is not: if the entity at the end of your path exists three times under slightly different names, its edges are split three ways and the path never had a chance. Check the entity count against your expectation before blaming hop depth — see what the merge step does and does not compare. And if the question is really about the corpus as a whole rather than one path through it, no amount of local traversal is the right instrument; global search reads every community report and pays a very different bill for it.
Local search at depth 1 returns a generic answer. You modify it to expand two hops before building the context, and the entity set grows from 10 to 450. Everything else is default. What happens to the relationship rows in the prompt?
Next: the structure that exists so you do not have to walk the graph at query time, hierarchical Leiden and the community report, and the ranking problem this lesson collapses into once the candidate pool is large, reranking a pool you cannot fit.