ノートのタイトルに cafe\u0301 が保存され、検索欄には café が入力されたとします。見えるアクセント付きの文字は同じでも、文字列の完全一致比較は失敗します。「文字化けだ」と片づけると本当の問題を見落としかねません。どちらも正しいテキストでありながら、コードポイントの並びが異なっている場合があります。

Unicode 正規化は、こうした違いの一部を扱えます。ただし検索や保存に組み込むなら、どの違いをまとめてよいか、原文をどう残すか、異なるレコードが同じキーになったらどうするかまで決める必要があります。この記事では、説明用に考えた個人ノートのタイトル検索を実装します。谷米の既存の検索機能を変更した記録ではありません。

比較表、索引の問い合わせ、境界を確認するアサーションは、2026-09-28 に Node.js v22.23.1、ICU 78.2、Unicode 17.0 で実行しました。実験スクリプトの全文を保存して node normalization.mjs を実行すれば、再確認できます。

1. コードポイントを見てから、違いを判断する

調査では文字列を端末に表示するだけで終わらせません。フォントの描画が違いを隠すことがあるため、何が保存されているかをコードポイントで確認します。

function showCodePoints(text) {
  return [...text].map(char => `U+${char.codePointAt(0).toString(16)
    .toUpperCase().padStart(4, '0')}`).join(' ');
}

showCodePoints('\u00E9'); // U+00E9
showCodePoints('e\u0301'); // U+0065 U+0301
'\u00E9' === 'e\u0301'; // false

前者は合成済みのアクセント付き文字、後者は e と結合アクセントです。JavaScript の通常の文字列比較は、自動的に正規化しません。明示的な変換には normalize を使います。根拠は ECMAScript の文字列正規化メソッドです。

コードポイントを調べると、形が似ているだけで正規化による等価関係がないケースも区別できます。たとえばラテン文字の a とキリル文字の а です。最初から「同じに見える文字を全部置き換える」関数を書くと、表示の判断、検索方針、識別の基準が混ざり、どのテキストが統合されるか説明しにくくなります。

2. 四つの形式で選ぶのは二つの方針

Unicode は正準等価と互換等価を区別します。前者はアクセント付き文字などの異なる符号表現を扱い、後者は全角文字や丸数字などの互換表現もまとめられます。四つの標準形式は、「どちらの等価関係を使うか」と「分解後に合成するか」で構成されます。定義は UAX #15 の正規化形式にあります。

形式分解その後の合成
NFD正準分解なし
NFC正準分解あり
NFKD互換分解なし
NFKC互換分解あり

NFC の「合成」は、見える一文字を必ず一つのコードポイントにする操作ではありません。今回のスクリプトでは、q\u0301 を NFC にしても二つのコードポイントが残ることを確認しました。最も徹底していそうな名前を選ぶのではなく、どの違いを無視してよいかから選びます。

左右それぞれに変換を適用して比較した、今回の結果です。

左側 / 右側元の ===両方を NFC に変換両方を NFKC に変換
é / e\u0301falsetruetrue
ガ / カ\u3099falsetruetrue
A / Afalsefalsetrue
① / 1falsefalsetrue
fi / fifalsefalsetrue
A / afalsefalsefalse
ラテン a / キリル аfalsefalsefalse

実装に直結する結果が三つあります。第一に、日本語の濁音にも合成済みの文字と結合文字列の違いがあり、ラテン文字のアクセントだけの問題ではありません。第二に、NFKC では ① と 1 が等しくなります。丸数字に書式や原文としての意味があるなら、上書きするとその情報が失われます。第三に、大文字と小文字や、異なる文字体系で形が似ている文字は、NFKC だけでは等しくなりません。

そこでこの記事では、アクセントと濁音の違いを説明するために NFC を使い、全角や丸数字の違いを許容する検索キーに NFKC を使います。具体的な用途に基づく選択であり、「すべてのテキストを最初に NFKC にすべき」という一般的な推奨ではありません。

3. 検索キーは派生データにし、原文を残す

このタイトル索引の方針は明確にします。互換等価なタイトルを検索できるようにし、大文字と小文字、先頭と末尾の空白は区別します。一致したレコードをすべて返し、個別の id で識別します。利用者が入力したタイトルは残します。実装するのはタイトル全体の等価検索で、部分文字列検索、スコアによる並べ替え、全文検索ではありません。

