Implementing Advanced Fuzzy Search
In daily development, we often encounter requirements for fuzzy search. Today, let’s briefly discuss how to implement some advanced fuzzy search using PostgreSQL.
Of course, the fuzzy search I’m talking about here isn’t the old-fashioned LIKE expressions with prefix, suffix, or bilateral fuzzy matching. Let’s start directly with a concrete example.
Problem
Now, suppose we’ve built an app store and want to provide search functionality for users. Users can input anything, and we find all applications matching the input content, rank them, and return them to users.
Strictly speaking, this requirement actually needs a search engine, preferably using specialized software like ElasticSearch. But in practice, as long as the logic isn’t particularly complex, PostgreSQL can implement it very well.
Data
The sample data is as follows—an application table. All irrelevant fields have been removed, leaving only an application name name as the primary key.
The data inside looks roughly like this, with mixed Chinese and English, totaling 1.5 million entries.
Input
What users might input in the search box is roughly the same as what you would type in an app store search box: “weather,” “food delivery,” “social networking”…
The effect we want to achieve is also similar to your expectations for app store query return results. Of course, the more accurate the better, preferably ranked by relevance.
Of course, as a production-level application, it must also respond promptly. Full table scans are not acceptable—indexes must be used.
So, how do we solve this type of problem?
Solution Approaches
There are three solution approaches for this problem:
- Pattern matching based on
LIKE - String similarity matching based on
pg_trgm - Fuzzy search based on custom tokenization and inverted indexes
LIKE Pattern Matching
The simplest and most straightforward approach is using LIKE '%' pattern matching queries.
This is an old topic with no technical sophistication. Add percent signs before and after user input keywords, then execute queries like:
Prefix and suffix fuzzy queries can be accelerated through regular Btree indexes. Note that when using LIKE queries in PostgreSQL, don’t fall into the LC_COLLATE trap. For details, refer to this article: Localization and Collation Rules in PostgreSQL.
If user input is very precise and clear, this approach is acceptable. Response speed is also good. But there are two problems:
Too mechanical and rigid. If an app vendor releases a name with an extra space or symbol in the original keywords, this query immediately fails.
No distance measurement. We don’t have a suitable metric to rank returned results. If several hundred results are returned without ranking, it’s hard to satisfy users.
Sometimes accuracy is still insufficient. For example, some apps do SEO by embedding various top app names into their own names to improve search rankings.
PG TRGM
PostgreSQL comes with an extension called pg_trgm, which provides fuzzy search based on three-character trigrams.
The pg_trgm module provides functions and operators for determining alphanumeric text similarity based on trigram matching, as well as index operator classes that support fast searching for similar strings.
Usage
The query method is also intuitive—directly use the % operator. For example, find apps related to Alipay from the app table.
Advantages of this approach:
- Provides string distance function
similarity, giving a quantitative measure of similarity between two strings. Therefore, results can be ranked. - Provides tokenization function
show_trgmbased on 3-character combinations. - Can use indexes to accelerate queries.
- SQL query statements are very simple and clear, index definition is also simple and straightforward, making maintenance easy.
Disadvantages of this approach:
- Poor recall rate for very short keywords (1-2 Chinese characters), especially when there’s only one character, no results can be queried
- Low execution efficiency. For example, the query above took 200ms
- Poor customizability. Can only use its own defined logic to define string similarity, and this metric’s effectiveness for Chinese is questionable (Chinese three-character word frequency is very low)
- Special requirements for
LC_CTYPE. DefaultLC_CTYPE = Ccannot correctly tokenize Chinese.
Special Issues
The biggest problem with pg_trgm is that it cannot be used for Chinese on instances with LC_CTYPE = C. Because LC_CTYPE=C lacks some character classification definitions. Unfortunately, once LC_CTYPE is set, there’s basically no way to change it except rebuilding the database.
Generally speaking, PostgreSQL’s Locale should be set to C, or at least set the collation rule LC_COLLATE in localization rules to C, to avoid huge performance losses and functional deficiencies. But because of this “problem” with pg_trgm, you need to specify LC_CTYPE = <non-C-locale> when creating the database. LOCALEs based on i18n should theoretically all work. Common en_US and zh_CN are both usable. But note that macOS has issues with Locale support. Behaviors that rely too heavily on LOCALE reduce code portability.
Advanced Fuzzy Search
Implementing advanced fuzzy search requires two things: tokenization and inverted indexes.
Advanced fuzzy search, or full-text search, is implemented based on the following approach:
- Tokenization: During the maintenance phase, each field that needs fuzzy searching (like application names) is processed by tokenization logic into a series of keywords.
- Indexing: Build inverted indexes from keywords to table records in the database
- Querying: Break down queries into keywords similarly, then use query keywords through inverted indexes to find relevant records.
PostgreSQL has built-in tokenizers for many languages that can automatically split documents into a series of keywords for full-text search functionality. Unfortunately, Chinese is quite complex, and PostgreSQL doesn’t have built-in Chinese tokenization logic. Although there are some third-party extensions like pg_jieba and zhparser, they’re poorly maintained and may not work on newer versions of PostgreSQL.
But this doesn’t prevent us from using PostgreSQL’s infrastructure to implement advanced fuzzy search. Actually, the tokenization logic mentioned above is for extracting summary information (keywords) from large texts (like web pages). Our requirement is exactly the opposite—not only do we not extract and summarize, but we need to expand keywords to achieve specific fuzzy requirements. For example, we can completely include Chinese pinyin, initials abbreviations, and English abbreviations of keywords in the keyword list when extracting application name keywords, or even put author, company, category, and other things users might be interested in. This way, rich input can be used when searching.
Basic Framework
Let’s first build the framework for solving the entire problem.
- Write a custom tokenization function to extract keywords from names (each character, each two-character phrase, pinyin, English abbreviations—anything can be included)
- Create a functional expression GIN index on the target table using the tokenization function
- Customize your fuzzy search through array operations or
tsquerymethods
PostgreSQL provides GIN indexes, which can support inverted index functionality very well. The more troublesome part is finding a suitable Chinese tokenization plugin to break down application names into a series of keywords. Fortunately, for this type of fuzzy search requirement, we don’t need semantic analysis as fine as search engines or natural language processing. We can just follow pg_trgm’s approach and manually handle Chinese in a rough way. Additionally, through custom tokenization logic, many interesting features can be implemented, such as pinyin fuzzy search and pinyin initial abbreviation fuzzy search.
Let’s start with the simplest tokenization.
Quick Start
First, let’s define a very simple and crude tokenization function that just splits input into combinations of 2-character words.
Using this tokenization function, we can break down an application name into a series of morphemes:
Now suppose a user searches for the keyword “艾米利”, which gets split into:
Then, we can very quickly find all records containing these two keyword morphemes through the following query:
Here, through keyword array inverted indexes, we can quickly achieve prefix and suffix fuzzy effects.
The condition here is quite strict—applications need to completely contain both keywords to match.
If we use more lenient conditions for fuzzy search, for example, containing any morpheme:
Then the candidate set of applications available for further filtering becomes broader. At the same time, execution time didn’t change dramatically.
Furthermore, we don’t need to use completely consistent tokenization logic in queries—we can completely manually perform precise query control.
We can completely control which keywords we want, which we don’t want, which are optional, and which are required through array boolean operations.
Of course, returned results can also be ranked by similarity. A commonly used string similarity measure is Levenshtein edit distance—the minimum number of single-character edits needed to change one string into another. This distance function levenshtein is provided in PostgreSQL’s official extension fuzzystrmatch.
Improving Full-Text-Search Methods
Next, we can make some improvements to the tokenization method:
- Reduce keyword scope: Remove punctuation from keywords, exclude modal particles (的得地,啊唔之乎者也) etc. (optional)
- Expand keyword list: Include Chinese pinyin and initial abbreviations of existing keywords in the keyword list.
- Optimize keyword size: Extract and optimize single characters, 3-character phrases, and 4-character idioms. Chinese is different from English—English splits into 3-character substrings work well, but Chinese has higher information density, with single or double characters having great discriminative power.
- Remove duplicate keywords: For example, repeated appearances, or variant characters, synonyms, etc.
- Cross-language tokenization processing. For example, for names with mixed Chinese and Western characters, we can process Chinese and English separately—Chinese, Japanese, Korean characters use Chinese tokenization logic, English letters use regular
pg_trgmprocessing logic.
Actually, these logics aren’t necessarily needed, and these logics don’t necessarily have to be implemented in the database using stored procedures. A better approach would be to read from the database externally, then use specialized tokenization libraries and custom business logic for tokenization, then write back to another column in the data table.
Of course, for demonstration purposes here, we’ll directly use stored procedures to implement a relatively simple improved tokenization logic.
Based on tsvector
Besides array-based operations, PostgreSQL also provides tsvector and tsquery types for full-text search.
We can use operations of these two types to replace array operations and write more flexible queries:
Using tsvector for queries is also quite intuitive:
Reference Articles:
PostgreSQL Fuzzy Search Best Practices - (Including single character, double character, multi-character fuzzy search methods)
