
Pagefind 的詞幹還原警告是假線索 — 讓日文複合詞搜尋失效的是斷詞不一致
Pagefind 每次建置都警告日文與中文缺少詞幹還原。真正的缺陷在別處:查詢端與索引端的斷詞器不一致,回傳了錯誤的頁面。
本頁目錄
引言
這個網站每次建置都會印出同樣兩則提示,而我已經讀過去好幾個星期了:
Note: Pagefind doesn't support stemming for the language ja-jp.Search will still work, but will not match across root words.ja-jp 一則,zh-tw 一則,en-us 則什麼都沒有。後來我把這些提示引出來的問題直接問了出來:這是 Pagefind 在日文與中文上真正的限制嗎,能不能修?我要的是誠實的答案而不是讓人安心的答案,因為在這個網站上,搜尋是任何人找到舊文章的唯一途徑。
誠實的答案分成兩半。這個警告是永久的、無害的,而且講的是從來沒壞掉的那一半。 在它底下藏著一個什麼都不會印出來的真缺陷:用日文複合詞搜尋會回傳錯誤的文章,而且回得理直氣壯,從 Pagefind 1.5 起就一直如此。這篇文章會談英文與 CJK 為什麼走不同的處理流程、警告為什麼指向無關的那一半、真正的缺陷是怎麼靠量測而不是靠閱讀找出來的,以及修好它的那一行修補。
詞幹還原是給英文的,斷詞是給日文的
這兩個詞聽起來可以互換,實際上描述的是相反的問題。
詞幹還原會削掉字尾,讓各種變化形收斂到同一個字根。 搜尋 repository 也能命中 repositories 就是靠它,而它之所以成立,是因為英文用在字尾加字母的方式表達文法。Pagefind 以 Snowball 實作它,Snowball 提供約 25 種語言的演算法。
斷詞是反過來的問題:先找出詞的邊界到底在哪裡。 日文與中文書寫時不放空格,所以在進索引之前,得先有東西判斷「画像変換」是兩個詞而不是一個。英文從不需要這一步,因為空格早就把這件事做完了。
Snowball 沒有中文、日文或韓文的演算法,將來也不會有,因為對一個不靠字尾變化的語言來說,削掉字尾不是有意義的操作。所以這則提示是準確且永久的。它同時也是無關的:日文搜尋完全不依賴提示所指的那一步。
斷詞那一半是有支援的,而且支援得很好。pagefind 的 npm 套件一律安裝 extended 執行檔,建置輸出的第一行就寫著:
Running Pagefind v1.5.2 (Extended)這個執行檔有 55 MB,是因為它帶著詞典。把裡面的字串抽出來就看得到內容:
charabia-0.9.9/src/segmenter/chinese.rs -> jieba-rs 0.8.1charabia-0.9.9/src/segmenter/japanese.rs -> lindera 0.43.3 + lindera-unidic中文用 Jieba,日文用搭配 UniDic 詞典的 Lindera。警告沒有提到的那項能力,一直都在。
那則警告永遠不會促成的量測
讀提示走不到任何地方,所以智能體改為提議直接量測搜尋本身:建置網站,透過 JavaScript API 直接查詢每個語系的索引,再把筆數和建置後 HTML 的 grep 筆數對照。如果一個詞出現在八個頁面而搜尋回傳八筆,這個語言就是正常的。
日文大部分確實正常。以下是 dist/ja/blog 底下的頁數與索引回傳筆數的比較:
| 查詢 | 含有該詞的頁數 | 筆數 | 是否正確 |
|---|---|---|---|
ドキュメント |
7 | 7 | 是 |
ブラウザ |
9 | 9 | 是 |
画像 |
7 | 7 | 是 |
コンポーネント |
4 | 4 | 是 |
リポジトリ |
8 | 1 | 否 |
リポジトリ 不是什麼冷僻的詞,它出現在這個網站的八篇文章裡。而它回傳的那唯一一筆,是一篇關於語言模型歷史、通篇沒有這個詞的文章。
回傳零筆的搜尋會告訴你它失敗了。這一次它交回了一個答案。
索引裡有那個詞,是查詢從來沒有問它
Pagefind 為 リポジトリ 回傳的摘要本身就說明了一切。被高亮的是 リレー,一個只是看起來有點像的無關詞。引擎沒能比對到任何東西,於是落到了模糊比對。
下一個測試把兩端分開了。用複合詞 ガベージコレクション 的前綴 ガベージ 去搜,回傳的是正確的文章,而且摘要裡整個複合詞都被高亮。用完整的詞 ガベージコレクション 去搜,回傳的卻是另一篇完全不同、靠 コレクション 命中的文章。
比較短的查詢找到了比較長、比較精確的查詢找不到的東西。 這把平常的關係整個顛倒過來,而且既然索引明顯保有整個複合詞,問題就落在查詢端。
去問瀏覽器它對這些查詢做了什麼,機制一行就出來了:
[...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 詞典。兩者一旦不一致,輸的是查詢那一端。
這就是缺陷的內容。碎片 ガ、ベ、ージ 都不是索引裡的詞,而 Pagefind 要求每個查詢詞都要命中,所以含有這個複合詞的文章被排除了。留下來的,是模糊比對針對唯一那個真實存在的碎片所找到的東西。
這個對應關係在測試過的每個詞上都成立。Intl.Segmenter 保住整個詞時,搜尋是精確的;把詞切碎時,搜尋是錯的。
把詞打完,才是壞掉的那一刻
因為前綴有效而完整的詞無效,這個缺陷有著格外刻薄的形狀。以下是依序搜尋 ガベージコレクション 的每個前綴,並檢查那篇真正命中的文章是否在結果裡:
讀者因為想要精確而受罰。 這也是這個缺陷長期沒被發現的原因:把查詢打到一半的人看到的是一個正常運作的搜尋,而任何日誌都沒提過它。
中文那邊則沒問題。ICU 把 儲存庫 切成 儲存 和 庫,看起來像同一個問題,但 Jieba 也把繁體中文切成短單位,所以兩端大致一致。十個 zh-TW 查詢在後續所有改動的前後都回傳相同的筆數。
退回 1.4 修好了搜尋,卻弄壞了搜尋框
既然查詢端斷詞是 1.5.0 才引入的,1.4.0 就不該有這個缺陷。用 pnpm dlx pagefind@1.4.0 對同一份 dist/ 重建索引並跑同樣的查詢,證實了這一點:
| 查詢 | 1.4.0 | 1.5.2 |
|---|---|---|
リポジトリ |
8 筆,全部正確 | 1 筆,錯誤的頁面 |
ガベージコレクション |
1 筆,正確 | 1 筆,錯誤的頁面 |
ドキュメント |
7 | 7 |
所以這個退步是真的,把版本鎖回去很有吸引力。但那條路走不通。這個網站的搜尋介面是 pagefind-searchbox 這個 Web 元件,而 1.4.0 附的那一版會呼叫一個它自己的 pagefind.js 並未定義的函式:
TypeError: s.createInstance is not a function這並不是伺服器送了過期的檔案。智能體用 cache: 'reload' 重新抓過每一個檔案,得到的是同樣的錯誤。退版就意味著要把兩個搜尋入口都改寫成舊的 PagefindUI 類別 API,為了換回一本詞典,這個改動太大了。
在可行的做法之前,還有兩種做法失敗了。把查詢用引號寫成 "リポジトリ" 並不能繞過斷詞,它回傳零筆,因為被引號框住的詞組同樣是由那些被切碎的碎片組成的。而只修補 pagefind.js 裡的語言清單,產出的檔案和原版一個位元組都不差,看起來就像修補默默失敗了。它並沒有失敗。從 1.5.0 起搜尋跑在 Web Worker 裡,而 pagefind-worker.js 自己也帶著一份同樣的語言清單。 只修其中一個檔案,使用者看得到的變化是零。
從一份清單裡拿掉一個語言
判斷本身是三行最小化過的 JavaScript:
needsWordSegmentation = (lang) => { if (!lang) return false; const primaryLang = lang.split('-')[0].toLowerCase(); return ['zh', 'ja', 'th'].includes(primaryLang);};沒有設定選項可以切換它。--force-language 也不是替代方案,因為它會把依 <html lang> 分開的索引壓成一份,而那正是讓 /ja/search 不會回傳英文文章的機制。
所以做法是改寫建置後套件裡的那份清單,兩個檔案都要改,而且只針對日文。我決定直接把它做進來而不是等上游修好,scripts/patch-pagefind-ja.ts 現在會作為 pnpm build 的一部分,在每次執行 pagefind 之後跑。
改寫建置輸出是一種權宜做法,而權宜做法會安靜地爛掉。所以這支腳本被寫成會大聲失敗:
- 找不到那份清單時,讓建置失敗,並在訊息裡點名原因。默默出貨一份沒被修補的套件,等於讓這支腳本存在的理由重新出現。
- Pagefind 不是當初量測的版本時,發出警告。
- 在未經驗證的版本上,清單如果已經寫著
['zh', 'th'],視為致命錯誤。那份清單有可能是上游自己的而不是這支腳本先前跑出來的,果真如此的話,這一步會什麼都沒做就正常結束。
程式碼審查還帶出另一個改動,那種只有看整條管線而不是只看差異時才會浮現的問題。pagefind 原本用的是插入符號版本範圍,這個儲存庫的 Dependabot 會自動合併,而且沒有必要通過的狀態檢查。也就是說,只要 Pagefind 有一版改變了最小化輸出的形狀,它就會自己合併進來,接著擋下每一次正式環境部署,包括和搜尋毫無關係的文章發布。智能體提議鎖成精確版本,把這件事變成一次得有人親自過目的版本更新。它同時提議把任何版本不一致都視為致命錯誤,那一項我基於同樣的理由沒有採納:那不是在防止中斷,而是在製造中斷。
現在的數字
| 查詢 | 修正前 | 修正後 |
|---|---|---|
リポジトリ |
1 筆,錯誤的頁面 | 8 筆,正確 |
ガベージコレクション |
1 筆,錯誤的頁面 | 1 筆,正確 |
ドキュメント / アーキテクチャ / コンポーネント |
7 / 5 / 4 | 沒有變化 |
| zh-TW 的十個查詢 | 8, 7, 5, 9, 14, 12, 12, 5, 14, 4 | 完全相同 |
repository / repositories |
5 / 5 | 5 / 5 |
英文照樣做詞幹還原,中文照樣斷詞,而日文現在會照著它自己的索引被建立的方式去比對。最後一列是值得盯著看的:它證明這個修補沒有越過它所針對的語言。
建置現在仍然每次都會印出那兩則詞幹還原的提示,而且每一則都印兩次,那是 Pagefind 輸出本身的一個外觀小毛病。以後也會一直如此,而這是對的。
總結
- 每次建置都會印出來的警告,不是你正在追的那個缺陷的證據。 這一則是準確的、永久的,而且無關的。躺在它旁邊的那個缺陷,什麼日誌都沒留下。
- 詞幹還原和斷詞解的是相反的問題。 英文送進來已經切好,需要修掉字尾;日文送進來沒有斷點,需要先切開。一個工具完全可以缺了前者而把後者做得很好。
- 用一本詞典建索引、用另一本詞典查詢的搜尋引擎,會剛好在那兩本詞典意見不合的詞上失敗,而且失敗的樣子是一個看似合理的錯誤答案,不是空結果。
- 測完整的詞,不要測前綴。 前綴查詢把這個缺陷藏得滴水不漏,而隨手檢查搜尋框的人實際打的正是前綴。
- 不會讀的語言,用數的來驗證。 把建置後 HTML 的
grep筆數和搜尋筆數對照起來不需要任何閱讀能力,而且第一天就能抓到這件事。 - 如果你在日文網站上跑 Pagefind 1.5.x,在認定搜尋沒問題之前,先試一個複合名詞。上游的 issue #1237 追的是同一個成因的反方向案例:ICU 整個保住的漢字複合詞,被 Lindera 切開了。