Find duplicate records in a file

Find the records that stand for the same person or company in a file entered by several people.

No AI needed

FreeInstantYour data never leaves

The verdict

N0 is the only level that decides in advance which pairs deserve a look, which is what keeps it standing as the file grows, as long as the keys keep the groups small: N1 and N2 compare every pair of the file, N0 the pairs of each group. When a key misses a duplicate, the answer is a second key — the email, the phone number — not the removal of blocking: the union of two rules costs the sum of two sets of small groups, where N1 costs every pair. Move up to N1 the day your duplicates share no field left to write a key on, not before.

Normalisation, several blocking keys, then a field-by-field comparison

No AI needed

Rule and classic algorithmN0FreeInstant

Python

snippets/find-duplicate-records/n0.pyTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
"""
Find duplicate records: normalise, block on several keys, compare field by
field.

Rung N0. Deterministic, standard library only. It compares only the records
that share at least one blocking key: every pair inside a group, none across
groups.

Three 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, leaves the pairs of each group: few when the groups are small,
all of them when every record lands in the same group. A key that misses a
duplicate is answered with a second key, not with the removal of the key: the
pairs of several keys are unioned, and the cost stays the sum of small groups.

Third, comparison field by field, each field weighed. Two sources rarely fill
the same columns: one holds a phone number, the other does not. A field only
one of the two carries is not a difference, and a record compared as one long
string would count that silence as one — and would give the same weight to a
town shared by two million people as to an email address.
"""

import unicodedata

# Fields compared for equality rather than by edit distance. Two different
# addresses at the same domain share most of their characters, and an edit
# distance would call them close; so would two postcodes of the same town.
IDENTIFYING = frozenset({"email", "phone", "postcode", "siret", "siren", "vat"})

# How much agreement on a field is worth. Agreeing on a town says almost
# nothing — thousands of people live in it — while agreeing on an email
# address says nearly everything. This is the crude end of the Fellegi-Sunter
# model, where the weight of a field comes from how often two unrelated
# records agree on it; here the numbers are declared rather than estimated,
# and they are yours to change for your file.
WEIGHTS = {"email": 3.0, "phone": 3.0, "siret": 3.0, "name": 3.0, "postcode": 1.0,
           "city": 0.3, "country": 0.1}
DEFAULT_WEIGHT = 1.0


def normalise(text: str | None) -> str:
    """Lower case, strip accents, invisible characters and punctuation, collapse spaces."""
    decomposed = unicodedata.normalize("NFKD", (str(text) if text is not None else "").lower())
    # A zero-width space is a format character: dropped, it does not split a word.
    letters = "".join(c for c in decomposed if not unicodedata.combining(c) and unicodedata.category(c) != "Cf")
    return " ".join("".join(c if c.isalnum() else " " for c in letters).split())


def record_text(record: dict) -> str:
    """
    One comparable string per record, columns in a stable order.

    Sorted by column name, so that two exports of the same data give the same
    text whatever order their columns come in. An empty cell adds nothing.
    """
    return normalise(" ".join(str(record[key]) for key in sorted(record) if record[key] is not None))


def blocking_key(record: dict) -> str:
    """
    Three letters of the family name and the postcode.

    Short enough to group real duplicates together, specific enough to keep
    the groups small. A record missing either half produces no key, and is
    then grouped by the other keys or not at all.
    """
    words = normalise(record.get("name")).split()
    family_name = words[-1] if words else ""
    postcode = normalise(record.get("postcode")).replace(" ", "")
    return f"{family_name[:3]}:{postcode}" if family_name and postcode else ""


def field_key(field: str):
    """
    A blocking key made of one identifying field: an email, a phone number.

    This is the second rule that answers a first one which missed: a mistyped
    postcode breaks `blocking_key`, and the pair still comes back through the
    email the two records share.
    """

    def key(record: dict) -> str:
        value = normalise(record.get(field)).replace(" ", "")
        return f"{field}:{value}" if value else ""

    return key


