Rapprocher des noms d’entreprises mal orthographiés
Reconnaître qu’une même société apparaît sous plusieurs graphies dans deux fichiers d’origines différentes.
Pas besoin d’IA
GratuitInstantanéVos données ne sortent pas
Le verdict
Avant tout : s’il existe un identifiant des deux côtés — SIREN, SIRET, numéro de TVA —, il n’y a pas de rapprochement flou à faire, et ce qui suit ne vous concerne pas. N0 ne coûte rien à exécuter, ne dépend de rien, et chacun de ses scores se vérifie par un test unitaire — ce qui compte quand un faux rapprochement fusionne les dossiers de deux sociétés. Un score au-dessus du seuil est une paire à relire, jamais une fusion : sur des noms français, deux pharmacies d’une même ville passent au-dessus de la vraie paire du test. N1 achète l’ordre des mots, la pondération des fragments rares et la recherche dans un registre entier ; son propre test dit ce qu’il n’achète pas, à savoir la paire sigle contre raison sociale. Celle-là ne se gagne pas par un niveau de plus mais par une table de sigles, que le répertoire Sirene donne pour les sociétés françaises. Montez à N1 quand vous cherchez un nom dans un registre au lieu de juger une paire, pas en espérant avoir plus souvent raison.
Normalisation, retrait de la forme juridique, puis Jaro-Winkler
Pas besoin d’IA
Règle et algorithme classiqueN0GratuitInstantané
Python
"""
Rapprocher deux noms d'entreprises : normaliser, retirer la forme juridique,
puis Jaro-Winkler.
Niveau N0. Déterministe, bibliothèque standard seule. Deux décisions portent
tout le résultat.
D'abord, la forme juridique est retirée plutôt que comparée.
« Boulangerie Martin SARL » et « Boulangerie Martin SAS » sont un seul nom
commercial sous deux statuts ; laisser SARL et SAS dans les chaînes les
éloignerait pour une raison dont personne n'a à se soucier.
Ensuite, Jaro-Winkler plutôt qu'une distance d'édition ordinaire. Il
récompense un début commun, ce qui convient à des noms qui diffèrent par la
fin : une forme, une ville, un reste de ponctuation après la même marque.
"""
import re
import unicodedata
# Legal forms, French and foreign. This is business knowledge, not example
# data. No "spa": it is also a word of trading names, and dropping it would
# merge "Nordic Spa" with "Nordic SA".
LEGAL_FORMS = frozenset(("sarl sas sasu sa eurl sci snc scop selarl gie ltd limited "
"plc gmbh ag inc llc corp bv nv srl").split())
# The forms a French filing also writes in front of the name. "sa" is not among
# them: it is an article in Catalan and Corsican trading names, and dropping a
# leading one would merge "Sa Nostra" with "Nostra". Nor is "nv", for the same
# reason in "NV Energy".
LEADING_FORMS = frozenset("sarl sas sasu eurl sci snc scop selarl gie".split())
# What a company register writes short. Expanded before anything is compared,
# because "Ets Martin" and "Établissements Martin" are one company and Jaro
# scores them 0.68.
ABBREVIATIONS = {
"ets": "etablissements", "etab": "etablissements", "etabs": "etablissements",
"ste": "societe", "stes": "societes", "cie": "compagnie",
}
# Winkler looks at the first four characters only, and gives back a tenth of
# the score Jaro withheld for each of them that both names share.
PREFIX_LENGTH, PREFIX_SCALING = 4, 0.1
# Latin diacritics only. Dropping every combining mark would take the vowels of
# Devanagari and Thai with it, and "कमल उद्योग" and "कोमल उद्योग" would score 1.0.
_LATIN_MARKS = re.compile(r"[\u0300-\u036f]")
def _words(text: str) -> list[str]:
r"""
Runs of letters, digits and the marks that belong to them.
The marks have to be kept, which a plain `\w` would not do: a Devanagari
vowel sign is a combining mark, and dropping it would empty half the name.
"""
found, current = [], []
for char in text:
if char.isalnum() or unicodedata.category(char).startswith("M"):
current.append(char)
elif current:
found.append("".join(current))
current = []
return found + ["".join(current)] if current else found
def normalise(name: str) -> str:
"""Lowercase, expand the abbreviations, strip accents and punctuation, drop the legal form."""
folded = _LATIN_MARKS.sub("", unicodedata.normalize("NFKD", name.lower()))
# "&" is the same word as "et" in a company name, and punctuation is about
# to be dropped, which would make the two spellings differ by one word.
plain = folded.replace("&", " et ")
# Letters and digits of every script: a Cyrillic or Japanese name is kept,
# not emptied into a string that would match every other emptied name.
words = [ABBREVIATIONS.get(w, w) for w in _words(plain)]
kept = list(words)
if len(kept) > 1 and kept[0] in LEADING_FORMS:
kept = kept[1:]
while len(kept) > 1 and kept[-1] in LEGAL_FORMS:
kept = kept[:-1]
# A name made of nothing but a legal form keeps it. Emptying it would make
# it match every other emptied name perfectly, which is worse than useless.
return " ".join(kept or words)
def _jaro(a: str, b: str) -> float:
"""
Share of characters found on both sides, discounted by their disorder.
A character counts as found only if its twin sits less than half the length
of the longer name away: at most that half, minus one. That window is what separates Jaro from a plain count
of common letters.
"""
if a == b:
return 1.0
if not a or not b:
return 0.0
window = max(max(len(a), len(b)) // 2 - 1, 0)
hit_a, hit_b = [False] * len(a), [False] * len(b)
for i, char in enumerate(a):
for j in range(max(0, i - window), min(len(b), i + window + 1)):
if not hit_b[j] and b[j] == char:
hit_a[i] = hit_b[j] = True
break
matched_a = [c for c, hit in zip(a, hit_a) if hit]
matched_b = [c for c, hit in zip(b, hit_b) if hit]
if not matched_a:
return 0.0
m = len(matched_a)
swaps = sum(x != y for x, y in zip(matched_a, matched_b)) // 2
return (m / len(a) + m / len(b) + (m - swaps) / m) / 3
def jaro_winkler(a: str, b: str) -> float:
"""Jaro, raised towards 1 in proportion to the shared opening."""
score = _jaro(a, b)
prefix = 0
for x, y in zip(a[:PREFIX_LENGTH], b[:PREFIX_LENGTH]):
if x != y:
break
prefix += 1
return score + prefix * PREFIX_SCALING * (1 - score)
def similarity(left: str, right: str) -> float:
"""Similarity of two company names, from 0 to 1."""
return jaro_winkler(normalise(left), normalise(right))JavaScript
/**
* Rapprocher deux noms d'entreprises : normaliser, retirer la forme juridique,
* puis Jaro-Winkler.
*
* Niveau N0. Déterministe, sans dépendance. Deux décisions portent tout le
* résultat.
*
* D'abord, la forme juridique est retirée plutôt que comparée.
* « Boulangerie Martin SARL » et « Boulangerie Martin SAS » sont un seul nom
* commercial sous deux statuts ; laisser SARL et SAS dans les chaînes les
* éloignerait pour une raison dont personne n'a à se soucier.
*
* Ensuite, Jaro-Winkler plutôt qu'une distance d'édition ordinaire. Il
* récompense un début commun, ce qui convient à des noms qui diffèrent par la
* fin : une forme, une ville, un reste de ponctuation après la même marque.
*
* Écrit en entier plutôt que tiré d'un paquet : l'algorithme tient en trente
* lignes, et c'est l'argument de cette fiche.
*/
// Legal forms, French and foreign. This is business knowledge, not example
// data. No "spa": it is also a word of trading names, and dropping it would
// merge "Nordic Spa" with "Nordic SA".
const LEGAL_FORMS = new Set(
'sarl sas sasu sa eurl sci snc scop selarl gie ltd limited plc gmbh ag inc llc corp bv nv srl'.split(' '),
);
// The forms a French filing also writes in front of the name. "sa" is not among
// them: it is an article in Catalan and Corsican trading names, and dropping a
// leading one would merge "Sa Nostra" with "Nostra". Nor is "nv", for the same
// reason in "NV Energy".
const LEADING_FORMS = new Set('sarl sas sasu eurl sci snc scop selarl gie'.split(' '));
// What a company register writes short. Expanded before anything is compared,
// because "Ets Martin" and "Établissements Martin" are one company and Jaro
// scores them 0.68.
const ABBREVIATIONS = {
ets: 'etablissements', etab: 'etablissements', etabs: 'etablissements',
ste: 'societe', stes: 'societes', cie: 'compagnie',
};
// Winkler looks at the first four characters only, and gives back a tenth of
// the score Jaro withheld for each of them that both names share.
const PREFIX_LENGTH = 4;
const PREFIX_SCALING = 0.1;
/** Lowercase, expand the abbreviations, strip accents and punctuation, drop the legal form. */
export function normalise(name) {
// Latin diacritics only. Dropping every combining mark would take the vowels
// of Devanagari and Thai with it, and "कमल उद्योग" and "कोमल उद्योग" would score 1.0.
const folded = name.toLowerCase().normalize('NFKD').replace(/[\u0300-\u036f]/gu, '');
// "&" is the same word as "et" in a company name, and punctuation is about to
// be dropped, which would make the two spellings differ by one word.
const plain = folded.replaceAll('&', ' et ');
// Letters, digits and the marks that belong to them, in every script: a
// Cyrillic, Japanese or Devanagari name is kept, not emptied into a string
// that would match every other emptied name.
const words = (plain.match(/[\p{L}\p{N}\p{M}]+/gu) ?? []).map((w) => ABBREVIATIONS[w] ?? w);
let kept = [...words];
if (kept.length > 1 && LEADING_FORMS.has(kept[0])) kept = kept.slice(1);
while (kept.length > 1 && LEGAL_FORMS.has(kept.at(-1))) kept = kept.slice(0, -1);
// A name made of nothing but a legal form keeps it. Emptying it would make
// it match every other emptied name perfectly, which is worse than useless.
return (kept.length ? kept : words).join(' ');
}
/**
* Share of characters found on both sides, discounted by their disorder.
*
* A character counts as found only if its twin sits less than half the length
* of the longer name away: at most that half, minus one. That window is what separates Jaro from a plain count of
* common letters.
*/
function jaro(a, b) {
if (!a.length || !b.length) return 0;
const window = Math.max(Math.floor(Math.max(a.length, b.length) / 2) - 1, 0);
const hitA = new Array(a.length).fill(false);
const hitB = new Array(b.length).fill(false);
for (let i = 0; i < a.length; i += 1) {
for (let j = Math.max(0, i - window); j < Math.min(b.length, i + window + 1); j += 1) {
if (!hitB[j] && b[j] === a[i]) {
hitA[i] = true;
hitB[j] = true;
break;
}
}
}
const matchedA = a.filter((_, i) => hitA[i]);
const matchedB = b.filter((_, j) => hitB[j]);
const m = matchedA.length;
if (!m) return 0;
const swaps = Math.floor(matchedA.filter((c, i) => c !== matchedB[i]).length / 2);
return (m / a.length + m / b.length + (m - swaps) / m) / 3;
}
/** Jaro, raised towards 1 in proportion to the shared opening. */
export function jaroWinkler(left, right) {
// Code points, as Python counts them, not UTF-16 units.
const [a, b] = [[...left], [...right]];
const score = left === right ? 1 : jaro(a, b);
let prefix = 0;
while (prefix < PREFIX_LENGTH && prefix < a.length && prefix < b.length && a[prefix] === b[prefix]) {
prefix += 1;
}
return score + prefix * PREFIX_SCALING * (1 - score);
}
/** Similarity of two company names, from 0 to 1. */
export function similarity(left, right) {
return jaroWinkler(normalise(left), normalise(right));
}Essayer
interactifLe code ci-dessus tourne dans votre navigateur. Écrivez dans le champ : le résultat se refait sous vos yeux.
Première ligne : le nom de référence. Lignes suivantes : les candidats. Un score au-dessus du seuil est une paire à revoir, jamais une décision prise.
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 qu’il renvoie
| Nom comparé | Après normalisation | Score (seuil 0,85) | Décision |
|---|---|---|---|
| BOULANGERIE MARTIN | boulangerie martin | 1,00 | à revoir |
| Boulangerie Martin SAS | boulangerie martin | 1,00 | à revoir |
Comparé à « Boulangerie Martin SARL », normalisé en « boulangerie martin ».
Là, ça casseVoir le niveau au-dessus
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é : les noms ne quittent pas votre infrastructure
- Une raison sociale d’entreprise individuelle porte le nom d’une personne physique
Point de rupture
Deux sociétés d’un même métier, dans une même ville. Un nom français commence par son métier et se distingue à la fin, quand Jaro-Winkler récompense le début commun : « Pharmacie de la Gare » et « Pharmacie de la Mairie » obtiennent 0,963, au-dessus de la vraie paire « Dubois & Fils SARL » contre « Dubois et Fils » à 0,957. Témoin : « Boulangerie Martin » contre « Boulangerie Dupont » descend à 0,897, mais aucun seuil ne sépare proprement les deux familles. Un sigle contre sa raison sociale, lui, n’est jamais rapproché : « SNCF » contre « Société Nationale des Chemins de fer Français » passe même sous « SNCF » contre « Sanofi ».
Quand passer au niveau suivant
Vous ne comparez plus deux noms mais un nom contre un registre entier, ou vos deux sources n’écrivent pas les mots dans le même ordre : « Menuiserie Dubois » ici, « Dubois Menuiserie » là.
Les quatre options
- RecommandéN0Normalisation, retrait de la forme juridique, puis Jaro-WinklerGratuit · Instantané
- N1TF-IDF sur n-grammes de caractères, voisins par cosinusnégligeable · Instantané
- N2Niveau sans objet ici
- 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
"""
Rapprocher des noms d'entreprises sur des n-grammes de caractères pondérés
par TF-IDF.
Niveau N1. N0 compare deux chaînes. Celui-ci compare un nom à un registre
entier, et il le fait en regardant les fragments dont un nom est fait.
Deux changements comptent.
Un fragment rare pèse désormais plus qu'un fragment courant.
« boulangerie » figure dans la moitié du registre et n'apprend presque rien ;
« quiquengrogne » y figure une fois et tranche la question. TF-IDF est
exactement cette arithmétique, et N0 n'en a pas d'équivalent : Jaro-Winkler
traite tous les caractères de la même façon.
Et comme un nom devient un vecteur, la recherche est un produit de matrices
plutôt qu'une boucle sur toutes les paires possibles : c'est ce qui rend un
registre entier consultable.
`char_wb` garde les n-grammes à l'intérieur des frontières de mots, si bien
qu'un fragment n'enjambe jamais deux mots. « Martin Dubois » et
« Dubois Martin » se rencontrent donc, parce que l'ordre des mots ne coûte
rien ici — au contraire de N0, où il coûte presque tout.
"""
import numpy as np
from sklearn.feature_extraction.text import TfidfVectorizer
# Two to four characters: long enough to be a syllable, short enough to
# survive a typo somewhere else in the word.
NGRAM_RANGE = (2, 4)
def build_index(names: list[str]) -> dict:
"""Fit the vocabulary on the register, once, and vectorise it."""
vectoriser = TfidfVectorizer(analyzer="char_wb", ngram_range=NGRAM_RANGE)
return {"names": list(names), "vectoriser": vectoriser,
"matrix": vectoriser.fit_transform(names)}
def match(index: dict, query: str, top_k: int = 3) -> list[tuple[str, float]]:
"""
The nearest names in the register, best first, with their cosine score.
TfidfVectorizer returns rows of length one, so the cosine similarity is
just the dot product. No normalisation to write, and none to get wrong.
"""
vector = index["vectoriser"].transform([query])
scores = (index["matrix"] @ vector.T).toarray().ravel()
# A stable sort, so two names with the same score always come back in
# register order. A matching run has to be replayable.
order = np.argsort(-scores, kind="stable")[:top_k]
return [(index["names"][i], float(scores[i])) for i in order]JavaScript
/**
* Rapprocher des noms d'entreprises sur des n-grammes de caractères pondérés
* par TF-IDF.
*
* Niveau N1. N0 compare deux chaînes. Celui-ci compare un nom à un registre
* entier, et il le fait en regardant les fragments dont un nom est fait.
*
* Un fragment rare pèse désormais plus qu'un fragment courant.
* « boulangerie » figure dans la moitié du registre et n'apprend presque rien ;
* « quiquengrogne » y figure une fois et tranche la question. TF-IDF est
* exactement cette arithmétique, et N0 n'en a pas d'équivalent : Jaro-Winkler
* traite tous les caractères de la même façon.
*
* Les n-grammes sont pris à l'intérieur des frontières de mots, si bien qu'un
* fragment n'enjambe jamais deux mots. « Martin Dubois » et « Dubois Martin »
* se rencontrent donc, parce que l'ordre des mots ne coûte rien ici — au
* contraire de N0, où il coûte presque tout.
*
* C'est la même arithmétique que celle de scikit-learn, écrite en entier :
* comptes bruts, fréquence inverse de document lissée des deux côtés, puis
* lignes ramenées à la longueur un pour qu'un cosinus soit un produit
* scalaire.
*/
// Two to four characters: long enough to be a syllable, short enough to
// survive a typo somewhere else in the word.
const MIN_N = 2;
const MAX_N = 4;
/** Every n-gram of every word, each word padded with a space at both ends. */
function ngrams(text) {
const out = [];
for (const word of text.toLowerCase().split(/\s+/).filter(Boolean)) {
// Code points, as scikit-learn counts them: an emoji is one character,
// not two UTF-16 units.
const padded = [...` ${word} `];
for (let n = MIN_N; n <= MAX_N; n += 1) {
// A word shorter than the window is counted once, whole, and no wider
// window can tell you anything more about it.
if (padded.length <= n) {
out.push(padded.join(''));
break;
}
for (let i = 0; i + n <= padded.length; i += 1) out.push(padded.slice(i, i + n).join(''));
}
}
return out;
}
/** Counts times idf, brought to length one. Unknown n-grams are dropped. */
function weigh(grams, idf) {
const counts = new Map();
for (const g of grams) if (idf.has(g)) counts.set(g, (counts.get(g) ?? 0) + 1);
const vector = new Map();
let sum = 0;
// Alphabetical order, so a name and the same name with its words swapped
// add their weights in the same order and land on the very same float.
for (const g of [...counts.keys()].sort()) {
const tf = counts.get(g);
const w = tf * idf.get(g);
vector.set(g, w);
sum += w * w;
}
const norm = Math.sqrt(sum);
if (norm) for (const [g, w] of vector) vector.set(g, w / norm);
return vector;
}
/** Fit the vocabulary on the register, once, and vectorise it. */
export function buildIndex(names) {
const grams = names.map(ngrams);
const df = new Map();
for (const g of grams) for (const gram of new Set(g)) df.set(gram, (df.get(gram) ?? 0) + 1);
const idf = new Map();
for (const [gram, d] of df) idf.set(gram, Math.log((1 + names.length) / (1 + d)) + 1);
return { names: [...names], idf, vectors: grams.map((g) => weigh(g, idf)) };
}
/** The nearest names in the register, best first, with their cosine score. */
export function match(index, query, topK = 3) {
const vector = weigh(ngrams(query), index.idf);
const scored = index.names.map((name, i) => {
let dot = 0;
for (const [gram, w] of vector) dot += w * (index.vectors[i].get(gram) ?? 0);
return [name, dot];
});
// A stable sort, so two names with the same score always come back in
// register order. A matching run has to be replayable.
return scored.sort((a, b) => b[1] - a[1]).slice(0, topK);
}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 sur votre infrastructure : le registre ne part pas chez un tiers
- Aucun corpus extérieur : le vocabulaire est ajusté sur votre seul registre, et rien n’en est conservé au-delà de l’index
Point de rupture
Le même sigle, toujours manqué. Pondérer les fragments rares répare l’ordre des mots et classe la bonne boulangerie devant l’autre, mais ne fait toujours voir que des fragments : dans le test, la raison sociale développée de la SNCF n’atteint même pas les trois premiers résultats de sa propre abréviation, doublée par deux entreprises sans rapport.
Quand passer au niveau suivant
Vos graphies à rapprocher sont deux désignations distinctes d’une même société — un sigle et sa forme développée, un nom commercial et une raison sociale — et non deux orthographes d’un même nom. La réponse n’est alors pas un niveau de plus, c’est une table de sigles : pour des sociétés françaises, le répertoire Sirene la donne, et gratuitement.
N2Niveau sans objet ici
Niveau sans objet ici
Petit modèle spécialisé auto-hébergéN2
La seule raison d’être de ce niveau serait le sigle, et rien ici ne démontre qu’un encodeur de phrases généraliste rapproche « SNCF » de sa raison sociale : un sigle peu connu n’a pas de sens appris. Mesurer cela demanderait de télécharger le modèle, ce que la vérification de ce site ne fait pas. Et la paire sigle contre raison sociale se résout de façon déterministe, par une table : le répertoire Sirene porte le sigle de chaque unité légale, à côté de sa dénomination.
N3Niveau sans objet ici
Niveau sans objet ici
API de LLM généralisteN3
Le rapprochement est quadratique : sur dix mille noms, cela fait cinquante millions de paires, donc cinquante millions d’appels. Un niveau du dessous doit de toute façon présélectionner les paires. S’y ajoute qu’un rapprochement doit pouvoir être rejoué : les extraits de cette fiche qui parcourent un registre fixent leur tri pour que deux exécutions rendent les mêmes paires, alors que le fournisseur pris en exemple ne garantit pas le déterminisme, même à graine fixée.
Pour aller plus loin
- Wikipedia — Jaro-Winkler distance, la définition et le poids du préfixe commun
- Base Sirene des entreprises et de leurs établissements — le répertoire ouvert, qui porte le sigle de chaque unité légale
- Insee — catégories juridiques, pour bâtir la liste des formes à retirer
- scikit-learn — TfidfVectorizer, analyseur char_wb et n-grammes de caractères
- Sentence-Transformers — les encodeurs de phrases auto-hébergés
Source des chiffres
- Base Sirene, description du fichier StockUniteLegale (Insee, 29 janvier 2026, lue) : la variable « sigleUniteLegale » porte le sigle de l’unité légale, à côté de sa dénomination ; jeu de données publié sous Licence Ouverte 2.0
- OpenAI Cookbook — le paramètre seed : « Determinism is not guaranteed »
Métadonnées
Information générale, tenue à jour au mieux. Ce n’est pas un avis juridique. Faites valider votre situation par un professionnel.