+1 (415) 649-9454

Entity Resolution in Neo4j: Blocking, Scoring, and GDS Clustering

Every knowledge graph eventually hits the same wall: the same real-world thing arrives three times under three names. "Acme Corp.", "ACME Corporation" and "acme corp" become three nodes, your GraphRAG retriever splits the evidence across all three, your fraud ring never closes, and your recommendation scores are diluted. Entity resolution — deciding which nodes refer to the same entity and merging them — is the unglamorous step that decides whether the rest of your graph work is trustworthy.

This tutorial builds a complete, auditable entity resolution pipeline in Neo4j: blocking to make comparisons tractable, similarity scoring in Cypher, nodeSimilarity and Weakly Connected Components in the Graph Data Science library to cluster the survivors, and a merge strategy that keeps a record of what was collapsed and why.

What you need

  • Neo4j 2025.x or 2026.x (the Docker setup works fine) with the GDS and APOC plugins installed and version-matched to the server line
  • A dataset with duplicates. We use a Customer label with name, email, phone and address properties; substitute your own labels throughout
  • Enough heap to project the working graph; entity resolution is comparison-heavy

Seed data to follow along:

UNWIND [
  {id: 1, name: 'Acme Corp.',        email: 'ap@acme.com',      phone: '+1 415 555 0101', city: 'San Francisco'},
  {id: 2, name: 'ACME Corporation',  email: 'AP@Acme.com',      phone: '4155550101',      city: 'San Francisco'},
  {id: 3, name: 'acme corp',         email: 'billing@acme.com', phone: '+1 415 555 0102', city: 'san francisco'},
  {id: 4, name: 'Acorn Ltd',         email: 'hello@acorn.co.uk',phone: '+44 20 7946 0000',city: 'London'},
  {id: 5, name: 'Acorn Limited',     email: 'hello@acorn.co.uk',phone: '+44 20 7946 0000',city: 'London'}
] AS row
MERGE (c:Customer {id: row.id})
SET c += row;

Step 1 — normalise before you compare

Most duplicate pairs are not a hard problem; they are a formatting problem. Normalising first removes 60–80% of the noise and makes every later step cheaper. Store the normalised values as separate properties so the originals survive for audit.

MATCH (c:Customer)
SET c.nameNorm = trim(
      apoc.text.replace(
        apoc.text.clean(toLower(c.name)),
        '\\b(inc|llc|ltd|limited|corp|corporation|co|plc|gmbh)\\b', ''
      )
    ),
    c.emailNorm  = toLower(trim(c.email)),
    c.phoneNorm  = apoc.text.replace(c.phone, '[^0-9]', ''),
    c.cityNorm   = toLower(trim(c.city));

Two rules that save pain later:

  • Never overwrite the source values. Resolution decisions get reviewed, disputed and reversed; you need the raw input to explain them.
  • Normalise phone numbers to the last N digits (right(c.phoneNorm, 10)) when your data mixes country prefixes.

Index what you are about to match on:

CREATE INDEX customer_name_norm IF NOT EXISTS FOR (c:Customer) ON (c.nameNorm);
CREATE INDEX customer_email_norm IF NOT EXISTS FOR (c:Customer) ON (c.emailNorm);
CREATE TEXT INDEX customer_phone_norm IF NOT EXISTS FOR (c:Customer) ON (c.phoneNorm);

Step 2 — block, or die comparing

Comparing every customer to every other customer is O(n²). At 1 million nodes that is 500 billion pairs; you will not finish. Blocking partitions the data into candidate buckets, and you only compare within a bucket.

Cheap, effective blocking keys for people and organisations:

Blocking keyExampleCatches
Email domain + first token of name`acme.comacme`
Last 10 digits of phone4155550101Reformatted numbers
First 4 chars of normalised name + city`acmesan francisco`
Sorted name bigrams (via apoc.text.phonetic)A250Phonetic spelling variants

Materialise the blocks as nodes, so the candidate pairs become a graph traversal rather than a cartesian product:

MATCH (c:Customer)
WHERE c.emailNorm IS NOT NULL
WITH c, split(c.emailNorm, '@')[1] AS domain
MERGE (b:Block {key: 'domain:' + domain})
MERGE (c)-[:IN_BLOCK]->(b);

