目次
- この記事の立ち位置
- 形1:見た目は正常、テキスト層だけが別の文字
- 形2:数値が文字になるとき桁が消える
- 形3:検査が見ていない文字種をすり抜ける
- 形4:区切り文字が1バイトではない
- 形5:日本語には単語の区切りがない
- 形6:引用符の有無で件数が20分の1になる
- 形7:大文字小文字の違いで関係が切れる
- 形8:先頭のゼロが消えて別物になる
- 形9:日付が1日ずれる
- 表計算ソフトで開くと壊れるもの
- 9つに共通する構造
- 機械で検出する方法
- 正規化をどこで行うか
- 入口で止めるか、出口で直すか
- 生成AIを使うときに増える問題
- まとめ
この記事の立ち位置
「文字化け」と聞いて思い浮かぶのは、画面が「譁�蟄怜喧縺�」のように崩れる形だろう。これは分かりやすい。開いた瞬間に気づく。
厄介なのは、見た目が変わらない形である。 画面上は完全に正常で、印刷しても問題なく、人が読む限り何も起きていない。しかし機械にとっては別の文字になっている。
編集部はこの形を9件経験した。うち複数は、公開後に別の作業をしていて偶然見つかった。 検査も目視も通過していた。
この記事はその記録である。各件について、何が起きたか、どう気づいたか、どう検出すべきだったかを書く。
形1:見た目は正常、テキスト層だけが別の文字
最も発見が遅れた件である。
何が起きたか
営業資料のPDFを、ブラウザの印刷機能を使って生成していた。出来上がったPDFの見た目は完全に正常だった。フォントも崩れておらず、レイアウトも意図どおりである。
しかしテキスト層の文字が、本来の漢字ではなく「康熙部首」に置き換わっていた。
康熙部首とは
Unicodeには、漢字の部首を表すための独立した領域がある。康熙部首(Kangxi Radicals)と呼ばれ、U+2F00からU+2FDFに割り当てられている。
たとえば「用」(U+7528)に対して、康熙部首の「⽤」(U+2F64)がある。字形はほぼ同じで、並べても区別がつかない。
どれだけ汚染されていたか
品質検査を全数にかけた結果、対象38本すべてが汚染されていた。 1本あたり20個から255個の康熙部首が含まれていた。
何が壊れるか
検索が効かない。 PDF内を「用」で検索しても、康熙部首の「⽤」はヒットしない。
コピーが壊れる。 テキストをコピーして別の文書に貼ると、見た目は同じだが検索も置換も効かない文字列になる。
機械での処理が空振りする。 文字列の一致判定、抽出、集計がすべて外れる。
原因
PDFを生成する際のフォント解決の過程で起きていた。システムにインストールされているフォントの選ばれ方が影響していた。同じHTMLから生成しても、フォントの構成が違う環境では発生しない。
どう気づいたか
専用の検査を書いて全数にかけて初めて分かった。 見た目の確認では通過し、フォントが読み込まれているかの確認でも通過する。
「見た目チェック」と「フォント読込チェック」の両方を通り抜ける故障なので、専用の検査でしか検出できない。
検出の方法
生成したPDFからテキストを抽出し、U+2F00からU+2FDFの範囲の文字が含まれていないかを数える。 1つでもあれば異常である。
形2:数値が文字になるとき桁が消える
何が起きたか
ある処理をPythonからJavaScriptへ移植した。データは機械的に書き出して移したので、値は同じはずだった。
移植前後で文字列を全件照合したところ、413件中403件(約98%)が不一致だった。
原因
数値を文字列にする規則が違う。
Pythonで2.0という数値を文字列にすると"2.0"になる。JavaScriptでは`${2.0}` が"2"になる。数値の文字列化の仕様上、小数部がゼロなら省略される。
値としては同じだが、文字列としては別物である。
この数値がプロンプトの文面に埋め込まれていたため、2文字だけ違う文面が大量に生成された。
なぜ気づかなかったか
目視では発見できない。 「2.0倍」と「2倍」の違いは、意味も通るし、間違いにも見えない。
そもそも「データを機械的に書き出したから安全」と考えていた。値の同一性と、文字列化の同一性は別の問題である。
どう気づいたか
移植の検収として、生成される文面を全件ハッシュ化して照合した。 1件ずつ目で見る代わりに、機械で全数を比べた。
一般化すると
言語をまたぐ移植では、値ではなく最終的な出力を照合する。 途中の値が同じでも、文字列にする段で差が出る。
日付、小数、真偽値、null。いずれも言語によって文字列表現が違う。
形3:検査が見ていない文字種をすり抜ける
何が起きたか
公開した記事の本文に、キリル文字の単語が1語だけ混入していた。「測る」を意味するロシア語の動詞が、日本語の文中にそのまま入っていた。
生成の過程で紛れ込んだもので、公開前の検査を通過していた。
なぜ通過したか
当時の検査は絵文字しか見ていなかった。 本文に絵文字が入っていないかを正規表現で調べる規則はあったが、それ以外の文字種は対象外だった。
対策
日本語とラテン文字以外の文字を検出する規則を追加した。キリル文字、ハングル、アラビア文字の範囲を対象にしている。
追加したあと、問題のある文例と正当な文例の両方で試した。 片方だけだと、何も検出しない検査になっていても気づけない。
この記事自体が検査に止められた
正直に書く。本記事を書いている途中で、この検査が本記事を止めた。
上の説明で、実際に混入していたキリル文字の単語をそのまま引用していた。検査は例として書かれたものか本文の誤りかを区別しないので、当然止まる。
一瞬「例外を設けようか」と考えた。そうしなかった。
理由は2つある。第1に、検査の正しさに疑いがない。 日本語の記事にキリル文字が必要な場面は、この1か所を除いて存在しない。1か所のために穴を開けると、その穴は残る。
第2に、例外を設けた検査は、例外の条件が正しいかを別に確かめる必要が出る。 検査が複雑になるほど、検査自体が壊れる余地が増える。
結果として、引用をやめて説明に置き換えた。記事としては少し読みにくくなったが、検査には穴が空いていない。
これは一般的な判断でもある
検査に引っかかったとき、最初に検討するのは「検査が正しいか」であって「例外を作るか」ではない。
編集部には、安全のための遮断が30日で97件発動し、そのうち85件が過検知だったという別の記録がある。この場合は検査の書き方が悪く、直すべきは検査の側だった。
検査が正しければ対象を直す。検査が間違っていれば検査を直す。例外を積むのは最後の手段である。
一般化すると
検査が「何を見ていないか」を明示する。 「絵文字検査」という名前は、絵文字以外を見ていないことを示している。しかし運用していると、「文字の検査がある」と丸めて認識される。
検査の名前と、検査の範囲を、同じ場所に書いておく。
形4:区切り文字が1バイトではない
何が起きたか
検証用の仕組みで、文字列を区切って一部を取り出す処理を書いた。区切り文字に全角の括弧「(」を指定した。
結果として、取り出した値がすべて空になった。
原因
使ったコマンドの区切り文字の指定が、1バイトの文字しか受け付けない。 全角の括弧はUTF-8で3バイトあるため、指定として成立しない。
エラーにはならず、区切りが行われないまま処理が進んだ。
何が起きたか(影響)
取り出した値が空のまま後続の処理へ渡り、検証の結果が「両方とも不合格」になった。 実際には対象が空だっただけである。
検査が0件を扱っていることに気づかず、結果を信じかけた。
対策
マルチバイト文字を扱える道具を使う。 単純な文字列操作でも、日本語の記号を区切りにするなら、対応している実装を選ぶ。
より本質的には、区切った結果が空でないことを確認する。 空なら異常として止める。
形5:日本語には単語の区切りがない
これが最も被害が大きかった。
何が起きたか
記事の続きを生成する処理で、「直近300語を文脈として渡す」 つもりの実装をした。空白で区切って後ろから300個を取る、という書き方である。
日本語には単語の区切りに空白がない。 結果として、本文全体が1つの塊として扱われ、全文が渡された。
渡された全文をモデルが再出力したため、348記事・約168万字の重複が生成された。
どう気づいたか
公開後である。同じ文章が2回出てくる記事が見つかって発覚した。
対策
文字数で切る。 語数で切る処理は、空白区切りの言語を前提にしている。
加えて、生成結果に段落の重複が無いかを検査する規則を追加した。これは公開を無条件で止めるゲートに入れている。
同じ形は他にもある
改行の位置。 日本語をそのまま折り返すと、意味の切れ目と無関係な位置で改行される。編集部ではBudouXを使って、意味の切れ目で分割している。
文字数の制限。 「100文字以内」を語数で判定すると、日本語では機能しない。
検索の一致判定。 空白区切りを前提とした検索は、日本語では単語の切り出しに別の仕組みが要る。
形6:引用符の有無で件数が20分の1になる
何が起きたか
記事のメタ情報に検索避けの指定が付いているものを数えようとした。正規表現で検索した結果、12本という数字が出た。
実際は262本だった。 約20分の1に過小評価していた。
原因
メタ情報の書き方に引用符があるものと無いものが混在していた。書いた正規表現は引用符が無い形だけを拾っており、大半を占める引用符ありの形を取りこぼしていた。
どう気づいたか
既知の1本が、検索結果の中に入っているかを確認した。 入っていなかったので、検索の書き方を疑った。
過去の記録を見ると、同じ場所で逆向きの取りこぼしも起きていた。引用符ありだけを拾って、無いものを落としていた。
対策
件数を報告する前に、既知の事例で検算する。 「この1本は必ず含まれるはずだ」というものを1つ決めておき、結果に入っているかを見る。
正規表現は表記ゆれの外側にある。 引用符、空白、大文字小文字。人にとっては同じでも、正規表現にとっては別である。
形7:大文字小文字の違いで関係が切れる
何が起きたか
人物どうしの関係を照合する処理で、識別子の大文字小文字を区別していた。
取り込み元のデータには表記ゆれがあり、934人中254人(27%)が小文字でない形で保存されていた。
結果として、506件の照合のうち123件が該当し、81件で既知の関係が「見知らぬ相手」に誤分類されていた。関係に応じた処理が、丸ごとスキップされていた。
どれだけ気づかれなかったか
約2か月である。
なぜ気づかなかったか
ここが重要である。評価用の集計コードが、製品のコードを経由していなかった。
評価側は独自に数えていたため、製品側の不具合が指標に一切現れなかった。評価は「問題なし」と言い続けた。
どう気づいたか
評価用のコードと製品のコードに、同じ入力を与えて結果を突き合わせた。 ここで初めて食い違いが出た。
修正後の一致率は99.41%になった。
一般化すると
評価が製品と同じ関数を呼んでいない限り、製品側の不具合は指標に反映されない。
これは文字コードの話を超えた一般則だが、表記ゆれは評価と製品で別々に処理されやすいため、この形で現れやすい。
形8:先頭のゼロが消えて別物になる
何が起きたか
登録番号のような識別子で、先頭にゼロが付く形と付かない形が混在していた。
「00123」と「123」は、文字列としては別だが、数値として扱うと同じになる。逆に、数値として読み込んで文字列に戻すと、先頭のゼロが消える。
重複の候補が5,117件見つかった。
なぜ起きるか
途中の処理で一度でも数値として扱うと、先頭のゼロは失われる。表計算ソフトで開いただけでも起こる。
対策
識別子は数値として扱わない。 桁数が決まっているなら、文字列として固定長で持つ。
照合の際は、正規化した形で比べる。先頭のゼロを詰めるか、逆に揃えるかを決めて、両側に同じ処理を適用する。
形9:日付が1日ずれる
何が起きたか
記事のファイル名から公開日を読み取り、URLを組み立てる処理を書いた。
タイムゾーンの扱いで、日付が1日ずれるケースがあった。 深夜に公開した記事のURLが、前日または翌日になる。
何が壊れたか
検索エンジンの実績データとの突合が静かに壊れた。 手元で組み立てたURLと、実際に配信されているURLが違うため、照合しても一致しない。
一致しないことは、エラーにならない。 「その記事は実績がない」という結果になるだけである。
対策
URLはファイル名から推測せず、ビルドの結果を正とする。
編集部には、ファイル名を基準に「壊れたリンク」を修正して、元から正常だった22件をすべて壊した記録もある。ビルド後の成果物と突き合わせて気づき、元に戻した。
これは文字化けか
厳密には文字コードの問題ではない。しかし「同じものを指しているはずの文字列が一致しない」という点で、他の8件と同じ形をしている。
表計算ソフトで開くと壊れるもの
本記事の9件は自社のシステム内で起きたものだが、外部とデータをやり取りする場面では、さらに定番の形がある。実務で影響が大きいので併せて書く。
先頭のゼロが消える
形8と同じ問題が、表計算ソフトで開いただけで起こる。「00123」という郵便番号や社員番号が「123」になる。
保存し直すと、消えた状態で上書きされる。開いて閉じただけで壊れる。
数字に見える文字列が日付になる
「1-2」「3/4」のような値が、日付として解釈される。遺伝子の名前が日付に化ける問題は学術分野でよく知られているが、商品コードや型番でも同じことが起きる。
長い数字が丸められる
16桁を超える数字は、末尾が失われる。取引番号や識別子で起きると、別のものと同一視される。
文字コードの指定が無いと読めない
CSVには文字コードを記述する場所がない。読む側が推測する。 推測が外れると、全体が崩れた形で開く。
対策として、先頭に印を付ける方法があるが、その印を理解しない道具では逆に1行目が壊れる。
編集部の運用
外部とやり取りするデータは、表計算ソフトで開かない前提で設計する。
具体的には、識別子の前に記号を置いて文字列であることを明示するか、そもそも表計算ソフトで開く形式を使わない。
そして、受け取ったデータは開く前に中身を確認する。 開いてから気づくと、既に壊れている。
これも「エラーが出ない」形である
本記事の9件と同じく、どれもエラーを出さない。 表計算ソフトは警告を出さずに変換する。
受け取った側が数えるまで、誰も気づかない。
9つに共通する構造
並べると、同じ形が見える。
| 形 | 見た目 | 機械にとって |
|---|---|---|
| 1 康熙部首 | 同じ | 別の文字 |
| 2 小数の文字列化 | ほぼ同じ | 別の文字列 |
| 3 他の文字体系 | 目立つが検査外 | 検出されない |
| 4 マルチバイト区切り | — | 区切られない |
| 5 単語の区切り無し | — | 全体が1語 |
| 6 引用符の有無 | 同じ意味 | 別の文字列 |
| 7 大文字小文字 | 同じ | 別の識別子 |
| 8 先頭のゼロ | ほぼ同じ | 別の識別子 |
| 9 日付のずれ | — | 別のURL |
共通するのは、人にとって「同じ」に見えるものが、機械にとって「違う」ことである。
だから目視では見つからない
9件のうち、目視で見つかったものは1件もない。 すべて機械的な照合か、専用の検査か、別の作業中の偶然である。
「確認しました」という報告は、この種の問題に対して無力である。 人が見ても同じに見えるのだから、何度見ても見つからない。
そして、エラーにならない
もう1つの共通点は、どれもエラーを出さないことである。
検索がヒットしない、照合が一致しない、件数が少なく出る。「何も起きない」という形で現れる。
エラーが出る問題は、遅くとも実行時に気づける。何も起きない問題は、誰かが数えるまで残る。
機械で検出する方法
9件それぞれに専用の検査を書いたが、共通して使える方法が3つある。
1. 正規化の前後で比べる
最も汎用性が高い。Unicodeの正規化を適用して、文字列が変わるかを見る。
正規化(Normalization)は、見た目が同じで番号が違う文字を、代表的な形に揃える仕組みである。康熙部首も、全角の英数字も、合成済みの文字も、正規化すると統一される。
正規化して変わったなら、その文字列には見た目で気づけない差が含まれている。
実装はPythonの標準ライブラリやJavaScriptの標準機能にある。1行で書ける。
2. 文字の範囲を数える
想定外の範囲の文字が含まれていないかを数える。
康熙部首ならU+2F00〜U+2FDF、キリル文字ならU+0400〜U+04FF。想定している文字の範囲を先に決めておき、外れたものを検出する。
日本語の文書なら、ひらがな・カタカナ・漢字・ラテン文字・記号の範囲を許可し、それ以外を異常とする。
3. 全数のハッシュを比べる
移植や変換の検収で使う。出力を全件ハッシュ化して、前後で照合する。
形2の小数の件は、これで発見した。413件を目で見るのは無理だが、機械なら一瞬で済む。
「サンプルを10件見て問題なかった」は、98%が壊れている状態と両立する。
正規化をどこで行うか
正規化は有効だが、適用する場所を決めないと別の問題を生む。
保存時に正規化する
入ってきたデータを正規化してから保存する。 以後の処理はすべて正規化済みの前提で書ける。
欠点は、元の表記が失われることである。正式名称に全角の記号が含まれている場合など、変えてはいけない場面がある。
照合時だけ正規化する
保存は原文のまま、比べるときだけ正規化する。 表記を保ちながら一致判定ができる。
欠点は、すべての照合箇所で忘れずに適用する必要があることである。形7の大文字小文字の件は、まさにこれを一部で忘れていた。
編集部の判断
照合用の値を別に持つ。 原文と、正規化済みの値を両方保存する。
保存の時点で1回だけ正規化すればよく、照合は正規化済みの列を使う。忘れる余地がない。
容量は増えるが、表記の保持と照合の確実性を両立できる。
入口で止めるか、出口で直すか
対策には2つの置き方がある。
入口で止める
想定外の文字が入ってきたら、その時点で拒否する。
利点は、おかしなデータが内部に入らないこと。欠点は、正当なデータまで弾くことがある点である。
出口で直す
保存は受け入れ、出力の直前で正規化する。
利点は取りこぼしが少ないこと。欠点は、内部に不揃いなデータが溜まることである。後から別の処理を足したとき、その処理が正規化を忘れる。
編集部の使い分け
再生成できるものは入口で止める。 記事の本文は、おかしな文字があれば公開を止める。作り直せばよい。
再生成できないものは出口で直す。 外部から取り込んだデータは、原文を保持したまま、使うときに正規化する。
止めるゲートは点数で扱わない
編集部の記事の検査では、文字の問題を点数の減点として扱っていない。 該当したら無条件で公開を止める。
点数にすると、他の項目が高得点だった場合に通ってしまう。「出してはいけない状態」と「良し悪し」は分けて扱う。
生成AIを使うときに増える問題
生成AIを業務に入れると、この種の問題が増える。理由が3つある。
1. 入力が多言語で学習されている
形3のキリル文字は、生成の過程で紛れ込んだ。モデルは多言語を学習しているため、日本語の文中に他の言語の語が混ざることがある。
頻度は低いが、量を出すと必ず起きる。 編集部は726記事を運用しているが、1語の混入は1件見つかった。
2. 文字数の制約が効きにくい
「300文字以内で」という指示は、守られないことがある。 モデルは文字を数えているわけではない。
日本語では特に、トークンと文字の対応が一定でないため、モデル側の感覚と実際の文字数がずれる。
対策は、指示ではなく出力後に数えて判定することである。
3. 引用や記号の扱いが揺れる
同じ内容でも、引用符が全角になったり半角になったりする。形6と同じ問題が、生成のたびに起こり得る。
出力後に正規化する処理を入れる。 指示で統一しようとすると、他の要素を押し出して品質が落ちる。
検査を先に作る
生成AIを量産に使う場合、生成の仕組みより先に検査を作るほうがよい。
編集部は逆の順序で作ったため、公開後に348記事の重複が見つかった。 検査が先にあれば、公開前に止まっていた。
まとめ
見た目で気づけない文字の問題を9件記録した。
PDFのテキスト層が康熙部首に置き換わっていた。 見た目は完全に正常で、全38本に20〜255個ずつ含まれていた。検索もコピーも効かない。
小数の文字列化の違いで413件中403件(98%)が不一致だった。 Pythonの2.0がJavaScriptで2になる。値は同じ、文字列は別物。
キリル文字1語が検査をすり抜けた。 当時の検査は絵文字しか見ていなかった。
全角の括弧を区切り文字に指定して、取り出しが全て空になった。 エラーは出なかった。
日本語に単語の区切りがないため、本文全体が1語として扱われ、348記事・約168万字が重複した。
引用符の有無で件数が12本と262本に割れた。 約20分の1の過小評価だった。
大文字小文字の違いで、2か月間、関係の判定が外れ続けた。 評価用のコードが製品のコードを経由していなかったため、指標に現れなかった。
先頭のゼロが消えて、重複の候補が5,117件見つかった。
タイムゾーンの扱いで日付が1日ずれ、実績データとの突合が静かに壊れた。
9件のうち、目視で見つかったものは1件もない。 人にとって同じに見えるものは、何度見ても同じに見える。
明日から試せる3つ
1つ目は、手元のデータに正規化をかけて、変わる文字列がどれだけあるかを数えることである。1行で書けて、変わったものには見た目で気づけない差が含まれている。
2つ目は、件数を報告する前に、既知の1件が結果に含まれているかを確認することである。形6はこれで気づいた。
3つ目は、いま動いている検査が「何を見ていないか」を1行で書くことである。「絵文字検査」なら、絵文字以外を見ていない。この1行があれば、形3は防げた。
編集部では、AI導入の支援と、生成の運用に検査を組み込む仕組みづくりを行っている。量産は始めたが品質の確かめ方が分からない、という段階の相談については、サービス案内を参照してほしい。
参考にした一次資料
- 康熙部首(Unicode の文字表) — 形1で混入していた文字の範囲
- Unicode 正規化(UAX #15) — 見た目が同じで番号が違う文字を揃える仕組み
- 漢字に関する Unicode の FAQ — 統合漢字と部首の扱い
- IDNA の互換処理(UTS #46) — 表記ゆれを揃える別系統の規定
- Python の unicodedata — 正規化の実装
- Python の Unicode ガイド — 文字とバイトの扱い
- JavaScript の String.normalize — 同上
- JavaScript の Number.toString — 形2の原因
- RFC 3629(UTF-8) — 文字が何バイトになるか。形4の背景
- ICU — 正規化・照合・分割の実装が揃っている
- BudouX — 日本語の意味の切れ目での分割
- Noto フォント — 形1で関係したフォント群
よくある質問
文字化けとは何ですか。
本来の文字とは違う文字が表示される、あるいは保存される現象です。「譁�蟄怜喧縺�」のように明らかに崩れる形が知られていますが、実務で厄介なのは見た目が変わらない形です。編集部の例では、PDFの見た目は完全に正常なのにテキスト層だけが別の文字コードに置き換わっており、全38本が汚染されていました。コピーしても検索してもヒットしない状態です。
見た目が同じなのに違う文字とはどういうことですか。
Unicodeには、字形がほぼ同じで別の番号を持つ文字があります。代表が康熙部首と呼ばれる一群で、漢字の部首を表すために独立した番号が割り当てられています。「用」と「⽤」は画面上ほぼ区別できませんが、コンピュータにとっては別の文字です。検索、一致判定、置換のすべてが空振りします。
数値なのに文字化けすることがありますか。
あります。編集部の例では、Pythonで2.0という数値を文字列にすると「2.0」になりますが、JavaScriptでは「2」になります。この違いで、移植したデータが413件中403件、約98%で不一致になりました。値としては同じですが、文字列としては別物です。目視では発見できず、全件のハッシュを比べて初めて分かりました。
検査をすり抜ける文字化けはどう防ぎますか。
検査が見ている文字の範囲を確認してください。編集部の記事に外国語の単語が1語紛れ込んでいた際、当時の検査は絵文字しか見ておらず通過していました。日本語とラテン文字以外の文字を検出する規則を追加して解決しましたが、重要なのは「何を見ていない検査か」を明示しておくことです。
日本語特有の問題はありますか。
単語の区切りに空白がないことが最大の違いです。編集部では、英語を前提とした空白区切りの処理を日本語に適用した結果、本文全体が1語として扱われ、348記事・約168万字の重複を生成しました。改行位置の決定、文字数の制限、検索の一致判定など、空白を前提にした処理はすべて日本語で挙動が変わります。
何から確認すればよいですか。
手元のデータで、同じに見える文字が実は違う番号になっていないかを調べることです。Unicodeの正規化を適用した前後で文字列が変わるかを見れば、機械的に検出できます。変わったなら、その文字列には見た目で気づけない差が含まれています。