Match misspelled company names
Recognise that the same company appears under several spellings across two files of different origins.
No AI needed
FreeInstantYour data never leaves
The verdict
First of all: if an identifier exists on both sides — SIREN, SIRET, VAT number — there is no fuzzy matching to do, and none of what follows concerns you. N0 costs nothing to run, depends on nothing, and every score it gives can be checked by a unit test — which matters when a wrong match merges two companies' files. A score above the threshold is a pair to read, never a merge: on French names, two chemists in one town score above the real pair in the test. N1 buys word order, rare-fragment weighting and search across a whole register; its own test says what it does not buy, namely the acronym against the spelled-out name. That one is not won by one more level but by a table of acronyms, which the Sirene register gives you for French companies. Move up to N1 when you are looking a name up in a register instead of judging a pair, not in the hope of being right more often.
Normalisation, legal form dropped, then Jaro-Winkler
No AI needed
Rule and classic algorithmN0FreeInstant
Python
"""
Match two company names: normalise, drop the legal form, then Jaro-Winkler.
Rung N0. Deterministic, standard library only. Two decisions carry the whole
result.
First, the legal form is removed rather than compared, and only where a legal
form is written: at the end of the name, or at the front for the forms French
filings put there. "Boulangerie Martin SARL" and "Boulangerie Martin SAS" are
one trading name under two statuses; leaving SARL and SAS inside the strings
would push them apart for a reason nobody cares about.
Second, the abbreviations a company register is full of are expanded before
anything is compared: "Ets Martin" and "Établissements Martin" are the same
company, and "&" and "et" are the same word.
Third, Jaro-Winkler rather than a plain edit distance. It rewards a shared
opening — which is a problem here, not a feature: a French company name opens
on its trade and differs at the tail, so two chemists of one town score higher
than a real pair. That is the breaking point of this rung, and the reason a
score above the threshold is a pair to read, never a merge.
"""
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
/**
* Match two company names: normalise, drop the legal form, then Jaro-Winkler.
*
* Rung N0. Deterministic, no dependency. Two decisions carry the whole
* result.
*
* First, the legal form is removed rather than compared, and only where a legal
* form is written: at the end of the name, or at the front for the forms French
* filings put there. "Boulangerie Martin SARL" and "Boulangerie Martin SAS" are
* one trading name under two statuses; leaving SARL and SAS inside the strings
* would push them apart for a reason nobody cares about.
*
* Second, the abbreviations a company register is full of are expanded before
* anything is compared: "Ets Martin" and "Établissements Martin" are the same
* company, and "&" and "et" are the same word.
*
* Third, Jaro-Winkler rather than a plain edit distance. It rewards a shared
* opening — which is a problem here, not a feature: a French company name opens
* on its trade and differs at the tail, so two chemists of one town score higher
* than a real pair. That is the breaking point of this rung, and the reason a
* score above the threshold is a pair to read, never a merge.
*
* Written out in full rather than pulled from a package: the algorithm is
* thirty lines, and that is the argument of this entry.
*/
// 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));
}Try it
interactiveThe code above runs in your browser. Type in the box: the result is redone in front of you.
First line: the reference name. Following lines: the candidates. A score above the cut is a pair to review, never a decision already made.
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 it returns
| Name compared | After normalisation | Score (cut at 0.85) | Decision |
|---|---|---|---|
| BOULANGERIE MARTIN | boulangerie martin | 1.00 | to review |
| Boulangerie Martin SAS | boulangerie martin | 1.00 | to review |
Compared with “Boulangerie Martin SARL”, normalised to “boulangerie martin”.
Here it breaksSee the level above
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 names never leave your infrastructure
- A sole trader’s business name carries a natural person’s name
Breaking point
Two companies in one trade, in one town. A French company name opens on its trade and differs at the tail, where Jaro-Winkler rewards the shared opening: « Pharmacie de la Gare » and « Pharmacie de la Mairie » score 0.963, above the real pair « Dubois & Fils SARL » against « Dubois et Fils » at 0.957. Witness: « Boulangerie Martin » against « Boulangerie Dupont » falls to 0.897 — but no threshold cleanly separates the two families. An acronym against its spelled-out name, meanwhile, is never brought together: SNCF against « Société Nationale des Chemins de fer Français » scores below SNCF against Sanofi.
When to move up a level
You are no longer comparing two names but one name against a whole register, or your two sources do not write the words in the same order: Menuiserie Dubois here, Dubois Menuiserie there.
The four options
- RecommendedN0Normalisation, legal form dropped, then Jaro-WinklerFree · Instant
- N1Character n-gram TF-IDF, neighbours by cosinenégligeable · Instant
- N2Not worth it here
- 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
"""
Match company names on character n-grams weighted by TF-IDF.
Rung N1. N0 compares two strings. This compares one name against a whole
register, and it does so by looking at the fragments a name is made of.
Two changes matter.
A rare fragment now weighs more than a common one. "boulangerie" appears in
half the register and tells you almost nothing; "quiquengrogne" appears once
and settles the question. TF-IDF is exactly that arithmetic, and N0 has no
equivalent: Jaro-Winkler treats every character alike.
And because a name becomes a vector, the search is a matrix product rather
than a loop over every possible pair, which is what makes a whole register
searchable at all.
`char_wb` keeps n-grams inside word boundaries, so a fragment never straddles
two words. "Martin Dubois" and "Dubois Martin" still meet, because word
order costs nothing here — unlike N0, where it costs almost everything.
"""
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
/**
* Match company names on character n-grams weighted by TF-IDF.
*
* Rung N1. N0 compares two strings. This compares one name against a whole
* register, and it does so by looking at the fragments a name is made of.
*
* A rare fragment now weighs more than a common one. "boulangerie" appears
* in half the register and tells you almost nothing; "quiquengrogne"
* appears once and settles the question. TF-IDF is exactly that arithmetic,
* and N0 has no equivalent: Jaro-Winkler treats every character alike.
*
* The n-grams are taken inside word boundaries, so a fragment never straddles
* two words. "Martin Dubois" and "Dubois Martin" still meet, because word
* order costs nothing here — unlike N0, where it costs almost everything.
*
* This is the same arithmetic scikit-learn performs, written out: raw counts,
* an inverse document frequency smoothed on both sides, then rows brought to
* length one so a cosine is a dot product.
*/
// 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);
}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 on your own infrastructure: the register goes to no third party
- No outside corpus: the vocabulary is fitted on your register alone, and nothing of it is kept beyond the index
Breaking point
The same acronym, still missed. Weighting rare fragments repairs word order and ranks the right bakery above the other, but it still sees fragments and nothing else: in the test, the spelled-out name of the SNCF does not even reach the top three results for its own abbreviation, beaten by two unrelated companies.
When to move up a level
The spellings you need to bring together are two distinct designations of one company — an acronym and its expansion, a trading name and a legal name — rather than two spellings of one name. The answer is then not one more level, it is a table of acronyms: for French companies, the Sirene register gives you one, free of charge.
N2Not worth it here
Not worth it here
Small self-hosted specialised modelN2
The only reason this level would exist is the acronym, and nothing here shows that a general-purpose sentence encoder brings SNCF close to its spelled-out name: a little-known acronym has no learnt meaning. Measuring that would mean downloading the model, which this site’s verification does not do. And the acronym against the spelled-out name is settled deterministically, by a table: the Sirene register carries the acronym of every legal unit, next to its name.
N3Not worth it here
Not worth it here
General-purpose LLM APIN3
Matching is quadratic: ten thousand names make fifty million pairs, so fifty million calls. A lower level has to shortlist the pairs anyway. Replayability settles it: the snippets in this entry that walk a register pin their sort so that two runs return the same pairs, whereas the provider taken as an example does not guarantee determinism, even with a fixed seed.
Further reading
- 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
Where the figures come from
- 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 »
Metadata
General information, kept up to date as best we can. It is not legal advice. Have a professional review your situation.