MATCH (c:Customer)
WHERE size(c.phoneNorm) >= 10
MERGE (b:Block {key: 'phone:' + right(c.phoneNorm, 10)})
MERGE (c)-[:IN_BLOCK]->(b);

MATCH (c:Customer)
WHERE c.nameNorm IS NOT NULL AND c.cityNorm IS NOT NULL
MERGE (b:Block {key: 'namecity:' + left(c.nameNorm, 4) + '|' + c.cityNorm})
MERGE (c)-[:IN_BLOCK]->(b);

Use several loose blocking keys rather than one strict one. Recall matters more than precision here: a pair that never enters a block can never be matched, but a bad pair inside a block is thrown out by scoring in the next step.

Guard against runaway blocks — a domain:gmail.com block containing 200,000 nodes is not a block, it is the whole dataset:

MATCH (b:Block)<-[:IN_BLOCK]-(c:Customer)
WITH b, count(c) AS members
WHERE members > 500
SET b:OversizedBlock
RETURN b.key, members ORDER BY members DESC LIMIT 20;

Either split oversized blocks with a second key or exclude them from pairwise scoring.

Step 3 — score candidate pairs in Cypher

Now compare only inside blocks, and combine several weak signals into one score. apoc.text.jaroWinklerDistance (a similarity in the 0–1 range, despite the name) is the workhorse for names; exact matches on email or phone are strong evidence on their own.

MATCH (a:Customer)-[:IN_BLOCK]->(b:Block)<-[:IN_BLOCK]-(c:Customer)
WHERE NOT b:OversizedBlock AND elementId(a) < elementId(c)
WITH DISTINCT a, c
WITH a, c,
     apoc.text.jaroWinklerDistance(a.nameNorm, c.nameNorm)          AS nameSim,
     CASE WHEN a.emailNorm = c.emailNorm THEN 1.0 ELSE 0.0 END      AS emailSim,
     CASE WHEN right(a.phoneNorm,10) = right(c.phoneNorm,10)
          THEN 1.0 ELSE 0.0 END                                     AS phoneSim,
     CASE WHEN a.cityNorm = c.cityNorm THEN 1.0 ELSE 0.0 END        AS citySim
WITH a, c, nameSim, emailSim, phoneSim, citySim,
     0.45 * nameSim + 0.25 * emailSim + 0.20 * phoneSim + 0.10 * citySim AS score
WHERE score >= 0.75
MERGE (a)-[r:SAME_AS_CANDIDATE]->(c)
SET r.score    = round(score, 3),
    r.nameSim  = round(nameSim, 3),
    r.emailSim = emailSim,
    r.phoneSim = phoneSim,
    r.citySim  = citySim,
    r.scoredAt = datetime()
RETURN count(r) AS candidatePairs;

Three details that matter in production:

  1. elementId(a) < elementId(c) stops you scoring each pair twice and stops a node matching itself.
  2. Write the component scores onto the relationship, not just the total. When someone asks "why were these merged?", the answer must be in the graph.
  3. Two thresholds, not one. Above 0.90 auto-merge; between 0.75 and 0.90 route to human review. The middle band is where your accuracy actually lives.
MATCH ()-[r:SAME_AS_CANDIDATE]->()
SET r.decision = CASE WHEN r.score >= 0.90 THEN 'auto' ELSE 'review' END;

Step 4 — cluster with GDS instead of merging pairwise

Pairwise merging is a trap: A matches B, B matches C, but A never matched C, and the order you process the pairs changes the result. Resolve it as a graph problem — project the accepted candidate relationships and run Weakly Connected Components to find transitive clusters.

MATCH (source:Customer)
OPTIONAL MATCH (source)-[r:SAME_AS_CANDIDATE]->(target:Customer)
WHERE r.decision = 'auto'
WITH gds.graph.project('er-graph', source, target,
       {relationshipProperties: r {.score}},
       {undirectedRelationshipTypes: ['*']}) AS g
RETURN g.graphName, g.nodeCount, g.relationshipCount;

CALL gds.wcc.write('er-graph', {
  writeProperty: 'entityClusterId',
  relationshipWeightProperty: 'score',
  threshold: 0.90
})
YIELD componentCount, componentDistribution
RETURN componentCount, componentDistribution.max AS largestCluster;

