Repérer les doublons dans un fichier

Retrouver les fiches qui désignent la même personne, ou la même entreprise, dans un fichier saisi par plusieurs mains.

Pas besoin d’IA

GratuitInstantanéVos données ne sortent pas

Le verdict

N0 est le seul niveau qui décide à l’avance quelles paires méritent un regard, et c’est ce qui le laisse tenir quand le fichier grossit, tant que les clés gardent les groupes petits : N1 et N2 comparent toutes les paires du fichier, N0 celles de chaque groupe. Quand une clé manque un doublon, la réponse est une seconde clé — le courriel, le téléphone — et non la suppression du blocage : l’union de deux règles coûte la somme de deux jeux de petits groupes, quand N1 coûte toutes les paires. Montez à N1 le jour où vos doublons ne partagent plus aucun champ sur lequel écrire une clé, pas avant.

Normalisation, plusieurs clés de blocage, puis comparaison champ par champ

Pas besoin d’IA

Règle et algorithme classiqueN0GratuitInstantané

Python

snippets/find-duplicate-records/n0.pyTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
"""
Repérer les doublons d'un fichier : normaliser, grouper sur plusieurs clés,
puis comparer champ par champ.

Niveau N0. Déterministe, bibliothèque standard seule. Il ne compare que les
fiches qui partagent au moins une clé de blocage : toutes les paires d'un
groupe, aucune d'un groupe à l'autre.

Trois choses font que ça marche.

D'abord, la normalisation retire ce qu'un humain ignore en lisant un nom : la
casse, les accents, la ponctuation, les espaces doubles. Deux graphies qui ne
diffèrent que par là deviennent la même chaîne, et les repérer ne coûte rien.

Ensuite, le groupement. Comparer toutes les paires de dix mille fiches fait
cinquante millions de comparaisons. Les grouper d'abord par une clé qui coûte
peu, et ne comparer qu'à l'intérieur d'un groupe, laisse les paires de chaque
groupe : peu nombreuses quand les groupes sont petits, toutes quand chaque
fiche tombe dans le même groupe. À une clé qui manque un doublon, on répond
par une seconde clé, et non par la suppression de la clé : les paires de
plusieurs clés sont réunies, et le coût reste la somme de petits groupes.

Enfin, la comparaison champ par champ, chaque champ pesé. Deux sources
remplissent rarement les mêmes colonnes : l'une porte un numéro de téléphone,
l'autre non. Un champ que l'une des deux seulement porte n'est pas une
différence, et une fiche comparée comme une longue chaîne compterait ce
silence comme telle — en donnant à une ville partagée par deux millions de
personnes le même poids qu'à une adresse électronique.
"""

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.jsTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
/**
 * Repérer les doublons d'un fichier : normaliser, grouper sur plusieurs clés,
 * puis comparer champ par champ.
 *
 * Niveau N0. Déterministe, bibliothèque standard seule. Il ne compare que les
 * fiches qui partagent au moins une clé de blocage : toutes les paires d'un
 * groupe, aucune d'un groupe à l'autre.
 *
 * Trois choses font que ça marche.
 *
 * D'abord, la normalisation retire ce qu'un humain ignore en lisant un nom : la
 * casse, les accents, la ponctuation, les espaces doubles. Deux graphies qui ne
 * diffèrent que par là deviennent la même chaîne, et les repérer ne coûte rien.
 *
 * Ensuite, le groupement. Comparer toutes les paires de dix mille fiches fait
 * cinquante millions de comparaisons. Les grouper d'abord par une clé qui coûte
 * peu, et ne comparer qu'à l'intérieur d'un groupe, laisse les paires de chaque
 * groupe : peu nombreuses quand les groupes sont petits, toutes quand chaque
 * fiche tombe dans le même groupe. À une clé qui manque un doublon, on répond
 * par une seconde clé, et non par la suppression de la clé : les paires de
 * plusieurs clés sont réunies, et le coût reste la somme de petits groupes.
 *
 * Enfin, la comparaison champ par champ, chaque champ pesé. Deux sources
 * remplissent rarement les mêmes colonnes : l'une porte un numéro de téléphone,
 * l'autre non. Un champ que l'une des deux seulement porte n'est pas une
 * différence, et une fiche comparée comme une longue chaîne compterait ce
 * silence comme telle — en donnant à une ville partagée par deux millions de
 * personnes le même poids qu'à une adresse électronique.
 */

