Skip to content
Select theme

Fuzzy matching and typo tolerance

Step 8 of 12, and the index starts answering questions that were asked slightly wrong. Step 7 left haystack with phrase and boolean queries over terms it knows exactly, and with one silent failure mode: a typo in a query misses, because the dictionary has no such key, and a missing key is an empty list rather than an error. This step searches the dictionary itself by edit distance and gives the query grammar a ~ for it, so borow~ finds what borrow finds.

The starting state is tag search-ch07, the end of step 7: postings that carry positions, a grammar with AND, OR and NOT, and the BM25 ranker from step 6. The end state of this step is tagged search-ch08. The index file does not change — the same corpus still saves to 2619 bytes at version 3 — because nothing about what is stored changes. What changes is what happens when the term a reader typed is not in the dictionary.

search is a HashMap lookup, and a lookup has two outcomes: the key is there, or it is not. So “borow” is not a badly asked question about “borrow” — it is a different question, about a term no document ever contributed, and it gets the same empty answer “zzzzz” gets. The engine cannot tell a typo from a word it has never seen, because both are absent keys.

The fix is to stop looking the term up and search the dictionary instead: for every term the index holds, measure how far it is from what the reader typed, and keep the ones that are close. The measure is Levenshtein’s edit distance.

The Levenshtein distance between two strings is the smallest number of single-character insertions, deletions and substitutions that turn one into the other. “borow” is one edit from “borrow” — an insertion. “cheker” is one from “checker” — a deletion. “teh” is two from “the”, because swapping two characters is not one of the three operations.

borrow -> borrow distance 0
borow -> borrow distance 1
cheker -> checker distance 1
teh -> the distance 2
kitten -> sitting distance 3
saturday -> sunday distance 3
flaw -> lawn distance 2
owner -> ownership distance 4
arger -> ärger distance 1
-> abc distance 3

The classic way to compute it fills a table where cell (i, j) is the distance between the first i characters of one term and the first j of the other. Each cell comes from three neighbours: the cell above plus one for a deletion, the cell to the left plus one for an insertion, the diagonal plus one for a substitution — or plus zero, when the two characters already match. The answer is the bottom-right cell.

Two rows are enough, because row i+1 only ever reads row i. The first row counts insertions into an empty prefix, the first column counts deletions, and the rest follows. The whole computation is O(m × n) time and O(n) memory, which for terms of ordinary length is cheap enough to run against the entire dictionary once per query.

One difference from Lucene is worth naming. Lucene’s default fuzzy query counts a transposition — “teh” for “the” — as a single edit, using Damerau-Levenshtein’s optimal string alignment. This engine knows three operations, not four, so “teh” costs two. Both are defensible; the three-operation version is the one that fits in two rows.

The block below is the engine’s dictionary scan over the demo corpus’s real terms, with the positions left out — this step searches the dictionary, and the dictionary does not care where a term sat. The documents are the six corpus files, their terms already normalised the way step 3’s tokeniser leaves them:

84 terms in the dictionary
borow within 1: [("borrow", 1)] within 2: [("borrow", 1)]
cheker within 1: [("checker", 1)] within 2: [("checker", 1)]
liefetime within 1: [("lifetime", 1)] within 2: [("lifetime", 1)]
valu within 1: [("value", 1)] within 2: [("value", 1), ("call", 2), ("valid", 2)]
zzzzz within 1: [] within 2: []

The dictionary is only 84 terms on this corpus, so the scan is trivial to run, but the shape of the cost is worth stating: one distance computation per dictionary term, each O(m × n). An engine with millions of terms does not fill a table per candidate — Lucene builds a Levenshtein automaton once per query and walks the dictionary through it. The cheap filter that is already here is the length check: the distance can never be smaller than the difference in lengths, so a candidate that differs in length by more than the radius is out before any table is filled. “ownership” against “owner” at a radius of three never reaches the DP.

The order of the hits is not the order of the dictionary. HashMap iteration order is random, so the hits are sorted by distance and then by term: nearest first, alphabetical within a distance. The query scores them in that order.

