日本語の全文検索を「表記が違っても引ける」ようにする正規化と読み
長音・中黒・全角半角・カタカナ英語。text_search と text_yomi の2列を持たせて吸収する。
やりたいこと
「コンピュータ」と「コンピューター」、「Surfshark」と「サーフシャーク」。 話している側は同じことを言っているのに、文字にすると別物になります。 検索する人はどちらで打つか分からないので、どちらでも引けるようにしたいところです。
ただ、表示用のテキストは触りたくありません。 番組が使っている表記に揃える校正をしている以上、検索の都合で書き換えるわけにいかない。 そこで、検索専用の列を2つ持たせました。
CREATE TABLE segments (
video_id TEXT, idx INTEGER,
text TEXT, -- 音声認識の生出力(触らない)
text_corrected TEXT, -- 校正後の表示用(これを画面に出す)
text_search TEXT, -- ① 正規化した検索用
text_yomi TEXT -- ② ひらがなに開いた検索用
);
① 正規化 — 記号と長音を落とす
def normalize_for_search(text):
text = unicodedata.normalize("NFKC", text) # 全角英数 → 半角
text = text.lower() # 英字は小文字に
text = "".join(c for c in text if c not in _STRIP_CHARS)
return text
# _STRIP_CHARS には 中黒・スラッシュ・長音「ー」・空白・括弧・句読点 が入る
normalize_for_search("MS-DOS") # → 'msdos'
効いたのは長音を落とすところです。 「コンピュータ/コンピューター」「サーバ/サーバー」がこれで同じ形になります。
② 読み — かなに開く
def yomi_for_search(text):
t = normalize_for_search(text)
t = _ASCII_TOKEN.sub(lambda m: english_yomi.get(m.group(0), m.group(0)), t)
hira = "".join(w["hira"] for w in kakasi.convert(t))
for a, b in (("ゔぁ","ば"), ("ゔぃ","び"), ("ゔ","ぶ")): # ヴ→バ行に畳む
hira = hira.replace(a, b)
return normalize_for_search(hira)
yomi_for_search("形態論") # → 'けいたいろん'
③ 英字の固有名詞は、カナ読みを登録する
# data/english_yomi.csv
surfshark,さーふしゃーく
geekom,ぎこむ
valuebooks,ばりゅーぶっくす
表示は正式表記のまま、検索は「さーふしゃーく」でも通ります。 表示と検索を分けているので、正式表記に揃える校正と、ゆるく引ける検索が両立しました。
④ クエリ側にも同じ関数を通す
当たり前ですが、片方だけ正規化しても一致しません。 検索語にも同じ関数をかけて、両方が同じ形に落ちるようにしています。
⑤ 落とし穴 — 長音を落とすと、漢字側と一致しない
この設計には穴がありました。
正規化で長音を落とすので、yomi_for_search("ソシュール") は そしゅる になります。
一方、音声認識が「ソシュール」を漢字に誤変換した「祖収る」の読みは そしゅうる です。
>>> yomi_for_search("ソシュール")
'そしゅる'
>>> yomi_for_search("祖収る風に言うのだったら")
'そしゅうるかぜにいうのだったら'
>>> 'そしゅる' in 'そしゅうるかぜに...'
False # 一致しない
誤変換を探すツールを書いたとき、これで実際に取りこぼしました。 対策は、長音を母音に開いた形も鍵にすることです。
def yomi_keys(word):
keys = {normalize_for_search(yomi_for_search(w)) for w in expand_choon(word)}
return sorted(k for k in keys if len(k) >= 3)
yomi_keys("ソシュール") # → ['そしゅうる', 'そしゅる']
⑥ おまけ — SQLite で踏んだやつ
# ❌ SELECT の反復中に同一カーソルで UPDATE すると、ループが1件で止まる
for row in con.execute("SELECT ... FROM segments"):
con.execute("UPDATE segments SET ...")
# ✅ 先に fetchall() する
for row in con.execute("SELECT ... FROM segments").fetchall():
con.execute("UPDATE segments SET ...")
← SQLite FTS5(contentless)の索… 「この単語、もっと出てたはずなんだけど」を機械で探す… → 目次へ