DEFAULT_KEYS = (blocking_key, field_key("email"), field_key("phone"))


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 _equal(a: str, b: str) -> float:
    """Equal once the spaces are out, or not equal at all."""
    return 1.0 if a.replace(" ", "") == b.replace(" ", "") else 0.0


def compare_records(a: dict, b: dict, weights: dict | None = None) -> float | None:
    """
    Score two records on the fields both of them fill, and only those.

    A weighted mean of the per-field scores. Two things matter here, and
    comparing the whole record as one string gets both wrong: a field only one
    of the two carries is skipped rather than counted as a difference, and a
    field everybody shares counts for little.

    Returns None when the two records share no filled field.
    """
    weights = WEIGHTS if weights is None else weights
    total, weighed = 0.0, 0.0
    for field in sorted(set(a) & set(b)):
        left, right = normalise(a[field]), normalise(b[field])
        if not left or not right:
            continue
        score = _equal(left, right) if field in IDENTIFYING else similarity(left, right)
        weight = weights.get(field, DEFAULT_WEIGHT)
        total, weighed = total + weight * score, weighed + weight
    return total / weighed if weighed else None


def find_duplicates(records: list[dict], threshold: float = 0.85, keys=DEFAULT_KEYS,
                    weights: dict | None = None) -> list[tuple]:
    """
    Return the pairs `(i, j, score)` that look like the same record.

    `keys` is the list of blocking rules. A pair that any one of them groups
    is compared, once: the pairs are unioned, not intersected, so adding a
    rule can only find more duplicates.

    `weights` says what agreement on each field is worth; see WEIGHTS.

    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.
    """
    pairs, seen = [], set()
    for key in keys:
        blocks: dict[str, list[int]] = {}
        for index, record in enumerate(records):
            group = key(record)
            if group:
                blocks.setdefault(group, []).append(index)
        for indexes in blocks.values():
            for position, i in enumerate(indexes):
                for j in indexes[position + 1:]:
                    if (i, j) in seen:
                        continue
                    seen.add((i, j))
                    score = compare_records(records[i], records[j], weights)
                    if score is not None and score >= threshold:
                        pairs.append((i, j, round(score, 3)))
    return sorted(pairs, key=lambda pair: (-pair[2], pair[0], pair[1]))

JavaScript

snippets/find-duplicate-records/n0.jsTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
/**
 * Find duplicate records: normalise, block on several keys, compare field by
 * field.
 *
 * Rung N0. Deterministic, no dependency. It compares only the records that
 * share at least one blocking key: every pair inside a group, none across
 * groups.
 *
 * Three 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, leaves the pairs of each group: few when the groups are
 * small, all of them when every record lands in the same group. A key that
 * misses a duplicate is answered with a second key, not with the removal of
 * the key: the pairs of several keys are unioned, and the cost stays the sum
 * of small groups.
 *
 * Third, comparison field by field, each field weighed. Two sources rarely
 * fill the same columns: one holds a phone number, the other does not. A field
 * only one of the two carries is not a difference, and a record compared as one
 * long string would count that silence as one — and would give the same weight
 * to a town shared by two million people as to an email address.
 */

// Fields compared for equality rather than by edit distance. Two different
// addresses at the same domain share most of their characters, and an edit
// distance would call them close; so would two postcodes of the same town.
export const IDENTIFYING = new Set(['email', 'phone', 'postcode', 'siret', 'siren', 'vat']);

// How much agreement on a field is worth. Agreeing on a town says almost
// nothing — thousands of people live in it — while agreeing on an email
// address says nearly everything. This is the crude end of the Fellegi-Sunter
// model, where the weight of a field comes from how often two unrelated
// records agree on it; here the numbers are declared rather than estimated,
// and they are yours to change for your file.
export const WEIGHTS = {
  email: 3, phone: 3, siret: 3, name: 3, postcode: 1, city: 0.3, country: 0.1,
};
export const DEFAULT_WEIGHT = 1;

