Graph RAG / retrieval / graph rag / indexing
Entity Resolution in a Knowledge Graph
That the pipeline deduplicates entities, because the step after the merge summarises a list of descriptions into one. It does not compare anything: membership was already decided by exact string equality. The fragments split an entity's edges, which breaks multi-hop paths and pushes whole subgraphs outside the largest connected component, where cluster_graph discards them and no community report ever mentions them.
Entity resolution in Microsoft GraphRAG is one line of pandas:
groupby(["title", "type"]). Two mentions become the same node
when their upper-cased title strings and their type strings are identical,
and never otherwise. "Sherlock Holmes", "Holmes", "Mr. Holmes" and
"Sherlock" are four nodes, and nothing downstream will tell you.
That is not a strawman — it is the reported behaviour. Issue #401 on the repository runs GraphRAG over the Sherlock Holmes canon and finds exactly those four nodes, then notes the part that actually hurts: "Baker Street has an edge with 'Mr. Holmes' but no other variants." Each fragment carries a slice of the real entity's edges. Any question whose answer needs two of those slices has no path to walk.
The simulation below is that corpus, in miniature: eleven extracted relationships over twenty-two entity mentions. Pick a resolution strategy, move the threshold, and watch the graph reassemble — then watch it collapse. Everything downstream reads whatever comes out of this step: the clustering, the community reports, and every answer built on them.
The cosine numbers are hand-set to reproduce the ordering a sentence-embedding model produces on these names — they are illustrative, not measured. Everything else is mechanism: the grouping key, single-linkage transitivity, and the largest-connected-component filter are what the pipeline actually does.
one real entity, all of its mentions · fragment — part of an entity that also lives in another node · false merge — two real entities in one node
Start on groupby(title, type) with question 1. Baker Street has a lodger — "Mr. Holmes" — and that node has no other edge in the entire corpus, so the second hop has nowhere to go. The answer exists in the documents. It does not exist in the graph. Switch to embedding similarity and drag the threshold down from 0.98: at 0.94 the honorific variants fuse and question 1 completes; at 0.88 the full name joins them and question 2 completes too. Keep dragging.
Why the grouping key is a string comparison and not a decision
Extraction runs once per text unit. The model returns tuples, the parser
upper-cases and cleans the title, and every text unit's output is
concatenated into one frame. The merge is then
groupby(["title", "type"]).agg(description=list, text_unit_ids=list,
frequency=count). The list of descriptions per group is what the next
step summarises into a single description — and that summarisation step is
the reason people believe resolution happened. It did not. It summarised
descriptions within a group whose membership was already decided by
string equality.
The type half of the key produces a failure people find far
later. The extractor labels each mention's type independently per chunk from
a fixed list — organization, person,
geo, event — and it is not consistent. Baker
Street comes back as geo in one passage and
organization in another, so it becomes two nodes with the same
displayed title. Look at the node panel on the exact strategy: two entries
read BAKER STREET, and there is no way to tell them apart in
any UI that shows titles. Issue #1718 on the repository is this, filed as a
fatal bug.
There is a related quiet loss just after the merge.
filter_orphan_relationships drops any relationship whose source
or target title has no entity row, logging Dropped N
relationship(s) referencing non-existent entities. The model does
hallucinate names in relationship tuples that it never emitted as entities,
so this filter is necessary — but it also silently removes real edges when
the two halves of the extraction disagree about a spelling. That warning
line is worth grepping for on every index build.
Fragmentation is not a display problem — it deletes the graph
Turn use_lcc on and off on the exact strategy and read the
outside the LCC number. Clustering does not run on the entity table.
It runs on the edge list, and
cluster_graph(..., use_lcc=True) — the default — first reduces
that edge list to its largest connected component. Every entity outside that
component is clustered into nothing, belongs to no community, and therefore
appears in no community report.
On the exact strategy this tiny corpus shatters into seven components, the largest holding a handful of nodes. Most of the cast is discarded before Leiden ever runs. Scale that to a real corpus and the effect is not that answers get slightly worse — it is that global search reads a covering set of community reports that does not cover a large part of your data, while reporting no error at all. You paid to extract those entities and you are paying to summarise the communities they are not in.
Be precise about the blast radius: use_lcc affects clustering
only. The entity and relationship tables still contain the orphaned
fragments, so local search can still surface them by embedding similarity.
The asymmetry is the trap — a fragment that local search finds happily is
invisible to global search, and the two modes disagree without either one
admitting it.
Single-linkage merging fails in one step
Put the strategy on embedding similarity and park the threshold at
0.85. Every entity resolves correctly; questions 1 and 2 both answer. Now
drop to 0.77 and read the log: MYCROFT HOLMES ~ HOLMES = 0.78
clears the bar, and because HOLMES was already unioned with
SHERLOCK HOLMES, Mycroft is pulled transitively into the
Sherlock node. One pair over the line, two people fused. The brother-of edge
becomes a self-loop and the graph now asserts that Sherlock Holmes is his
own brother.
Keep going to 0.66 and Pall Mall merges with Baker Street — two London streets, embedded close, no relation to each other. The answer to question 2 is now a node covering both addresses. This is what single linkage does: it only needs one bad pair anywhere in a chain to weld two clusters together, and the chance of at least one bad pair grows with the number of entities. A threshold tuned on a thousand-entity index will over-merge at fifty thousand, with no signal that anything changed.
The usable window here — every entity resolved, nothing fused — is 0.79 to 0.88. Ten points of a scale that runs to a hundred, and its position is a property of your corpus, your embedding model and your entity types. It is not transferable. Any writeup that hands you a threshold without your data is handing you a coin flip. If you are going to do this, do it with blocking and pairwise scoring rather than a global cosine cut, and hold out a labelled sample to measure both directions of error.
The failure no resolver fixes
Select question 3 with the Mycroft passage on, and try every strategy in turn. All four get it wrong, and the better the strategy, the more confidently wrong it is. In that passage "Holmes" refers to Mycroft — the same surface string that means Sherlock everywhere else. Merging by title puts it in the Sherlock node; merging by embedding puts it there too; the curated alias table puts it there most decisively of all, and now the graph states plainly that Sherlock Holmes is employed by the British Government.
Every strategy on offer is a merge operation. Deciding that one surface string denotes two different entities is a split, and it requires the context the mention appeared in, not the string. That is coreference and disambiguation, which GraphRAG does not attempt — issue #1244 asks for it and it is not in the default pipeline. So improving resolution monotonically improves recall of true paths and monotonically increases the confidence of false ones. There is no threshold that fixes this, which is why it is the failure that survives into production.
Downstream, a false merge is worse than a fragment. A fragment breaks a path and you get "I don't know" — annoying, honest. A false merge manufactures a path that never existed, and every hop after it inherits the error. This is the mechanism behind most of what gets reported as multi-hop noise: the extra hops are not finding weaker evidence, they are walking through a node that fused two things.
Checking it yourself
Three checks, in order of how much they will tell you per minute spent.
Count components. Load relationships.parquet, build an
undirected graph, and print the connected-component size distribution
against the row count of entities.parquet. If the largest
component holds well under 90% of your entities, resolution has fragmented
your graph and everything built on communities is reading a subset. This is
the single most diagnostic number in the whole index and it takes five lines.
Sort entities by title similarity. Sort
entities.parquet by title and read the neighbours;
adjacent near-duplicates fall out immediately. Then check
frequency — the count of text units a title came from. A real
entity with a fat description and frequency: 1 is usually a
variant spelling of something that appeared fifty times.
Look for one title with two types.
entities.groupby("title").size() and keep anything above one.
Every hit is a node pair that a title-based UI renders identically. Fixing
these is cheap and it is where the surprise-per-fix ratio is highest.
If you are going to add resolution, add it between extraction and clustering, and version the alias table as data — because re-running it changes community membership, which changes every community report, which changes what the re-index costs you. And test the effect on answers, not on node counts: fewer nodes is not the goal, and the strategy that minimises node count is the one that fused Sherlock with Mycroft.
Your index has 40,000 entities and 55,000 relationships. The largest connected component of the relationship graph contains 12,000 entities. Global search returns confident but oddly narrow answers. What is the most likely cause?
Next: what hierarchical Leiden does with the graph you hand it, and the query-side consequence, global search over every community summary. If your retrieval problem turns out to be a ranking problem rather than a graph problem, start there instead.