Which fuzzy matching algorithm finds duplicate customer names?
Trigram Jaccard on normalised names had the best F1 in our test, 0.914 against 0.767 for Levenshtein, over 31,342 labelled pairs of Vietnamese and English names. But at that threshold it merged 100% of 5,457 pairs of different Vietnamese people who share a family and middle name. On a synthetic 10,0
Trigram Jaccard on normalised names had the best F1 of the five algorithms, 0.914, against 0.767 for Levenshtein, over 31,342 labelled pairs of Vietnamese and English names. But no similarity score can tell Nguyễn Văn An from Nguyễn Văn Anh: at its best threshold, trigram Jaccard merged 100% of 5,457 pairs of different Vietnamese people who share a family and middle name. For English pairs sharing only a surname, the same threshold merged 3.8%. Usual advice says "use Levenshtein" for fuzzy name matching. Here it tied with Damerau-Levenshtein for second-worst of the five, and it fails the same way.
Everything below ran on an Apple M3 with 16 GB, macOS 26.4.1 and Node v23.5.0. It uses no libraries or network, and every algorithm is written by hand. Other benchmarks were running on the same machine at the time. Timings are medians of seven runs, each algorithm in its own process, and the ratios between them matter more than the milliseconds.
The names are synthetic, and the vocabulary is small. A generator with a fixed seed built them from 16 Vietnamese family names weighted by real frequency (Nguyễn at 38%), 18 middle names and 62 given names, plus 40 English first names and 40 surnames. The absolute scores belong to this set. What carries over is which edit breaks which algorithm, and the gap between a benchmark threshold and a real contact list.
The short answer
- Normalise before you compare. That means stripping diacritics (including
đ → d), lowercasing, removing punctuation and dropping a leading honorific. It lifted trigram Jaccard's F1 from 0.780 to 0.914, and every algorithm's F1 went up. Un-normalised Levenshtein's best F1 was 0.684, barely above the 0.657 you get by declaring every pair a match. - Every algorithm has one variation it cannot see. After normalisation, Levenshtein, Damerau-Levenshtein and Jaro-Winkler matched under 1% of reordered names (
Văn An Nguyễn). Trigram Jaccard and token sort matched 100% of those, but only 33.5% and 17.8% of initials (A. Nguyễn). - Vietnamese names are the surprise. When two people share a family and middle name, the given name is the only part that differs. It is often two or three letters, so a different person looks exactly like a typo. The five algorithms merged 52.4% to 100% of such pairs. For English names sharing a surname, the range was 0.5% to 23.7%.
- A benchmark threshold does not survive a contact list. On 10,000 synthetic contacts (1,708 true duplicate pairs), every algorithm at its benchmark threshold had precision between 0.13% and 0.19%. Even with the threshold re-tuned for best F1 on the list itself, precision stayed between 5.0% and 6.6%.
- Comparing every pair grows with the square of the list. Jaro-Winkler measured 378 ms per million comparisons and trigram Jaccard 1,485 ms. At 50,000 contacts that works out to 7.9 and 30.9 minutes (calculated). Blocking on the family name cut the candidate pairs by 94%, but it also threw away 57% of the true duplicates.
How was this measured?
The generator made 2,000 unique base names: 1,342 Vietnamese written family-first, and 658 English. Each base name got every variant that applies to it. Different-person pairs were then added to match:
| Class | Pairs | Example |
|---|---|---|
| typo, 1 or 2 edits | 2,000 each | Nguyễn Thu Thu Hoa → Nguyễn Thu Thu Ha |
| UPPER CASE | 2,000 | NGUYỄN THU THU HOA |
| no diacritics (vi) | 1,342 | Nguyen Thu Thu Hoa |
| reordered | 2,000 | Thu Thu Hoa Nguyễn, Thomas, Victoria |
| given + family, no marks (vi) | 1,342 | Hoa Nguyen |
| initial | 2,000 | H. Nguyễn |
| honorific | 2,000 | Ms. Nguyễn Thu Thu Hoa, Chị …, Ông … |
| nickname (en) | 658 | Victoria Thomas → Vicky Thomas |
| different person, same family name | 8,000 | Nguyễn Thu Thu Hoa / Nguyễn Duy Sơn |
| different person, random | 8,000 | Nguyễn Thu Thu Hoa / Lê Công Long |
That makes 15,342 same-person pairs and 16,000 different-person pairs. Half of the same-family negatives use a noisy variant on one side. For every algorithm, with and without normalisation, I tried thresholds from 0.00 to 1.00 in steps of 0.01 and kept the one with the highest F1. Damerau-Levenshtein here is the optimal-string-alignment variant. Token sort splits a name into words, sorts them, and takes the Levenshtein ratio.
Which fuzzy name matching algorithm scores best?
| Algorithm | F1 raw | F1 normalised | Threshold | Precision | Recall |
|---|---|---|---|---|---|
| Levenshtein ratio | 0.684 | 0.767 | 0.66 | 0.941 | 0.647 |
| Damerau-Levenshtein | 0.684 | 0.767 | 0.66 | 0.940 | 0.648 |
| Jaro-Winkler | 0.714 | 0.741 | 0.88 | 0.929 | 0.616 |
| trigram Jaccard | 0.780 | 0.914 | 0.35 | 0.930 | 0.898 |
| token-sort ratio | 0.802 | 0.865 | 0.57 | 0.899 | 0.834 |
The raw column is easy to misread. Levenshtein's best raw threshold was 0.15, low enough to match 45.8% of random different-person pairs. It simply gave up and matched almost everything, which is why its F1 sits just above the 0.657 match-everything baseline. Raw trigram Jaccard matched 0.0% of UPPER CASE variants, because NGUYỄN and Nguyễn share almost no trigrams. It also matched only 71.8% of variants without diacritics. Normalisation fixes both before any algorithm runs. This is the same finding as normalising text for search, one layer further in.
Damerau-Levenshtein bought nothing over Levenshtein. At most a quarter of the typos were transpositions. In a name of 14 characters, one transposition costs 0.14 of the ratio under Levenshtein and 0.07 under Damerau. Either way the score stays well above the 0.66 threshold.
Which variation does each algorithm miss?
Recall by class after normalisation, each at its own best threshold. The last two columns are false-positive rates, so lower is better:
| Algorithm | typo 1 | typo 2 | reordered | given + family | initial | nickname | same family FP | random FP |
|---|---|---|---|---|---|---|---|---|
| Levenshtein | 100.0 | 99.7 | 0.8 | 0.1 | 1.8 | 82.5 | 8.5 | 1.2 |
| Damerau-Levenshtein | 100.0 | 100.0 | 0.8 | 0.1 | 1.8 | 82.5 | 8.7 | 1.2 |
| Jaro-Winkler | 98.0 | 93.3 | 0.9 | 0.1 | 1.2 | 37.4 | 9.7 | 1.6 |
| trigram Jaccard | 100.0 | 95.1 | 100.0 | 94.3 | 33.5 | 90.3 | 11.9 | 1.9 |
| token-sort ratio | 91.0 | 82.3 | 100.0 | 78.2 | 17.8 | 88.0 | 18.6 | 1.9 |
| token alignment (below) | 76.0 | 36.5 | 100.0 | 100.0 | 94.5 | 0.0 | 0.3 | 0.1 |
UPPER CASE, missing diacritics and honorifics scored 100.0% for all five once normalised. The edit-distance family finds typos and nothing else. Moving the family name to the end rewrites the whole string for them. Trigram Jaccard is order-blind, because a set of trigrams has no order, and that explains most of its lead. None of the five can handle A. Nguyen. One letter and a family name do not carry enough characters to score as similar, and should not.
Why do Vietnamese names break fuzzy matching?
Because the part that identifies the person is the shortest part of the string. Here are some normalised scores:
| Pair | Same person? | Levenshtein | Jaro-Winkler | trigram | token sort |
|---|---|---|---|---|---|
Nguyễn Văn An / Nguyen Van Ann |
yes, typo | 0.929 | 0.986 | 0.867 | 0.929 |
Nguyễn Văn An / Nguyễn Văn Anh |
no | 0.929 | 0.986 | 0.867 | 0.929 |
Nguyễn Văn An / Nguyễn Văn Nam |
no | 0.857 | 0.970 | 0.647 | 0.857 |
Nguyễn Văn An / An Nguyen |
yes | 0.231 | 0.710 | 0.533 | 0.692 |
John Smith / Jane Smith |
no | 0.700 | 0.880 | 0.375 | 0.700 |
The first two rows score identically on all four. A string metric cannot separate them, because the strings are equally far apart. A Vietnamese name is family, middle and given, and the first two are drawn from small, heavily shared sets. Among the 2,000 base names, 5,457 pairs of different Vietnamese people share both a family and a middle name. The share each algorithm merged, at its best threshold:
| Algorithm | vi: same family + middle | en: same surname |
|---|---|---|
| Levenshtein | 96.7% | 5.8% |
| Damerau-Levenshtein | 96.7% | 6.1% |
| Jaro-Winkler | 97.9% | 0.5% |
| trigram Jaccard | 100.0% | 3.8% |
| token-sort ratio | 52.4% | 23.7% |
| token alignment | 1.8% | 0.2% |
For English names this is a precision problem you can tune away. For Vietnamese names it is a category error. Jane differs from John in three of ten characters, while Anh differs from An in one of fourteen.
One rule cut that rate from 100% to 1.8%, and it is not a similarity score. Token alignment requires every word of the shorter name to pair with a different word of the longer one. A pair counts when the words are equal, when one is the initial of the other, or when both have at least four letters and are one edit apart. An never matches Anh, because short words must be exact. A. Nguyen matches Nguyễn Văn An. It scored precision 0.991 and F1 0.907, almost the same F1 as trigram Jaccard. But it pays in exactly the places the table shows: 36.5% on two-typo names and 0.0% on nicknames. It trades recall for the one error a CRM cannot undo, merging two real customers.
What happens on a real contact list?
The benchmark above is balanced, with about one same-person pair for every different-person pair. A contact list is not. I generated 10,000 contacts from the same material: 8,521 distinct people, 15% of records a variant of an earlier one, and 1,708 true duplicate pairs among 49,995,000. Blocking on a shared word left 5,296,102 candidate pairs, and I scored all of them:
| Algorithm | Precision at benchmark threshold | Recall | Best F1 on the list (precision / recall) |
|---|---|---|---|
| Levenshtein @0.66 | 0.19% | 58.8% | 0.086 (5.0% / 29.9%) |
| Jaro-Winkler @0.88 | 0.13% | 54.7% | 0.090 (5.1% / 36.9%) |
| trigram Jaccard @0.35 | 0.16% | 86.1% | 0.090 (5.0% / 42.0%) |
| token-sort @0.57 | 0.15% | 78.2% | 0.115 (6.6% / 42.3%) |
| token alignment | 2.29% | 78.1% | 0.045 (2.3% / 78.1%) |
Trigram Jaccard at its benchmark threshold flagged 917,012 pairs to find 1,471 real ones. The floor under all of them is harder: 9,669 pairs of different people in that list have the identical normalised name. That is 5.7 times the number of true duplicates, and no string algorithm can separate them. My vocabulary is smaller than a real population, so it overstates those collisions. The direction does not change: Nguyễn is 38% of Vietnamese family names, and first names repeat too.
Names find candidates; they do not confirm duplicates. The confirmation has to come from a field with more entropy: email, phone, company. That is also why the capture step in a business card to CRM pipeline keeps the email and phone it can read with certainty.
How long does comparing every pair take?
Measured over 4,000 normalised name pairs (13.9 characters on average), cycled to one million comparisons. All-pairs times are calculated from the median as n(n−1)/2 comparisons:
| Algorithm | ms per 1M (measured) | vs Jaro-Winkler | 1k contacts | 10k | 50k |
|---|---|---|---|---|---|
| Jaro-Winkler | 378 | 1.00x | 0.19 s | 18.9 s | 7.9 min |
| Levenshtein ratio | 808 | 2.14x | 0.40 s | 40.4 s | 16.8 min |
| Damerau-Levenshtein | 1,073 | 2.84x | 0.54 s | 53.6 s | 22.4 min |
| token-sort ratio | 1,365 | 3.61x | 0.68 s | 68.2 s | 28.4 min |
| trigram Jaccard | 1,485 | 3.93x | 0.74 s | 74.2 s | 30.9 min |
Trigram Jaccard builds both trigram sets on every call. Build them once per contact and most of that 3.93x goes away, but the pair count does not. Going from 10,000 to 50,000 contacts multiplies the work by 25.
Does blocking by family name help?
Blocking means comparing only records that share a key. The candidate pairs, and the share of true duplicate pairs each key keeps, were measured on the 50,000-contact list (trigram row at 10,000):
| Blocking key | Candidate pairs | % of all pairs | True duplicates kept |
|---|---|---|---|
| none | 1,249,975,000 | 100% | 100% |
| first word (family name as typed) | 78,865,130 | 6.31% | 42.8% |
| sorted initials of every word | 4,002,609 | 0.32% | 75.0% |
| shares any word | 129,770,248 | 10.38% | 95.7% |
| shares a word, ignoring the 20 commonest | 25,408,440 | 2.03% | 81.7% |
| shares ≥50% of trigrams (10k list) | 2,141,557 of 49,995,000 | 4.28% | 97.5% |
Blocking on the family name is the obvious move, and it fails twice for Vietnamese data. The largest block, Nguyễn, held 10,897 of the 50,000 records on its own. It also kept only 42.8% of the duplicates, because An Nguyen starts with the given name and a typo changes the key. Blocking on any shared word keeps 95.7% and still cuts the work tenfold. That is 49 seconds of Jaro-Winkler at 50,000 contacts, calculated, instead of 7.9 minutes. A trigram index kept the most duplicates of all. It is the same candidate-generation problem as finding near-duplicate documents, where LSH does the job that blocking does here.
What should you actually use?
- Normalise first: NFD, strip marks,
đ → d, lowercase, drop punctuation and a leading honorific. It was worth up to 0.134 F1 in this test. - Generate candidates by shared word or trigram index, not by family name.
- Score with trigram Jaccard or token sort if you want recall. Use token alignment if a wrong merge is expensive.
- Never auto-merge on name alone. A name match is a suggestion for review until email, phone or another stable field agrees.
Check it yourself
A single file with no dependencies. It ran in about five seconds with plain node on the M3, and the .cjs extension lets it run inside an ES-module repo. It uses a smaller vocabulary than the benchmark above and plain arrays in place of typed ones, so the numbers differ. The pattern is the thing to check.
// fuzzy-names-check.cjs — precision/recall of five name matchers, raw vs normalised,
// plus the false-positive rate on different people who share a family name.
// node fuzzy-names-check.cjs (Node 18+, no dependencies)
'use strict';
let s = 20260915;
const rnd = () => { s = s + 0x6D2B79F5 | 0; let t = Math.imul(s ^ s >>> 15, 1 | s); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; };
const pick = a => a[Math.floor(rnd() * a.length)];
const FAM = ['Nguyễn', 'Nguyễn', 'Nguyễn', 'Nguyễn', 'Trần', 'Lê', 'Phạm', 'Hoàng', 'Phan', 'Vũ', 'Đặng'];
const MID = ['Văn', 'Thị', 'Hữu', 'Đức', 'Minh', 'Ngọc', 'Thanh', 'Thu'];
const GIV = ['An', 'Anh', 'Bình', 'Dũng', 'Đạt', 'Hà', 'Hải', 'Hòa', 'Hùng', 'Hương', 'Lan', 'Linh', 'Long', 'Mai', 'Nam', 'Nga', 'Phong', 'Quân', 'Sơn', 'Tâm', 'Thảo', 'Trang', 'Tuấn', 'Vy', 'Yến', 'Khánh', 'Minh', 'Chi', 'Việt', 'Nhung'];
const NICK = { William: 'Bill', Robert: 'Bob', Elizabeth: 'Liz', Michael: 'Mike', James: 'Jim', Thomas: 'Tom', Jennifer: 'Jen', Katherine: 'Kate', Daniel: 'Dan', Rebecca: 'Becky', Andrew: 'Andy', Samuel: 'Sam', Patricia: 'Pat', Anthony: 'Tony', Susan: 'Sue', Joseph: 'Joe' };
const LAST = ['Smith', 'Johnson', 'Brown', 'Williams', 'Jones', 'Miller', 'Davis', 'Wilson', 'Taylor', 'Clark', 'Walker', 'Moore', 'Martin', 'Jackson', 'White', 'Harris', 'Lewis', 'Young', 'Allen', 'King', 'Wright', 'Scott', 'Green', 'Baker', 'Adams', 'Nelson', 'Hill', 'Carter', 'Evans', 'Turner', 'Parker', 'Collins', 'Reed', 'Cook', 'Bell', 'Ward', 'Cox', 'Gray', 'Wood', 'Hughes', 'Price', 'Long', 'Ross', 'Hayes', 'Fisher', 'Ellis', 'Shaw', 'Grant', 'Black'];
const strip = x => x.normalize('NFD').replace(/\p{M}/gu, '').replace(/đ/g, 'd').replace(/Đ/g, 'D');
const HON = new Set(['mr', 'mrs', 'ms', 'dr', 'ong', 'ba', 'chi']);
const norm = x => { let t = strip(x).toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim().split(' '); if (t.length > 2 && HON.has(t[0])) t.shift(); return t.join(' '); };
function typo(x, k) { const a = [...x]; while (k--) { const i = pick([...a.keys()].filter(j => a[j] !== ' ')); const op = Math.floor(rnd() * 3); if (op === 0) a[i] = pick([...'abcdefghiklmnoprstuy']); else if (op === 1) a.splice(i, 0, pick([...'aeinot'])); else if (a.length > 3) a.splice(i, 1); } return a.join(''); }
function base() {
if (rnd() < 0.7) { const p = [pick(FAM), pick(MID), pick(GIV)]; return { vi: true, p, fam: p[0], giv: p[2] }; }
const g = pick(Object.keys(NICK)), f = pick(LAST); return { vi: false, p: [g, f], fam: f, giv: g };
}
function variants(b) {
const n = b.p.join(' '), v = [['typo 1', typo(n, 1)], ['typo 2', typo(n, 2)], ['UPPER', n.toUpperCase()], ['initial', b.giv[0] + '. ' + b.fam], ['honorific', (b.vi ? 'Ông ' : 'Mr. ') + n]];
if (b.vi) v.push(['no marks', strip(n)], ['reordered', [...b.p.slice(1), b.fam].join(' ')], ['given+family', strip(b.giv + ' ' + b.fam)]);
else v.push(['reordered', b.fam + ', ' + b.giv], ['nickname', NICK[b.giv] + ' ' + b.fam]);
return v;
}
function lev(a, b) { let p = Array.from({ length: b.length + 1 }, (_, j) => j); for (let i = 1; i <= a.length; i++) { const c = [i]; for (let j = 1; j <= b.length; j++) c[j] = Math.min(p[j] + 1, c[j - 1] + 1, p[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)); p = c; } return p[b.length]; }
function dl(a, b) { const d = [...Array(a.length + 1)].map((_, i) => [i]); for (let j = 0; j <= b.length; j++) d[0][j] = j; for (let i = 1; i <= a.length; i++) for (let j = 1; j <= b.length; j++) { d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)); if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1); } return d[a.length][b.length]; }
const ratio = f => (a, b) => 1 - f(a, b) / (Math.max(a.length, b.length) || 1);
function jw(a, b) {
const w = Math.max(0, (Math.max(a.length, b.length) >> 1) - 1), ma = [], mb = []; let m = 0;
for (let i = 0; i < a.length; i++) for (let j = Math.max(0, i - w); j <= Math.min(b.length - 1, i + w); j++) if (!mb[j] && a[i] === b[j]) { ma[i] = mb[j] = true; m++; break; }
if (!m) return 0; let t = 0, k = 0;
for (let i = 0; i < a.length; i++) if (ma[i]) { while (!mb[k]) k++; if (a[i] !== b[k]) t++; k++; }
const j = (m / a.length + m / b.length + (m - t / 2) / m) / 3; let l = 0;
while (l < 4 && a[l] && a[l] === b[l]) l++; return j + l * 0.1 * (1 - j);
}
const grams = x => { const p = ' ' + x + ' ', g = new Set(); for (let i = 0; i + 3 <= p.length; i++) g.add(p.slice(i, i + 3)); return g; };
const tri = (a, b) => { const A = grams(a), B = grams(b); let i = 0; for (const g of A) if (B.has(g)) i++; return i / (A.size + B.size - i); };
const tsort = x => x.split(/\s+/).sort().join(' ');
const ALGOS = { 'Levenshtein ratio': ratio(lev), 'Damerau-Levenshtein': ratio(dl), 'Jaro-Winkler': jw, 'trigram Jaccard': tri, 'token-sort ratio': (a, b) => ratio(lev)(tsort(a), tsort(b)) };
// labelled pairs: [a, b, isSamePerson, class]
const seen = new Set(), bases = [];
for (let tries = 0; bases.length < 2000 && tries < 1e6; tries++) { const b = base(), k = b.p.join(' '); if (!seen.has(k)) { seen.add(k); bases.push(b); } }
const pairs = [];
for (const b of bases) {
for (const [c, v] of variants(b)) pairs.push([b.p.join(' '), v, 1, c]);
const same = bases.filter(o => o !== b && o.fam === b.fam);
for (let r = 0; r < 4; r++) pairs.push([b.p.join(' '), pick(same).p.join(' '), 0, 'same family'], [b.p.join(' '), pick(bases).p.join(' '), 0, 'random']);
}
const pos = pairs.filter(p => p[2]).length;
console.log(`node ${process.version} ${bases.length} names, ${pairs.length} pairs (${pos} same person)`);
console.log(`"everything matches" baseline F1 ${(2 * pos / (pos + pairs.length)).toFixed(3)}\n`);
// different people sharing family AND middle name — "Nguyễn Văn An" vs "Nguyễn Văn Anh"
const twins = [];
for (let i = 0; i < bases.length; i++) for (let j = i + 1; j < bases.length; j++)
if (bases[i].vi && bases[j].vi && bases[i].p[0] === bases[j].p[0] && bases[i].p[1] === bases[j].p[1]) twins.push([bases[i].p.join(' '), bases[j].p.join(' ')]);
const row = (...c) => console.log(c.map((x, i) => String(x).padEnd(i ? 11 : 32)).join(''));
row('algorithm', 'best th', 'precision', 'recall', 'F1', 'initial', 'reorder', 'fam+mid FP');
for (const [name, f] of Object.entries(ALGOS)) for (const nz of [false, true]) {
const prep = nz ? norm : x => x.normalize('NFC');
const sc = pairs.map(p => f(prep(p[0]), prep(p[1])));
let best = { F: -1 };
for (let t = 0; t <= 100; t++) {
let tp = 0, fp = 0; sc.forEach((v, i) => { if (v >= t / 100 - 1e-9) pairs[i][2] ? tp++ : fp++; });
const P = tp / (tp + fp || 1), R = tp / pos, F = 2 * P * R / (P + R || 1);
if (F > best.F) best = { t: t / 100, P, R, F };
}
const cls = c => { const ix = pairs.map((p, i) => i).filter(i => pairs[i][3] === c); return (100 * ix.filter(i => sc[i] >= best.t - 1e-9).length / ix.length).toFixed(0) + '%'; };
const tw = (100 * twins.filter(([a, b]) => f(prep(a), prep(b)) >= best.t - 1e-9).length / twins.length).toFixed(0) + '%';
row(name + (nz ? ' + normalise' : ''), best.t.toFixed(2), best.P.toFixed(3), best.R.toFixed(3), best.F.toFixed(3), cls('initial'), cls('reordered'), tw);
}
console.log(`\n${twins.length} pairs of different Vietnamese people share family and middle name.`);
console.log('"fam+mid FP" is the share of them each matcher would merge at its own best threshold.');
node fuzzy-names-check.cjs
On the M3 above:
node v23.5.0 2000 names, 31362 pairs (15362 same person)
"everything matches" baseline F1 0.658
algorithm best th precision recall F1 initial reorder fam+mid FP
Levenshtein ratio 0.08 0.524 0.969 0.680 96% 87% 100%
Levenshtein ratio + normalise 0.72 0.930 0.628 0.750 0% 0% 72%
Damerau-Levenshtein 0.08 0.524 0.969 0.680 96% 87% 100%
Damerau-Levenshtein + normalise 0.72 0.930 0.628 0.750 0% 0% 72%
Jaro-Winkler 0.51 0.571 0.898 0.698 85% 75% 100%
Jaro-Winkler + normalise 0.90 0.904 0.589 0.714 0% 0% 86%
trigram Jaccard 0.30 0.832 0.680 0.749 40% 100% 100%
trigram Jaccard + normalise 0.33 0.882 0.909 0.896 38% 100% 100%
token-sort ratio 0.31 0.686 0.898 0.778 96% 100% 94%
token-sort ratio + normalise 0.58 0.889 0.830 0.859 14% 100% 54%
14306 pairs of different Vietnamese people share family and middle name.
"fam+mid FP" is the share of them each matcher would merge at its own best threshold.
Swap in names from your own CRM export for the generator and read the last column first.
Where this goes next
Name matching is one of the problems any CRM meets once contacts arrive from more than one source, including Simple CRM. The measurement above says to treat a name match as a question for a person, not an answer.