変換方針に名前を付けると、将来索引を再構築するときにもバージョンを識別できます。

const KEY_POLICY = 'note-key-v1-nfkc';

function requireText(text) {
  if (typeof text !== 'string') throw new TypeError('Expected a string');
  if (!text.isWellFormed()) throw new TypeError('Unpaired UTF-16 surrogate');
}

function toSearchKey(text) {
  requireText(text);
  return text.normalize('NFKC');
}

function buildTitleIndex(records) {
  const index = new Map();
  for (const record of records) {
    const key = toSearchKey(record.title);
    const bucket = index.get(key);
    if (bucket) bucket.push(record);
    else index.set(key, [record]);
  }
  return index;
}

function findByTitle(index, query) {
  return [...(index.get(toSearchKey(query)) ?? [])];
}

isWellFormed による確認は正規化とは別の仕事です。この例で受け入れる入力が完全な Unicode テキストであることを確認してから、検索方針を適用します。実験スクリプトは API の存在も先に確認します。対応していない環境では互換実装を明示的に用意する必要があります。確認を省略したまま同じルールだとは言えません。

四つのレコードを登録してみます。

const records = [
  { id: 'n1', title: '① 設定' },
  { id: 'n2', title: '1 設定' },
  { id: 'n3', title: 'ABC' },
  { id: 'n4', title: 'cafe\u0301' },
];
const index = buildTitleIndex(records);

findByTitle(index, '1 設定').map(r => r.id); // ['n1', 'n2']
findByTitle(index, 'ABC')[0].title; // 'ABC'
findByTitle(index, 'caf\u00E9')[0].id; // 'n4'
findByTitle(index, 'abc'); // []
findByTitle(index, ' ABC'); // []

ABC で検索しても、表示するタイトルは元の ABC のままです。検索の許容範囲を広げながら、利用者が半角で入力したことにしてしまわずに済みます。最後の二つの空配列も確認すべき結果です。この例では小文字化や trim を行わず、暗黙のルールにしていません。

findByTitle は新しい配列を返しますが、要素は元のレコードオブジェクトへの参照です。呼び出し側がオブジェクトを書き換えると、元データも変わり得ます。書き込みを分離したいアプリケーションでは、イミュータブルなデータやコピー方針を別に設計します。この例が示すのは索引と衝突の扱いであり、データベースへの永続化、権限管理、並行更新まで実装したものではありません。

4. 同じキーになってもレコードを上書きしない

簡単に書ける索引は index.set(key, record) です。しかし上の n1 と n2 は同じ正規化キーを持ち、二回目の登録で一回目が上書きされます。検索結果から一件が消える原因は、格納方法にあります。「等価に検索できるレコードの集まり」を「一意のレコード」として保存してしまったからです。

この例ではキーごとに配列を持ち、両方を残します。検索では、一緒に取得できる範囲を広げることが意図した設計です。一意なユーザー名や業務上の識別番号なら、重複を拒否したり人が判断したりする必要があるかもしれません。検索上の一致から、同じレコードだとは判断できません。 独立した id は、タイトルの方針を変えたときにレコードまで消えたり名前を変えたりしないために必要です。

既存システムを NFC から NFKC に変更するなら、まず候補となる索引を作り、同じ新しいキーに複数の既存 id が対応するグループを列挙します。各グループの業務上の意味を確認した後で、すべて残す、新規の重複を拒否する、明示的にデータを統合するといった方針を決めます。フィールドを再計算して一意制約を追加し、リリースが失敗してから履歴データの衝突を知る順序にはしません。

このメモリー内の例では KEY_POLICY は方針を示す名前です。永続化するシステムなら派生キーと一緒にバージョンを保存するか、索引構築のメタデータに含められます。問い合わせ側と書き込み側は同じ版を使います。後で大文字・小文字の同一視や空白処理を加えるなら、方針の変更として結果集合を再確認します。挙動に影響しないコード整理として扱うべきではありません。

5. 変換の順序は境界、長さ、ハイライト位置に影響する

連結した全体を確認する

