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)
- 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.
- 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.
- NFKD maps compatibility characters (
- 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):
segment255 MB/s (about 5.7 bytes per token), fulltokenize41 MB/s. The cost is in normalization (iterator chains and oneStringper 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-normalizationor ICU.
Efficiency
- Cost: linear in the input. Measured on 15 MB of English with some accents, one Apple M-series core:
segment255 MB/s (about 5.7 bytes per token), fulltokenize41 MB/s; the cost is normalization (iterator chains and oneStringper 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
Stringper 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
- split on whitespace,
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 instr::to_lowercase). For case-insensitive matching use full case folding (CaseFolding.txt,ß→ss), as ICU andcaselessdo. - Accent folding is language-specific: fine for English and French search, but it merges Spanish
año/anoand Germanschö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-mailise+mailoremail, and whetherl'hommeshould also producehomme. Search engines often emit both. - Numbers: keep
3.14and1,000intact, but beware locale:1.000is 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_whitespaceonly: 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,ISTANBULandistanbulmatch 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
unicode-segmentation crate, ICU BreakIterator, Lucene StandardTokenizer.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
| name | lang | repo | install | verdict | licence | health | as_of | notes |
|---|---|---|---|---|---|---|---|---|
| charabia | Rust | meilisearch/charabia | cargo add charabia | — | — | — | — | multilingual segmentation and normalization pipeline |
| unicode-segmentation | Rust | unicode-rs/unicode-segmentation | cargo add unicode-segmentation | — | — | — | — | UAX #29 word and grapheme boundaries |
| unicode-normalization | Rust | unicode-rs/unicode-normalization | cargo add unicode-normalization | — | — | — | — | NFC, NFD, NFKC, NFKD |
| ICU4X | Rust | unicode-org/icu4x | cargo add icu | — | — | — | — | segmenter (dictionary-based CJK, Thai), normalizer, case mapping |
| Lindera | Rust | lindera/lindera | cargo 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 (
unicode61tokenizer). docs