// 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]);
}

Essayer

interactif

Le code ci-dessus tourne dans votre navigateur. Écrivez dans le champ : le résultat se refait sous vos yeux.

Une fiche par ligne : nom ; code postal ; ville. Deux fiches qui ne partagent pas trois lettres de nom de famille et le même code postal ne sont jamais comparées.

à vous d’écrire

Recalculé à chaque frappe.

Ou partez d’un exemple

La saisie libre et les autres exemples ont besoin de JavaScript. L’exemple affiché ci-dessous, lui, a été calculé à la construction du site.

Ce que le code en fait

4 passages attrapés

Ce que le code a repéré

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

Ce qu’il renvoie

FicheEn doublon avecScore (seuil 0,85)
Jean Dupont, 75011Jean Dupônt, 750111,00
Marie Martin, 69003Marie Martln, 690030,94

2 comparaisons effectuées sur 10 paires possibles : les autres ne partagent pas la clé de blocage.

Ce que ça implique

  • Vos données ne sortent pas
  • Résultat toujours identique
  • Se teste unitairement, cas par cas
  • Aucun fournisseur dans la boucle
  • Empreinte négligeable

Ce que cela ajoute à votre périmètre réglementaire

  • Aucun périmètre spécifique ajouté : le fichier ne quitte pas votre infrastructure
  • Ne vous dispense pas de contrôler une fusion : l’extrait propose des paires et un score, il ne fusionne rien

Point de rupture

Une paire qu’aucune clé ne rapproche n’est jamais comparée, quel que soit le seuil. « Jean Dupont » à 75011 et le même à 75012, sans courriel ni téléphone, ne partagent aucune des trois clés : 0,96 de ressemblance sur le texte entier, et rien dans le résultat. Témoin : le même couple avec le même courriel des deux côtés est comparé, et sort à 0,863.

Quand passer au niveau suivant

Vos doublons n’ont plus aucun champ fiable en commun sur lequel écrire une clé de plus : ni nom écrit pareil, ni courriel, ni téléphone. Tant qu’il en reste un, la réponse est une clé, pas un niveau.

Les quatre options

  1. RecommandéN0Normalisation, plusieurs clés de blocage, puis comparaison champ par champGratuit · Instantané
  2. N1TF-IDF sur n-grammes de caractères, voisins par cosinusnégligeable · Instantané
  3. N2Encodeur de phrases auto-hébergé, comparaison par cosinusPeu coûteux · Rapide
  4. N3Niveau sans objet ici

Les autres options

N1TF-IDF sur n-grammes de caractères, voisins par cosinusnégligeable · Instantané

TF-IDF sur n-grammes de caractères, voisins par cosinus

Modèle classique légerN1négligeableInstantané

Python

