Why this is the make-or-break stage
A knowledge graph's value comes from paths. A path only exists if the node in the middle is shared by both ends. Miss one merge and the path disappears — silently. The query returns zero rows, nobody sees an error, and the team concludes graphs are overhyped.
The opposite error is worse. Over-merge two different people into one node and you have manufactured a false connection, which in fraud, compliance or healthcare is not a bug, it is an incident. Entity resolution is therefore a precision/recall trade-off with asymmetric costs, and the first design question is which direction your domain wants to err.
The standard pipeline
Normalisation
The cheapest wins live here. Deterministic clean-up before any fuzzy matching typically removes a third of your duplicate pairs at zero risk.
- Unicode NFKC, case-fold, collapse whitespace and punctuation.
- Strip legal suffixes for organisations: Ltd, Inc, GmbH, Pvt, LLC.
- Standardise structured fields: E.164 phone numbers, ISO dates, lower-cased email domains, postal codes via a proper address parser.
- Keep the original value. Always. Normalisation feeds matching; display uses the source.
const STRONG_KEYS = ["taxId", "email", "phoneE164", "isin"];
function deterministicKey(r: Record) {
for (const k of STRONG_KEYS) {
if (r[k]) return `${k}:${normalise(r[k])}`;
}
return null; // falls through to probabilistic matching
}Blocking: escaping O(n²)
Ten million records is 5×10¹³ pairs. You will not score those. Blocking restricts comparisons to candidates that share a cheap key, and choosing that key well is the difference between a job that finishes in minutes and one that never does.
| Strategy | Key | Good for | Watch out |
|---|---|---|---|
| Standard blocking | First 4 chars of surname + postcode | Clean, structured data | One typo in the key loses the pair entirely |
| Phonetic | Double Metaphone / Soundex | Names with spelling variants | Weak outside English-style names |
| Sorted neighbourhood | Sort by key, slide a window | Ordinal data, dates | Window size is a recall knob |
| Q-gram / MinHash LSH | Shingles hashed into bands | Free text, addresses | Tune bands/rows for target similarity |
| Embedding ANN | Vector nearest neighbours | Multilingual, messy descriptions | Embeddings drift when the model changes |
Use several blocking keys in parallel and take the union of candidate pairs. Multiple weak keys give far better recall than one clever key, because different keys fail on different errors.
Two numbers to track
- Pair completeness — the fraction of true duplicates that end up in at least one block. Below ~0.95 you are capping your recall before scoring even starts.
- Reduction ratio — how much of the full pair space you eliminated. You want >0.999 at scale.
Similarity scoring
Score each candidate pair field by field, then combine. Field comparators matter: use Jaro-Winkler for person names (it rewards matching prefixes), token-set Jaccard for organisation names (word order varies), edit distance for identifiers, and geo distance for addresses.
function score(a: Record, b: Record) {
if (a.taxId && b.taxId && a.taxId !== b.taxId) return 0; // veto
if (a.dob && b.dob && a.dob !== b.dob) return 0; // veto
const parts = [
{ w: 0.35, s: jaroWinkler(a.nameNorm, b.nameNorm) },
{ w: 0.20, s: a.emailNorm && a.emailNorm === b.emailNorm ? 1 : 0 },
{ w: 0.15, s: tokenSetRatio(a.addressNorm, b.addressNorm) },
{ w: 0.15, s: cosine(a.embedding, b.embedding) },
{ w: 0.15, s: sharedNeighbourRatio(a.id, b.id) }, // graph signal
];
return parts.reduce((t, p) => t + p.w * p.s, 0);
}The last term is the one people forget and the one a graph gives you for free: two nodes that share several neighbours are more likely to be the same entity, even when their strings disagree. Structural evidence often beats string evidence on messy real data.
Rules, probabilistic, or learned?
| Approach | When it fits | Cost |
|---|---|---|
| Hand-written rules + weights | Start here; a few thousand to low millions of records | Hours; fully explainable |
| Fellegi–Sunter (probabilistic) | You have no labels but do have field agreement patterns | Days; well-understood statistics |
| Supervised classifier | You can label 2–5k pairs and quality really matters | Weeks; needs monitoring for drift |
| LLM adjudicator | The grey band only, at low volume | Expensive per pair; use as a tiebreaker, never as the whole matcher |
From pairs to clusters
Pairwise decisions are not transitive: A≈B and B≈C does not make A≈C. Naive union-find over all matched pairs is how you get one giant blob node containing 40,000 "entities" — the classic transitive-closure explosion.
- Build a graph of pairs weighted by score, dropping anything below the low threshold.
- Cluster with a method that can split weak links — correlation clustering, or connected components followed by a modularity-based split.
- Cap cluster size. A cluster of 500 people is a bug, not a discovery; send it to review instead of merging.
- Pick a canonical record per cluster: most complete, most recently updated, or highest-trust source.
Merge policy and reversibility
Never destroy the inputs. The durable pattern is a canonical node plus preserved source nodes:
MERGE (c:Canonical:Customer {clusterId: $clusterId})
SET c.name = $canonicalName, c.updatedAt = datetime()
WITH c
UNWIND $sourceKeys AS sk
MATCH (s:SourceCustomer {key: sk})
MERGE (s)-[r:SAME_AS]->(c)
SET r.score = $score, r.method = $method, r.decidedAt = datetime();- Queries traverse
Canonicalnodes; audits traverse back downSAME_AS. - Unmerging is deleting one
SAME_ASedge and re-clustering — not a data recovery exercise. - Record method and score on the edge. "Why are these one customer?" must be answerable in one query.
- Human overrides live in a separate table of forced-merge and forced-split pairs, applied after scoring and never overwritten by a re-run.
Measuring it
You cannot tune what you have not labelled. Sample 300–500 candidate pairs stratified by score band and label them once; that gold set will serve you for a year.
- Pairwise precision/recall for threshold tuning.
- Cluster-level metrics (B-cubed or V-measure) — pairwise numbers hide blob clusters.
- Grey-band volume — how many pairs land in review. This is your human cost per million records.
- Drift alarms — merge rate per ingest batch. A sudden jump usually means an upstream format change, not a real change in the world.
Doing it at scale
- Incremental, not batch. New records only compare against their blocks; re-clustering is local to affected clusters.
- Idempotent runs. Same input, same clusters — otherwise downstream ids churn and every consumer breaks.
- Stable canonical ids. Derive from the cluster's oldest member, not from a hash of the whole membership, or ids change on every merge.
- Backpressure the review queue. If the grey band grows faster than humans clear it, tighten blocking rather than raising thresholds blindly.
Checklist
- Decide the error asymmetry for your domain before setting thresholds.
- Exhaust deterministic keys before any fuzzy matching.
- Use multiple blocking keys; measure pair completeness.
- Add graph structural similarity to your score — it is free and it is strong.
- Cluster with size caps; never raw transitive closure.
- Merge non-destructively with score, method and timestamp on every link.
- Keep a labelled gold set and re-measure on every threshold change.
Further reading: Splink for a production-grade probabilistic implementation you can read, the dedupe library docs for active learning, and Neo4j's node similarity algorithms for the structural signals.