タグ
ほぼ黒のカードに白のpagefindのマークとロゴタイプ。マークは針を囲む楕円で、その横に小文字のロゴタイプが並ぶ

Pagefindのステミング警告は見当違いだった — 日本語検索を壊していたのは分かち書きの不一致

Pagefindはビルドのたびに日本語と中国語のステミング未対応を警告していました。本当の不具合は別にあり、検索語と索引のトークナイザーの不一致が誤ったページを返していました。

目次

はじめに

このサイトはビルドのたびに同じ 2 つの注意書きを出していて、私は何週間もそれを読み飛ばしていました。

Note: Pagefind doesn't support stemming for the language ja-jp.
Search will still work, but will not match across root words.

ja-jp に 1 つ、zh-tw に 1 つ、そして en-us には何も出ません。あるとき、この注意書きが誘っている問いをそのまま投げてみました。これは Pagefind の日本語と中国語における本当の制限なのか、直せるのか。安心させるだけの答えではなく正直な答えが欲しかったのは、このサイトで古い記事にたどり着く手段が検索しかないからです。

正直な答えは 2 つに分かれました。この警告は恒久的で、無害で、そもそも壊れていない部分についてのものです。 その下には、何ひとつログを出さない本物の不具合が潜んでいました。日本語の複合語で検索すると誤った記事が返ってくる、しかも自信たっぷりに返ってくる。Pagefind 1.5 以降ずっとそうなっていました。この記事では、英語と CJK が別々の処理を通る理由、警告がそのうち関係のない側を指している理由、本当の不具合を読むのではなく計測して見つけた経緯、そしてそれを直した 1 行のパッチを扱います。

ステミングは英語のもの、分かち書きは日本語のもの

この 2 つの語は同じもののように聞こえますが、正反対の問題を指しています。

ステミングは接尾辞を削り、活用した形を 1 つの語根にまとめます。 repository で検索して repositories にも当たるのはこれのおかげで、英語が語の末尾に文字を足して文法を表す言語だから成立します。Pagefind は Snowball でこれを実装していて、Snowball は約 25 言語ぶんのアルゴリズムを持っています。

分かち書きは逆の問題です。そもそも語の切れ目がどこなのかを見つけます。 日本語と中国語はスペースを使わずに書くので、索引に入れる前に「画像変換」が 1 語ではなく 2 語だと判断する処理が要ります。英語にこの工程は要りません。スペースがすでにその仕事を終えているからです。

2 つの言語における分かち書きとステミング2 つの列が並ぶ。英語はスペースで区切られたあと、repositories が repository へステミングされる。日本語は辞書で 4 つのトークンに切られたあと、ステミングのセルに到達するが、そこは点線でグレーになっている。該当するアルゴリズムが存在しないためで、ビルド時の警告はこの空のセルについてのものである。英語git repositories分かち書きスペースで自動的にステミングSnowball の英語規則gitrepository日本語画像を変換する分かち書きLindera + UniDicステミングなし。今後も追加されない画像変換するビルド時の注意書きが指しているのはグレーのセルです。日本語の検索はそこを使っていません。
英語は最初から区切られていて、必要なのは語尾を削ることだけです。日本語は切れ目のない一続きで届くので、まず切る必要があります。警告が指しているのは削るほうの工程で、日本語はそこを通りません。

Snowball に中国語・日本語・韓国語のアルゴリズムはなく、今後も追加されません。接尾辞で活用しない言語にとって、接尾辞を削る操作は意味を持たないからです。つまりこの注意書きは正確で恒久的です。同時に無関係でもあります。日本語の検索は、注意書きが指す工程に一切依存していません。

分かち書きのほうは対応済みで、しかもよく対応されています。pagefind の npm パッケージは常に extended バイナリを入れており、ビルド出力の 1 行目がそれを示しています。

Running Pagefind v1.5.2 (Extended)

このバイナリが 55 MB あるのは辞書を抱えているからです。中の文字列を取り出すと、何が入っているかが分かります。

charabia-0.9.9/src/segmenter/chinese.rs -> jieba-rs 0.8.1
charabia-0.9.9/src/segmenter/japanese.rs -> lindera 0.43.3 + lindera-unidic

