靜態站搜尋的問題空間

靜態站沒有後端可以接查詢,所有搜尋工作必須在兩個時點之一完成:build 時產生索引、client runtime 執行匹配。這個前提決定了所有靜態站搜尋方案共同面對的兩個設計軸:

設計軸意義
索引產生時機build 時靜態產生,或 client 載入後動態建立
索引交付方式一次全量下載,或按查詢 lazy-load

方案差異來自這兩軸的組合。Pagefind 選的是「build 時產生、按需載入」,它的所有設計決策都是這個選擇的延伸。


核心設計:索引切片與按需載入

商業邏輯:搜尋索引的 scaling 關鍵是單次查詢需要下載多少資料,而非壓縮率或演算法效率。若索引是一整包、每次查詢都要先整包載入,訪客體驗與站的大小線性綁定 — 站大 10 倍,首次搜尋延遲 10 倍。

要脫離這條綁定,索引必須能以「與查詢相關」的粒度切片、按需傳輸。這把「索引多大」的問題從訪客手上移回 build pipeline。

CASE:Pagefind 的索引是三層結構:

層次內容大小
pagefind-entry.json索引目錄,記載有哪些 chunk 與 fragment<10KB
index/*.pf_index倒排索引切片,依 term 前綴分片10-50KB / chunk
fragment/*.pf_fragment每篇文章的 metadata、URL、摘要2-5KB / fragment

查「WAF」時,client 下載路徑是:entry(10KB)→ 涵蓋 “W” 的 index chunk(~30KB)→ 命中文章的 fragment(每筆 3KB)。總傳輸量與全站大小幾乎脫鉤 — 站擴大 10 倍,單次搜尋仍然只下載「W」那個 chunk 與少數 fragment。


架構選擇:爬 rendered HTML

商業邏輯:索引內容的來源有兩種可能:source 層(markdown、frontmatter、結構化資料)或 output 層(render 後的 HTML)。選哪一層決定工具與 framework 的耦合程度 — source 層要求工具懂特定 framework 的內容模型;output 層只要求結果是 HTML。

Pagefind 選 output 層。含義是:它跟 Hugo、Jekyll、Zola、Next.js static export 完全解耦,只要該 framework 產出的是 HTML,Pagefind 都能索引。

CASE:此選擇在 blog 端的具體要求:希望被搜到的內容必須出現在 rendered HTML 上。frontmatter 的 description 欄位若只存在於 markdown source、沒被 theme 輸出成 <meta> 或可見文字,就不會進索引。

這個 blog 天然滿足 — theme 把 description 寫進 <meta name="description">,render hook 也用它做 tooltip。移植到任何其他 static site generator,只要目標的 output HTML 有這些欄位,搜尋整合不用重寫。


整合步驟

1. Build pipeline

核心動作:Hugo build 後加一步 Pagefind。

1hugo --minify
2npx -y pagefind --site public

兩步,沒有中間檔。Pagefind 自行讀取 public/ 的 HTML,將索引寫回 public/pagefind/

2. 搜尋頁路由

核心動作:建立 Hugo 單頁,指向專屬 layout。

1---
2title: "搜尋"
3layout: search
4sitemap:
5  disable: true
6---

sitemap.disable 避免搜尋頁自己被 Hugo sitemap 收錄。

3. UI 掛載

核心動作:在 layout 中載入 Pagefind UI 資源,指定 mount point。

 1{{ define "main" }}
 2<div data-pagefind-ignore>
 3  <link href="{{ "pagefind/pagefind-ui.css" | relURL }}" rel="stylesheet">
 4  <div id="search"></div>
 5  <script src="{{ "pagefind/pagefind-ui.js" | relURL }}"></script>
 6  <script>
 7    window.addEventListener('DOMContentLoaded', function () {
 8      new PagefindUI({
 9        element: "#search",
10        showSubResults: true,
11        translations: { placeholder: "搜尋卡片或文章…" }
12      });
13    });
14  </script>
15</div>
16{{ end }}

兩個細節:

  • data-pagefind-ignore 告訴 Pagefind 這頁本身不要進索引(避免搜「搜尋」出現搜尋頁)。
  • relURL 處理 baseURL 的 subpath(例如 /blog/),讓 UI 自動推斷 chunk 相對位置。

4. CI workflow

核心動作:GitHub Actions 在 Hugo build 步驟後插入 Pagefind。

1- name: Build Pagefind search index
2  run: npx -y pagefind --site public

ubuntu-latest runner 內建 node,npx -y 首次執行會下載並 cache binary,後續執行直接從 cache 取用。


方案的內在屬性

評估 Pagefind 不看「比較快」「比較省事」這類時間維度,用下列內在屬性:

維度Pagefind 的特徵
覆蓋完整性索引全站 HTML;不需要逐 section 註冊
可逆性產物是檔案,移除就是刪除 public/pagefind/ 與搜尋頁,無殘留依賴
維護成本build pipeline 多一步;無 runtime 服務、無 key 管理、無版本相依性
可理解性UI drop-in、filter 用 HTML 屬性宣告、三層索引結構直觀
依賴前提要求目標 framework 能產出 HTML(絕大多數 static generator 滿足)
擴展性單次查詢下載量與全站大小脫鉤 — scaling 由 build time 吸收,不轉嫁到訪客

內建的一等公民特性

  • Filter by facetdata-pagefind-filter="type:card" 標在 HTML 元素上,UI 自動出現對應 filter checkbox
  • Snippet highlighting:命中的關鍵字在結果摘要中高亮
  • 無障礙:Component UI(1.5.0+)內建 keyboard navigation、ARIA label、screen reader 公告

這些特徵都源自「build 時產生 + 按需載入」這個核心選擇的延伸,不是外掛功能。


運作特徵

zh-tw 走 character n-gram

核心定義:Pagefind 對非空白分詞語言採 n-gram — 以字元序列作為匹配單位,而非詞。

行為:搜「負載平衡」能命中「負載平衡器」、「負載平衡器測試」等任何包含該字元序列的頁面。啟動時會印一行 stemming note,那是針對屈折變化語言(英文、德文)的 stemming 提示,對中文無意義也無限制。

邊界:少數情境下跨詞邊界的字元組合會誤命中(例如搜「負載過」可能命中「負載過高」與「負載過往」)。在名詞為主的技術站影響極小。

索引來自 rendered HTML

核心定義:索引內容 = Pagefind 在 public/*.html 看到的可見文字與 meta tag。

含義:想加入索引的欄位必須出現在 output HTML 上。想排除的區塊用 data-pagefind-ignore 標記。想作為 filter 的屬性用 data-pagefind-filter="name:value"

Default UI 的樣式是 Pagefind 自家風格

核心定義PagefindUI component 有固定的視覺設計,透過 CSS variable 可微調顏色、圓角、spacing。

含義:想要與 theme 完全融合有兩條路 — 覆寫 CSS variable(官方 docs 列出可覆寫清單),或改用 Pagefind JS API 自己組 UI(更完整客製)。

Build pipeline 多一步

核心定義:Pagefind 是 Hugo build 外的獨立步驟。

含義:CI 與本地都要記得跑 npx pagefind。這個 blog 以 Makefile 的 make site 封裝 hugo + pagefind 兩步,把「記得」轉成 infrastructure 強制項。


適合的場景

  • 靜態站、內容持續成長
  • 部署在 GH Pages / Netlify / Cloudflare Pages 等純靜態平台
  • 希望零外部依賴、完全自託管
  • 內容以文字為主(blog、docs、knowledge base)
  • 未來可能換 framework — 希望搜尋整合不隨之重寫