/** Lower case, strip accents, invisible characters and punctuation, collapse spaces. */
export function normalise(text) {
  // A zero-width space is a format character: dropped, it does not split a word.
  return String(text ?? '').toLowerCase().normalize('NFKD').replace(/\p{Cf}/gu, '')
    .replace(/\p{Diacritic}/gu, '').replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
}

/**
 * One comparable string per record, columns in a stable order.
 *
 * Sorted by column name, so that two exports of the same data give the same
 * text whatever order their columns come in. An empty cell adds nothing.
 */
export function recordText(record) {
  const keys = Object.keys(record).sort();
  return normalise(keys.filter((key) => record[key] !== null && record[key] !== undefined)
    .map((key) => String(record[key])).join(' '));
}

/**
 * Three letters of the family name and the postcode.
 *
 * Short enough to group real duplicates together, specific enough to keep the
 * groups small. A record missing either half produces no key, and is then
 * grouped by the other keys or not at all.
 */
export function blockingKey(record) {
  const words = normalise(record.name).split(' ').filter(Boolean);
  const familyName = words.at(-1) ?? '';
  const postcode = normalise(record.postcode).replace(/ /g, '');
  return familyName && postcode ? `${familyName.slice(0, 3)}:${postcode}` : '';
}

/**
 * A blocking key made of one identifying field: an email, a phone number.
 *
 * This is the second rule that answers a first one which missed: a mistyped
 * postcode breaks `blockingKey`, and the pair still comes back through the
 * email the two records share.
 */
export function fieldKey(field) {
  return (record) => {
    const value = normalise(record[field]).replace(/ /g, '');
    return value ? `${field}:${value}` : '';
  };
}

export const DEFAULT_KEYS = [blockingKey, fieldKey('email'), fieldKey('phone')];

/** 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;
}

/** Equal once the spaces are out, or not equal at all. */
function equal(a, b) {
  return a.replace(/ /g, '') === b.replace(/ /g, '') ? 1 : 0;
}

/**
 * Score two records on the fields both of them fill, and only those.
 *
 * A weighted mean of the per-field scores. Two things matter here, and
 * comparing the whole record as one string gets both wrong: a field only one of
 * the two carries is skipped rather than counted as a difference, and a field
 * everybody shares counts for little.
 *
 * Returns null when the two records share no filled field.
 */
export function compareRecords(a, b, weights = WEIGHTS) {
  const shared = Object.keys(a).filter((field) => field in b).sort();
  let total = 0;
  let weighed = 0;
  for (const field of shared) {
    const left = normalise(a[field]);
    const right = normalise(b[field]);
    if (!left || !right) continue;
    const score = IDENTIFYING.has(field) ? equal(left, right) : similarity(left, right);
    const weight = weights[field] ?? DEFAULT_WEIGHT;
    total += weight * score;
    weighed += weight;
  }
  return weighed === 0 ? null : total / weighed;
}

