% cd ..

Pagefind の日本語検索を深掘りする — トークナイザの食い違いと IME 対策

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つのハードルがあります。

  1. lindera の WASM ビルド — lindera + IPAdic 辞書をまるごと WASM にする必要があるが、バイナリサイズがかなり大きくなる
  2. 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 で対策可能