始まりは、社内文書 3000 件の投入テスト
ある日本企業向けの PoC で、社内 FAQ と業務規定 3000 件を LLM に食わせる工程を書いていた。動くには動いた。だが、返ってくる回答が妙に取りこぼす。「株式会社◯◯」で通るのに「(株)◯◯」だと引かない。「サーバー」で引けるのに「サーバ」だと空振り。原因は明白で、前処理が甘かった。
正直、日本語の前処理を舐めていた。英語の感覚で lower() と空白詰めくらいで済むと思っていたのだが、日本語は入力段でもっと崩れている。全角と半角、異体字、カタカナの長音、丸数字や単位記号。これらを LLM に投げる前に潰しておかないと、埋め込み検索も RAG も生成も、すべて微妙に外す。

前提と使うもの
Python 3.11
unicodedata(標準ライブラリ) /mojimoji/ 軽い独自辞書用途: LLM への入力(検索クエリ・埋め込み対象・プロンプト同梱テキスト)の正規化
先に断っておく。正規化は「かけすぎるとむしろ意味が壊れる」。後半でその線引きを書く。
1. NFKC — まず素直に一本入れる
迷ったら NFKC を先に一発通す。全角英数字、半角カナ、丸数字、単位記号、㈱ の類までまとめて素直な文字に寄る。Python なら標準ライブラリだけで済む。
import unicodedata
def normalize_nfkc(text: str) -> str:
return unicodedata.normalize("NFKC", text)
print(normalize_nfkc("ABC123 アイウエオ ①②③ ㈱㌖"))
→ ABC123 アイウエオ 123 (株)キロメートル
ここまでで、たとえば「サーバー1台」と「サーバー1台」が同じキーで比較できる。素朴だが、これを入れていない社内システムがまだ本当に多い。
2. 全角半角のうち「英数だけ」を寄せたい時
NFKC は乱暴に言うと全部持っていく。だが「カタカナは全角のまま残したい」「英数と記号だけ半角に寄せたい」という要件がわりとある。日本語は全角カナのほうが可読性が高く、検索精度が落ちない事も多い。
ここで mojimoji の細粒度が効く。
import mojimoji
s = "サーバー1台導入"
半角カナ・半角英数をすべて全角に
print(mojimoji.han_to_zen(s))
→ サーバー1台導入
カタカナだけ全角に、英数はそのまま半角
print(mojimoji.han_to_zen(s, digit=False, ascii=False))
→ サーバー1台導入
実運用では「NFKC で一旦寄せて、そのあとカタカナだけ全角に戻す」二段構えにしていることが多い。
3. 異体字問題 — 髙・﨑・辻の "はしご" は消せない
ここが日本語独特の罠だ。「髙橋」の "はしご高"、「﨑」の "たつさき"、「辻」の点の数。これらは NFKC ではほとんど寄らない (一部の CJK 互換漢字は寄るが、"はしご高" のような字形バリアントは寄らない)。
で、実装で厄介なのは「寄せて良いのか」の判断だ。人名や社名は勝手に "高橋" に書き換えると失礼どころか、誤同定になる。だが、社内文書の検索キーとしては同一人物と扱いたい。答えは、「原文は保持しつつ、検索キーだけ別に持つ」構造にするしかない。
VARIANT_MAP = {
"髙": "高", "﨑": "崎", "德": "徳",
"眞": "真", "澤": "沢",
"邊": "辺", "邉": "辺",
}
def fold_variants(text: str) -> str:
for k, v in VARIANT_MAP.items():
text = text.replace(k, v)
return text
保存は原文 / 検索は fold_variants(nfkc(text)) を index に
辞書は業界・会社ごとに追記していく。網羅を諦め、頻出だけ抑えるほうが実用的だ。全 CJK 互換漢字を機械的に寄せる方法もあるが、副作用が読めない。
4. カタカナ表記ゆれ — 長音とヴィ/ヴェの闇
「サーバ」/「サーバー」、「コンピュータ」/「コンピューター」、「インターフェース」/「インターフェイス」。JIS 規格の指針(音引きを付ける/付けない)は複数の流派があり、社内文書の中でも書き手ごとにブレる。
ここは辞書というより変換ルールで攻める。
import re
def fold_katakana(text: str) -> str:
# 末尾のカタカナ長音を落とす(検索キー用)
text = re.sub(r"([ァ-ヴ])ー(?=[^ァ-ヴー]|$)", r"\1", text)
# ヴィ→ビ、ヴェ→ベ、ヴァ→バ、ヴォ→ボ
for a, b in [("ヴィ","ビ"), ("ヴェ","ベ"), ("ヴァ","バ"), ("ヴォ","ボ"), ("ヴ","ブ")]:
text = text.replace(a, b)
return text
これも「原文は残す・検索キーだけ寄せる」の思想で組む。生成に混ぜてはいけない — LLM に "ヴィヴィアン" を "ビビアン" として渡すと、固有名詞の同定に失敗する。
5. 数字と単位の混在
「3,000円」/「3000円」/「3千円」/「三千円」。この揺れは NFKC では取れない。金額や個数を検索・集計するなら、数字列を独立に抽出して正規化する。
KANJI_NUM = {"零":0,"一":1,"二":2,"三":3,"四":4,"五":5,
"六":6,"七":7,"八":8,"九":9,
"十":10,"百":100,"千":1000,"万":10000}
def kanji_to_int(s: str) -> int:
# 簡易版 — "三千五百" 程度まで
total, current = 0, 0
for c in s:
n = KANJI_NUM.get(c, 0)
if n >= 10:
current = (current or 1) * n
total += current
current = 0
else:
current = current * 10 + n
return total + current
数値表現の抽出は別途 regex で(1234, 5,000, 3千 など)
ここまで来ると "前処理" というより "意味解析" の入口だが、業務データではよく必要になる。
動作確認 — 4 パターンを同一キーに寄せる
samples = [
"㈱テスト商事 サーバー1台 3,000円",
"(株)テスト商事 サーバ1台 3000円",
"㈱テスト商事 サーバー壱台 三千円",
"(株)テスト商事 サーバー 1 台 3,000 円",
]
def key(text: str) -> str:
text = normalize_nfkc(text)
text = fold_variants(text)
text = fold_katakana(text)
text = re.sub(r"\s+", "", text)
return text
for s in samples:
print(key(s))
上 3 パターンはだいたい同一キーに寄る。4 番目の "壱台" と "三千円" は数値正規化まで積まないと寄らない。ここが線引きで、「どこまでを検索キーの一致に含めるか」は業務要件次第。ふつうは "壱/一" を潰す前で止める(誤集計リスクのほうが大きい)。
正規化しすぎて壊れる境界
正規化は片方向で不可逆な作業だ。だから、原文を残さないと後戻りできない。実装した後で気づいた "やってはいけない" を並べておく。
生成に渡す本文まで正規化しない — LLM に "顧客名: 高橋" と渡してしまうと、返信で "髙橋様" と書きたい場面で使えない。正規化は index / 検索キーだけに閉じる
敬称・敬語まで潰さない — "様" を "さん" に寄せるような処理を軽率に入れると、日本語のトーンが崩壊する
固有名詞辞書を先に持たない — 顧客リスト・製品名の辞書がある業務なら、そちらを優先し、機械的な正規化はそのあと
結局のところ、日本語の前処理は「入力を潰す」より「入力の別レイヤーを作る」設計になる。原文はそのまま、検索用キーは別、生成用テキストはさらに別。3 レイヤー持つのが実装として一番素直だった。
応用・発展
この基盤ができると、上に何を乗せるかで用途が広がる。
RAG の埋め込みインデックス作成前段 — 揺れに強いドキュメントストアになる
社内 FAQ の重複検出 — 同一質問の別表記を束ねる
問い合わせフォームの名寄せ — 顧客識別の精度が上がる
ちなみに次回は、この正規化の "手前" にあるもっと厄介な話 — FAX・スキャン PDF・レイアウトが崩れた文書を、そもそも LLM に載せられる形に持ってくる話を書く予定。日本企業の文書は Word や Markdown ではなく、紙と PDF が主戦場だ。正規化以前に、テキスト抽出そのものが罠になる。
まとめ
NFKC 一発で 8 割は片付く。残り 2 割 — 異体字、カタカナの揺れ、数字表現 — は業務ドメインごとに手当てするしかない。そして正規化は「原文を残す」設計と表裏一体で、ここを間違えると後段で LLM の応答品質が下がる。
筆者は 5years+ で日本市場向けの LLM 業務応用を担当している。同じ場所で 30 分ハマる開発者が少しでも減れば、この記事の役割は終わりだ。