中国語は Jieba、日本語は UniDic 辞書を伴う Lindera。警告が触れていない能力のほうは、最初から備わっていました。

その警告からは決して出てこない計測

注意書きを読んでも先に進まないので、エージェントは代わりに検索そのものを計測することを提案しました。サイトをビルドし、各ロケールの索引を JavaScript API から直接引き、その件数をビルド後の HTML に対する grep の件数と突き合わせる。ある語が 8 ページに出現して検索が 8 件返すなら、その言語は動いています。

日本語もほとんどは動いていました。dist/ja/blog 以下のページ数と索引が返した件数の比較です。

検索語 含むページ数 件数 正しいか
ドキュメント 7 7 はい
ブラウザ 9 9 はい
画像 7 7 はい
コンポーネント 4 4 はい
リポジトリ 8 1 いいえ

リポジトリ は珍しい語ではありません。このサイトの 8 記事に出てきます。それなのに返ってきた 1 件は、その語をどこにも含まない、言語モデルの歴史についての記事でした。

0 件を返す検索は、失敗したことを教えてくれます。この検索は答えを返してきました。

索引には語があった。検索がそれを問い合わせなかった

リポジトリ に対して Pagefind が返した抜粋が、そのまま答えになっていました。ハイライトされていたのは リレー、字面が少し似ているだけの無関係な語です。エンジンは何にも一致できず、曖昧一致に落ちていました。

次の試験で 2 つの側が切り分けられました。複合語 ガベージコレクション前方一致である ガベージ で検索すると、正しい記事が返り、抜粋にはその複合語がまるごとハイライトされます。完全な語 ガベージコレクション で検索すると、コレクション に一致した全く別の記事が返ります。

短い検索語が見つけたものを、長くより正確な検索語は見つけられませんでした。 これは通常の関係を逆転させていて、しかも索引が複合語をまるごと保持していたことが示されている以上、原因は検索側にあります。

その検索語をブラウザがどう扱うかを尋ねると、仕組みが 1 行で出てきました。

[...new Intl.Segmenter('ja-JP', { granularity: 'word' }).segment('ガベージコレクション')]
.filter((s) => s.isWordLike)
.map((s) => s.segment);
// ['ガ', 'ベ', 'ージ', 'コレクション']
// リポジトリ -> ['リ', 'ポジ', 'トリ']
// ドキュメント -> ['ドキュメント']
// 画像 -> ['画像']

Pagefind 1.5.0 は、検索語も本文と同じ切り方をすべきだというもっともらしい理由から、ブラウザ側で Intl.Segmenter を使って検索語を切りはじめました。しかし両者は同じ辞書を使っていません。 索引を切るのは Rust バイナリ内の Lindera と UniDic です。検索語を切るのは、読者のブラウザがたまたま積んでいる ICU の辞書です。両者が食い違えば、負けるのは検索語のほうです。

1 つの複合語をめぐって食い違う 2 つの辞書1 つの日本語の複合語が 2 つの経路に分かれる。索引側では Lindera が 1 語として格納する。検索側ではブラウザの Intl.Segmenter が 4 つの断片に切り、サイト全体の索引に存在するのはそのうち最後の 1 つだけである。全ての語が一致する必要があるため複合語を含むページが除外され、代わりにその実在する断片が無関係なページを引き寄せる。同じ語を、片方は索引に、片方は検索語にガベージコレクション索引側(ビルド時)Rust バイナリ内の Lindera + UniDicガベージコレクション1 語として格納検索側(ブラウザ)ブラウザの ICU を使う Intl.Segmenterージコレクション4 つに分割。索引にあるのは最後の 1 つだけ✗ 全ての語は一致しない全ての語が一致する必要があるため、その語を含むページが候補から外れ、代わりに曖昧一致の結果が返ります。
1 つの複合語に 2 つの辞書。Lindera はそれを 1 語として格納し、ICU は 4 つの断片に切りました。索引にあるのは最後の 1 つだけです。全ての語が一致する必要があるため、複合語を持つページが候補から外れ、その実在する断片が別のものを引き寄せます。

