π Search Engine: Misspelling Handling & Autocorrect¶
Target: Staff/Principal Engineer | Focus: Search engine misspelling handling, autocorrect algorithms, Levenshtein distance, BK-tree, Soundex | Reviewed: October 2026
30-second answer
Spelling correction is candidate generation + ranking. Generate candidates cheaply (edit distance †2 via a BK-tree, Levenshtein automaton or SymSpell's precomputed deletes; n-gram overlap; phonetic codes), then rank them by how likely the user meant each one: the noisy-channel model, P(word) à P(typo | word), with word and query frequencies from your own query logs and context from neighbouring words. In production you rarely write this yourself: Elasticsearch/OpenSearch fuzzy queries and the term/phrase suggesters do it, and the product decision is when to silently auto-correct versus show "Did you mean�".
1. THE PROBLEM¶
User types: "Harry Poter" β Intent: "Harry Potter"
User types: "recieve" β Intent: "receive"
User types: "Califonia" β Intent: "California"
A search engine must correct misspellings while still showing relevant results.
2. ALGORITHM: How It Works¶
User Query: "Harry Poter"
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββ
β SPELL CORRECTION β
β β
β 1. Tokenize: ["Harry", "Poter"] β
β β
β 2. For each token: β
β βββ Exact match in index? β Use it β
β βββ Fuzzy match (Levenshtein distance) β
β β βββ "Poter" β "Potter" (dist=1) β
β βββ Phonetic match (Soundex/Metaphone) β
β β βββ "Poter" β "Potter" (same sound) β
β βββ N-gram overlap? β
β βββ "Poter" β "Potter" (4/6 char match)β
β β
β 3. Rank candidates by distance + frequency β
β (+ context: "harry potter" is a frequent β
β query, "harry porter" is not) β
β β
β 4. Suggest correction: "Showing results for β
β 'Harry Potter'. Search instead for 'Poter'"β
βββββββββββββββββββββββββββββββββββββββββββββββ
Figure: per-token spell correction pipeline.
flowchart TD
A["Query"] --> B["Tokenize"]
B --> C{"Exact match in index?"}
C -- "yes" --> D["Use token"]
C -- "no" --> E["Candidates: Levenshtein, phonetic, n-gram"]
E --> F["Rank by distance, frequency and context"]
D --> G["Suggest corrected query"]
F --> G
3. LEVENSHTEIN DISTANCE IMPLEMENTATION¶
def levenshtein_distance(s1: str, s2: str) -> int:
"""
Compute the Levenshtein (edit) distance between two strings.
Operations: insert, delete, substitute (each costs 1)
Example:
"Poter" β "Potter"
- Insert 't' at position 4 β cost 1
"""
m, n = len(s1), len(s2)
# Two rows instead of the full (m+1) x (n+1) matrix: O(n) memory
prev = list(range(n + 1))
curr = [0] * (n + 1)
for i in range(1, m + 1):
curr[0] = i
for j in range(1, n + 1):
cost = 0 if s1[i - 1] == s2[j - 1] else 1
curr[j] = min(
prev[j] + 1, # Deletion
curr[j - 1] + 1, # Insertion
prev[j - 1] + cost # Substitution
)
prev, curr = curr, prev
return prev[n]
def damerau_distance(s1: str, s2: str) -> int:
"""Optimal string alignment distance: Levenshtein + adjacent transposition.
Typos are often swaps ("recieve" -> "receive"): Levenshtein counts that as 2,
this counts it as 1. Lucene's fuzzy queries also count transpositions as 1
by default. Note: OSA is not a true metric (no triangle inequality).
"""
m, n = len(s1), len(s2)
d = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(m + 1):
d[i][0] = i
for j in range(n + 1):
d[0][j] = j
for i in range(1, m + 1):
for j in range(1, n + 1):
cost = 0 if s1[i - 1] == s2[j - 1] else 1
d[i][j] = min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost)
if i > 1 and j > 1 and s1[i - 1] == s2[j - 2] and s1[i - 2] == s2[j - 1]:
d[i][j] = min(d[i][j], d[i - 2][j - 2] + 1) # transposition
return d[m][n]
# βββ Efficient Fuzzy Search βββββββββββββββββββββββ
class FuzzySearch:
"""
Fuzzy search with a BK-tree (Burkhard-Keller tree).
Works for any metric distance (it relies on the triangle inequality):
at a node at distance d from the query, only children whose edge label
lies in [d - k, d + k] can contain matches. That prunes a lot for small
k, but it is NOT O(log n): with k = 2 a query typically still visits a
sizeable fraction of the tree. Fine for dictionaries of ~10^5 words.
"""
def __init__(self, distance=levenshtein_distance):
self.distance = distance
self.bk_tree = None
def build_index(self, words: list):
"""Build BK-tree from a dictionary of words."""
for word in words:
if self.bk_tree is None:
self.bk_tree = BKTreeNode(word)
else:
self._insert(self.bk_tree, word)
def _insert(self, node: 'BKTreeNode', word: str):
# Iterative to avoid deep recursion on large dictionaries
while True:
dist = self.distance(node.word, word)
if dist == 0:
return # duplicate
child = node.children.get(dist)
if child is None:
node.children[dist] = BKTreeNode(word)
return
node = child
def search(self, query: str, max_distance: int = 2) -> list:
"""Find all words within max_distance of the query, closest first."""
if not self.bk_tree:
return []
results, stack = [], [self.bk_tree]
while stack:
node = stack.pop()
dist = self.distance(node.word, query)
if dist <= max_distance:
results.append((node.word, dist))
# Triangle inequality: only these children can be within range
for d in range(max(1, dist - max_distance), dist + max_distance + 1):
if d in node.children:
stack.append(node.children[d])
return sorted(results, key=lambda x: x[1])
class BKTreeNode:
def __init__(self, word: str):
self.word = word
self.children = {}
4. AUTOCORRECT IMPLEMENTATION¶
from collections import Counter
class Autocorrect:
"""
Autocorrect combining candidate generation and ranking.
Candidate generation: edit distance (BK-tree), then n-grams, then phonetics.
Ranking: smaller edit distance first, then higher word frequency. This is a
crude version of the noisy-channel model P(correction) * P(typo | correction).
Without frequency, ties are broken arbitrarily ("beleive" could become
"receive" instead of "believe": both are 2 Levenshtein edits away).
"""
def __init__(self, word_counts: dict):
self.freq = Counter({w.lower(): c for w, c in word_counts.items()})
self.dictionary = set(self.freq)
# BK-trees need a true metric. OSA (damerau_distance) violates the
# triangle inequality, so index with Levenshtein and re-rank with OSA.
self.fuzzy_searcher = FuzzySearch(distance=levenshtein_distance)
self.fuzzy_searcher.build_index(list(self.dictionary))
self.ngram_index = self._build_ngram_index(self.dictionary)
self.soundex_index = {}
for w in self.dictionary:
self.soundex_index.setdefault(self._soundex(w), []).append(w)
def correct(self, word: str) -> str:
"""Correct a misspelled word, preserving simple capitalization."""
lower = word.lower()
best = self._best(lower)
if best == lower:
return word
return best.capitalize() if word[:1].isupper() else best
def _best(self, word: str) -> str:
# Strategy 1: Exact match
if word in self.dictionary:
return word
# Strategy 2: Edit distance (Damerau, β€ 2), ranked by (distance, -frequency)
# Short words get a tighter limit, like Elasticsearch's fuzziness AUTO
max_d = 0 if len(word) <= 2 else 1 if len(word) <= 5 else 2
# A transposition costs 2 in Levenshtein, so search one step wider,
# then keep candidates within max_d by the transposition-aware distance
wide = self.fuzzy_searcher.search(word, max_distance=max_d + 1) if max_d else []
fuzzy = [(w, damerau_distance(w, word)) for w, _ in wide]
fuzzy = [(w, d) for w, d in fuzzy if d <= max_d]
if fuzzy:
return min(fuzzy, key=lambda wd: (wd[1], -self.freq[wd[0]]))[0]
# Strategy 3: N-gram overlap
ngram_matches = self._ngram_match(word, threshold=0.6)
if ngram_matches:
return ngram_matches[0][0]
# Strategy 4: Phonetic match (Soundex)
phonetic = self.soundex_index.get(self._soundex(word), [])
if phonetic:
return max(phonetic, key=lambda w: self.freq[w])
# No correction found
return word
def suggest(self, query: str, max_suggestions: int = 5) -> list:
"""
Suggest corrections for a multi-word query.
"Harry Poter" β ["Harry Potter"]
"""
tokens = query.split()
corrected = [self.correct(t) for t in tokens]
if corrected == tokens:
return []
# Real systems score whole-query candidates with a language model or
# query logs ("harry potter" is a frequent query; "harry porter" isn't).
return [" ".join(corrected)][:max_suggestions]
def _build_ngram_index(self, words, n: int = 3) -> dict:
"""Build trigram index for efficient fuzzy matching."""
index = {}
for word in words:
for gram in self._get_ngrams(word, n):
index.setdefault(gram, []).append(word)
return index
def _get_ngrams(self, word: str, n: int = 3) -> set:
"""Get n-grams with padding."""
padded = f"^{word}$"
return {padded[i:i+n] for i in range(len(padded) - n + 1)}
def _ngram_match(self, word: str, threshold: float = 0.5) -> list:
"""Find words with high n-gram overlap (Jaccard similarity)."""
word_grams = self._get_ngrams(word)
candidates = set()
for gram in word_grams:
candidates.update(self.ngram_index.get(gram, []))
scored = []
for candidate in candidates:
cand_grams = self._get_ngrams(candidate)
score = len(word_grams & cand_grams) / len(word_grams | cand_grams)
if score >= threshold:
scored.append((candidate, score))
return sorted(scored, key=lambda x: (-x[1], -self.freq[x[0]]))
@staticmethod
def _soundex(word: str) -> str:
"""
American Soundex: first letter + 3 digits.
Example: "Robert" β R163, "Rupert" β R163, "Tymczak" β T522
"""
codes = {**dict.fromkeys("BFPV", "1"), **dict.fromkeys("CGJKQSXZ", "2"),
**dict.fromkeys("DT", "3"), "L": "4", **dict.fromkeys("MN", "5"),
"R": "6"}
word = "".join(c for c in word.upper() if c.isalpha())
if not word:
return ""
result = word[0]
last = codes.get(word[0], "")
for ch in word[1:]:
code = codes.get(ch, "")
if code and code != last:
result += code
if ch not in "HW": # H and W don't separate equal codes;
last = code # vowels do (they reset `last` to "")
return (result + "000")[:4] # pad AND truncate to 4 characters
# βββ Demo: Autocorrect in Action ββββββββββββββββββ
def demo_autocorrect():
# Word frequencies would come from your corpus or query logs
dictionary = {
"harry": 900, "potter": 800, "porter": 50, "hermione": 300, "granger": 200,
"hogwarts": 250, "ron": 400, "weasley": 300, "voldemort": 200,
"receive": 500, "believe": 700, "achieve": 300, "perceive": 100,
"california": 600, "colorado": 300, "connecticut": 100,
"definitely": 400, "separate": 350, "necessary": 300, "accommodate": 150,
}
corrector = Autocorrect(dictionary)
test_cases = [
"Harry Poter",
"recieve",
"Califonia",
"seperate",
"definately",
"beleive",
]
for query in test_cases:
print(f"Input: '{query}' Suggestions: {corrector.suggest(query)}")
# Output:
# Input: 'Harry Poter' Suggestions: ['Harry Potter']
# Input: 'recieve' Suggestions: ['receive']
# Input: 'Califonia' Suggestions: ['California']
# Input: 'seperate' Suggestions: ['separate']
# Input: 'definately' Suggestions: ['definitely']
# Input: 'beleive' Suggestions: ['believe']
5. PRODUCTION CONSIDERATIONS¶
| Approach | Lookup cost | Notes |
|---|---|---|
| Brute-force edit distance | O(N Β· LΒ²) per query | Fine for a few thousand words |
| BK-tree | Prunes by triangle inequality; visits a fraction of N | Needs a true metric (Levenshtein or full Damerau-Levenshtein, not OSA) |
| SymSpell (symmetric delete) | Near O(1) lookups after precomputing deletes | Very fast, large memory footprint |
| Levenshtein automaton (Lucene) | Intersect an automaton with the term dictionary FST | What Elasticsearch fuzzy / match with fuzziness uses; max edit distance 2 |
In Elasticsearch / OpenSearch:
matchwith"fuzziness": "AUTO": 0 edits for terms of 1β2 characters, 1 for 3β5, 2 for longer; transpositions count as one edit by default. Setprefix_length(e.g. 1β2) to cut the search space, since first letters are rarely mistyped.- Term suggester corrects individual tokens; phrase suggester scores whole-phrase candidates with an n-gram language model built from a shingle field (this is what turns "harry poter" into "harry potter" rather than "harry porter"); completion suggester is for prefix autocomplete (an in-memory FST), not correction.
Things that matter more than the algorithm:
- Query logs are the best dictionary: real queries, real frequencies, and reformulations ("user typed X, then immediately typed Y") give you P(typo | word) for free.
- Don't correct valid rare terms: product codes, names and new brands look like typos. Only auto-correct when the original query returns few or no results and the correction is much more likely; otherwise show "Did you mean�".
- Measure it: correction precision (how often the change was right), recall, and downstream click-through or zero-result rate.
- Semantic search (embeddings) tolerates some misspellings on its own, but rare tokens like SKUs and names still need lexical fuzzy matching, which is one reason hybrid (BM25 + vector) retrieval is common.
Previous: Redis Lease Next: Elasticsearch Internals