/**
 * Return the pairs `[i, j, score]` that look like the same record.
 *
 * `keys` is the list of blocking rules. A pair that any one of them groups is
 * compared, once: the pairs are unioned, not intersected, so adding a rule can
 * only find more duplicates.
 *
 * `weights` says what agreement on each field is worth; see WEIGHTS.
 *
 * 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, keys = DEFAULT_KEYS, weights = WEIGHTS) {
  const pairs = [];
  const seen = new Set();
  for (const key of keys) {
    const blocks = new Map();
    records.forEach((record, index) => {
      const group = key(record);
      if (!group) return;
      if (!blocks.has(group)) blocks.set(group, []);
      blocks.get(group).push(index);
    });
    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]];
          if (seen.has(`${i}:${j}`)) continue;
          seen.add(`${i}:${j}`);
          const score = compareRecords(records[i], records[j], weights);
          if (score !== null && 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]);
}

Try it

interactive

The code above runs in your browser. Type in the box: the result is redone in front of you.

One record per line: name ; postcode ; town. Two records that do not share three letters of family name and the same postcode are never compared.

yours to edit

Recomputed on every keystroke.

Or start from an example

Free typing and the other examples need JavaScript. The example shown below was computed when the site was built.

What the code makes of it

4 matches

What the code picked up

Jean Dupont ; 75011 ; Paris Jean Dupônt ; 75011 ; PARIS Marie Martin ; 69003 ; Lyon Marie Martln ; 69003 ; Lyon Paul Bernard ; 33000 ; Bordeaux

What it returns

RecordDuplicate ofScore (cut at 0.85)
Jean Dupont, 75011Jean Dupônt, 750111.00
Marie Martin, 69003Marie Martln, 690030.94

2 comparisons run out of 10 possible pairs: the others do not share the blocking key.

What this means for you

  • Your data stays put
  • Always the same result
  • Unit-testable, case by case
  • No provider in the loop
  • Negligible footprint

What this adds to your 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

A pair that no key brings together is never compared, whatever the threshold. “Jean Dupont” at 75011 and the same man at 75012, with no email and no phone number, share none of the three keys: 0.96 alike over the whole text, and nothing in the result. Witness: the same two records carrying the same email address are compared, and come out at 0.863.

When to move up a level

Your duplicates no longer share a single dependable field to write one more key on: no name spelt the same way, no email, no phone number. As long as one remains, the answer is a key, not a level.

The four options

  1. RecommendedN0Normalisation, several blocking keys, then a field-by-field comparisonFree · Instant
  2. N1Character n-gram TF-IDF, neighbours by cosinenégligeable · Instant
  3. N2Self-hosted sentence encoder, compared by cosineInexpensive · Fast
  4. N3Not worth it here

The other options

N1Character n-gram TF-IDF, neighbours by cosinenégligeable · Instant

Character n-gram TF-IDF, neighbours by cosine

Lightweight classic modelN1négligeableInstant

Python

snippets/find-duplicate-records/n1.pyTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
"""
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 every pair of the file is
scored, so the cost grows with the square of the file. The latency class shown
for this rung is measured on the nominal file of its tests; twenty thousand
records are a matter of seconds, not of milliseconds.
"""

import unicodedata

from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.neighbors import NearestNeighbors


def normalise(text: str | None) -> str:
    """Lower case, strip accents, invisible characters and punctuation, collapse spaces."""
    decomposed = unicodedata.normalize("NFKD", (text or "").lower())
    # A zero-width space is a format character: dropped, it does not split a word.
    letters = "".join(c for c in decomposed if not unicodedata.combining(c) and unicodedata.category(c) != "Cf")
    return " ".join("".join(c if c.isalnum() else " " for c in letters).split())


def record_text(record: dict) -> str:
    """
    One comparable string per record, columns in a stable order.

    Sorted by column name, so that two exports of the same data give the same
    text whatever order their columns come in: n-grams taken across a column
    boundary would otherwise differ. An empty cell adds nothing.
    """
    return normalise(" ".join(str(record[key]) for key in sorted(record) if record[key] is not None))


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 sum(1 for text in texts if text) < 2:
        return []  # fewer than two records with something to compare

    vectors = TfidfVectorizer(analyzer="char_wb", ngram_range=(2, 4)).fit_transform(texts)

    # Every neighbour within the radius, for every record. The radius takes a
    # hair more than 1 - threshold: a cosine computed in floating point can land
    # just under 1.0 for two identical records.
    index = NearestNeighbors(metric="cosine").fit(vectors)
    distances, neighbours = index.radius_neighbors(vectors, radius=1 - threshold + 1e-9)

    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

snippets/find-duplicate-records/n1.jsTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
/**
 * 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, written here with no dependency.
 *
 * The price is the search itself: no key means every pair of the file is
 * scored, so the cost grows with the square of the file. The latency class
 * shown for this rung is measured on the nominal file of its tests; twenty
 * thousand records are a matter of seconds, not of milliseconds.
 */

