↓ メインコンテンツへスキップ

静的ブログのサイト内検索を、日本語の表記ゆれに強くした

·2967 文字·6 分
mota
著者
mota
AI開発、個人開発、ベトナムでの仕事と暮らしについて書いています。

このブログの記事は400本を超えました。 書いた本人でも、数年前の記事はタイトルを覚えていません。

それでも、サイト内検索に「ホーチミン」と打てば出てくるので、困っていないつもりでした。 ところが試しに「ほーちみん」と打つと、一件も出てきませんでした。

読者は、記事のタイトルどおりに打ってくれるとは限りません。 ひらがなで打つこともあれば、「Cloudflare」を「Cloudfare」と打ち間違えることもあります。 「株」の記事を「かぶ」で探すこともあるはずです。

サーバーを持たない静的サイトのまま、こうした検索にどこまで応えられるのかを試しました。

Fuse.jsのままでは何が足りなかったのか
#

このブログはHugoとBlowfishテーマで作っています。 テーマの検索は、ビルド時に全ページの本文を index.json へ書き出し、ブラウザ側の Fuse.js で絞り込む仕組みです。

Fuse.jsはあいまい検索のライブラリなので、表記ゆれにも強いだろうと思っていました。 ただ、設定を見ると、一致の厳しさを決める threshold が 0.0 になっていました。 これでは実質的に完全な部分一致で、あいまいさは働きません。

では threshold を上げればよいのかというと、それも難しいです。 Fuse.jsのあいまいさは文字の編集距離に基づくので、日本語の本文全体に適用すると、関係のない記事まで大量に拾います。 そもそも「ホーチミン」と「ほーちみん」は、編集距離で見ると、長音以外の4文字がすべて違う語です。

足りなかったのは、あいまいさの強さではありませんでした。 日本語で「同じ語」とみなしてよい差を、比較の前に消しておく処理です。

比較の前に、同じ語へ揃える
#

参考にしたのは、仕事で作っている社内ポータルの検索です。 そこでは、入力と検索対象の両方を同じ規則で正規化してから比べていました。 この考え方をブログ向けに広げて、Fuse.jsを使わない検索エンジンを search.js に書きました。

正規化で吸収している差は次のとおりです。

  • 全角と半角(NFKC正規化)
  • 大文字と小文字
  • カタカナとひらがな
  • 長音、空白、記号

この処理を通すと、「ホーチミン」「ほーちみん」「ホー・チ・ミン」はすべて同じ文字列になります。 「Claude Code」と「claudecode」も同じです。

正規化したあとは、空白で区切った語を一つずつ記事と照らし合わせます。 一致の仕方には強弱をつけました。

  1. 完全一致
  2. 先頭一致
  3. 部分一致
  4. 同義語での一致
  5. タイプミスを許す一致

最後のタイプミスへの対応は、語を2文字ずつに区切り、その7割以上が記事側に含まれていれば一致とみなす方法です。 「Cloudfare」は「cl」「lo」「ou」…と区切ると、ほとんどの組が「Cloudflare」にも含まれます。 本文に使うと誤ヒットが増えるので、タイトル、タグ、要約など短い項目だけに適用しています。

一致した場所にも重みをつけました。 タイトル、タグ、検索キーワード、要約、カテゴリー、本文の順に重く、本文は出現回数でも加点します。 空白で区切った語が記事の中で続けて現れる場合(「ベトナム 株」に対する「ベトナム株」)と、新しい記事も少しだけ上に出します。

これで「ほーちみん」も「Cloudfare」も見つかるようになりました。 全445ページに対して、一回の検索はおおむね1〜10ミリ秒で終わります。

正規化では「かぶ」に届かない
#

ひらがなとカタカナは機械的に揃えられます。 ただ、「かぶ」と「株」は文字の上では何の関係もありません。 読みを辞書で引く仕組みを入れれば解決しますが、形態素解析の辞書をブラウザへ配るのは、静的ブログの検索としては重すぎます。

そこで、人の判断が必要な部分は二つに分けました。

一つは同義語辞書です。 「就活」と「就職活動」、「生成AI」と「ChatGPT」のように、どの記事にも共通する言い換えをYAMLにまとめ、検索時に展開します。

もう一つは、記事ごとの検索キーワードです。 front matterに searchKeywords という項目を足し、本文やタイトルには出てこないけれど読者が打ちそうな語を入れます。

searchKeywords: ["しゅっせいとどけ", "領事館", "パスポート"]

既存の約400記事には、Claude Codeのサブエージェントを6つ並べて、本文を読んだうえでキーワードを付けてもらいました。 ここでも一度つまずいています。 最初の結果を見ると、多くのエージェントがタイトルとタグだけを見て、本文を読まずにキーワードを作っていました。 本文を読み直すよう指示して作り直してもらい、漢字の読み、サービス名、本文にしか出てこない固有名詞が入りました。

付けたキーワードが効いていなかった
#

キーワードを全記事に入れてビルドしても、検索結果は何も変わりませんでした。

原因はHugoの仕様でした。 Hugoはfront matterの項目名を小文字に変換して保持するので、テンプレートで .Params.searchKeywords と書いても値は空になります。 .Params.searchkeywords と書き直して、ようやくインデックスに入りました。

効くようになると、今度は効きすぎました。 「ベトナム株」で検索すると、生成AIについて書いた記事が1位に出てきたのです。 その記事のキーワードに、本文で一度だけ触れた「ベトナム株」が入っていました。

エージェントが付けたキーワードには、こうした周辺的な語も混じります。 そこで検索キーワードの重みを、自分で選んでいるタグより下げました。 タイトルとの完全一致も最優先にして、「Note Booster」と打てばプロダクトページが先頭に来るようにしています。

記事が増えても検索を崩さないために
#

重みを一つ変えると、別の検索の順位が静かに崩れることがあります。 今回の調整でも、それを何度も見ました。

そこで、検索の品質をビルドの途中で確かめるスクリプトを足しました。 確かめている内容は次の三つです。

  • 全ページについて、タイトルで検索すると上位5件に自分が出るか
  • 記事のタグで絞り込んだときに、その記事がヒットするか
  • 「しゅっせいとどけ」なら出生届の記事、のように決めておいた期待クエリで、指定の記事が上位に出るか

どれかが崩れるとビルドが止まり、デプロイされません。 この確認はCloudflareの自動ビルドでも毎回走ります。

このブログでは、記事の執筆と公開もClaude Codeに任せています。 リポジトリの作業手順書に、記事を追加したら searchKeywords、同義語、期待クエリを更新するという手順を足しました。 新しい記事も、書いた時点で検索に乗る状態から公開されます。

静的サイトの検索に、どこまで手を入れるか
#

最初は、検索サービスを導入するか、ベクトル検索のような仕組みが必要になると思っていました。 実際に試すと、ほとんどの不満は正規化と重みづけで解消しました。 残った「かぶ」のような読みの問題も、記事を書く側が数語のキーワードを足せば届きます。

一方で、「やり直したくなった話」のように内容の雰囲気で探す検索には、この仕組みは応えられません。 語が一致しない限り、記事は見つからないからです。

それが必要かどうかは、検索欄に実際に何が打たれているかを見てから決めようと思います。 まずは、自分で「ほーちみん」と打って出てくることを確かめるところからです。

関連記事