kaiji — CJK Fuzzy Match & Normalization Engine
kaiji is a high-performance Rust library that solves the "variant character" problem endemic to Japanese, Chinese, and Korean text processing.
斎藤 ≡ 齋藤 ≡ 齊藤 · 渡辺 ≡ 渡邊 ≡ 渡邉 · 吉野家 ≡ 𠮷野家 · 広島 ≡ 廣島
The Problem
Standard Unicode normalization (NFC / NFD) fixes form differences but leaves semantic CJK variants untouched. The result: the same name or word stored in two systems looks different to a computer, even though every human reader knows they are identical.
| Symptom | Root Cause |
|---|---|
| eKYC rejects a customer whose name uses old kanji | OCR output ≠ user input |
| RAG/LLM embeds 齋藤 and 斎藤 as separate tokens | Tokenizer sees different bytes |
| Search returns 0 results for "𠮷野家" | EC database stored "吉野家" |
| Property registry cannot be joined to CRM | 登記簿 uses 旧字体, CRM uses 新字体 |
Each team works around it with bespoke regex lists or giant REPLACE() chains. There is no
authoritative, fast, cross-language core — until now.
Performance
Benchmarked on Apple M2 (single thread, cargo bench, optimized release build).
Bulk normalization throughput
| Batch size | Time | Throughput |
|---|---|---|
| 1,000 rows | 42 µs | ~250 MiB/s |
| 100,000 rows | 4.25 ms | ~251 MiB/s |
| 1,000,000 rows | 42 ms | ~252 MiB/s |
Throughput is flat across all batch sizes — the library scales linearly with no overhead. Extrapolating: 10 million rows ≈ 420 ms.
Single-string latency
| Input | Scenario | Latency |
|---|---|---|
| 1 char (斉) | already canonical — zero allocation | 5.8 ns |
| 1 char (齋) | variant fold required | 22 ns |
| 4 chars (斉藤一郎) | clean input | 17 ns |
| 4 chars (齋藤一郎) | 1 variant to fold | 50 ns |
| 30 chars | clean input | 132 ns |
| 30 chars | mixed variants | 236 ns |
| 5,000 chars | clean | 29 µs |
| 5,000 chars | mixed variants | 36 µs |
Why kaiji over existing tools
| Tool | What it covers | JP variants | CN variants | IVS | WASM | Language |
|---|---|---|---|---|---|---|
| mojimoji | Fullwidth ↔ halfwidth only | ✗ | ✗ | ✗ | ✗ | Python |
| jaconv | Kana + historical kana + romaji (Python only) | ✗ | ✗ | ✗ | ✗ | Python |
| ja_cvu_normalizer | JP variant chars (dict-based) | △ partial | ✗ | ✗ | ✗ | Python |
| normalize-japanese-addresses | JP addresses only | ✗ | ✗ | ✗ | ✓ | JS/TS |
| OpenCC | CN simplified ↔ traditional | ✗ | ✓ | ✗ | △ heavy | C++/Python/Node |
| ICU / CLDR | Unicode NFC/NFKC only | ✗ | ✗ | ✗ | ✗ | C++/Java |
| kaiji | Variants + width + kana + historical kana + romaji + IVS + corpus search | ✓ 446+ | ✓ | ✓ | ✓ lightweight | Rust/Python/WASM/CLI |
kaiji is the first library to unify all of these capabilities in a single, compile-to-anywhere Rust core.
What kaiji Solves
| Capability | Description |
|---|---|
| Variant folding | Maps 300+ old/traditional/variant CJK characters to their canonical new forms (JIS X 0213, 人名用漢字, Traditional Chinese) |
| IVS stripping | Removes Ideographic Variation Sequences (U+E0100–U+E01EF) that are invisible in most fonts but cause byte-level mismatches |
| Width normalization | Fullwidth ASCII (ABC→ABC), halfwidth katakana (ガ→ガ) with dakuten composition |
| Zero-copy fast path | Input with no variants is returned as a borrowed &str slice — no allocation |
| Fuzzy matching | matches("齋藤", "斎藤") → true in a single call |
| Kana normalization | Hiragana↔Katakana conversion — ひらがな→ヒラガナ or カタカナ→かたかな |
| Historical kana | Obsolete kana to modern: ゐ→い, ゑ→え, を→お, ぢ→じ, づ→ず / ヰ→イ, ヱ→エ, ヲ→オ, ヂ→ジ, ヅ→ズ |
| Romaji conversion | Kana → Modified Hepburn romaji with long-vowel collapse: サトウ→"sato", トウキョウ→"tokyo" |
| Katakana halfwidth | Fullwidth katakana → halfwidth with dakuten decomposition: ガ→ガ, パ→パ |
| Modular pipeline | Enable only what you need via NormalizerConfig or the builder API |
Quick Start
First-class bindings (publish to pip / npm / crates.io): Rust · Python · JavaScript / TypeScript · CLI
Advanced bindings (require building the Rust native library locally): Go · Java / Kotlin
Rust
[]
= { = "0.1", = ["japanese"] }
use ;
// Simple fuzzy name matching
assert!; // 斎 vs 齋
assert!; // 辺 vs 邊
assert!; // tsuchi-yoshi vs kichi
// Normalization (zero-copy when input is already canonical)
let s = normalize_default.unwrap;
assert_eq!;
Python
# "斉藤"
# True
# 1.0
=
# "ABC斉藤"
# Batch normalization (pandas-friendly)
# → ["斉藤", "渡辺", "吉野家"]
# Corpus index — fuzzy search over N records
=
= # threshold 0.0–1.0
. # "斎藤一郎"
. # 1.0
CLI
# macOS (Homebrew)
# Cross-platform (Rust)
# stdin → stdout normalization
|
# 斉藤一郎
# with fullwidth conversion
|
# ABC斉藤
# fuzzy match (exit 0 = match, exit 1 = no match)
&&
# similarity score
# 0.9333
# batch normalization with tsv or json output
|
|
JavaScript / TypeScript (WebAssembly)
import init from "kaiji-wasm";
await ;
; // "斉藤"
; // true
; // 1.0
const n = ;
n.; // "ABC斉藤"
Go
Note: This binding requires building the Rust C shared library locally before use. See the instructions below.
# 1. Build the native library
# 2. Add the Go module
import (
"fmt"
kaiji "github.com/kent-tokyo/kaiji/bindings/go"
)
// Set CGO_LDFLAGS to point to the compiled library before building.
// export CGO_LDFLAGS="-L/path/to/crates/kaiji-c/target/release"
result, _ := kaiji.Normalize("齋藤") // "斉藤"
matched, _ := kaiji.Matches("斎藤", "齋藤") // true
score, _ := kaiji.Similarity("斎藤", "齋藤") // 1.0
fmt.Println(result, matched, score)
Java / Kotlin
Note: This binding requires building the Rust JNI shared library locally before use. See bindings/java/README.md for full setup instructions.
Builder API (recommended, Rust)
use Normalizer;
let n = builder
.width_normalization // ABC → ABC, ガ → ガ
.fold_variants // 齋 → 斉
.strip_ivs // remove invisible variation selectors
.build;
assert_eq!;
assert!;
// Kana normalization (jaconv replacement)
let n = builder.kana_to_katakana.build;
assert_eq!;
let n = builder.kana_to_hiragana.build;
assert_eq!;
Corpus index (N-record fuzzy search)
[]
= { = "0.1", = ["japanese", "index"] }
use ;
// Build once — all corpus strings are normalised and stored in an FST
let corpus = vec!;
let index = build?;
// Query — variant-form input still matches the canonical entry
let hits = index.search?;
// hits[0].original == "斎藤一郎", hits[0].score == 1.0
Strings that normalise to the same form share a slot — both originals are returned when that slot matches. Results are sorted by Jaro-Winkler score descending.
Cargo features
[]
= { = "0.1", = ["japanese"] } # default
= { = "0.1", = ["japanese", "nfkc"] } # + NFKC normalization
= { = "0.1", = ["japanese", "index"] } # + FST corpus index
= { = "0.1", = ["full"] } # all features (excl. index)
Use Cases
1. NLP / LLM Pre-processing (Data Engineers, ML Engineers)
Normalise millions of rows before tokenisation so that variant-form characters don't produce duplicate embeddings or mislead your RAG retrieval pipeline. At ~250 MiB/s, a 10 million-row CSV of Japanese names normalises in under half a second.
2. eKYC & AML (FinTech / Security Engineers)
Match a user-entered name against an OCR-read identity document even when they use different kanji variants. Eliminate expensive manual review queues caused by byte-level mismatches.
3. Property Registry Matching (PropTech)
Join 旧字体-heavy 登記簿 records to modern CRM / GIS systems without maintaining a forest of
REPLACE() SQL functions that kill database performance.
4. E-Commerce Search (Frontend / Search Engineers)
Compile kaiji to WebAssembly and run it client-side: silently normalise the search query before it hits your index, eliminating "0 results" failures from variant-character input.
Architecture
src/
├── lib.rs — public re-exports
├── error.rs — CjkFuzzyError, Result<T>
├── config.rs — NormalizerConfig (#[non_exhaustive])
├── normalizer.rs — Normalizer builder + 4-stage pipeline runner
├── width.rs — Stage 1: fullwidth↔halfwidth, halfwidth-kana, NFKC
├── variants.rs — Stage 2: static OnceLock HashMap (300+ variant→canonical)
├── normalize.rs — Stage 2: normalize() → Cow<'a, str> (zero-copy fast path)
├── matcher.rs — matches() / matches_default()
└── index.rs — KaijiIndex: FST corpus index + Jaro-Winkler search (`index` feature)
Pipeline stages:
| Stage | What it does | Feature gate |
|---|---|---|
| 1a | Fullwidth ASCII → halfwidth, halfwidth kana → fullwidth (dakuten composition) | width_normalization: true |
| 1b | Unicode NFKC normalization | nfkc feature + nfkc: true |
| 2 | IVS strip + CJK semantic variant folding | strip_ivs, fold_variants |
| 3 | Word-level context conversion (OpenCC-style) | planned — chinese feature |
| 4 | Domain rules (address normalization etc.) | planned |
The variant map is built once at first call via std::sync::OnceLock and shared across threads
with no further locking. normalize() borrows the input unchanged when no substitution is needed,
avoiding any heap allocation on the hot path.
Roadmap
| Status | Item |
|---|---|
| [done] | Stage 2: variant folding + IVS strip |
| [done] | Stage 1: width normalization (fullwidth, halfwidth kana, dakuten) |
| [done] | Normalizer builder API |
| [done] | Cargo features (japanese, chinese, nfkc, full) |
| [done] | Variant dictionary expanded to 446+ character families |
| [done] | similarity_score() (Jaro-Winkler on normalized strings) |
| [done] | Python bindings via PyO3 (pip install kaiji) |
| [done] | WebAssembly build via wasm-bindgen (npm install kaiji-wasm) |
| [done] | KaijiIndex — FST corpus index + Jaro-Winkler fuzzy search (index feature) |
| [done] | Japanese address normalization — 漢数字→Arabic, 丁目/番/号 unification (address feature) |
| [done] | Python bindings — normalize_batch(), Index class, SearchHit, type stubs (.pyi) |
| [done] | WASM KaijiIndex + SearchHit — JavaScript corpus search |
| [done] | Browser Playground — GitHub Pages |
| [done] | Homebrew formula — brew tap kent-tokyo/kaiji |
| [done] | CLI (kaiji-cli) — normalize, match, score subcommands |
| [planned] | Stage 3: OpenCC-style word-level conversion (chinese feature) |
See tasks/todo.md for the detailed task list.
Building & Testing
Why "kaiji"?
解字 (kaiji, かいじ) — "to analyze and resolve a character to its canonical form."
Kanji Analysis and IVS Jormalization Ingine
The name comes from Shuowen Jiezi (説文解字, "Explaining Simple and Analyzing Compound Characters"), the first systematic Chinese character dictionary, compiled by Xu Shen (許慎) around 100 CE. In that work, each entry starts from a character as it appears in one written form and resolves it back to its authoritative structure and meaning — stripping away regional or era-specific variation to arrive at the single canonical reading.
That is exactly what this library does: it takes any written form of a CJK character — old kanji,
traditional Chinese, IVS glyph, fullwidth variant — and resolves it to its canonical code point,
so that 齋藤 and 斎藤 are finally the same string to a computer.
License
MIT OR Apache-2.0