const NGRAM_SIZES = [2, 3, 4];

/** Lower case, strip accents, invisible characters and punctuation, collapse spaces. */
export function normalise(text) {
  // A zero-width space is a format character: dropped, it does not split a word.
  return String(text ?? '').toLowerCase().normalize('NFKD').replace(/\p{Cf}/gu, '')
    .replace(/\p{Diacritic}/gu, '').replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
}

/**
 * One comparable string per record, columns in a stable order.
 *
 * Sorted by column name, so that two exports of the same data give the same
 * text whatever order their columns come in: n-grams taken across a column
 * boundary would otherwise differ. An empty cell adds nothing.
 */
export function recordText(record) {
  const keys = Object.keys(record).sort();
  return normalise(keys.filter((key) => record[key] !== null && record[key] !== undefined)
    .map((key) => String(record[key])).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) {
      // Walking the shorter vector: only the grams the two records share
      // contribute anything to a cosine.
      const [small, large] = vectors[i].size <= vectors[j].size ? [vectors[i], vectors[j]] : [vectors[j], vectors[i]];
      let score = 0;
      for (const [gram, weight] of small) score += weight * (large.get(gram) ?? 0);
      // A hair of tolerance: in floating point, two identical records can score just under 1.0.
      if (score >= threshold - 1e-9) 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]);
}

What this means for you

  • Your data stays put
  • Always the same result
  • Unit-testable, case by case
  • Depends on a library
  • Low footprint

What this adds to your 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 level

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.

N2Self-hosted sentence encoder, compared by cosineInexpensive · Fast

Self-hosted sentence encoder, compared by cosine

Small self-hosted specialised modelN2InexpensiveFast

Python