これが不具合の中身です。断片 ージ は索引上の語ではなく、Pagefind は全ての検索語の一致を要求するので、複合語を含む記事が除外されます。残るのは、唯一実在する語である断片について曖昧一致が見つけたものです。

この対応関係は試したすべての語で成り立ちました。Intl.Segmenter が語を保ったときは検索が正確で、語を刻んだときは検索が誤っていました。

語を打ち切ることが不具合の引き金になる

前方一致は効いて完全な語は効かないので、この不具合は不釣り合いに意地の悪い形をしています。ガベージコレクション の各前方一致を順に検索し、本当に一致する 1 記事が結果に入っているかを見たものです。

語を打ち終えた瞬間に消える正しい検索結果日本語の複合語を 1 文字ずつ入力した 10 列。その下の行は返ってきた件数、さらに下の行はその語を実際に含む記事が結果に入っていたかどうかを示す。1 打鍵目では含まれ、2 打鍵目で外れ、3 打鍵目から 8 打鍵目までの 6 打鍵は連続して含まれ、最後の 2 打鍵で消える。入力された文字。1 列が 1 打鍵返された件数正しい記事が含まれるか1211111111111途中でやめれば見つかり、最後まで打つと消えます。
正しい記事は 6 打鍵連続で結果に入り、最後の 2 打鍵で消えます。途中でやめた読者は見つけられ、語を打ち終えた読者は見つけられません。

読者は正確であろうとしたことで罰を受けます。 この不具合が長く気づかれなかった理由もここにあります。検索語を途中まで打った人には検索が機能して見え、どのログもそれについて何も言わなかったからです。

一方、中国語は問題ありませんでした。ICU は 儲存庫 を 儲存 と 庫 に切ります。同じ問題に見えますが、Jieba も繁体字中国語を短い単位に切るため、両者はおおむね一致します。zh-TW の 10 個の検索語は、以下のすべての作業の前後で同じ件数を返しました。

1.4 への差し戻しは検索を直し、検索窓を壊した

検索語の分かち書きを導入したのが 1.5.0 である以上、1.4.0 にこの不具合はないはずです。同じ dist/pnpm dlx pagefind@1.4.0 で再インデックスし、同じ検索語を流すと、そのとおりでした。

検索語 1.4.0 1.5.2
リポジトリ 8 件、すべて正しい 1 件、誤ったページ
ガベージコレクション 1 件、正しい 1 件、誤ったページ
ドキュメント 7 7

つまり退行は本物で、バージョンを固定したくなりました。しかしそれは使えませんでした。このサイトの検索 UI は pagefind-searchbox という Web コンポーネントですが、1.4.0 が同梱するその版は、自身の pagefind.js が定義していない関数を呼びます。

TypeError: s.createInstance is not a function

古いバンドルが配信されていたわけではありません。エージェントが全ファイルを cache: 'reload' で取り直しても同じエラーでした。差し戻すなら、2 つの検索の入り口をどちらも旧来の PagefindUI クラス API に書き換えることになります。 辞書 1 つを取り戻すには大きすぎる変更です。

うまくいった方法の前に、2 つの方法が失敗しています。検索語を "リポジトリ" と引用符で囲んでも分かち書きは回避できず、0 件が返りました。引用された語句も、同じように刻まれた断片から組み立てられるからです。そして pagefind.js の中の言語リストにパッチを当てても、出力は素の状態と 1 バイトも変わらず、パッチが黙って失敗したように見えました。失敗してはいませんでした。1.5.0 以降、検索は Web Worker で走り、pagefind-worker.js が同じ言語リストを自前で持っています。 片方だけにパッチを当てても、利用者から見える変化は何も起きません。

1 つのリストから 1 つの言語を外す

判定そのものは、最小化された 3 行の JavaScript です。

needsWordSegmentation = (lang) => {
if (!lang) return false;
const primaryLang = lang.split('-')[0].toLowerCase();
return ['zh', 'ja', 'th'].includes(primaryLang);
};

これを切り替える設定項目はありません。--force-language も代わりにはなりません。/ja/search が英語記事を返さないようにしている <html lang> ごとの索引分割を、それは潰してしまうからです。

