Extensions
- textsearch_intl 1.0.1
- Multilingual full text search: ICU word segmentation, normalization and n-gram matching
README
Contents
textsearch_intl
Multilingual full text search for PostgreSQL, built for names and other short text in any script. It provides:
- the
intlparser, which splits text into words with ICU word boundaries, including Chinese, Japanese, Thai, Khmer, Lao and Burmese, which don’t put spaces between words; - the
intl_normalizedictionary, which folds character widths, decomposed accents and apostrophes; - the
intl_runsparser, the same asintlexcept that it keeps each unspaced Thai, Lao, Khmer or Burmese run as one token; - the
intl_ngramdictionary, which lets a search match words inside long Korean, Chinese and Japanese tokens and inside Thai, Lao, Khmer and Burmese runs; - three ready-made configurations that combine them.
PostgreSQL’s built-in default parser only splits on spaces and punctuation, so text in scripts written without spaces between words comes out as a single token. Chinese, Japanese, Thai, Khmer, Lao and Burmese all have this problem:
SELECT token FROM ts_debug('simple', '東京駅前ラーメン店');
-- 東京駅前ラーメン店
The intl parser uses ICU’s word break iterator, which uses dictionaries to segment those scripts:
CREATE EXTENSION textsearch_intl CASCADE;
SELECT alias, token FROM ts_debug('intl_simple', '東京駅前ラーメン店 ร้านกาแฟ Café');
-- ideo | 東京
-- ideo | 駅前
-- ideo | ラーメン
-- ideo | 店
-- blank |
-- word | ร้าน
-- word | กาแฟ
-- blank |
-- word | Café
SELECT to_tsvector('intl_simple', '東京駅前ラーメン店') @@ plainto_tsquery('intl_simple', 'ラーメン');
-- t
Requirements
- PostgreSQL 14 or later, in a
UTF8database - ICU development headers (
libicu-dev,libicu-develor Homebrewicu4c) andpkg-config - The
unaccentextension from contrib, whichCREATE EXTENSION textsearch_intl CASCADEinstalls
Install
make
make install
make installcheck
Point PG_CONFIG at a specific server’s pg_config if more than one is installed. If pkg-config can’t find ICU, set PKG_CONFIG_PATH, or pass ICU_CFLAGS and ICU_LIBS yourself. With Homebrew on macOS:
PKG_CONFIG_PATH="$(brew --prefix icu4c)/lib/pkgconfig" make PG_SYSROOT="$(xcrun --show-sdk-path)"
Usage
The extension creates the parsers intl and intl_runs, the dictionaries intl_normalize, intl_ngram and intl_join, and three configurations:
| Configuration | Parser | Dictionaries for each word | Use it for |
|---|---|---|---|
intl_search_ngram |
intl_runs |
intl_normalize, intl_ngram, unaccent, intl_join |
Searching names in any language, including Korean, words inside CJK compounds, and Thai, Lao, Khmer and Burmese names ICU doesn’t know |
intl_search |
intl |
intl_normalize, unaccent, intl_join |
Whole-word search; the smallest index, and no matches inside longer Korean or CJK words |
intl_simple |
intl |
simple |
Words exactly as written, keeping accents, apostrophes and character widths |
CREATE INDEX places_name_intl_idx ON places USING gin (to_tsvector('intl_search_ngram', name));
SELECT * FROM places
WHERE to_tsvector('intl_search_ngram', name) @@ phraseto_tsquery('intl_search_ngram', 'mcdonalds');
-- finds McDonald's, McDonald’s and MCDONALDS
SELECT * FROM places
WHERE to_tsvector('intl_search_ngram', name) @@ phraseto_tsquery('intl_search_ngram', '편의점');
-- finds 행복한편의점 and CU편의점 as well as 편의점
Prefer phraseto_tsquery: when ICU splits a name into several words, plainto_tsquery matches them in any order.
Normalization
intl_normalize is a filtering dictionary: it changes a token and passes it on to the next dictionary, or passes it through untouched. For each token it:
- applies Unicode NFKC with case folding, so half-width katakana (
セブン→セブン), full-width letters and digits (ABC123→abc123) and decomposed accents match their usual forms, and case matches without depending on the database locale:ΑΘΗΝΑΣmatchesΑθήνας(final sigma),STRASSEmatchesStraße, andİstanbulbecomesistanbul; - drops invisible characters such as soft hyphens, zero-width joiners and variation selectors;
- recombines the Thai
ำand Laoຳvowels, which NFKC splits into two characters; - removes apostrophes and the letters and accents typed in their place (
',’,‘,ʻ,ʼ,´,`,′), soMcDonald'sbecomesmcdonalds,OʻahubecomesoahuandПʼятницяbecomesпятниця; - folds Arabic and Persian letter variants that people type interchangeably in names (alef forms,
ى/ی→ي,ک→ك,ة→ه) and removes short-vowel marks and tatweel, soأحمد,احمدandمُحَمَّد/محمدmatch; - maps every decimal digit to ASCII, so
٧,۷and๗match7; - writes hiragana as katakana (
すし→スシ) and traditional Chinese characters as simplified (麥當勞→麦当劳, using ICU’sTraditional-Simplifiedtransliterator), so either spelling finds the other. Japanese kanji that have a simplified Chinese form are folded too (東京→东京), which only matters if you display lexemes.
A word with an elided French or Italian article or pronoun (L'Occitane, l’École, dell'Arte) or the Arabic article (المطعم, والمطعم) gets two lexemes at its position, the whole word and the word without the article, so a search for either L'Occitane or occitane, المطعم or مطعم, finds it. A filtering dictionary can only pass one lexeme on, so for these words intl_normalize returns the final lexemes itself, already accent-folded like unaccent would, and later dictionaries don’t see them. A search that includes the article still needs it in the name: المطعم doesn’t find a name written مطعم.
A word with a lowercase letter followed by an uppercase one (CarrefourSA, McDonald's) gets the whole word and its parts at its position, as two variants: carrefoursa, and carrefour with sa. A search for carrefour then finds CarrefourSA, and a search for CarrefourSA finds either the whole word or both parts, so Carrefoursa and CARREFOURSA still match.
intl_search then strips Latin, Greek and Cyrillic accents with unaccent (café → cafe, ё → е). unaccent leaves Thai tone marks and Indic vowel signs alone, which a generic “remove all combining marks” step would break. ts_headline still shows the original text.
Words written together or apart
Names are spelled with and without spaces and dashes: 7-Eleven, 7 Eleven, 7Eleven; Sài Gòn and Saigon; Mc Donald's and McDonald's.
The parsers split a token where digits meet letters, so 7Eleven gives 7 and eleven, the same as 7-Eleven and 7 Eleven, and all three find each other. 3.5km gives 3.5 and km.
Letters written apart and together only match if you turn on intl_join, the last dictionary in intl_search and intl_search_ngram. By default it just lowercases, like simple. With max_length set, when only spaces or dashes separate two words and one of them has at most max_length characters, it also indexes the two run together at the second word’s position, as a separate variant:
ALTER TEXT SEARCH DICTIONARY intl_join (max_length = 3);
-- then REINDEX every index built with intl_search or intl_search_ngram
SELECT phraseto_tsquery('intl_search', 'Mc Donalds');
-- 'mc' <-> ( 'donalds' | 'mcdonalds' )
So mcdonalds finds Mc Donald's, saigon finds Sài Gòn, and walmart finds Wal Mart. Words in Chinese, Japanese, Korean, Thai, Lao, Khmer and Burmese are never joined, and neither are words separated by other punctuation (Cafe, Shop).
It’s off by default because each joined pair is a new, nearly unique lexeme. On 500,000 English Wikipedia titles the intl_search index grows from 23 MB to 31, 36 and 44 MB with max_length 2, 3 and 4, and to 64 MB when joining every pair (max_length = 100). Chinese grows by 10–30%.
A phrase can’t match across a different number of words: Mc Donalds doesn’t find Mcdonalds, which has no case change to split at, and Carrefour SA doesn’t find CarrefourSA, whose parts share one position. plainto_tsquery finds the second.
intl_join relies on the parser telling it which token is current, so it only joins words inside to_tsvector, to_tsquery and ts_headline with this extension’s parsers. ts_debug and ts_lexize show the lexemes without the joined word.
Matching inside words
ICU keeps some long runs as a single token: Korean puts particles and branch names directly onto a word (서울의, 스타벅스안양평촌점), and ICU’s Chinese and Japanese dictionaries keep compounds whole (中村公園店, 駅前). A whole-word search for 스타벅스, 公園 or 駅 misses those.
For tokens made entirely of Han, kana or Hangul, intl_ngram indexes every character and every pair of adjacent characters, all at the token’s position. A query goes through the same expansion, and to_tsquery ANDs lexemes that share a position:
SELECT phraseto_tsquery('intl_search_ngram', '스타벅스 안양');
-- ( '스' & '타' & '벅' & '스' & '스타' & '타벅' & '벅스' ) <-> ( '안' & '양' & '안양' )
So inside a single token, a search matches the way LIKE '%…%' does, and phrase operators still enforce word order between tokens. ICU’s word boundaries still apply: 京都 doesn’t match 東京|都. The price is overmatch inside a token, the same kind substring search has everywhere: 스타벅스 also matches 파스타벅스 (a pasta restaurant), and 主教 (bishop) matches 天主教 (Catholicism). Pieces only need to appear somewhere in the token, not necessarily adjacent, so a long token can occasionally match a query whose characters it contains out of order.
Thai, Lao, Khmer and Burmese have the opposite problem: ICU splits names it doesn’t know at the wrong places. เซเว่นอีเลฟเว่น (7-Eleven) comes out as เซ|เว่|นอีเลฟเว่น, so เซเว่น never appears as a token. The intl_runs parser therefore keeps each unspaced run of those scripts as one token, and does the same for kana, since ICU’s dictionary splits hiragana words (らーめん → ら|ー|めん) while keeping katakana ones whole (ラーメン), and intl_ngram turns it into every pair and every three adjacent grapheme clusters, so vowel and tone marks stay with their consonant:
SELECT ts_lexize('intl_ngram', 'กาแฟ');
-- {กา,าแ,แฟ,กาแ,าแฟ}
SELECT to_tsvector('intl_search_ngram', 'เซเว่นอีเลฟเว่น') @@ phraseto_tsquery('intl_search_ngram', 'เซเว่น');
-- t
A search then matches anywhere inside a run, like pg_trgm does, and a single-cluster search only matches a run that is exactly that cluster. Words separated by spaces are still separate tokens for phrase queries. A run mixed with other scripts (กาแฟcoffee), and tokens in other scripts, pass through unchanged.
Build your own configuration on the parser to add stemming or stop words:
CREATE TEXT SEARCH CONFIGURATION intl_english (PARSER = intl);
ALTER TEXT SEARCH CONFIGURATION intl_english
ADD MAPPING FOR word WITH intl_normalize, unaccent, english_stem;
ALTER TEXT SEARCH CONFIGURATION intl_english
ADD MAPPING FOR number, kana, ideo WITH intl_normalize, simple;
Token types
| id | alias | ICU rule status |
|---|---|---|
| 2 | word |
UBRK_WORD_LETTER |
| 12 | blank |
UBRK_WORD_NONE: spaces, punctuation, symbols |
| 22 | number |
UBRK_WORD_NUMBER |
| 24 | kana |
UBRK_WORD_KANA |
| 25 | ideo |
UBRK_WORD_IDEO |
The ids reuse the default parser’s word, blank and uint ids, because ts_headline uses the built-in prsd_headline, which hardcodes those ids.
A token is number when it starts with a digit. Tokens are split where digits meet letters, so v1.2.3 gives v and 1.2.3, and ABC123 gives ABC and 123. Chinese and Japanese words found through ICU’s dictionary come out as ideo, kana included, so map kana and ideo together.
ICU version
intl_icu_version() returns the version of the ICU library the server process loaded, for example 78.3. Record it next to your indexes, since a change means they need a REINDEX (see below).
Caveats
- Segmentation depends on the ICU version. A different ICU release can split the same text differently, the same way an ICU upgrade can change collation order. Run
REINDEXon indexes built with this parser after PostgreSQL starts linking against a new ICU major version. Until you do, rows indexed under the old segmentation can be missed. - Dictionary segmentation isn’t perfect for names. ICU’s dictionaries cover ordinary vocabulary. Transliterated brand names (
星巴克,สตาร์บัคส์) often split into fragments. Queries go through the same parser and usually split the same way, soplainto_tsqueryandphraseto_tsquerystill match, but segmentation depends on context and can differ between a name on its own and the same name inside a longer string. If you need exact substring matching in these scripts, use n-gram indexes (pg_trgm,pg_bigm). - Prefix queries are unreliable for partial CJK words.
to_tsquery('intl_simple', 'ラー:*')segmentsラーintoラ <-> ー, which doesn’t match the single lexemeラーメン. Prefixes that end on a word boundary work, as do Latin prefixes. intl_simpledoesn’t normalize. A decomposedé(e+ U+0301) and a precomposedégive different lexemes, as do half-width and full-width forms. Useintl_search, or putintl_normalizein your own configuration.- Japanese is segmented by vocabulary, not morphology. ICU has no inflection rules, so conjugated verbs fragment:
食べましたbecomes食 / べ / ま / した. Nouns and names, the usual search targets, segment well. For full morphological analysis, look at MeCab-based tools. - Different splitting for Latin text than
default. ICU follows Unicode word boundary rules (UAX #29):7-Elevengives7andeleven,McDonald'sstays one token, and URLs and email addresses are not recognized as single tokens. This extension also splits where digits meet letters, so1e10gives1,eand10.
Performance
Measured on Wikipedia article titles, which are short, name-like strings: 500,000 titles each for English, Chinese, Japanese and Korean, and 366,332 for Thai. PostgreSQL 18.4, ICU 78.3, Apple M4 Pro, one backend, median of 5 runs. Full tables and method notes, including intl_simple, are in bench/results.md.
Time to tokenize one title, net of the table scan:
| default parser | intl_search | intl_search_ngram | pg_trgm | pg_bigm | |
|---|---|---|---|---|---|
| English | 0.75 µs | 1.00 µs | 1.08 µs | 1.22 µs | 0.93 µs |
| Chinese | 0.46 µs | 2.11 µs | 2.33 µs | 0.77 µs | 0.48 µs |
| Japanese | 0.60 µs | 2.37 µs | 2.66 µs | 1.02 µs | 0.66 µs |
| Korean | 0.56 µs | 1.06 µs | 1.41 µs | 0.90 µs | 0.63 µs |
| Thai | 1.09 µs | 2.50 µs | 3.43 µs | 2.23 µs | 1.58 µs |
GIN index size:
| default parser | intl_search | intl_search_ngram | pg_trgm | pg_bigm | |
|---|---|---|---|---|---|
| English | 25.5 MB | 23.3 MB | 23.4 MB | 29.5 MB | 20.4 MB |
| Chinese | 38.2 MB | 13.3 MB | 17.1 MB | 72.0 MB | 41.8 MB |
| Japanese | 39.6 MB | 18.2 MB | 20.7 MB | 63.7 MB | 33.9 MB |
| Korean | 30.1 MB | 28.7 MB | 24.5 MB | 48.9 MB | 24.3 MB |
| Thai | 37.6 MB | 13.6 MB | 45.5 MB | 23.4 MB | 18.8 MB |
LANGUAGES.md reports, language by language, where textsearch_intl works well and where it falls short.
200 single-word queries per language, each a word that intl_simple produced from a held-out set of titles, written in the language’s own script. Recall and overmatch are measured on 250 term/title pairs per language, drawn at random from all titles that contain a query term as a substring, and labeled in bench/labels.csv as a real word match or an overmatch (first pass by Claude, spot-checked; corrections welcome) (see the labeling rule). Average time per query, recall, overmatch:
| default parser | intl_search | intl_search_ngram | pg_trgm | pg_bigm | |
|---|---|---|---|---|---|
| Chinese | 0.04 ms, 10%, 0% | 0.18 ms, 93%, 16% | 0.34 ms, 99%, 18% | 23.1 ms, 100%, 18% | 0.11 ms, 100%, 18% |
| Japanese | 0.04 ms, 7%, 0% | 0.14 ms, 86%, 20% | 0.53 ms, 100%, 54% | 19.9 ms, 100%, 54% | 0.28 ms, 100%, 54% |
| Korean | 0.07 ms, 47%, 0% | 0.08 ms, 48%, 1% | 0.40 ms, 100%, 22% | 17.7 ms, 100%, 22% | 0.14 ms, 100%, 22% |
| Thai | 0.04 ms, 5%, 0% | 0.29 ms, 79%, 44% | 0.59 ms, 98%, 69% | 17.2 ms, 100%, 77% | 1.09 ms, 100%, 77% |
Recall rests on 204 real matches for Chinese, 115 for Japanese, 195 for Korean and 57 for Thai, so the Thai figure is good to about ±10 points.
What this shows:
- The default parser can’t find words in unspaced scripts. It finds 5–10% of real Chinese, Japanese and Thai matches, only where the word happens to be the whole run between spaces or punctuation.
- intl_search finds most real matches in Chinese and Japanese, 93% and 86%, with far less overmatch than substring search in Japanese and Thai. What it misses is the term inside a dictionary compound:
機場(airport) doesn’t find國際機場(international airport), because ICU keeps the compound as one word. - intl_search does nothing for Korean. Particles and suffixes attach to nouns (
서울은,스타벅스커피), and word segmentation doesn’t split them off. - intl_search_ngram closes those gaps. Korean recall goes from 48% to 100%, Chinese from 93% to 99%, Japanese from 86% to 100% and Thai from 79% to 98%. In exchange it overmatches like the substring methods do, because inside Korean, CJK and Thai runs it is a substring match. It leaves English and other spaced scripts exactly as intl_search does.
- ICU breaks up names it doesn’t know. Unfamiliar transliterated names come out as fragments that are real tokens (
อง,ซ์in Thai;ンズ,ジョin Japanese). Those fragments match inside unrelated names, which is where intl_search’s Thai and Japanese overmatch comes from. It also biases this benchmark: the query terms are drawn from the tokenizer’s own output, so they include such fragments, which nobody would search for. - The n-gram methods return every substring. They score 100% recall by definition, and the overmatch column shows what that costs.
- pg_trgm can’t use its index for one- and two-character terms, which most CJK words are. It used the index for only 22 of 200 Chinese queries, and the rest scanned the table. pg_bigm gives the same results at index speed.
- Cost: intl_search takes 2–5× the default parser’s time to tokenize Chinese, Japanese and Thai, and intl_search_ngram a little more (3.4 µs per Thai title). intl_search’s indexes are the smallest for Chinese, Japanese and Thai. intl_search_ngram’s are smaller than pg_bigm’s for Chinese and Japanese and the same size for Korean, but for Thai, whose runs get both bigrams and trigrams, it is about twice the size of pg_trgm’s.
Reproduce with ./bench/run.sh (needs curl, psql, pg_trgm and optionally pg_bigm). It downloads the title dumps into bench/data/, scores every method against bench/labels.csv, and writes bench/results.md. To label a fresh sample, for example after changing the dump or the term rules, run psql -d textsearch_intl_bench -v per_lang=250 -f bench/pairs.sql > pairs.csv after a run, add a label column, and save it as bench/labels.csv. BENCH_SAMPLE, BENCH_RUNS, BENCH_LANGS and BENCH_DUMP change the sample size, repetitions, languages and dump date. LC_CTYPE must be a UTF-8 locale, or pg_trgm treats every CJK and Thai character as a separator; the script picks C.UTF-8 when it exists.
Tests
make installcheck
The regression suite in sql/ covers segmentation across scripts, edge cases, query operators, ts_headline, custom configurations, GIN and GiST indexes, parallel workers, and the non-UTF-8 error. CI runs it on PostgreSQL 14 through 18.