Image source
06 Jul 2026 - The Dark Side

Pragmatic Edition Distance

I deal with multilingual environments, for which I am pretty fond of phonetic indexing, it is a nice way to go from what you think you have heard to the actual name of a thing. However sometimes the collisions are retrieving more data than you would like.

After matching (getting items that are relevant for a query), you need a form of scoring where you put the best ones first. Common use cases are search results or autocomplete suggestions. Scoring processes less entries so it can afford to be slower than matching.

Small note here: this algorithm has a high complexity, it will scale poorly with big chunks of text. On top of it some caching and optimizations are possible.

To frame the problem:

# All the code here is in PHP

$input = 'rue Evy';

$rawMatches = [  # Addresses
    '4 Rue de Houffalize, 1737 Luxembourg',
    '9 Rue Jean l\'Aveugle, 1148 Luxembourg',
    '310 rue Evy Friedrich, 1552 Luxembourg',
    '10 Rue Edouard Oster, 2272 Howald',
];

Edition distances

PHP comes with a pretty standard and configurable edition distance function: levenshtein. This function accepts custom costs for adding, replacing or removing a character. In this specific case, we rather mostly trust the user input: if something needs to be removed it is rather a bad sign and needs to be penalized.

function getEditionDistance(string $source, string $target): int {
    # 2 cost on replace and 3 cost on deletion to penalize changing the input
    return levenshtein($source, $target, 1, 1, 2);
}

$distancesInfo = array_map(
    fn($m) => $m . ': ' . getEditionDistance($input, $m),
    $rawMatches
);

[
  '4 Rue de Houffalize, 1737 Luxembourg: 33',
  '9 Rue Jean l\'Aveugle, 1148 Luxembourg: 33',
  '310 rue Evy Friedrich, 1552 Luxembourg: 31', # Lowest cost ⬇️
  '10 Rue Edouard Oster, 2272 Howald: 29',
]

The third entry which seems the obvious match does not have the lowest score.

Normalizing string length

One of the reason for the above behavior, is that the edition distance penalizes adding characters, so a shorter target string gets a boost that can be disproportionate. The obvious solution is to normalize with the string length: a simple way to give more “proportion”.

function getEditionDistance(string $source, string $target): float {
    $distance = levenshtein($source, $target, 1, 1, 2);
    $normalizationFactor = strlen($target) + 1; // no 0 division
    return $distance / $normalizationFactor;
}

$distancesInfo = array_map(
    fn($m) => $m . ': ' . round(getEditionDistance($input, $m), 4),
    $rawMatches
);

Now we have something better:

[
  '4 Rue de Houffalize, 1737 Luxembourg: 0.8919',
  '9 Rue Jean l\'Aveugle, 1148 Luxembourg: 0.8684',
  '310 rue Evy Friedrich, 1552 Luxembourg: 0.7949', # lowest cost 👌
  '10 Rue Edouard Oster, 2272 Howald: 0.8529',
]

Normalizing for exoticism

Let’s change our problem a bit.

$input = '4 rue Evy'; // Note the number

$rawMatches = [
    '4 avenue d\'evý, 1737 Luxembourg',
    '310 rue Evy Friedrich, 1552 Luxembourg',
];

A bunch of topics here:

  • Casing
  • Diacritics
  • Common names, I will expand later on “rue” and “avenue”
  • Numbers

English has strong points as a lingua franca (simple grammar mostly), it also has some weakness as the de-facto standard for software: it is so simple that it leads us to ignore a lot of edge cases. From a Latin European point of view the most obvious ones are diacritics (fancy name for accents) that makes a lot of letters look similar to the reader but being written with a different character it simply two different symbols for the computer ; or often more

Fortunately PHP has a Transliterator to handle this. I have no deep understanding of the $rules but someone on the internet said it works.

function removeDiacritics(string $input): string {
    $input = strtolower($input); # case removal
    
    $rules = ':: Any-Latin; :: Latin-ASCII; :: NFD; :: ' .
                '[:Nonspacing Mark:] Remove;';

    # you can also strtolower post facto
    $rules .= ' :: Lower();';
    
    $rules .= ' :: NFC;';
    $transliterator = \Transliterator::createFromRules(
        $rules, \Transliterator::FORWARD);
  
    return $transliterator->transliterate($input);
}

removeDiacritics($rawMatches[0]);
# From
'4 avenue d\'evý, 1737 Luxembourg'

# To
'4 avenue d\'evy, 1737 luxembourg'

Another one might be the German “ß”, that could be substituted with “ss” (not handled by the code above).

Normalizing for bloat

Then comes the very poorly named “stop words”, which describe “small words conveying little meaning in a language”. English could have “of”, “the”, “a”, … French “du”, “d’”, “la”, “des”, … German “der”, “die”, “das”, …

