← Recipes

阅读中文版本

Deduplicate Fuzzy-Matched Records Without Losing Real Rows

Generate similarity-scored candidate pairs, review the match threshold with humans, and merge duplicates through an auditable, reversible process.

~45 mindatadata-qualitydeduplication

Task

Build a scored candidate-pairs pipeline, choose a threshold from labeled examples, and merge only pairs that survive review.

When to use this

A CRM import created three variants of every other customer: Acme LLC, Acme L.L.C., and acme llc. A naive exact-match dedupe changed nothing; a careless fuzzy one deleted distinct companies.

Prerequisites

  • PostgreSQL with pg_trgm, or DuckDB with a similarity UDF
  • A labeled sample of 100 known duplicate and known-distinct pairs
  • A staging table with room for a merge log

Steps

1. Normalize the obvious noise before any similarity work.

duckdb -c "SELECT lower(trim(regexp_replace(name, '\\.|,|Inc|LLC|Ltd', '', 'g'))) AS norm, count(*) FROM accounts GROUP BY norm HAVING count(*) > 1 ORDER BY count(*) DESC LIMIT 8"

Expected output: Exact duplicates surface after normalization: punctuation and legal-suffix stripping collapses Acme LLC and acme llc for free.

2. Generate scored candidate pairs with trigram similarity.

psql -d crm -c "SELECT a.id a_id, b.id b_id, round(similarity(a.norm, b.norm)::numeric, 3) score, a.norm, b.norm FROM accounts a JOIN accounts b ON a.id < b.id AND similarity(a.norm, b.norm) > 0.3 ORDER BY score DESC LIMIT 12;"

Expected output: Candidate pairs print with scores from 1.0 down; the interesting band is where real duplicates and lookalike distincts mix.

3. Choose the threshold from labeled examples, not vibes.

-- label 100 pairs by hand first (or from past merges)
CREATE TABLE dedupe_labels (a_id int, b_id int, is_dup bool);
-- INSERT ... (20 min of work, one-time)

-- threshold sweep: how does each cutoff perform?
WITH scored AS (
  SELECT l.a_id, l.b_id, l.is_dup,
         similarity(a.norm, b.norm) AS s
  FROM dedupe_labels l
  JOIN accounts a ON a.id = l.a_id
  JOIN accounts b ON b.id = l.b_id
)
SELECT
  count(*) FILTER (WHERE is_dup) AS total_dups,
  count(*) FILTER (WHERE NOT is_dup) AS total_distinct,
  count(*) FILTER (WHERE is_dup AND s >= 0.55) AS caught,
  count(*) FILTER (WHERE NOT is_dup AND s >= 0.55) AS false_merges
FROM scored;

Expected output: The sweep shows the trade: threshold 0.55 catches 92 percent of duplicates with 2 false merges, while 0.45 catches 97 with 11, making the cutoff a deliberate choice.

4. Merge through a logged, reversible process.

BEGIN;

CREATE TABLE dedupe_merge_log (
  merged_at timestamptz DEFAULT now(),
  survivor_id int, absorbed_id int, score numeric, rolled_back bool DEFAULT false
);

-- record every merge decision with its score
WITH pairs AS (
  SELECT a.id survivor, b.id absorbed,
         similarity(a.norm, b.norm) score
  FROM accounts a JOIN accounts b ON a.id < b.id
  WHERE similarity(a.norm, b.norm) >= 0.55
)
INSERT INTO dedupe_merge_log (survivor_id, absorbed_id, score)
SELECT survivor, absorbed, score FROM pairs;

-- re-point child rows, then deactivate (never DELETE)
UPDATE orders SET account_id = m.survivor_id
FROM dedupe_merge_log m WHERE orders.account_id = m.absorbed_id;
UPDATE accounts SET active = false WHERE id IN (SELECT absorbed_id FROM dedupe_merge_log);

COMMIT;

Expected output: Duplicates merge inside one transaction with every decision logged; absorbed rows are deactivated, not deleted, so a wrong merge is one UPDATE away from reversal.

5. Audit the result and spot-check the survivors.

psql -d crm -c "SELECT count(*) merges FROM dedupe_merge_log WHERE NOT rolled_back;" -c "SELECT survivor_id, count(*) absorbed FROM dedupe_merge_log GROUP BY 1 ORDER BY 2 DESC LIMIT 5;"

Expected output: Merge counts and the biggest survivor groups print, giving reviewers a queue of the riskiest merges to spot-check before announcing completion.

Troubleshooting

Problem

Similarity scores miss swapped word order

Solution

Trigram similarity is order-sensitive; add a sorted-token variant (word-split, sort, rejoin) as a second normalized column and take the max of both scores.

Problem

Two distinct companies share a name

Solution

No string metric can separate Acme Inc (Ohio) from Acme Inc (Nevada); include region or phone in the pair key, and let the label set teach the threshold where fields must agree.

Problem

A reviewed merge turns out wrong next week

Solution

The merge log plus deactivate-instead-of-delete makes reversal mechanical: re-point child rows back and reactivate the absorbed account, all inside a new logged transaction.

Limitations

  • Fuzzy thresholds encode a business judgment; different error costs justify different cutoffs, and the labels make that explicit.
  • Similarity is string-level; cross-system identity needs entity keys or matching ids where they exist.

Exact match finds nothing; fuzzy without labels destroys things

Dirty duplicates live in the gap between identical and distinct. Trigram similarity covers the gap, but a threshold chosen without labeled examples is a coin flip with your data. One hundred hand-labeled pairs turn the cutoff into a measured trade between missed duplicates and destroyed distincts.

-- Threshold sweep on 100 labeled pairs
-- cutoff  caught_dups  false_merges
--  0.45        97%            11      <-- too aggressive
--  0.55        92%             2      <-- chosen
--  0.70        61%             0      <-- too timid

-- Merge with an audit trail, deactivate instead of delete
INSERT INTO dedupe_merge_log (survivor_id, absorbed_id, score)
SELECT a.id, b.id, similarity(a.norm, b.norm)
FROM accounts a JOIN accounts b ON a.id < b.id
WHERE similarity(a.norm, b.norm) >= 0.55;

Labels make the threshold a decision

The sweep table converts an argument into a choice: at 0.55 you accept 8 percent of duplicates staying and 2 false merges, at 0.45 you trade 11 destroyed companies for 5 percent more coverage. Whoever owns the error cost picks the row; the label set guarantees the numbers mean what they say.

Never delete what you merged

Deactivation plus a merge log means every decision is reversible and attributable: which pairs, what scores, when, by what run. The alternative, DELETE WHERE id IN (fuzzy query), is the version of this task that ends in a restore-from-backup meeting.