そこで、ビルド後のバンドル内のそのリストを、両方のファイルで、日本語についてだけ書き換えることにしました。上流の修正を待つのではなくこれを入れると私が決め、scripts/patch-pagefind-ja.tspnpm build の一部として pagefind の実行後に毎回走るようになっています。

ビルド出力を書き換えるのはワークアラウンドで、ワークアラウンドは静かに腐ります。そこでこのスクリプトは、代わりに大きな音を立てて失敗するように書かれています。

  • リストが見つからなければ、原因を明示してビルドを失敗させます。パッチの当たっていないバンドルを黙って出荷すれば、このスクリプトが存在する理由そのものの不具合が戻ってきます。
  • Pagefind が計測時のバージョンでなければ、警告を出します。
  • 未検証のバージョンでリストがすでに ['zh', 'th'] になっていた場合は致命的エラーにします。それはこのスクリプトの以前の実行結果ではなく上流自身のリストである可能性があり、その場合この工程は何もしないまま正常終了してしまうからです。

コードレビューからもう 1 つ変更が出ました。差分ではなくパイプライン全体を見て初めて分かる種類のものです。pagefind はキャレット範囲で指定されていて、このリポジトリでは Dependabot が自動マージし、必須のステータスチェックもありません。つまり最小化された出力の形を変える Pagefind のリリースが出れば、それは自分でマージされ、その後は検索と無関係な記事公開まで含めてすべての本番デプロイを止めていたはずです。エージェントは厳密なバージョン固定を提案し、それによってこの事象は誰かが目を通す必要のあるバージョン更新に変わりました。エージェントはバージョン不一致も一律に致命的にすることを併せて提案しましたが、そちらは同じ理由で私が見送りました。それは障害を防ぐのではなく、障害を起こす側だからです。

数字が今どうなっているか

検索語 修正前 修正後
リポジトリ 1 件、誤ったページ 8 件、正しい
ガベージコレクション 1 件、誤ったページ 1 件、正しい
ドキュメント / アーキテクチャ / コンポーネント 7 / 5 / 4 変化なし
zh-TW の 10 個の検索語 8, 7, 5, 9, 14, 12, 12, 5, 14, 4 同一
repository / repositories 5 / 5 5 / 5

英語は今もステミングし、中国語は今も分かち書きし、日本語は自分の索引が作られたとおりに一致するようになりました。目を離さないでおく価値があるのは最後の行です。パッチが狙った言語の外にはみ出していないことを、そこが示しています。

ビルドは今も 2 つのステミングの注意書きを毎回出しますし、そのそれぞれが 2 回ずつ出ます。これは Pagefind 側の出力の見た目上の癖です。これからもずっとそうで、それで正しいのです。

まとめ

  • 毎回のビルドで出ている警告は、いま追っている不具合の証拠ではありません。 これは正確で、恒久的で、無関係でした。その隣にあった不具合のほうは、何もログを出しませんでした。
  • ステミングと分かち書きは正反対の問題を解きます。 英語は最初から区切られていて語尾を削る必要があり、日本語は切れ目なく届いて切る必要があります。前者を持たず後者を完璧にこなすツールはありえます。
  • 一方の辞書で索引を作り、もう一方の辞書で検索するエンジンは、その 2 つの辞書が食い違う語でちょうど失敗します。 しかもその失敗は、空の結果ではなくもっともらしい誤答として現れます。
  • 前方一致ではなく、完全な語で試してください。 前方一致の検索はこの不具合を完全に隠していましたし、検索窓を軽く確認する人が実際に打つのは前方一致です。
  • 読めない言語は数えて検証してください。 ビルド後の HTML に対する grep の件数と検索の件数を突き合わせる作業に読解力は要らず、初日にこれを捕まえられたはずです。
  • 日本語のサイトで Pagefind 1.5.x を使っているなら、検索が動いていると決める前に複合名詞を 1 つ試してください。上流の issue #1237 は、ICU がまるごと保つ漢語の複合語を Lindera が切るという逆向きの事例で、同じ原因を追っています。

参考リンク

この記事をシェア