Pagefind の日本語検索を深掘りする — トークナイザの食い違いと IME 対策
「サイバー」で「サイドバー」がヒットする
前回の記事で、このブログに Pagefind で検索機能を追加しました。導入自体は簡単で、一般的な日本語の検索は問題なく動きます。
ただ、使っていると精度が怪しいケースに気づきました。
「サイバー」で検索すると、なぜか「サイドバー」や「バージョン」を含む記事がヒットします。直感的にはおかしい。「サイバー」と「サイドバー」は別の単語のはず。
一方で、「対応」と「対策」は正しく区別されます。「対策」で検索して「対応」が混ざることはない。
怪しいのは日本語形態素解析(分かち書き)周りの実装です。
もしかして「サイバー」は「サイ(犀)」のコスプレをしたホステスがいる謎のコンカフェ「犀バー」ですか?笑
何が起きているのか、Pagefind のソースコードを追いかけてみました。
Pagefind の日本語検索は二段構成
Pagefind の Extended ビルド(npm でインストールすると自動的にこちらになる)は、日本語の処理をインデックス構築時と検索時で別のトークナイザで行っています。
| フェーズ | ツール | 概要 |
|---|---|---|
| インデックス構築(Rust) | charabia + lindera(IPAdic 辞書) | 記事本文を形態素解析で単語に分割 |
| 検索クエリ解析(ブラウザ) | Intl.Segmenter | ユーザーの入力を単語に分割 |
lindera は MeCab でも標準辞書として使われている IPAdic を使う Rust 製の形態素解析器です。
一方 Intl.Segmenter はブラウザ内蔵の ICU ベースの単語分割 API。
どちらも「日本語テキストを単語に分割する」という同じ目的のツールですが、分割結果が一致しないケースがある。
MeCab(IPAdic)と Intl.Segmenter の分割比較
実際に同じ単語を、MeCab(IPAdic 辞書)と Node.js の Intl.Segmenter に通して比較してみました。
| 入力 | MeCab / IPAdic(インデックス側) | Intl.Segmenter(クエリ側) | 一致 |
|---|---|---|---|
| サイバー | サイバー | サイ + バー | 不一致 |
| サイドバー | サイド + バー | サイド + バー | 一致 |
| ステミング | ステミング | ステミング | 一致 |
| ドラゴンボール | ドラゴン + ボール | ドラゴン + ボール | 一致 |
| 翻訳 | 翻訳 | 翻訳 | 一致 |
| 対応 | 対応 | 対応 | 一致 |
| 対策 | 対策 | 対策 | 一致 |
ほとんどのケースでは一致しますが、「サイバー」で食い違いが出ています。(やっぱり「犀バー」でした…)
なぜ「サイドバー」がヒットするのか
流れを整理するとこうなります。
インデックス構築時(lindera/IPAdic):
- 「サイバー」→ 1トークン(IPAdic に「サイバー」が登録されている)
- 「サイドバー」→ 「サイド」+「バー」の2トークン
検索時(Intl.Segmenter):
- ユーザーが「サイバー」と入力 → 「サイ」+「バー」の2トークンに分割される
クエリが「サイ」と「バー」の2語で検索されるため、インデックス上の「バー」(「サイドバー」の一部)にもヒットしてしまう。これが「サイバー」で「サイドバー」が出てくる原因でした。
「対応」「対策」のような一般的な漢字熟語は、どちらのトークナイザでも同じ分割結果になるので正しく検索できます。
クエリ側でも同じトークナイザを使えないのか
Pagefind のソースコードを調べてみました。pagefind-entry.json の各言語に wasm フィールドがありますが、これは Snowball ステマー(語幹抽出)用の WASM であって、クエリ側のトークナイザとは無関係です。
日本語には Snowball ステマーが存在しないため "wasm": null になっており、ステミング非対応のステータスを示しているだけです。クエリ側のトークナイザを差し替える仕組みは、現時点の Pagefind には存在しません。
{
"languages": {
"ja": {
"hash": "ja_798d52173d",
"wasm": null,
"page_count": 18
}
}
}
OSS の依存関係
ここで、Pagefind の日本語処理に関わるライブラリの依存関係を整理しておきます。
Pagefind (CloudCannon)
└── charabia (Meilisearch)
└── lindera (lindera org)
└── IPAdic 辞書
- Pagefind — CloudCannon が開発する静的サイト向け検索エンジン。本記事の主役
- charabia — Meilisearch(検索エンジンの会社)が開発する多言語トークナイザライブラリ。言語の判定と分割を担当
- lindera — lindera org がメンテする Rust 製の形態素解析ライブラリ。日本語の単語分割の実体はここ
- IPAdic — lindera が使う日本語辞書。MeCab でもおなじみ
それぞれ独立した OSS で、メンテナも別です。Pagefind が直接 lindera を触っているわけではなく、charabia 経由で間接的に使っています。
解決への道筋
クエリ側でも lindera を使えれば、インデックスとクエリで分割結果が一致して問題は解消します。ただ、そこに至るには少なくとも2つのハードルがあります。
- lindera の WASM ビルド — lindera + IPAdic 辞書をまるごと WASM にする必要があるが、バイナリサイズがかなり大きくなる
- Pagefind 側のクエリトークナイザ差し替え機構 — 現時点ではクエリ側は
Intl.Segmenterにハードコードされていて、差し替える仕組みがない
道のりは長そうですが、実現すれば日本語検索の精度は大幅に改善するはず。lindera も CloudCannon も、どっちもガンバ。応援しています。
構造的な問題、でもまぁいい感じ
これは Pagefind の設計上の制約です。サーバーサイドで動く lindera(Rust)をブラウザにそのまま持ち込むのは現実的ではないので、クエリ側はブラウザ内蔵の Intl.Segmenter に頼るしかない。
とはいえ、一般的な日本語(漢字の熟語、よく使うカタカナ語)では両者の分割結果はほぼ一致するので、実用上は大きな問題にはなりません。ステミング(語幹抽出)も非対応ですが、個人ブログの検索なら十分です。
完璧な日本語検索が必要なら Algolia や Meilisearch のようなサーバーサイド検索エンジンが必要ですが、無料・ゼロ設定・ビルド時完結という制約のなかでは、Pagefind は最善の選択だと思います。
IME(日本語入力)との相性問題
トークナイザの話とは別に、もうひとつ日本語特有の問題があります。
Pagefind のインクリメンタルサーチは、1文字入力するたびに検索が走る仕組みです。なかなか快適なのですが、日本語の場合は IME(入力メソッド)の変換が絡みます。
たとえば「ステミング」を検索したいとき、IME の変換確定前に「す」「すて」「すてみ」と1文字ずつ検索が走ってしまい、ひらがなの段階ではカタカナの「ステミング」にはヒットしません。最後に、カタカナに変換確定してようやく正しい結果が出る、という微妙な挙動になります。
これは Pagefind に限らず、インクリメンタルサーチ一般での日本語あるある問題です。
対策: compositionend + debounce
ブラウザの compositionstart / compositionend イベントを使うことで、IME の変換中かどうかを検知できます。
compositionstartが発火したら、検索を一時停止compositionendが発火したら(=変換確定)、その時点の文字列で検索を実行
これで「すてみんぐ」の段階では検索が走らず、「ステミング」に変換確定した瞬間に検索が実行されます。
加えて、英数字の入力に対しても 200ms の debounce(遅延実行)を入れています。キーストロークのたびに検索 API を叩くのではなく、入力が 200ms 止まってから検索を実行する仕組みです。体感ではほぼ即時ですが、高速タイピング時の無駄な検索リクエストを抑えられます。
// IME 変換中フラグ
const composingRef = useRef(false);
<input
onChange={(e) => handleInput(e.target.value)}
onCompositionStart={() => { composingRef.current = true; }}
onCompositionEnd={handleCompositionEnd}
/>
IME 対応とパフォーマンス最適化、両方をカバーできるシンプルな実装です。
まとめ
- Pagefind の日本語検索は、インデックス側(lindera/IPAdic)とクエリ側(Intl.Segmenter)で異なるトークナイザを使う二段構成
- 両者の分割結果が食い違うと、意図しない検索結果が出る(「サイバー」→「サイドバー」)
- 解決には lindera の WASM 化と Pagefind のクエリトークナイザ差し替え機構の両方が必要
- 一般的な日本語では実用上十分。個人ブログの検索には問題ない
- IME の変換中に検索が走る問題は
compositionend+ debounce で対策可能