snippets/find-duplicate-records/n1.pyTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
"""
Repérer les doublons d'un fichier en comparant l'orthographe, pas les
chaînes.

Niveau N1. La clé de blocage de N0 décide à l'avance quelles paires méritent
un regard, et tout ce qu'elle place dans deux groupes différents reste
invisible. Ce niveau abandonne la clé : chaque fiche devient un vecteur de
ses n-grammes de caractères, et les voisins se cherchent par cosinus.

Pourquoi des n-grammes de caractères plutôt que des mots : ils survivent à
une faute de frappe, à un ordre des mots inversé et à un champ tronqué,
parce qu'un mot mal orthographié partage encore l'essentiel de ses tranches
de trois lettres avec le mot correct. Rien ici n'est appris d'un corpus, et
il n'y a aucun modèle à entraîner. C'est une pondération et une distance.

Le prix, c'est la recherche elle-même : sans clé, toutes les paires du
fichier reçoivent un score, et le coût croît donc avec le carré du fichier.
La classe de latence affichée pour ce niveau est mesurée sur le fichier
nominal de ses tests ; vingt mille fiches sont une affaire de secondes, pas
de millisecondes.
"""

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.jsTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
/**
 * Repérer les doublons d'un fichier en comparant l'orthographe, pas les
 * chaînes.
 *
 * Niveau N1. La clé de blocage de N0 décide à l'avance quelles paires méritent
 * un regard, et tout ce qu'elle place dans deux groupes différents reste
 * invisible. Ce niveau abandonne la clé : chaque fiche devient un vecteur de
 * ses n-grammes de caractères, et les paires sont classées par cosinus.
 *
 * Pourquoi des n-grammes de caractères plutôt que des mots : ils survivent à
 * une faute de frappe, à un ordre des mots inversé et à un champ tronqué,
 * parce qu'un mot mal orthographié partage encore l'essentiel de ses tranches
 * de trois lettres avec le mot correct. Rien ici n'est appris d'un corpus, et
 * il n'y a aucun modèle à entraîner. C'est une pondération et une distance,
 * écrites ici sans dépendance.
 *
 * Le prix, c'est la recherche elle-même : sans clé, toutes les paires du
 * fichier reçoivent un score, et le coût croît donc avec le carré du fichier.
 * La classe de latence affichée pour ce niveau est mesurée sur le fichier
 * nominal de ses tests ; vingt mille fiches sont une affaire de secondes, pas
 * de millisecondes.
 */

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]);
}

Ce que ça implique

  • Vos données ne sortent pas
  • Résultat toujours identique
  • Se teste unitairement, cas par cas
  • Dépend d’une bibliothèque
  • Empreinte faible

Ce que cela ajoute à votre périmètre réglementaire

  • Traitement de données personnelles sur votre infrastructure
  • Aucun corpus d’entraînement : rien du fichier n’est appris ni conservé au-delà de l’exécution

Point de rupture

Les n-grammes de caractères mesurent l’orthographe, pas l’identité. « SNCF » et « Société Nationale des Chemins de Fer » sont une seule entreprise et ne partagent presque aucune tranche de lettres, tandis que « SNEF », qui en est une autre, obtient un meilleur score : baisser le seuil jusqu’à faire apparaître le vrai doublon fusionne d’abord les deux sociétés.

Quand passer au niveau suivant

Vos doublons sont deux désignations différentes de la même chose — un sigle et sa forme développée, un nom commercial et une raison sociale — et non des variantes d’une même graphie.

N2Encodeur de phrases auto-hébergé, comparaison par cosinusPeu coûteux · Rapide

Encodeur de phrases auto-hébergé, comparaison par cosinus

Petit modèle spécialisé auto-hébergéN2Peu coûteuxRapide

Python

