VL08 Fuzzy Search, Prefix Edit Distance, Q-Gram Index
Error-tolerant (prefix) search over a dictionary using edit distance, prefix edit distance, the q-gram lemma and its corollaries, and a q-gram index for fast candidate filtering.
Given a dictionaryD, a list of “names” of any kind (for ES8: 1,736,613 Wikidata entities), find the names from D that match a query (Slide 5, Fuzzy Search):
Query
Match
Kind
frei
freiburg
prefix search
breifurg
freiburg
fuzzy search
brei
freiburg
fuzzy prefix search
The lecture solves the last two; normal prefix search is a special case. As in Lectures 1–2 there are two challenges:
a good model of relevance (what matches), here (P)ED,
an index to find the matches fast, here the q-gram index.
Possible origins of the dictionary (Slide 6): popular queries from a query log (basis of Google’s query suggestions); words and common phrases of a text collection (phrase extraction is a problem of its own, not covered); entities of a knowledge graph (ES8: Wikidata entities with popularity and synonyms).
Iterate over all strings of the dictionary and check each one (Slide 7). The command-line tools grep and agrep do this:
grep -x frei.* <file> # -x: match whole lineagrep -x -2 breifurg <file> # -2: allow up to 2 "errors"agrep -x -2 brei.* <file>
This takes time linear in the size of the dictionary: good enough for medium-sized, not for large dictionaries. The rest of the lecture precomputes an index to do better.
Notation (Slide 9): ε is the empty word, ∣x∣ the length (number of characters) of x, and x[i..j] the substring from position i to j, 1≤i≤j≤∣x∣. Below, n:=∣x∣ and m:=∣y∣.
ProofProof sketches (the slides recommend proving these as exam preparation)
Every operation can be undone by one operation (insert ↔ delete, replace ↔ replace). Reversing an optimal sequence from x to y gives a sequence of the same length from y to x, so ED(y,x)≤ED(x,y), and vice versa.
∣x∣ deletions suffice. Each operation reduces the length by at most 1, so at least ∣x∣ operations are needed.
Each operation changes the length by at most 1, and the length must change from ∣x∣ to ∣y∣.
Apply an optimal sequence for x[1..n−1]→y[1..m−1] to the first n−1 characters of x; this leaves x[n] as the last character. Then one replace turns x[n] into y[m] (or nothing is needed if x[n]=y[m]).
The recurrence leads to a DP algorithm: fill a table whose entry in row i, column j is ED(x[1..i],y[1..j]) (rows: prefixes of x, columns: prefixes of y, row/column 0 = ε). Each entry depends only on its left, upper and upper-left neighbour. The result is the bottom-right entry (Slide 11).
Time and space:Θ(∣x∣⋅∣y∣).
Space O(min{∣x∣,∣y∣}) also works: only the previous row is needed (row/column roles can be swapped).
PED is the relevance model for fuzzy search-as-you-type suggestions; all large web search engines have this feature because it is so convenient. The entity search on wikidata.org does not have it.
The PED is the minimum of the entries in the last row (instead of the bottom-right entry), since entry (∣x∣,j) is ED(x,y[1..j]).
Optimization: if ∣x∣≪∣y∣ and we only want to know whether PED(x,y)≤δ for a given threshold δ, it suffices to compute the first ∣x∣+δ+1 columns (column ε and the prefixes y[1..j] for j≤∣x∣+δ).
ProofWhy the first ∣x∣+δ+1 columns suffice
For j>∣x∣+δ, property 3 of ED gives ED(x,y[1..j])≥j−∣x∣>δ. So no column to the right can contribute an entry ≤δ to the last row: if PED(x,y)≤δ, the minimum is attained among the first ∣x∣+δ+1 columns, i.e. PED(x,y)=PED(x,y[1..∣x∣+δ]).
The PED is performance-critical, so ES8 provides it (and merge_lists) ready-made: a Rust implementation (pip install ad-freiburg-qgram-utils) and a Python fallback; the code template uses Rust if installed (Slide 14).
Goal: find fuzzy matches of x in D faster than by computing ED(x,y) or PED(x,y) for all y∈D. Intuition: strings with a small edit distance overlap in many of their parts. ED(freiburg,stuttgart) is large, the strings differ “everywhere”; ED(freiburg,breifurg)=2 is small, the strings agree “at many places” (Slide 15).
Case ED(x,y)=1: one edit operation changes at most q q-grams (those that overlap the edited position), hence ∣Qq(x)∖Qq(y)∣≤q.
General case ED(x,y)=k: let x=x0→x1→⋯→xk=y be a sequence of k edit operations, so ED(xi−1,xi)=1 and therefore ∣Qq(xi−1)∖Qq(xi)∣≤q. It remains to show that for any sequence of sets A0,…,Ak
A0∖Ak⊆i=1⋃k(Ai−1∖Ai).
Let a∈A0∖Ak and let i≥1 be the smallest index with a∈/Ai (it exists because a∈/Ak). Then a∈Ai−1, so a∈Ai−1∖Ai. With Ai:=Qq(xi):
∣Qq(x)∖Qq(y)∣≤i=1∑k∣Qq(xi−1)∖Qq(xi)∣≤k⋅q.
(The last step after the inclusion is the usual completion; the slides end with the inclusion.)
The algorithm gives the same result with padding, and padding is an improvement: it yields more q-grams (including distinctive ones for the word boundaries), so step 2 of the filter applies more often.
The filter algorithm needs ∣Qq(x)∩Qq(y)∣ for a given x and ally∈D; for ES8, D contains 1.7 million strings. This is done efficiently with a q-gram index (Slide 23).
A q-gram index is an Inverted Index with different objects than a document index:
document index: for each word, a list of document ids,
q-gram index: for each q-gram, a list of word ids.
Accordingly, the code from Lecture 1 carries over almost unchanged (lecture code public/code/lecture-08/qgram_index.py):
defq_grams(self, x: str) -> list[str]:
x = self.padding + x + self.padding # padding = "$" * (q - 1)return [x[i : i + self.q] for i in range(len(x) - self.q +1)]
defbuild_from_file(self, file_name: str):
with open(file_name, "r") as file:
word_id =0for line in file:
word_id +=1 word = line.split("\t", 2)[0].lower()
for q_gram in self.q_grams(word):
if q_gram notin self.inverted_lists:
self.inverted_lists[q_gram] = []
self.inverted_lists[q_gram].append(word_id)
For the (appropriately padded) input x (Slide 25):
Compute Qq(x).
For each q-gram of Qq(x), fetch its inverted list.
Compute the union of these ∣Qq(x)∣ inverted lists and count how often each word occurs.
If a word y occurs k times in the union, it contains exactly k q-grams of Qq(x), hence ∣Qq(x)∩Qq(y)∣=k. Words that do not occur in the union at all have ∣Qq(x)∩Qq(y)∣=0; they are only relevant if the right-hand side of the filter test (step 1 on Slide 21) is ≤0.
The complete fuzzy prefix search (Fuzzy Search): left-pad x, fetch and merge the inverted lists of its q-grams, keep the words with at least ∣Qq(x)∣−q⋅δ occurrences, and compute PED only for those. The filter phase takes time linear in the total length of the fetched inverted lists, which is typically much smaller than computing PED for every word.