Watch largestCluster. If WCC returns one component containing half your customers, a single over-generous rule has chained unrelated entities together — the classic entity resolution failure mode. Raise the threshold, drop the weakest signal, or run gds.leiden (community detection) on the weighted candidate graph instead, which resists chaining because it optimises for internally dense communities:

CALL gds.leiden.write('er-graph', {
  writeProperty: 'entityClusterId',
  relationshipWeightProperty: 'score',
  gamma: 1.5,
  randomSeed: 42
})
YIELD communityCount, modularity;

Where you have no reliable attributes but rich structure — shared devices, shared addresses, shared invoices — gds.nodeSimilarity over those relationships gives you a behavioural signal you can feed into the same score. That is the same machinery as our GDS recommendations tutorial, pointed at deduplication instead of suggestions.

Always drop the projection when you are done: CALL gds.graph.drop('er-graph');

Step 5 — collapse to a golden record you can undo

Do not delete the source nodes. Create a :Entity "golden record" per cluster, link the sources to it, and repoint queries at the :Entity layer. Deletion is irreversible; a canonical layer is not.

MATCH (c:Customer)
WHERE c.entityClusterId IS NOT NULL
WITH c.entityClusterId AS cid, collect(c) AS members
MERGE (e:Entity {clusterId: cid})
SET e.name       = head([m IN members | m.name]),
    e.emails     = apoc.coll.toSet([m IN members | m.emailNorm]),
    e.phones     = apoc.coll.toSet([m IN members | right(m.phoneNorm,10)]),
    e.sourceCount= size(members),
    e.resolvedAt = datetime()
WITH e, members
UNWIND members AS m
MERGE (m)-[:RESOLVES_TO]->(e);

Survivorship — which value wins — is a business rule, not a technical one. Common policies: most recent update, most complete record, highest-trust source system. Encode the rule explicitly and record it (e.survivorshipRule = 'most_recent') rather than letting head() decide by accident.

If you genuinely must collapse the nodes (relationship counts matter for later algorithms), apoc.refactor.mergeNodes does it, but export first:

MATCH (e:Entity)<-[:RESOLVES_TO]-(c:Customer)
WITH e, collect(c) AS nodes WHERE size(nodes) > 1
CALL apoc.refactor.mergeNodes(nodes, {
  properties: 'combine', mergeRels: true
}) YIELD node
RETURN count(node);

Step 6 — measure it, then keep it running

Hand-label a few hundred pairs and treat resolution like any other classifier: precision (how many merges were correct), recall (how many true duplicates you found), and the size of the review queue. Sweep the threshold against that labelled set instead of arguing about it in a meeting. In most client work, precision above 0.98 with recall around 0.85 and a small review queue beats a "clever" pipeline that merges aggressively and quietly corrupts the graph.

Then make it incremental. Full re-resolution nightly does not scale; instead, resolve on arrival:

  1. New or updated node is written and normalised in the same transaction.
  2. It is attached to its blocking keys.
  3. Only its block-mates are scored — a few dozen comparisons, not millions.
  4. Auto-matches attach to an existing :Entity; ambiguous ones land in the review queue.

The Neo4j CDC stream is a natural trigger for step 1, and a PROFILE of the block-mate query (see reading PROFILE plans) is worth doing once, because it runs on every write.

Why this matters for GraphRAG

If you are building retrieval on top of a knowledge graph, entity resolution is retrieval quality. An unresolved graph splits the facts about one entity across duplicates, so a multi-hop question that should traverse two edges never finds the path, and the LLM answers from whichever fragment the vector index happened to return. Resolving the entity layer typically improves multi-hop answer quality more than any prompt change — which is exactly the effect we measured in our GraphRAG vs plain RAG benchmark.

Getting help

Entity resolution is where graph projects either become the organisation's system of record or quietly lose trust. Getting the blocking keys, weights and thresholds right for your data is judgement work, and it is a lot cheaper to get right before the merges are baked in. GraphGuru's senior Neo4j consultants build and tune resolution pipelines like this one — including the labelled evaluation set and the review workflow around it. Tell us about your data and we will tell you what it will take.