Home › Sorting, searching & vector search › Text retrieval
algorithm low

Unicode text tokenization for search

Problem A search engine over user text in many languages must make different spellings of the same word (case, accents, ligatures, fullwidth, curly apostrophes, CJK) match, when building the index and when handling queries.

Turn multilingual text into index terms: segment words (UAX 29 style, CJK bigrams), normalize, case-fold and accent-fold, identically at index and query time.

O(n) in text length; order of 50-250 MB/s per core
tokenizationunicodenormalizationfull-text-searchanalyzers
Overview

Search only matches what the analyzer makes equal. "Café", "CAFE", the ligature "file", fullwidth "Full" and "don’t" with a curly apostrophe must become the same terms as their plain forms. Segment the text into word spans (letters, digits, combining marks, a few joiners), with overlapping bigrams for CJK, which has no spaces. Then normalize each span: NFKD, lowercase, optionally drop accents, NFC.

Key facts

Use when

  • Building an inverted index for BM25 over user text in many languages (search engines: Lucene, Elasticsearch, Tantivy, Meilisearch).
  • Databases with built-in full-text search (SQLite FTS5 unicode61, PostgreSQL to_tsvector).
  • Any place where visually or semantically equal strings must compare equal: Python normalizes identifiers to NFKC while parsing (PEP 3131).

Avoid when

  • Neural models: use their own tokenizer (BPE).
  • Exact byte matching (code, identifiers).
  • Thai, Lao, Khmer, Japanese with high precision needs: dictionary segmenters.

Deep dive

How it works