二つの断片がそれぞれ NFC でも、連結した結果が NFC とは限りません。UAX #15 の連結の説明に明記されており、今回のスクリプトでも次を確認しました。

const joined = 'e'.normalize('NFC') + '\u0301'.normalize('NFC');
joined === '\u00E9'; // false
joined.normalize('NFC') === '\u00E9'; // true

タイトルを複数の断片から組み立てるなら、最終的なタイトルから検索キーを生成します。この例は、テキストを任意のブロックに分けて変換し、つなげば同じ正規化の約束を満たすとは言えないことも示します。ストリーミング処理には、ブロックをまたぐ境界を扱える実装が必要です。この記事ではストリーミング正規化器は提供していません。

実際に保存する値の長さを検証する

実験では、e\u0301 を NFC にすると UTF-16 長が 2 から 1 へ変わりました。fi を NFKC にすると、逆に 1 から 2 へ変わります。変換が長さを変えるため、「原文の長さを検証してから変換して保存」と「変換して保存する値の長さを検証」は同じとは限りません。

私なら原文と派生キーの容量を別に定義し、それぞれ実際に書き込む値を検証します。原文を残すシステムで、短くなったキーだけを調べても、元のフィールドが制限を満たすとは言えません。文字数とバイト数の選び方は文字の長さと入力検証の記事で扱っています。

検索キーの添字を原文にそのまま使わない

fi が fi になると、キーの二つの位置が原文の一つのコードポイントに対応します。正規化したキーの一致位置を、そのまま原タイトルの slice とハイライトに使うことはできません。この記事では原レコード全体を返し、正確でない位置対応を実装したことにはしていません。

部分文字列の検索と原文のハイライトを実装するなら、変換結果と原文範囲の対応関係を別に維持するか、原文範囲を返せる照合方法を選びます。結合文字、展開される文字、境界の変化を検証する必要があります。タイトル全体の検索が正しく動いても、全文ハイライトの正しさまでは証明できません。

6. 正規化の役割と、別に決めるルール

表では NFKC 後も A と a は異なります。大文字と小文字を無視したいなら、どの照合方針を使い、どの言語を対象にするかを別に決めます。単に toLowerCase() を追加すれば、この記事で定めた等価関係の範囲が広がります。英語の一部のサンプルで問題がなくても、すべての言語を扱えたとは言えません。

同様に、今回の例では正規化は é を e にしません。アクセントを無視する検索は、追加のルールです。原文の保存、利用者がその一致を期待するか、結果が広がりすぎないかを評価します。正規化 API は符号表現を変換するもので、製品に代わって「利用者が探している語」を決めるものではありません。

似た字形も別の問題です。ラテン文字の a とキリル文字の а は、今回の NFC と NFKC でも異なりました。Unicode には混同しやすい文字を扱う別の仕組みがあります。UTS #39 の Confusable Detectionを参照してください。正規化キーだけをログインの識別や安全性判断の根拠にすると、ここで検証した範囲を超えます。

実装を分けるうえでは、文字列の形式が正しいか、制御文字を許可するか、業務上のフィールドとして有効かも、それぞれ検証します。cleanText という名前の関数にすべてを押し込み、正規化まで含む説明しにくい処理にしないことが大切です。関数名と方針のバージョンを明確にすれば、変更の影響を追いやすくなります。

7. テキストの方針に残しておく検証結果

実験スクリプトは表を表示するだけでなく、衝突した二つのレコードが返ること、元の全角タイトルが残ること、大文字と小文字や先頭空白が区別されること、未対のサロゲートを拒否することを確認します。方針の境界を検証するアサーションは、「便利だから」と加えた変換による挙動の変更も見つけられます。

実際のシステムなら、業務上のサンプルも残します。どの表記の違いは一致させ、どの違いは保つべきか、それぞれに理由を付けます。成功するアクセントの例だけでなく、統合してはいけない番号、名前、書式も必要です。すべて true になる表だけでは、有用な違いを保てているか判断できません。

最終的な関係は説明できる形にします。原文は表示と再構築に使い、派生キーは明示した照合方針に使い、独立した識別子はレコードの同一性に使う。 NFC や NFKC の選択は、その設計の一部です。衝突、処理順序、索引のバージョンまで考えることで、正規化を維持できる検索ルールとして使えます。