Mac で日本語 grep・DB 検索がヒットしない原因と3言語の直し方(NFC/NFD)

Mac で日本語 grep・DB 検索がヒットしない原因と3言語の直し方(NFC/NFD)

8分で読めるテック
最終更新:

Mac で検索機能を実装していて、濁点を含む日本語文字列だけがヒットしない症状に遭遇したことがあります。コードは正しく見えるのに、「ブログ」「プログラム」だけが引っかからない。原因は Mac 特有の Unicode 正規化形式(NFD)でした。

症状

具体的にはこんな状況です。

query = "ブログ"   # ユーザーの入力(Mac で入力)
stored = "ブログ"  # DB に保存された文字列(Linux サーバーから保存)

print(query == stored)  # False になる

見た目はまったく同じ「ブログ」なのに、比較すると False になります。print(len(query)) を試すと、Mac で入力した文字列だけ文字数が多くなっていることも確認できます。

原因:Mac は日本語をNFD形式で分解する

Mac のファイルシステム(APFS および HFS+)は、日本語の濁点・半濁点を NFD 形式で処理します。

NFD 形式では「ブ」を「フ」+「゛」(2文字の組み合わせ)として扱います。一方、多くの Linux サーバーやデータベースでは「ブ」を1文字(NFC 形式)として扱います。この不一致が検索ミスの原因です。

バイト列を確認するとはっきりわかります。

import unicodedata

nfc = "ブ"  # NFC: 1文字
nfd = unicodedata.normalize("NFD", "ブ")  # NFD: 2文字に分解

print(len(nfc), len(nfd))  # 1 2
print(nfc == nfd)           # False

NFC と NFD の違い

Unicode は文字列の「正規化形式」を複数定めています。

  • NFC(Normalization Form Canonical Composition): 「が」を1つの合成済み文字として扱う。Linux/Windows のデフォルト。
  • NFD(Normalization Form Canonical Decomposition): 「が」を「か」+「゛」の2文字として扱う。Mac ファイルシステムのデフォルト。

同じ文字列でもNFCとNFDではバイト列が異なるため、単純な == 比較では一致しません。macOS 10.13 High Sierra 以降のデフォルトは APFS ですが、この挙動は APFS でも変わりません。

対処法:比較前に NFC に正規化する

検索時に文字列を NFC に揃えてから比較するのが基本の対処法です。

Python の場合:

import unicodedata

def normalize(text: str) -> str:
    return unicodedata.normalize("NFC", text)

# 使用例(入力値を正規化してから DB と比較)
query = normalize(request.params["q"])

Ruby の場合:

# ActiveSupport を使う場合
query = params[:q].unicode_normalize(:nfc)

# 標準ライブラリを使う場合(デフォルトで NFC)
query = params[:q].unicode_normalize

JavaScript の場合:

const query = input.normalize("NFC");

入力値とデータベースの値の両方を同じ正規化形式に揃えることで、OS 差異に依存しない文字列比較ができます。

DB 保存前に正規化する方法もあります

アプリケーション層で毎回正規化するのではなく、データを DB に保存するタイミングで NFC に変換しておく方法もあります。特に既存データが混在している場合は、保存時に一度正規化してしまうと以降の比較がシンプルになります。

# 保存前に正規化
title = unicodedata.normalize("NFC", raw_input)
db.save(title=title)

PostgreSQL や MySQL 自体はどちらの形式でも保存できますが、正規化形式が混在するとインデックスや LIKE 検索が意図どおりに動かなくなるため、入口でそろえておくのが安全です。

Mac 固有の NFD 問題は、Linux サーバーとの組み合わせ開発でよく踏む地雷です。grep でファイルを検索しても日本語だけヒットしない、という症状もこれが原因であることが多いです。

ターミナルでの確認方法

コマンドラインで現在の文字がNFCかNFDかを調べたいときは、Pythonを使って確認できます。

echo -n "ブログ" | python3 -c "
import sys, unicodedata
text = sys.stdin.read()
for i, c in enumerate(text):
    print(i, repr(c), unicodedata.name(c, 'UNKNOWN'))
"

NFD形式で保存された「ブ」は「フ」と「゛」(結合濁点 COMBINING KATAKANA-VOICED ITERATION MARK)の2文字として表示されます。この出力で何文字に分解されているかを確認することで、NFDかどうかを判定できます。

なぜMacだけNFDになるのか

macOS が NFD 形式で文字を扱う理由はファイルシステムの設計にあります。Apple の HFS+(旧ファイルシステム)はファイル名の比較を Unicode の NFD ベースで行う仕様を採用しており、この挙動は現行の APFS でも維持されています。NFD 形式のほうがファイル名の同一性比較(「が」と「が」が同じ文字かどうか)を定義しやすいという設計上の判断が背景にあります。

Linuxの ext4 や Windows の NTFS はファイルシステム自体が正規化を行わないため、保存時の文字列がそのままバイト列として扱われます。結果としてMacからファイルをアップロードしたときに、同じ「ブログ」という文字列でも Linux 側と Mac 側でバイト列が異なる状態になります。

PostgreSQLでの対処

PostgreSQL を使っている場合、pg_trgm の類似検索や LIKE 検索でもNFD/NFC混在は問題になります。アプリ層で正規化を徹底するのが最もシンプルですが、関数インデックスを使って正規化後の値でインデックスを張る方法もあります(環境によって使用可能な関数が異なります)。最も確実なのは、データ書き込み時に必ず NFC に正規化することです。

一度混入した NFD 文字列を修正する場合は、既存データを一括で NFC に変換するスクリプトを走らせる必要があります。

import unicodedata

def fix_nfd_text(text: str) -> str:
    return unicodedata.normalize("NFC", text)

# 既存データの修正例
for record in db.query("SELECT id, title FROM articles"):
    fixed = fix_nfd_text(record["title"])
    if fixed != record["title"]:
        db.execute("UPDATE articles SET title = %s WHERE id = %s",
                   (fixed, record["id"]))

修正前後の文字列が != で一致しないことを確認してから更新するのが安全です。正規化が不要なレコードまで触れないよう、差分チェックをはさみます。

質問・リクエストを送る

記事についての質問や、取り上げてほしいテーマがあればお気軽にどうぞ。いただいた質問はブログ記事として回答し、Q&Aページで公開することがあります。

このサイトについて

井上 周(Amane Inoue)の個人ブログです。技術・読書・ドラマ・旅・大学生活のことを書いています。