In: a UTF-8 text · Out: normalized index terms with byte offsets · State: none (the analyzer version is stored with the index) · Cost: O(n), 255 MB/s segmentation and 41 MB/s with normalization (measured below)

  1. Segment (on the original text, keeping byte offsets for highlighting):
    • A word is a maximal run of letters, digits and combining marks.
    • A joiner continues the word only between suitable neighbours: an apostrophe between letters (don't), . or , between digits (3.14, 1,000), _ anywhere (snake_case). This is a small subset of UAX #29's MidLetter, MidNum and ExtendNumLet rules.
    • Runs of Han, Kana or Hangul become overlapping bigrams (a single character stays a unigram).
    • Everything else (spaces, punctuation, symbols, emoji) separates tokens.
  2. Normalize each token:
    • NFKD maps compatibility characters (fi → fi, fullwidth → ASCII, ½ → 1⁄2) and splits accents into base + combining mark.
    • Lowercase, and map ’ to '.
    • Optionally drop combining marks after Latin letters (é → e).
    • NFC recomposes what remains.
  3. Optional filters: stop words, stemming (Snowball), synonyms. All of them sit at the same position in the index and query pipelines.
"Don't stop—the Café’s 3.14 MB_file"
 segment : Don't | stop | the | Café’s | 3.14 | MB_file
 normalize: don't  stop   the   cafe's   3.14   mb_file
"東京大学" -> 東京 京大 大学        "नमस्ते" -> one token (vowel signs and virama are combining marks)

Code

Uses the unicode-normalization crate for the NFKD and NFC tables; the rest uses std only.

use unicode_normalization::{char::is_combining_mark, UnicodeNormalization};

#[derive(Debug, PartialEq)]
pub struct Token { pub term: String, pub start: usize, pub end: usize } // byte offsets into the input

fn is_cjk(c: char) -> bool { // Hiragana, Katakana, CJK ideographs (+ext A, compat, ext B+), Hangul syllables
    matches!(c as u32, 0x3040..=0x30FF | 0x3400..=0x4DBF | 0x4E00..=0x9FFF
        | 0xF900..=0xFAFF | 0xAC00..=0xD7AF | 0x20000..=0x323AF)
}
// Combining marks are part of words: decomposed accents, Devanagari vowel signs and viramas.
fn is_word(c: char) -> bool { (c.is_alphanumeric() || is_combining_mark(c)) && !is_cjk(c) }

/// A joiner char stays inside a word only between the right kinds of neighbours (UAX #29 style).
fn joins(prev: char, mid: char, next: char) -> bool {
    match mid {
        '\'' | '\u{2019}' => prev.is_alphabetic() && next.is_alphabetic(), // don't, l’homme
        '.' | ',' => prev.is_numeric() && next.is_numeric(),              // 3.14, 1,000
        '_' => true,                                                      // snake_case
        _ => false,
    }
}

/// Word tokens for letters and digits, overlapping bigrams for CJK runs (no dictionary needed).
pub fn segment(text: &str) -> Vec<(usize, usize)> {
    let cs: Vec<(usize, char)> = text.char_indices().collect();
    let end = |j: usize| if j < cs.len() { cs[j].0 } else { text.len() };
    let (mut out, mut i) = (Vec::new(), 0);
    while i < cs.len() {
        let c = cs[i].1;
        if is_cjk(c) {
            let mut j = i;
            while j < cs.len() && is_cjk(cs[j].1) { j += 1; }
            if j - i == 1 { out.push((cs[i].0, end(j))); }
            for k in i..j - 1 { out.push((cs[k].0, end(k + 2))); } // 東京大学 -> 東京 京大 大学
            i = j;
        } else if is_word(c) {
            let mut j = i + 1;
            loop {
                if j < cs.len() && is_word(cs[j].1) { j += 1; }
                else if j + 1 < cs.len() && is_word(cs[j + 1].1) && joins(cs[j - 1].1, cs[j].1, cs[j + 1].1) { j += 2; }
                else { break; }
            }
            out.push((cs[i].0, end(j)));
            i = j;
        } else {
            i += 1; // spaces, punctuation, symbols, emoji
        }
    }
    out
}

/// Search-key normalization: compatibility decomposition, lowercase, optional Latin accent folding, NFC.
pub fn normalize(raw: &str, fold_accents: bool) -> String {
    let mut base = ' ';
    let lower = raw.nfkd().flat_map(char::to_lowercase).map(|c| if c == '\u{2019}' { '\'' } else { c });
    let folded = lower.filter(move |&c| {
        if !is_combining_mark(c) { base = c; return true; }
        !(fold_accents && base <= '\u{024F}') // drop marks only after Latin letters; keep Indic vowel signs
    });
    folded.nfc().collect()
}

pub fn tokenize(text: &str, fold_accents: bool) -> Vec<Token> {
    segment(text).into_iter()
        .map(|(start, end)| Token { term: normalize(&text[start..end], fold_accents), start, end })
        .collect()
}

Complexity and constants

  • Linear in input. Measured on 15 MB of English with some accents (one Apple M-series core): segment 255 MB/s (about 5.7 bytes per token), full tokenize 41 MB/s. The cost is in normalization (iterator chains and one String per token).
  • An ASCII fast path (when a token's bytes are all < 0x80, lowercase them in place and skip normalization) recovers most of the gap on mostly-ASCII text.
  • CJK bigrams: n − 1 terms for an n-character run, so the index is larger and posting lists are longer than with word segmentation. Phrase queries on bigrams restore precision.
  • Normalization tables: order of 100 KB of static data in unicode-normalization or ICU.

Efficiency

  • Cost: linear in the input. Measured on 15 MB of English with some accents, one Apple M-series core: segment 255 MB/s (about 5.7 bytes per token), full tokenize 41 MB/s; the cost is normalization (iterator chains and one String per token). Memory: order of 100 KB of static normalization tables; CJK bigrams give n − 1 terms for an n-character run. Bound: compute in the per-token normalization.
  • Levers:
    • ASCII fast path (all bytes < 0x80: lowercase in place, skip normalization) — recovers most of the 255 → 41 MB/s gap (order of) — mostly-ASCII text
    • tokenize the original, normalize per token — valid highlight offsets and no merged tokens — always
    • reuse buffers instead of a String per token — fewer allocations (order of) — high-volume indexing
    • cap token length at order of 255 bytes — keeps base64 blobs and URLs out of the dictionary — web and log text
    • bigrams plus phrase queries for CJK — precision without a dictionary segmenter — CJK text, at the price of longer posting lists
  • Naive → standard → state of the art:
    • split on whitespace, to_lowercase · fast, but punctuation sticks and accents or ligatures do not match
    • UAX #29 segmentation, NFKD, case folding, NFC · 41 MB/s in the code above
    • the same pipeline with ASCII fast paths and dictionary segmenters where needed · segmentation speed (255 MB/s) on ASCII-heavy text

Implementation notes

  • Tokenize the original, normalize per token: offsets stay valid for highlighting, and normalization cannot merge or split tokens unexpectedly.
  • Pin the Unicode version: normalization and character properties change between Unicode versions. Store the analyzer version with the index and reindex on upgrades.
  • Case folding: Rust std has only to_lowercase (full lowercase mapping, context-free except final sigma in str::to_lowercase). For case-insensitive matching use full case folding (CaseFolding.txt, ß → ss), as ICU and caseless do.
  • Accent folding is language-specific: fine for English and French search, but it merges Spanish año/ano and German schön/schon. Index both folded and unfolded terms, or score exact matches higher. Never strip marks from Indic or Thai scripts, where they are vowels.
  • Apostrophes and hyphens: decide once whether e-mail is e + mail or email, and whether l'homme should also produce homme. Search engines often emit both.
  • Numbers: keep 3.14 and 1,000 intact, but beware locale: 1.000 is one thousand in German.
  • Emoji and symbols: dropped above. Index them as separate terms if users search for them.
  • Max token length: cap at order of 255 bytes to stop base64 blobs and URLs from bloating the dictionary.

Pitfalls

  • Different pipelines at index and query time (a later NFKC upgrade, a different lowercase function): silent recall loss.
  • Splitting on char::is_whitespace only: punctuation sticks to words (end., "quoted").
  • Treating combining marks as separators: Hindi, decomposed é (e + U+0301) and Vietnamese break into fragments.
  • Byte-slicing UTF-8 at non-boundaries when computing offsets: panics in Rust, garbage elsewhere. Use char_indices.
  • NFKC on content you display: it changes the text (² → 2, fi → fi). Normalize keys only and keep the original.
  • Turkish and Azerbaijani dotless ı and dotted İ need locale-aware lowercasing. Without it, ISTANBUL and istanbul match but Turkish users see odd results.
  • Stop-word lists remove meaningful queries ("The Who", "to be or not to be"). Modern BM25 setups often keep stop words.

Variants and alternatives

Full UAX #29 word segmentationthe unicode-segmentation crate, ICU BreakIterator, Lucene StandardTokenizer.
Dictionary or ML segmentersICU (Thai, Lao, Khmer, Myanmar, CJK dictionaries), Kuromoji or Lindera (Japanese), jieba (Chinese), MeCab.
Character n-grams (trigrams)language-agnostic, robust to typos; larger index; used for substring search.
StemmingPorter or Snowball stemmers fold running → run; lemmatizers are more precise but slower.BPE and other subword tokenizersfor neural models and learned sparse retrieval, not for classic term matching.

In production

Search engines: Lucene, Elasticsearch and OpenSearch analyzers (standard tokenizer implements UAX #29; icu_tokenizer, cjk_bigram filter, asciifolding), Databases: SQLite FTS5 unicode61 tokenizer (case folding and diacritic removal by default), PostgreSQL full-text search (to_tsvector with parser + dictionaries). More search engines: Tantivy tokenizers and Meilisearch's charabia. Programming languages: identifiers normalized to NFKC in Python (PEP 3131).

Implementations

namelangrepoinstallverdictlicencehealthas_ofnotes
charabiaRustmeilisearch/charabiacargo add charabia————multilingual segmentation and normalization pipeline
unicode-segmentationRustunicode-rs/unicode-segmentationcargo add unicode-segmentation————UAX #29 word and grapheme boundaries
unicode-normalizationRustunicode-rs/unicode-normalizationcargo add unicode-normalization————NFC, NFD, NFKC, NFKD
ICU4XRustunicode-org/icu4xcargo add icu————segmenter (dictionary-based CJK, Thai), normalizer, case mapping
LinderaRustlindera/linderacargo add lindera————dictionary morphological analyzer for Japanese, Korean, Chinese

Sources

  • Unicode Standard Annex #29, Unicode Text Segmentation (unicode.org/reports/tr29). spec
  • Unicode Standard Annex #15, Unicode Normalization Forms (unicode.org/reports/tr15). spec
  • The Unicode Standard, Chapter 5 Implementation Guidelines (caseless matching) and CaseFolding.txt. data
  • M. F. Porter, An Algorithm for Suffix Stripping, Program, 1980. paper
  • C. D. Manning, P. Raghavan, H. Schütze, Introduction to Information Retrieval, Cambridge University Press, 2008 (Chapter 2). book
  • PEP 3131, Supporting Non-ASCII Identifiers (NFKC normalization of identifiers). spec
  • SQLite documentation, FTS5 Extension (unicode61 tokenizer). docs

Builds on

Used by

address parsing · plannedSplit free-text addresses into components (house, street, city, postcode) with rules or a sequence model
BM25 rankingA search box over documents must order results by relevance to the typed keywords, favouring rare terms without letting repeated words or long documents dominate. Also the first-stage retriever for RAG.
font fallback itemization · plannedSplit text into runs by script, direction and font coverage; pick fallback fonts per run
HTML main-text extractionSystems that index, summarize, archive or train on web pages receive full documents (navigation, ads, footers, scripts) when they need only the article text, in the right order, with headings and metadata.
log template mining · plannedExtract log templates and parameters by prefix-tree clustering (Drain)
markdown parsing · plannedCommonMark two-phase parsing: block structure, then inline delimiter runs
text shaping · plannedMap Unicode to positioned glyphs via OpenType GSUB/GPOS lookups, clusters and complex scripts
unicode bidi algorithm · plannedUAX 9: resolve embedding levels and reorder mixed left-to-right and right-to-left text
Unicode confusablesAnyone who lets outsiders pick names that others must recognise, such as usernames, domains, package names, payee names or variable names, can be impersonated by a name that looks identical but is a different string. How do you detect that at registration or review time?

Systems (21):

Alternatives and related