標籤
近黑色卡片上的白色 pagefind 標誌與小寫字樣,標誌是一個包著指針的橢圓形

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 種語言的演算法。

斷詞是反過來的問題:先找出詞的邊界到底在哪裡。 日文與中文書寫時不放空格,所以在進索引之前,得先有東西判斷「画像変換」是兩個詞而不是一個。英文從不需要這一步,因為空格早就把這件事做完了。

兩種語言的斷詞與詞幹還原兩個並排的欄位。英文靠空格切開後,再把 repositories 還原成 repository。日文先由字典切成四個詞元,接著抵達詞幹還原那一格,該格以虛線灰底呈現,因為並不存在對應的演算法。建置時的警告講的正是這個空格子。英文git repositories斷詞靠空格,不需處理詞幹還原Snowball 的英文規則gitrepository日文画像を変換する斷詞Lindera + UniDic詞幹還原沒有,將來也不會有画像変換する建置時的提示指的是灰色那格。日文搜尋從來不經過它。
英文送進來時已經切好,只需要修掉字尾。日文送進來是一整串沒有斷點的字,得先切開。警告講的是修字尾那一步,而日文根本不會經過它。

Snowball 沒有中文、日文或韓文的演算法,將來也不會有,因為對一個不靠字尾變化的語言來說,削掉字尾不是有意義的操作。所以這則提示是準確且永久的。它同時也是無關的:日文搜尋完全不依賴提示所指的那一步。

斷詞那一半是有支援的,而且支援得很好。pagefind 的 npm 套件一律安裝 extended 執行檔,建置輸出的第一行就寫著:

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 筆數對照。如果一個詞出現在八個頁面而搜尋回傳八筆,這個語言就是正常的。

日文大部分確實正常。以下是 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 詞典。兩者一旦不一致,輸的是查詢那一端。

兩本字典對同一個複合詞的分歧一個日文複合詞分成兩條路徑。索引端由 Lindera 以單一詞儲存。查詢端由瀏覽器的 Intl.Segmenter 切成四個片段,其中只有最後一段在整站的索引裡。由於每個詞都必須命中,含有該複合詞的頁面被排除,反而是那唯一真實存在的片段把一個不相關的頁面拉了回來。同一個詞,一邊進索引,一邊當查詢ガベージコレクション索引端(建置時)Rust 執行檔內的 Lindera + UniDicガベージコレクション以單一詞儲存查詢端(瀏覽器)使用瀏覽器 ICU 的 Intl.Segmenterージコレクション切成四段,只有最後一段在索引裡✗ 不是每個詞都對得上所有查詢詞都必須命中,因此真正含有該詞的頁面被排除,換回來的是一個模糊比對的猜測。
一個複合詞,兩本詞典。Lindera 把它整個存起來,ICU 把它切成四段,其中只有最後一段是索引裡的詞。所有查詢詞都必須命中,因此含有這個複合詞的頁面被擠掉,而那唯一真實的片段拉回了別的東西。

這就是缺陷的內容。碎片 ージ 都不是索引裡的詞,而 Pagefind 要求每個查詢詞都要命中,所以含有這個複合詞的文章被排除了。留下來的,是模糊比對針對唯一那個真實存在的碎片所找到的東西。

這個對應關係在測試過的每個詞上都成立。Intl.Segmenter 保住整個詞時,搜尋是精確的;把詞切碎時,搜尋是錯的。

把詞打完,才是壞掉的那一刻

因為前綴有效而完整的詞無效,這個缺陷有著格外刻薄的形狀。以下是依序搜尋 ガベージコレクション 的每個前綴,並檢查那篇真正命中的文章是否在結果裡:

把詞打完的瞬間,正確結果就消失了十個欄位,對應輸入一個日文複合詞的每一次按鍵。下一列是回傳的筆數,再下一列是真正含有該詞的文章是否在結果中。第一次按鍵時在,第二次不在,第三到第八次連續六次都在,最後兩次消失。輸入的字元,每欄一次按鍵回傳筆數正確的文章是否在其中1211111111111打到一半找得到,打完整個詞反而不見。
正確的文章連續六次按鍵都在結果裡,最後兩次消失。中途停手的讀者找得到它,把詞打完的讀者找不到。

讀者因為想要精確而受罰。 這也是這個缺陷長期沒被發現的原因:把查詢打到一半的人看到的是一個正常運作的搜尋,而任何日誌都沒提過它。

中文那邊則沒問題。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 切開了。

參考連結

分享這篇文章