Those small grammatical markers are rarely important for a meaning and are subjects to a lot of errors in international contexts. Plus those small markers, will be disproportionately penalized based on their length mistaking “de” and “du” will cost less that “the” and “a”.

$stopWords = [
    // english
    'of', 'the', 'a', // ...
    
    // french
    'le', 'la', 'de', 'd\'', // ...
    
    // german
    'der', 'die', 'das', // ... 
];

function replaceStopWords(string $input): string {
    global $stopWords;
    foreach ($stopWords as $stopWord) {
        $input = preg_replace("/\b$stopWord\b/", '_', $input);
    }
    
    return $input;
}

replaceStopWords('le prince de sang mele');
'_ prince _ sang mele';

Normalizing inputs for domain knowledge

Similar to stop words for a language, I am dealing with addresses here, so “boulevard” and “blvd” should be the same, and “rue” (“street” in french), should not be too far from it.

$domainSubstitutions = [
    // Normalize wording
    'blvd' => 'boulevard',
    
    // reduce cost of edition
    'boulevard' => 'b_',
    'avenue' => 'a_',
    'rue' => 'r_',
];

function substituteForDomain(string $input, bool $keepNumbers=false): string {
    global $domainSubstitutions;
    $substitutions = $domainSubstitutions;   
    if (!$keepNumbers) {
        $substitutions['\d+'] = '';
        $substitutions['\s+'] = ' '; // redundant spaces
    }
    
    foreach ($substitutions as $pattern => $newValue) {
        $input = preg_replace("/\b$pattern\b/", $newValue, $input);
    } 
    
    return trim($input);   
}

var_export(substituteForDomain('4 avenue   d\'evy'));
'a_ d\'evy';

var_export(substituteForDomain('31 rue evy'));
'rue evy';

Normalizing inputs for edge cases

In some countries, streets can have multiple names (Luxembourg’s has three on the bad days) ; sometimes just translations, sometimes fully different, sometimes people mix them.

I will leave it unimplemented but mappings like this might be relevant:

$commonMismatches = [
    'Jean' => 'John',
    'Jan' => 'John',
    'Giovanni' => 'John'
];

Final version

function preprocess(string $string, bool $keepNumbers): string {
    $string = replaceStopWords(removeDiacritics($string)); 
    return substituteForDomain($string, $keepNumbers);
}

function getFinalEditionDistance(string $source, string $target): float {
    $hasDigits = preg_match('/\d/', $string) === 1;
    $source = preprocess($source, $hasDigits);
    $target = preprocess($target, $hasDigits);

    $distance = levenshtein($source, $target, 1, 1, 2);
    $normalizationFactor = strlen($target) + 1;
    return $distance / $normalizationFactor;
}

$input = '4 rue Evy';

$rawMatches = [
    '4 avenue d\'evý, 1737 Luxembourg',
    '9 Rue Jean l\'Aveugle, 1148 Luxembourg',
    '310 rue Evy Friedrich, 1552 Luxembourg',
    '10 Rue Edouard Oster, 2272 Howald',
];

$distancesInfo = array_map(
    fn($m) => $m . ': ' . round(getFinalEditionDistance($input, $m), 4),
    $rawMatches
);

[
    '4 avenue d\'evý, 1737 Luxembourg: 0.7143', # lowest cost 👌
    '9 Rue Jean l\'Aveugle, 1148 Luxembourg: 0.8065',
    '310 rue Evy Friedrich, 1552 Luxembourg: 0.7667', # second lowest cost 🫶
    '10 Rue Edouard Oster, 2272 Howald: 0.8077',
];

The matching street number in the input gives a bonus to the first line, even if “rue” and “avenue” are a lot different it stays first.

Conclusion

This workflow is not perfect. It is even hackish to be honest, too many _ around, and if you can use a FULLTEXT field search in your database, you probably should. However, I think it is important to keep in mind, as shown in this piece, that a lot of things might require ad-hoc fine-tuning or configuration, as language and domain have a non-negligible impact.

This approach also totally neglects writing systems that are syllabic, redundant or logographic as I have zero experience on them. For sure addressing similar topics in Chinese scripts or in a combination of Japanese Hiragana and Katakana must be a pretty interesting topic.


Would you like to hear more from me?

I thought about interrupting your reading midway, but then I decided to respect my readership.

If you wish, let's stay in touch regardless of changes in your social network feed!


Fräntz Miccoli

This blog is wrapping my notes about software engineering, computer science and sometimes more general hacking. My main interests are architecture, quality and machine learning but content on this blog may diverge from time to time.

For some time now, I have been the happy cofounder, COO & CTO of Nexvia.

Ideas are expressed here to be challenged.


About me Out Of The Comfort Zone Twitter