Find duplicate records in a file
Find the records that stand for the same person or company in a file entered by several people.
RecommendedN0 Reviewed on
| Rung | Approach | Cost | Latency | Data | Deterministic | Verdict |
|---|---|---|---|---|---|---|
| N0 — Rule and classic algorithm | Normalisation, blocking key, then edit distance | None | <1 ms | Nothing leaves | Yes | Recommended |
| N1 — Lightweight classic model | Character n-gram TF-IDF, neighbours by cosine | Negligible | ~10 ms | Nothing leaves | Yes | |
| N2 — Small self-hosted specialised model | Self-hosted sentence encoder, compared by cosine | Low | ~100 ms | Stays on your infrastructure | Yes | |
| N3 — General-purpose LLM API | Rung not applicable Comparing pairs through a general-purpose model is quadratic: ten thousand records make fifty million calls. And the call only answers the easy question, judging a pair somebody already picked out: a lower rung still has to choose which pair to send. | |||||
N0 — Rule and classic algorithm Rule and classic algorithm Recommended
Normalisation, blocking key, then edit distance
- Cost
- None
- Latency
<1 ms
Proof of execution : Code runs as shown
This snippet runs with its real dependencies, and its test runs on every build of the site.
Python
"""
Find duplicate records: normalise, block, then compare inside a block only.
Rung N0. Deterministic, standard library only, and the only rung here that
stays fast when the file grows, because it never compares every pair.
Two things make this work.
First, normalisation removes what a human ignores when reading a name: case,
accents, punctuation, double spaces. Two spellings that only differ by those
become the same string, and cost nothing to detect.
Second, blocking. Comparing every pair of ten thousand records is fifty
million comparisons. Grouping them by a cheap key first, and comparing only
inside a group, turns that into a few thousand. The whole cost of the rung is
in the key you pick, and so is its blind spot.
"""
import unicodedata
def normalise(text: str) -> str:
"""Lower case, strip accents and punctuation, collapse spaces."""
decomposed = unicodedata.normalize("NFKD", text.lower())
letters = "".join(c for c in decomposed if not unicodedata.combining(c))
return " ".join("".join(c if c.isalnum() else " " for c in letters).split())
def record_text(record: dict) -> str:
"""One comparable string per record."""
return normalise(" ".join(str(value) for value in record.values()))
def blocking_key(record: dict) -> str:
"""
Two records that do not share this key are never compared.
Three letters of the family name and the postcode: short enough to group
real duplicates together, specific enough to keep the groups small.
"""
words = normalise(record["name"]).split()
family_name = words[-1] if words else ""
return f"{family_name[:3]}:{normalise(str(record['postcode']))}"
def edit_distance(a: str, b: str) -> int:
"""Levenshtein distance, two rows at a time rather than a full matrix."""
previous = list(range(len(b) + 1))
for i, char_a in enumerate(a, start=1):
current = [i]
for j, char_b in enumerate(b, start=1):
cost = 0 if char_a == char_b else 1
current.append(min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + cost))
previous = current
return previous[-1]
def similarity(a: str, b: str) -> float:
"""1.0 for identical strings, 0.0 for strings sharing nothing."""
longest = max(len(a), len(b))
return 1.0 if longest == 0 else 1.0 - edit_distance(a, b) / longest
def find_duplicates(records: list[dict], threshold: float = 0.85) -> list[tuple]:
"""
Return the pairs `(i, j, score)` that look like the same record.
The threshold is yours to set: towards 1.0 if merging two different
customers is the worse outcome, towards 0.0 if missing a duplicate is.
"""
blocks: dict[str, list[int]] = {}
for index, record in enumerate(records):
blocks.setdefault(blocking_key(record), []).append(index)
pairs = []
for indexes in blocks.values():
for position, i in enumerate(indexes):
for j in indexes[position + 1:]:
score = similarity(record_text(records[i]), record_text(records[j]))
if score >= threshold:
pairs.append((i, j, round(score, 3)))
return sorted(pairs, key=lambda pair: (-pair[2], pair[0], pair[1]))JavaScript
/**
* Find duplicate records: normalise, block, then compare inside a block only.
*
* Rung N0. Deterministic, no dependency, and the only rung here that stays
* fast when the file grows, because it never compares every pair.
*
* Two things make this work.
*
* First, normalisation removes what a human ignores when reading a name:
* case, accents, punctuation, double spaces. Two spellings that only differ
* by those become the same string, and cost nothing to detect.
*
* Second, blocking. Comparing every pair of ten thousand records is fifty
* million comparisons. Grouping them by a cheap key first, and comparing only
* inside a group, turns that into a few thousand. The whole cost of the rung
* is in the key you pick, and so is its blind spot.
*/
/** Lower case, strip accents and punctuation, collapse spaces. */
export function normalise(text) {
return String(text)
.toLowerCase()
.normalize('NFKD')
.replace(/\p{Diacritic}/gu, '')
.replace(/[^\p{L}\p{N}]+/gu, ' ')
.trim();
}
/** One comparable string per record. */
export function recordText(record) {
return normalise(Object.values(record).join(' '));
}
/**
* Two records that do not share this key are never compared.
*
* Three letters of the family name and the postcode: short enough to group
* real duplicates together, specific enough to keep the groups small.
*/
export function blockingKey(record) {
const words = normalise(record.name).split(' ').filter(Boolean);
const familyName = words.at(-1) ?? '';
return `${familyName.slice(0, 3)}:${normalise(record.postcode)}`;
}
/** Levenshtein distance, two rows at a time rather than a full matrix. */
export function editDistance(a, b) {
let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
for (let i = 1; i <= a.length; i += 1) {
const current = [i];
for (let j = 1; j <= b.length; j += 1) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
current.push(Math.min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + cost));
}
previous = current;
}
return previous.at(-1);
}
/** 1.0 for identical strings, 0.0 for strings sharing nothing. */
export function similarity(a, b) {
const longest = Math.max(a.length, b.length);
return longest === 0 ? 1 : 1 - editDistance(a, b) / longest;
}
/**
* Return the pairs `[i, j, score]` that look like the same record.
*
* The threshold is yours to set: towards 1.0 if merging two different
* customers is the worse outcome, towards 0.0 if missing a duplicate is.
*/
export function findDuplicates(records, threshold = 0.85) {
const blocks = new Map();
records.forEach((record, index) => {
const key = blockingKey(record);
if (!blocks.has(key)) blocks.set(key, []);
blocks.get(key).push(index);
});
const pairs = [];
for (const indexes of blocks.values()) {
for (let a = 0; a < indexes.length; a += 1) {
for (let b = a + 1; b < indexes.length; b += 1) {
const [i, j] = [indexes[a], indexes[b]];
const score = similarity(recordText(records[i]), recordText(records[j]));
if (score >= threshold) pairs.push([i, j, Math.round(score * 1000) / 1000]);
}
}
}
return pairs.sort((x, y) => y[2] - x[2] || x[0] - y[0] || x[1] - y[1]);
}Risks
- Data leaving
- Nothing leaves
- Determinism
- Yes
- Testability
- Unit testable
- Vendor dependency
- None
- Footprint
- Negligible
- Regulatory scope
-
- No specific scope added: the file never leaves your infrastructure
- Does not stand in for a check before merging: the snippet proposes pairs and a score, it merges nothing
Breaking point
Two spellings that do not share the blocking key are never compared, whatever the threshold. One wrong digit in a postcode, or a name entered family-name first, is enough: the test scores the two texts far above the threshold and still returns nothing, because the comparison is never run.
When to move up a rung
You keep finding duplicates by hand whose key field differs: a mistyped postcode, or a first name and a family name swapped between two sources.
N1 — Lightweight classic model Lightweight classic model
Character n-gram TF-IDF, neighbours by cosine
- Cost
- Negligible
- Latency
~10 ms
Proof of execution : Code runs as shown
This snippet runs with its real dependencies, and its test runs on every build of the site.
Python
"""
Find duplicate records by comparing spelling, not strings.
Rung N1. The blocking key of N0 decides in advance which pairs deserve a look,
and everything it puts in two different groups stays invisible. This rung
drops the key: every record becomes a vector of its character n-grams, and
neighbours are looked up by cosine.
Why character n-grams rather than words: they survive a typo, a swapped word
order and a truncated field, because a misspelt word still shares most of its
three-letter slices with the correct one. Nothing here is learnt from a corpus
and there is no model to train. It is a weighting scheme and a distance.
The price is the search itself: no key means, in the worst case, every pair.
A neighbour index earns its place well before the file gets large.
"""
import unicodedata
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.neighbors import NearestNeighbors
def normalise(text: str) -> str:
"""Lower case, strip accents and punctuation, collapse spaces."""
decomposed = unicodedata.normalize("NFKD", text.lower())
letters = "".join(c for c in decomposed if not unicodedata.combining(c))
return " ".join("".join(c if c.isalnum() else " " for c in letters).split())
def record_text(record: dict) -> str:
"""One comparable string per record."""
return normalise(" ".join(str(value) for value in record.values()))
def find_duplicates(records: list[dict], threshold: float = 0.6) -> list[tuple]:
"""
Return the pairs `(i, j, score)` that look like the same record.
`char_wb` keeps n-grams inside word boundaries, so "dupont" and "dupond"
share most of their slices while "dupont paris" borrows nothing from the
join between the two words.
"""
texts = [record_text(record) for record in records]
if len(texts) < 2:
return []
vectors = TfidfVectorizer(analyzer="char_wb", ngram_range=(2, 4)).fit_transform(texts)
# A neighbour index, not a full similarity matrix: the matrix is the thing
# that stops fitting in memory first.
index = NearestNeighbors(metric="cosine").fit(vectors)
distances, neighbours = index.radius_neighbors(vectors, radius=1 - threshold)
pairs = []
for i, (row_distances, row_neighbours) in enumerate(zip(distances, neighbours)):
for distance, j in zip(row_distances, row_neighbours):
if i < j:
pairs.append((i, int(j), round(1 - float(distance), 3)))
return sorted(pairs, key=lambda pair: (-pair[2], pair[0], pair[1]))JavaScript
/**
* Find duplicate records by comparing spelling, not strings.
*
* Rung N1. The blocking key of N0 decides in advance which pairs deserve a
* look, and everything it puts in two different groups stays invisible. This
* rung drops the key: every record becomes a vector of its character n-grams,
* and pairs are ranked by cosine.
*
* Why character n-grams rather than words: they survive a typo, a swapped
* word order and a truncated field, because a misspelt word still shares most
* of its three-letter slices with the correct one. Nothing here is learnt
* from a corpus and there is no model to train. It is a weighting scheme and
* a distance, which is why it fits in one screen with no dependency.
*
* The price is the search itself: no key means, in the worst case, every
* pair. A neighbour index earns its place well before the file gets large.
*/
const NGRAM_SIZES = [2, 3, 4];
/** Lower case, strip accents and punctuation, collapse spaces. */
export function normalise(text) {
return String(text).toLowerCase().normalize('NFKD')
.replace(/\p{Diacritic}/gu, '').replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
}
/** One comparable string per record. */
export function recordText(record) {
return normalise(Object.values(record).join(' '));
}
/**
* Character n-grams, counted, taken inside word boundaries.
*
* Padding each word with spaces is what keeps "dupont" from borrowing an
* n-gram from the join with the word that follows it.
*/
function ngrams(text) {
const counts = new Map();
for (const word of text.split(' ').filter(Boolean)) {
const padded = ` ${word} `;
for (const size of NGRAM_SIZES) {
for (let i = 0; i + size <= padded.length; i += 1) {
const gram = padded.slice(i, i + size);
counts.set(gram, (counts.get(gram) ?? 0) + 1);
}
}
}
return counts;
}
/**
* TF-IDF weights, L2 normalised, one sparse vector per record.
*
* The inverse document frequency is what stops "paris", present in every
* record of a Paris file, from making every pair look alike.
*/
function vectorise(texts) {
const counted = texts.map(ngrams);
const documentFrequency = new Map();
for (const counts of counted) {
for (const gram of counts.keys()) {
documentFrequency.set(gram, (documentFrequency.get(gram) ?? 0) + 1);
}
}
return counted.map((counts) => {
const vector = new Map();
for (const [gram, count] of counts) {
const idf = Math.log((1 + texts.length) / (1 + documentFrequency.get(gram))) + 1;
vector.set(gram, count * idf);
}
const norm = Math.hypot(...vector.values());
for (const [gram, weight] of vector) vector.set(gram, norm ? weight / norm : 0);
return vector;
});
}
/**
* Return the pairs `[i, j, score]` that look like the same record.
*
* The threshold is yours to set: towards 1.0 if merging two different
* customers is the worse outcome, towards 0.0 if missing a duplicate is.
*/
export function findDuplicates(records, threshold = 0.6) {
const vectors = vectorise(records.map(recordText));
const pairs = [];
for (let i = 0; i < vectors.length; i += 1) {
for (let j = i + 1; j < vectors.length; j += 1) {
let score = 0;
// Walking the shorter vector: only the grams the two records share
// contribute anything to a cosine.
for (const [gram, weight] of vectors[i]) score += weight * (vectors[j].get(gram) ?? 0);
if (score >= threshold) pairs.push([i, j, Math.round(score * 1000) / 1000]);
}
}
return pairs.sort((a, b) => b[2] - a[2] || a[0] - b[0] || a[1] - b[1]);
}Risks
- Data leaving
- Nothing leaves
- Determinism
- Yes
- Testability
- Unit testable
- Vendor dependency
- Library
- Footprint
- Low
- Regulatory scope
-
- Processing of personal data on your own infrastructure
- No training corpus: nothing of the file is learnt or kept beyond the run
Breaking point
Character n-grams measure spelling, not identity. SNCF and its spelled-out name are one company and share almost no letter slices, while SNEF, which is another company entirely, scores higher: lowering the threshold until the true duplicate appears merges the two companies first.
When to move up a rung
Your duplicates are two different names for one thing — an acronym and its expansion, a trading name and a legal name — rather than variants of one spelling.
N2 — Small self-hosted specialised model Small self-hosted specialised model
Self-hosted sentence encoder, compared by cosine
- Cost
- Low
- Latency
~100 ms
Proof of execution : Code runs, external service simulated
This snippet runs on every build of the site, but its test replaces the external service with a local double. What is verified: the request sent, the response decoded, and the error paths. What is not: how good the model’s answer is.
Python
"""
Find duplicate records with a self-hosted sentence encoder.
Rung N2. N1 compares spellings; two records that say the same thing in
different words share no character n-grams and stay invisible. A sentence
encoder maps each record to a vector where "Société Nationale des Chemins de
Fer" and "SNCF" can land close together, because the model was trained on text
where they occur in the same places.
What this rung really costs is not the comparison, it is the deployment: a few
hundred megabytes of weights to load, a process to keep warm, and a vector
index once the file no longer fits in a list.
The encoder is a parameter with a real default, so the reader sees the loading
code while the test injects a local double.
"""
from __future__ import annotations
import unicodedata
# A multilingual model, because a customer file is rarely in one language.
MODEL_NAME = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
class EncodingFailed(RuntimeError):
"""The encoder could not be run, or returned something unusable."""
def normalise(text: str) -> str:
"""Lower case, strip accents and punctuation, collapse spaces."""
decomposed = unicodedata.normalize("NFKD", text.lower())
letters = "".join(c for c in decomposed if not unicodedata.combining(c))
return " ".join("".join(c if c.isalnum() else " " for c in letters).split())
def record_text(record: dict) -> str:
"""One comparable string per record."""
return normalise(" ".join(str(value) for value in record.values()))
def unit(vector) -> list[float]:
"""Normalise once, so that a cosine is a dot product afterwards."""
values = [float(v) for v in vector]
norm = sum(v * v for v in values) ** 0.5
return [v / norm for v in values] if norm else values
def find_duplicates(records: list[dict], encoder=None, threshold: float = 0.75) -> list[tuple]:
"""Return the pairs `(i, j, score)` that look like the same record."""
texts = [record_text(record) for record in records]
if len(texts) < 2:
return []
if encoder is None: # pragma: no cover - loads several hundred megabytes
from sentence_transformers import SentenceTransformer
encoder = SentenceTransformer(MODEL_NAME)
# One batched call. Encoding record by record wastes most of the machine.
try:
vectors = [unit(v) for v in encoder.encode(texts)]
except Exception as error: # noqa: BLE001 - a model failure is not the caller's fault
raise EncodingFailed(str(error)) from error
if len(vectors) != len(texts):
raise EncodingFailed(f"{len(vectors)} vectors returned for {len(texts)} records")
pairs = []
for i in range(len(vectors)):
for j in range(i + 1, len(vectors)):
score = sum(x * y for x, y in zip(vectors[i], vectors[j]))
if score >= threshold:
pairs.append((i, j, round(score, 3)))
return sorted(pairs, key=lambda pair: (-pair[2], pair[0], pair[1]))JavaScript
/**
* Find duplicate records with a self-hosted sentence encoder.
*
* Rung N2. N1 compares spellings; two records that say the same thing in
* different words share no character n-grams and stay invisible. A sentence
* encoder maps each record to a vector where "Société Nationale des Chemins
* de Fer" and "SNCF" can land close together, because the model was trained
* on text where they occur in the same places.
*
* What this rung really costs is not the comparison, it is the deployment: a
* few hundred megabytes of weights to load, a process to keep warm, and a
* vector index once the file no longer fits in an array.
*
* The encoder is a parameter with a real default, so the reader sees the
* loading code while the test injects a local double.
*/
// A multilingual model, because a customer file is rarely in one language.
export const MODEL_NAME = 'Xenova/paraphrase-multilingual-MiniLM-L12-v2';
export class EncodingFailed extends Error {}
/** Lower case, strip accents and punctuation, collapse spaces. */
export function normalise(text) {
return String(text).toLowerCase().normalize('NFKD')
.replace(/\p{Diacritic}/gu, '').replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
}
/** One comparable string per record. */
export function recordText(record) {
return normalise(Object.values(record).join(' '));
}
/** Normalise once, so that a cosine is a dot product afterwards. */
export function unit(vector) {
const norm = Math.hypot(...vector);
return norm ? vector.map((v) => v / norm) : [...vector];
}
/**
* Return the pairs `[i, j, score]` that look like the same record.
*
* @param {object[]} records
* @param {object} options
* @param {{encode: Function}} [options.encoder] injected so this can be
* tested without downloading a model; defaults to a real local encoder
* @param {number} [options.threshold]
*/
export async function findDuplicates(records, { encoder, threshold = 0.75 } = {}) {
const texts = records.map(recordText);
if (texts.length < 2) return [];
if (!encoder) {
// Downloads and loads the weights, so it is never reached in the tests.
const { pipeline } = await import('@xenova/transformers');
const extract = await pipeline('feature-extraction', MODEL_NAME);
encoder = { encode: async (batch) => (await extract(batch, { pooling: 'mean' })).tolist() };
}
// One batched call. Encoding record by record wastes most of the machine.
let vectors;
try {
vectors = (await encoder.encode(texts)).map(unit);
} catch (error) {
throw new EncodingFailed(String(error));
}
if (vectors.length !== texts.length) {
throw new EncodingFailed(`${vectors.length} vectors returned for ${texts.length} records`);
}
const pairs = [];
for (let i = 0; i < vectors.length; i += 1) {
for (let j = i + 1; j < vectors.length; j += 1) {
const score = vectors[i].reduce((sum, value, d) => sum + value * vectors[j][d], 0);
if (score >= threshold) pairs.push([i, j, Math.round(score * 1000) / 1000]);
}
}
return pairs.sort((a, b) => b[2] - a[2] || a[0] - b[0] || a[1] - b[1]);
}Risks
- Data leaving
- Stays on your infrastructure
- Determinism
- Yes
- Testability
- Statistically testable
- Vendor dependency
- Library
- Footprint
- High
- Regulatory scope
-
- Processing of personal data on your own infrastructure: the file goes to no third party
- The model weights come from a third party, with the licence and provenance that implies checking
Breaking point
A record is encoded as a whole, so the one token that tells two lines apart is a small part of a vector built from everything they share. In the test, “Câble HDMI 2 m” lands closer to “Câble HDMI 3 m” than to the same item written “HDMI lead, 2 metres, black”: at the default threshold the snippet pairs the two different cables and misses the real duplicate. The local double sharpens the gap; pulling two near-identical lines together is what real encoders do too.
When to move up a rung
There is no rung above this one here: past it, the question is no longer which algorithm to use but who reads the pairs the score leaves in the grey zone.
N3 — General-purpose LLM API General-purpose LLM API
Rung not applicable
Comparing pairs through a general-purpose model is quadratic: ten thousand records make fifty million calls. And the call only answers the easy question, judging a pair somebody already picked out: a lower rung still has to choose which pair to send.
The verdict
RecommendedN0
N0 is the only rung that decides in advance which pairs deserve a look, which is what keeps it standing as the file grows: the other two compare, in the worst case, every pair. Its price is written into the code as the blocking key, and it is yours to set: you choose what you agree never to compare, and the test shows you exactly what that costs. Move up to N1 the day your duplicates hide in the very field the key is made of, not before.
Further reading
- Splink — règles de blocage, ou pourquoi on ne compare pas toutes les paires
- Wikipedia — Record linkage, le vocabulaire du domaine
- scikit-learn — TfidfVectorizer, analyseur char_wb et n-grammes de caractères
- Sentence-Transformers — documentation des encodeurs de phrases auto-hébergés