A tilde at the end of a bare word makes that word fuzzy. borow~ uses the default radius of two, borow~1 asks for one, and borow~0 is an exact match — a strange thing to write, a consistent thing to mean. The cap at two is Lucene’s as well: past two edits a radius stops correcting typos and starts matching half the dictionary, which is why its documentation points anyone who wants more at n-gram techniques instead.

borrow an exact term
borow~ the word "borow" within 2 edits
borow~1 the word "borow" within 1 edits
borow~0 the word "borow" within 0 edits
borow~2 the word "borow" within 2 edits
borow~3 refused: the fuzzy operator on "borow~3" needs an edit count between 0 and 2
borow~x refused: the fuzzy operator on "borow~x" needs an edit count between 0 and 2
borow~-1 refused: the fuzzy operator on "borow~-1" needs an edit count between 0 and 2

A malformed fuzzy token is refused rather than guessed at, the same way a dangling operator is. borow~3 asks for a radius this engine does not have, and borow~x is not a radius at all; both come back with an error naming the token. The radius is the only thing the tilde takes.

The fuzzy word goes through the same tokeniser the index used before the scan, so FOx~1 looks for “fox”. A stop word with a tilde matches nothing, exactly as it does without one. A hyphenated word with a tilde — “borrow-checker~” — normalises to two terms, which is a phrase, and this operator does not do phrases: it matches nothing rather than guessing which half the typo was in.

What a fuzzy operand matches is the union of its variants’ documents, and what it scores by is the variants themselves, each contributing its own BM25 score the way an OR’s terms do. valu~2 on the demo corpus reaches three terms — “value” at one edit, “call” and “valid” at two — so the answer is six documents: the four that hold “value”, plus docs/lifetimes.md for “valid” and docs/iterators.md for “call”. “call” being two substitutions away from “valu” is the radius doing what it says. A radius of two is wide, and the engine does not second-guess it:

"borow~" and "borrow" answer the same:
true
query "valu~2":
2.1019 docs/lifetimes.md
2.1019 docs/iterators.md
0.4871 docs/borrow-checker.md
0.4871 docs/error-handling.md
0.4542 docs/ownership.md
0.2333 docs/style-guide.md

NOT applies to a fuzzy operand like any other: fox~1 NOT dog is the documents within one edit of “fox”, minus the ones holding “dog”.

The mistake this step invites is the type of the radius. Document ids, positions and lengths are all u32 in this engine, so a radius parsed as a u32 is the natural slip — and the distance the DP produces is a usize, because that is what chars().count() returns:

error[E0308]: mismatched types
--> src/main.rs:5:17
|
5 | distance <= max
| -------- ^^^ expected `usize`, found `u32`
| |
| expected because this is `usize`
|
help: you can convert a `u32` to a `usize` and panic if the converted value doesn't fit
|
5 | distance <= max.try_into().unwrap()
| ++++++++++++++++++++
For more information about this error, try `rustc --explain E0308`.

The engine parses the radius as a usize and the comparison never comes up.

One file: the two-row DP, the dictionary scan, the tilde in the query grammar, and the tests. The block below is the file at tag search-ch08, byte for byte.

Eighty-eight tests: the sixty-four from step 7, and twenty-four around the new machinery — the distance on known pairs, the transposition gap, the character counting, the radius cap and its refusals, the normalisation of the fuzzy word, and what a fuzzy query scores. The order is shuffled on every run, so nothing should be read into it:

test result: ok. 88 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s

This step’s state is tagged search-ch08 in the examples repository:

Terminal window
git clone https://github.com/hackingwithrust/examples
cd examples/search-engine
git checkout search-ch08

The tag contains Cargo.toml, the README and src/main.rs in full. If your build misbehaves, diff your files against the tag’s — the drifting line is usually findable that way. cargo run prints the whole demo, and cargo test runs the eighty-eight tests. The track overview keeps the step list current.

Step 9, An HTTP query API, serves these same queries over HTTP, and it is the first step that reaches for a crate outside the standard library.