Back to course overview
Module 4Entity resolution 15 min

Matching & blocking

Normalize aggressively, compare cheaply: similarity scoring across fields, and the blocking trick that makes comparison feasible at any scale.

Matching asks, for a pair of records: same person? The naive plan — compare every record with every other — dies on arithmetic: 460 records is a manageable 105k pairs, but 1M records is half a trillion pairs. Resolution at scale is therefore two moves: blocking (only compare pairs that share some cheap signal) then scoring (spend real comparison effort inside blocks only).

Step zero: normalize like you mean it

  • Emails: lowercase, trim. (Deeper waters exist — gmail dot-insensitivity, plus-addressing — note them, don't drown in them.)
  • Phones: strip everything but digits, keep the last 10. All three of Harbor Lane's POS formats collapse to one canonical string. Normalization alone typically recovers a third of your matches.
  • Names: lowercase, collapse whitespace. 'ELENA DIAZ' → 'elena diaz'. Accents and nicknames (José/Jose, Bob/Robert) are the next tier — real systems carry lookup tables for them.

Blocking: the feasibility trick

A blocking key is a cheap hash of a record that probable-matches share: normalized email; phone digits; (last name, zip). Records land in a block per key; you compare within blocks only. A record with an email, a phone, and a name lands in three blocks — three chances to meet its duplicate, no chance of meeting the 999,000 strangers. Blocking trades a sliver of recall (duplicates sharing no key are never compared) for a factor-of-a-million cost reduction. Every production resolver — including Meld — is built on this trade.

Scoring inside blocks

the shape of a match scorepython
def score(a, b):
    s = 0.0
    if a['email'] and a['email'] == b['email']:
        s += 0.6                     # a shared email is nearly decisive
    if a['phone'] and a['phone'] == b['phone']:
        s += 0.3                     # shared phone: strong, families blur it
    s += 0.3 * name_similarity(a['name'], b['name'])   # fuzzy, 0..1
    return s                         # match if s >= threshold (e.g. 0.6)
  • Weights encode evidence strength. Email exact ≈ decisive. Phone exact is strong but families share phones. Name similarity is weak alone — the world contains many John Smiths — but decisive in combination.
  • Fuzzy name comparison uses string similarity (edit distance, or Python's built-in difflib.SequenceMatcher) so 'Jon Smith' ≈ 'John Smith' scores ~0.9 instead of 0.
  • The threshold is a business dial, not a constant of nature. Too low → false merges (two people become one — the worst failure: wrong data served confidently, hard to unwind). Too high → missed matches (the status quo you came to fix). Production systems use two thresholds: auto-merge above the high bar, human review queue in the gray zone.
Tune for the failure you can't afford

False merges are usually costlier than false splits — merging two real people leaks one person's history into another's support calls, marketing, and AI-agent context. When in doubt, raise the threshold and let the gray zone go to review. Meld ships with exactly this asymmetry as the default.