NFKC を使わずに、入力された日本語をそろえる
Normalizing typed Japanese without NFKC
D02-3-T03-text-normalization・技術・日本語入力でプレイする仕組み
「アリバイは?」と「ありばいは」は、照合の手前で同じ文字列になります。
「見ませんでしたか」と「見なかったか」も、同じになります。
推理ゲームの入力欄の、いちばん手前の一手の話です。
『霧走町 流氷に眠る』は、入力された日本語を、書き下ろした証言のどれに当てるかを決めるゲームです。
返事は一つも生成しません。
当てる前に、入力と辞書(意図ごとの言い換え)の両方を、同じ規則で「そろえ」ます。
この回は、その規則 norm/1 と、名前のある方法 Unicode 正規化(NFKC)との違いを書きます。
この回の位置と、前後の回#
区切り: T03〜T24 日本語入力を解いて返すまで(22 回のうち 1 回目)
先に読むとよい回: なし(この回だけで読めます)
次に読む回: T04(文字 n-gram と tf–idf)
名前 — Unicode Normalization Forms#
- 正式名
- Unicode Normalization Forms(Unicode Standard Annex #15、略して UAX #15)
- 略称
- NFC・NFD・NFKC・NFKD。この回で比べるのは NFKC(Normalization Form KC)
- 日本語の呼び名
- Unicode 正規化、互換正規化
- 初出
- UAX #15 の最初の承認版は Revision 15(1999 年 7 月 23 日)。Unicode 3.0 に結びついた版は Revision 18(1999 年 11 月 11 日)
NFKC という名前がどの版で初めて出たかは、確かめられませんでした。
あわせて使う一般の呼び名: width folding(全角・半角の畳み込み)、kana folding(カタカナ・ひらがなの畳み込み)、case folding(大文字・小文字の畳み込み)
NFKC は何をするものか#
NFKC(Normalization Form KC)とは、Unicode 正規化の 4 つの形の一つで、互換分解のあと正準合成をする形です。
Unicode には、同じ文字を表す符号が複数あります。
「が」は 1 文字(U+304C)でも、「か」+濁点の結合文字(U+304B U+3099)でも書けます。
こういう見た目も意味も同じものを canonical equivalence(正準等価)と呼びます。
もう一段ゆるい compatibility equivalence(互換等価)は、意味は同じだが見た目が違うものです。
全角の「A」と半角の「A」、半角カナの「ア」と全角の「ア」、丸数字の「①」と「1」、「㈱」と「(株)」などです。
正規化の形は 4 つです。
NFD は正準分解、NFC は正準分解のあと正準合成。
NFKD は互換分解、NFKC は互換分解のあと正準合成。
NFKC を通すと、半角カナの「バ」(ハ+半角濁点の 2 文字)は全角の「バ」1 文字になり、全角英数字は半角になり、全角空白はふつうの空白になります。
ふつうの使われ方は、検索の索引、識別子の比較、サブワード分割器の前処理です。
たとえば SentencePiece の既定の正規化は、NFKC に手を加えた nmt_nfkc です(明日の夜の T06 で使います)。
大文字・小文字もそろえたいときは、NFKC と case folding を組み合わせた NFKC_Casefold という派生があります。
よく知られた弱点もあります。
互換正規化は情報を捨てます。「²」は「2」になり、「㍉」は「ミリ」になります。元へは戻せません。
そして、日本語の検索で欲しい畳み込みの多くを、NFKC はしません。
カタカナとひらがなは別の文字のまま。句読点も消えません。長音の揺れ(ハル/ハール)も残ります。
だから日本語の検索系は、NFKC の後ろに別の処理を足すのが普通です。
Lucene には、カタカナ語の末尾の長音を落とす JapaneseKatakanaStemFilter があり、その前に CJKWidthFilter で半角カナを全角にしておけ、と文書に書いてあります。
うちの実装 norm/1#
定義は Plugins/KaedeAdv/Source/KaedeAdv/Private/core/AdvText.hpp の 1 か所です。
UE 版も、コマンドライン版の adv-sim も、ブラウザ版のサーバも、このヘッダを写さずにコンパイルします。
1 文字ずつ、この順で処理します(NormalizeTextMapped から写しました)。
if (C >= 0xFF01 && C <= 0xFF5E) { C -= 0xFEE0; } // 全角ASCII→半角
if (NormIsDropped(C)) { continue; }
if (C >= U'A' && C <= U'Z') { C += 32; }
if (C >= 0x30A1 && C <= 0x30F6) { C -= 0x60; } // カタカナ→ひらがな
Out.chars.push_back(C);
Out.spans.push_back({ Begin, i });
NormIsDropped が落とすのは、ASCII の英数字以外の全部(空白と記号)と、次の文字です。
全角空白 U+3000、読点「、」、句点「。」、鉤括弧「」、中黒「・」、三点リーダー「…」、ダッシュ「―」、そして長音「ー」U+30FC。
長音を落とすのは、「ハル」「ハール」「はるー」を同じにするためです。
今日(10月29日)、番屋で女将に「ハールさん、佐伯さんは何時に帰りましたか」と入力すると、正規化の結果は「はるさん佐伯さんは何時に帰りたか」でした。
最後に、敬体を常体へ畳みます(FoldPoliteMapped)。
規則は 5 つで、長いものから順に当てます。
ませんでし → なかっ
ませんか → ないか
まして → て
ました → た
ません → ない
短い方を先に当てると、「ませんでした」が「ないでした」になるからです。
「ます」は畳みません。「行きます」を「行きる」にしてしまうように、五段活用では語幹を戻せないからです。
直前の 2 文字が「すみ」なら畳みません。「すみません」が「すみない」になるのを避けるためです。
この畳みは、形態素解析ではありません。ただの文字列の置き換えです。
「帰りました」は「帰った」ではなく「帰りた」になります。日本語としては存在しない形です。
それでかまわないのは、同じ規則を入力にも辞書にもかけるからです。
辞書の「船長は何時に帰りました」も「帰りた」に畳まれるので、敬体どうしは当たります。
今日の値で、「佐伯さんは何時に帰りましたか」の表層の点が 0.676、「佐伯さんは何時に帰ったか」が 0.478 でした。
敬体で入力した方が、敬体で書いた言い換えに近くなります。
なぜ入れたか。
別名も言い換えも、部分一致と文字 n-gram でしか当たりません。動詞を活用させる仕組みはありません。
「見た」としか書いていない辞書は、「見ませんでしたか」に一文字も当たりません。
刑事が敬体で聞くのはいちばん普通のことなので、この穴は必ず開きます。
2026-08-19 の AI の初見プレイで、「見ませんでしたか」と入力した問いが届かず、同じ返事を何度も繰り返させました。
それまでは、届かなかった言い方を人が 1 本ずつ辞書に足して埋めていました。
辞書を厚くするのをやめ、照合の側で畳むことにしました。足し忘れが起きようがない場所です。
いまの言い換え 2,845 文のうち、敬体を含むのは 242 文です。
データの形#
戻り値は、正規化した文字(char32_t の列)と、各文字が原文のどの byte 区間から来たか(spans)の組です。
struct NormalizedText
{
std::vector<char32_t> chars;
std::vector<NormSpan> spans; // chars と同じ長さ
};
畳んだ語は、元の語全体の区間を指します(「ました」から来た「た」は、「ました」の 9 byte 全体を指す)。
入力の解析(日付・否定・節の切り出し)が、正規化した側で見つけたものを原文の位置へ戻すために使います。
照合だけが使うなら区間は要りません。NormalizeText は、区間を捨てて chars だけを返す関数として実装してあります。規則を 2 か所に書かないためです。
境界の扱い — Unicode Normalization Forms#
空の文字列は、空の列になります。
「?」だけの入力も、全部落ちて空になります。空の入力は表層の点が全部の候補で 0 になり、聞き返しにも届かず受け流しになります。
UTF-8 の先頭バイトとして不正な byte は、1 byte ずつ読み飛ばします。
末尾で途切れた多バイト文字に当たったら、そこで読むのをやめます。
「すみ」の例外は、直前の 2 文字だけを見ます。「すみませんでした」は畳まれず、そのまま残ります。
版を閾値に縛る#
正規化を変えると、全部の点が動きます。
凍結した検査(入力と期待する着地を固定した 176 件)も、閾値も、その上で決めたものです。
そこで、正規化に版を付け、閾値のファイルに同じ版を書かせています。
inline constexpr int kNormVersion = 1;
if (J.value("normVersion", 0) != kNormVersion)
{
outError = "normVersion mismatch (want " + std::to_string(kNormVersion) + ")";
return false;
}
Game/adv/thresholds.json の normVersion が 1 でなければ、閾値の読み込みを拒否します。
正規化だけ変えて、閾値をそのまま使う事故を防ぐためです。
単体テスト(adv-core-text-tests)が、入力と期待値の表で norm/1 を固定しています。今日も 35 の確認がすべて通りました。
畳まない版がもう一つある#
書き写しの検出(lint の L4・L18・L37。検査の入力が辞書に写されていないかを見る)だけは、敬体を畳まない NormalizeTextRaw を使います。
畳んでから比べると、別々に書いた「見ませんでしたか」と「見なかったか」が同じ文字列になり、写したと誤検出するからです。
2026-08-19 に、実際に 1 件そうなりました。
照合は畳んだ側で、重複の検査は畳まない側で見る、と分けています。
名前どおりでない所 — Unicode Normalization Forms#
norm/1 は NFKC の部分集合ではありません。
NFKC と重なるのは、全角英数字(U+FF01〜U+FF5E)を半角にする所と、全角空白を扱う所だけです(NFKC は空白に直し、norm/1 は落とします)。
残りは NFKC がしないことです。
カタカナをひらがなに寄せる kana folding、英字の case folding、約物と長音の除去、敬体の畳み。
逆に、NFKC がすることの大半を norm/1 はしません。
半角カナ(U+FF61〜U+FF9F)、丸数字、単位の組文字、結合文字の濁点の合成。
理由は三つです。
一つ目。核は UE・CLI・Linux のサーバで同じヘッダをコンパイルする、外部ライブラリを持たない C++ です。NFKC の全表を持つには、表のデータを三つの外側すべてに同じ版で持ち込む必要があります。
二つ目。入力するのは IME の利用者です。IME で普通に入力する限り、半角カナや丸数字はほとんど来ません。辞書の言い換え 2,845 文にも、半角カナは 0 文でした。
三つ目。正規化を変えると、凍結した検査を全部作り直すことになります。表を足すたびに版を上げるより、範囲を固定して版を 1 のまま保つ方を選びました。
もう一つ、名前に関わる所があります。
埋め込み(意味の側)には、norm/1 をかけていません。原文をそのまま渡します。
今日、港で佐伯に「アリバイは?」と入力すると、文字の側は 0.754、埋め込みと混ぜた点は 0.838 でした。
同じ意味で「ありばいは」と入力すると、文字の側は同じ 0.754 なのに、混ぜた点は 0.754 でした。
正規化したあとの文字列は同じでも、埋め込みは「ありばい」を「アリバイ」ほど強く読みません。
埋め込みの側には、モデルの学習時と同じ前処理(トークナイザの正規化)がかかります。そちらを変えると、モデルが見たことのない形の入力になるからです。
弱点と、採らなかった案 — Unicode Normalization Forms#
半角カナが通りません。
今日、佐伯に「アリバイ」と入力すると、正規化しても「アリバイ」のまま。文字の側は 0.000 でした。
埋め込みの側も、トークナイザが 5 文字すべてを未知語(<unk>)にしました。
いちばん高い候補が 0.269 で、聞き返しを出す下限 0.27 に 0.001 届かず、受け流されます。
全角の「アリバイは?」なら 0.988 で通ります。
半角カナを全角にする 1 表(63 文字と濁点・半濁点の合成)を足せば直りますが、版が 2 に上がり、凍結した検査の作り直しが要ります。IME の入力ではまず起きないので、いまは入れていません。
長音を落とすと、別の語がぶつかることがあります。
テストの表にもある「ラーメン」は「らめん」になります。「ビール」も「びる」です。
いまの辞書の範囲では、ぶつかって取り違えた例は見つかっていません。
Lucene のように「カタカナ語の末尾の長音だけ、4 文字より長い語だけ」と絞る案はありますが、語の切れ目を知らない norm/1 では「語の末尾」が決められません。
畳みは文字列の置き換えなので、日本語として壊れた形も作ります。
「ありがとうございました」は「ありがとうございた」になります。
入力と辞書の両側が同じに壊れる限りは当たるので、直していません。
常体の「帰ったか」と敬体の「帰りましたか」は、畳んでも同じ文字列にはなりません。そこは文字 n-gram の部分一致と、埋め込みに任せています(明日以降の回)。
形態素解析で原形に戻す案は採っていません。
辞書と解析器を三つの外側に持ち込むことになり、誤字や助詞の抜けに弱くなるからです。文字 n-gram の方が実測で強かった、という判断で(今夜の T04 で書きます)。
漢数字と算用数字(十五日/15日)は、norm/1 ではそろえません。日付と時刻は、別の解析器が取り出します(T11)。
検索語#
Unicode Normalization Forms UAX 15 NFKC
NFKC_Casefold
CJK width folding katakana hiragana normalization
JapaneseKatakanaStemFilter
半角カナで入力すると受け流されるのか、全角なら通るのか。第一章はブラウザでそのまま試せます。入口はプロフィールのリンクに置いています。