# Pagefind 的詞幹還原警告是假線索 — 讓日文複合詞搜尋失效的是斷詞不一致

> Pagefind 每次建置都警告日文與中文缺少詞幹還原。真正的缺陷在別處：查詢端與索引端的斷詞器不一致，回傳了錯誤的頁面。

- Source: https://oharu121.com/zh-tw/blog/pagefind-stemming-vs-segmentation-japanese-search-red-herring/
- Published: 2026-08-14T15:21:55+09:00
- Tags: Pagefind, 國際化, Astro

---
## 引言

這個網站每次建置都會印出同樣兩則提示，而我已經讀過去好幾個星期了：

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

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

*Figure — StemmingVsSegmentation: 英文送進來時已經切好，只需要修掉字尾。日文送進來是一整串沒有斷點的字，得先切開。警告講的是修字尾那一步，而日文根本不會經過它。*

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

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

```text
Running Pagefind v1.5.2 (Extended)
```

這個執行檔有 55 MB，是因為它帶著詞典。把裡面的字串抽出來就看得到內容：

```text
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 為 `リポジトリ` 回傳的摘要本身就說明了一切。被高亮的是 `リレー`，一個只是看起來有點像的無關詞。引擎沒能比對到任何東西，於是落到了模糊比對。

下一個測試把兩端分開了。用複合詞 `ガベージコレクション` 的*前綴* `ガベージ` 去搜，回傳的是正確的文章，而且摘要裡整個複合詞都被高亮。用完整的詞 `ガベージコレクション` 去搜，回傳的卻是另一篇完全不同、靠 `コレクション` 命中的文章。

**比較短的查詢找到了比較長、比較精確的查詢找不到的東西。** 這把平常的關係整個顛倒過來，而且既然索引明顯保有整個複合詞，問題就落在查詢端。

去問瀏覽器它對這些查詢做了什麼，機制一行就出來了：

```js
[...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 詞典。兩者一旦不一致，輸的是查詢那一端。

*Figure — TokenizerMismatch: 一個複合詞，兩本詞典。Lindera 把它整個存起來，ICU 把它切成四段，其中只有最後一段是索引裡的詞。所有查詢詞都必須命中，因此含有這個複合詞的頁面被擠掉，而那唯一真實的片段拉回了別的東西。*

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

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

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

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

*Figure — VanishingResult: 正確的文章連續六次按鍵都在結果裡，最後兩次消失。中途停手的讀者找得到它，把詞打完的讀者找不到。*

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

中文那邊則沒問題。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` 並未定義的函式：

```text
TypeError: s.createInstance is not a function
```

這並不是伺服器送了過期的檔案。智能體用 `cache: 'reload'` 重新抓過每一個檔案，得到的是同樣的錯誤。**退版就意味著要把兩個搜尋入口都改寫成舊的 `PagefindUI` 類別 API**，為了換回一本詞典，這個改動太大了。

在可行的做法之前，還有兩種做法失敗了。把查詢用引號寫成 `"リポジトリ"` 並不能繞過斷詞，它回傳零筆，因為被引號框住的詞組同樣是由那些被切碎的碎片組成的。而只修補 `pagefind.js` 裡的語言清單，產出的檔案和原版一個位元組都不差，看起來就像修補默默失敗了。它並沒有失敗。**從 1.5.0 起搜尋跑在 Web Worker 裡，而 `pagefind-worker.js` 自己也帶著一份同樣的語言清單。** 只修其中一個檔案，使用者看得到的變化是零。

## 從一份清單裡拿掉一個語言

判斷本身是三行最小化過的 JavaScript：

```js
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](https://github.com/Pagefind/pagefind/issues/1237) 追的是同一個成因的反方向案例：ICU 整個保住的漢字複合詞，被 Lindera 切開了。

## 參考連結

- [Pagefind: Multilingual search（含有支援與不支援詞幹還原的語言清單）](https://pagefind.app/docs/multilingual/)
- [Pagefind 1.5.0 發行說明（查詢端以 `Intl.Segmenter` 斷詞就是在這一版引入的）](https://github.com/Pagefind/pagefind/releases/tag/v1.5.0)
- [Pagefind issue #1237：索引端的 Lindera 與查詢端的 Intl.Segmenter 在日文複合詞上不一致](https://github.com/Pagefind/pagefind/issues/1237)
- [MDN: Intl.Segmenter（Pagefind 用來切查詢的瀏覽器 API）](https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/Intl/Segmenter)
- [Snowball（支撐 Pagefind 各語言詞幹還原的函式庫）](https://snowballstem.org/algorithms/)