snippets/find-duplicate-records/n2.pyTesté, service simulé — Cet extrait s’exécute à chaque construction du site, mais son test remplace le service externe par un double local : la requête envoyée, la réponse décodée et les cas d’erreur sont vérifiés, la réponse du vrai fournisseur ne l’est pas.
"""
Repérer les doublons d'un fichier avec un encodeur de phrases auto-hébergé.

Niveau N2. N1 compare des graphies ; deux fiches qui disent la même chose
avec d'autres mots ne partagent aucun n-gramme de caractères et restent
invisibles. Un encodeur de phrases envoie chaque fiche vers un vecteur dense,
la moyenne des vecteurs de ses jetons, et les fiches sont comparées sur ces
vecteurs plutôt que sur les lettres qu'elles partagent.

Deux prix, et c'est le second qui grossit. Le prix fixe est le déploiement :
des poids à télécharger et à charger, et un processus à garder chaud.
L'autre est la comparaison, et il est quadratique — toutes les paires du
fichier reçoivent un score, mille fiches font un demi-million de produits
scalaires de trois cent quatre-vingt-quatre nombres, dix mille en font
cinquante millions. Au-delà de quelques dizaines de milliers de fiches, il
faut bloquer d'abord avec la clé du niveau N0, ou passer à un index
approché ; cet extrait ne fait ni l'un ni l'autre, à dessein, parce qu'il
montre la distance et non la recherche.

L'encodeur est un paramètre doté d'une vraie valeur par défaut : le lecteur
voit le code de chargement, et le test injecte un double local.
"""

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.jsTesté, service simulé — Cet extrait s’exécute à chaque construction du site, mais son test remplace le service externe par un double local : la requête envoyée, la réponse décodée et les cas d’erreur sont vérifiés, la réponse du vrai fournisseur ne l’est pas.
/**
 * Repérer les doublons d'un fichier avec un encodeur de phrases auto-hébergé.
 *
 * Niveau N2. N1 compare des graphies ; deux fiches qui disent la même chose
 * avec d'autres mots ne partagent aucun n-gramme de caractères et restent
 * invisibles. Un encodeur de phrases envoie chaque fiche vers un vecteur dense,
 * la moyenne des vecteurs de ses jetons, et les fiches sont comparées sur ces
 * vecteurs plutôt que sur les lettres qu'elles partagent.
 *
 * Deux prix, et c'est le second qui grossit. Le prix fixe est le déploiement :
 * des poids à télécharger et à charger, et un processus à garder chaud.
 * L'autre est la comparaison, et il est quadratique — toutes les paires du
 * fichier reçoivent un score, mille fiches font un demi-million de produits
 * scalaires de trois cent quatre-vingt-quatre nombres, dix mille en font
 * cinquante millions. Au-delà de quelques dizaines de milliers de fiches, il
 * faut bloquer d'abord avec la clé du niveau N0, ou passer à un index
 * approché ; cet extrait ne fait ni l'un ni l'autre, à dessein, parce qu'il
 * montre la distance et non la recherche.
 *
 * L'encodeur est un paramètre doté d'une vraie valeur par défaut : le lecteur
 * voit le code de chargement, et le test injecte un double local.
 */

// 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]);
}

Ce que ça implique

  • Vos données restent chez vous
  • Résultat toujours identique
  • Se mesure sur un échantillon, pas cas par cas
  • Dépend d’une bibliothèque
  • Empreinte élevée

Ce que cela ajoute à votre périmètre réglementaire

  • Traitement de données personnelles sur votre infrastructure : le fichier ne part pas chez un tiers
  • Les poids du modèle viennent d’un tiers, avec la licence et la provenance que cela suppose de vérifier

Point de rupture

Une fiche est encodée en entier : le vecteur est la moyenne de ceux de tous ses jetons, et le jeton qui distingue deux lignes n’y compte que pour sa part. Si « Câble HDMI 2 m » tombe plus près de « Câble HDMI 3 m » que de la même référence écrite « HDMI lead, 2 metres, black », comme avec les vecteurs du test, l’extrait rapproche au seuil par défaut les deux câbles différents et rate le vrai doublon. Ces vecteurs sont ceux d’un double local : ce que le vrai modèle rend sur ces lignes n’est pas mesuré ici.

Quand passer au niveau suivant

Aucun niveau ne vient au-dessus dans cette fiche : au-delà, ce n’est plus un choix d’algorithme mais une relecture humaine des paires que le score laisse en zone grise.

N3Niveau sans objet ici

Niveau sans objet ici

API de LLM généralisteN3

Comparer les paires par appel à un modèle généraliste est quadratique : sur dix mille fiches, cela fait cinquante millions d’appels. Et l’appel ne répond qu’à la question facile, celle de juger une paire qu’on lui a déjà désignée : il faut de toute façon un niveau en dessous pour choisir laquelle.

Pour aller plus loin

Source des chiffres

Métadonnées