snippets/find-duplicate-records/n2.pyTested, service simulated — This snippet runs on every build of the site, but its test replaces the external service with a local double: the request sent, the response decoded and the error paths are verified, the real provider’s answer is not.
"""
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 dense vector, the mean of the vectors of its
tokens, and records are compared on those vectors rather than on shared
letters.

Two prices, and the second is the one that grows. The fixed one is the
deployment: weights to download and load, and a process to keep warm. The
other is the comparison, and it is quadratic — every pair of the file is
scored, a thousand records make half a million dot products of three hundred
and eighty-four numbers, ten thousand make fifty million. Past a few tens of
thousands of records, block first with the key of rung N0, or reach for an
approximate index; this snippet does neither, on purpose, because it shows the
distance and not the search.

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 math
import unicodedata

import numpy as np

# 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 | None) -> str:
    """Lower case, strip accents, invisible characters and punctuation, collapse spaces."""
    decomposed = unicodedata.normalize("NFKD", (text or "").lower())
    # A zero-width space is a format character: dropped, it does not split a word.
    letters = "".join(c for c in decomposed if not unicodedata.combining(c) and unicodedata.category(c) != "Cf")
    return " ".join("".join(c if c.isalnum() else " " for c in letters).split())


def record_text(record: dict) -> str:
    """
    One comparable string per record, columns in a stable order.

    Sorted by column name, so that two exports of the same data give the same
    text whatever order their columns come in: n-grams taken across a column
    boundary would otherwise differ. An empty cell adds nothing.
    """
    return normalise(" ".join(str(record[key]) for key in sorted(record) if record[key] is not None))


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")
    if len({len(v) for v in vectors}) != 1 or not all(math.isfinite(x) for v in vectors for x in v):
        raise EncodingFailed("vectors of different sizes, or holding something other than numbers")

    # Every pair at once, as one matrix product. Quadratic all the same — the
    # matrix has one cell per pair — but not at the speed of a Python loop.
    matrix = np.array(vectors, dtype=float)
    scores = matrix @ matrix.T
    above = np.triu_indices(len(vectors), k=1)
    # A hair of tolerance: in floating point, two identical records can score just under 1.0.
    kept = scores[above] >= threshold - 1e-9
    pairs = [
        (int(i), int(j), round(float(scores[i, j]), 3))
        for i, j in zip(above[0][kept], above[1][kept])
    ]
    return sorted(pairs, key=lambda pair: (-pair[2], pair[0], pair[1]))

JavaScript

snippets/find-duplicate-records/n2.jsTested, service simulated — This snippet runs on every build of the site, but its test replaces the external service with a local double: the request sent, the response decoded and the error paths are verified, the real provider’s answer is not.
/**
 * 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 dense vector, the mean of the vectors of its
 * tokens, and records are compared on those vectors rather than on shared
 * letters.
 *
 * Two prices, and the second is the one that grows. The fixed one is the
 * deployment: weights to download and load, and a process to keep warm. The
 * other is the comparison, and it is quadratic — every pair of the file is
 * scored, a thousand records make half a million dot products of three hundred
 * and eighty-four numbers, ten thousand make fifty million. Past a few tens of
 * thousands of records, block first with the key of rung N0, or reach for an
 * approximate index; this snippet does neither, on purpose, because it shows
 * the distance and not the search.
 *
 * 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, invisible characters and punctuation, collapse spaces. */
export function normalise(text) {
  // A zero-width space is a format character: dropped, it does not split a word.
  return String(text ?? '').toLowerCase().normalize('NFKD').replace(/\p{Cf}/gu, '')
    .replace(/\p{Diacritic}/gu, '').replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
}

/**
 * One comparable string per record, columns in a stable order.
 *
 * Sorted by column name, so that two exports of the same data give the same
 * text whatever order their columns come in: n-grams taken across a column
 * boundary would otherwise differ. An empty cell adds nothing.
 */
export function recordText(record) {
  const keys = Object.keys(record).sort();
  return normalise(keys.filter((key) => record[key] !== null && record[key] !== undefined)
    .map((key) => String(record[key])).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('@huggingface/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`);
  }
  if (new Set(vectors.map((v) => v.length)).size !== 1 || !vectors.every((v) => v.every(Number.isFinite))) {
    throw new EncodingFailed('vectors of different sizes, or holding something other than numbers');
  }

  // Every pair, in one flat array rather than an array of arrays: quadratic
  // all the same — one dot product per pair — but without a closure per cell.
  const width = vectors[0].length;
  const flat = new Float64Array(vectors.length * width);
  vectors.forEach((vector, i) => flat.set(vector, i * width));

  const pairs = [];
  for (let i = 0; i < vectors.length; i += 1) {
    for (let j = i + 1; j < vectors.length; j += 1) {
      let score = 0;
      for (let d = 0; d < width; d += 1) score += flat[i * width + d] * flat[j * width + d];
      // A hair of tolerance: in floating point, two identical records can score just under 1.0.
      if (score >= threshold - 1e-9) 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]);
}

What this means for you

  • Your data stays on your own machines
  • Always the same result
  • Measured on a sample, not case by case
  • Depends on a library
  • High footprint

What this adds to your 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: its vector is the mean of the vectors of all its tokens, and the one token that tells two lines apart only counts for its share. If “Câble HDMI 2 m” lands closer to “Câble HDMI 3 m” than to the same item written “HDMI lead, 2 metres, black”, as with the vectors in the test, the snippet pairs the two different cables at the default threshold and misses the real duplicate. Those vectors come from a local double: what the real model returns on these lines is not measured here.

When to move up a level

No level comes 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.

N3Not worth it here

Not worth it here

General-purpose LLM APIN3

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 level still has to choose which pair to send.

Further reading

Where the figures come from

Metadata