告别 Pagefind,用 Orama 实现静态博客的全文搜索
本文部分内容(Pagefind vs Orama 原理分析)存在 AI 辅助生成
最近我把博客的搜索从 astro-pagefind 换成了 Orama,过程踩了不少坑。本文记录一下完整的方案和踩坑经验。
为什么换掉 Pagefind
Pagefind 本身是个优秀的静态搜索方案——构建时生成碎片化索引,按需加载。但用了这么长时间,几个问题越来越不能忍:
Dev 模式下搜索不可用。这是最大的痛点。Pagefind 只在 astro build 时生成索引,astro dev 下搜索框打了字永远返回空结果。每次想在本地调搜索样式,都得先 build 一遍,非常影响效率。
中文分词不够好。Pagefind 对中文有一定支持,但实际体验中分词精度不足。比如搜"公安",有很多时候搜不到。
需要 HTML 标注。Pagefind 依赖 data-pagefind-body 之类的属性来控制索引范围,对于高度自定义的 Astro 组件来说,多了一层心智负担。
选型:为什么是 Orama
Orama 是一个运行在浏览器里的全文搜索引擎,核心包 @orama/orama 。几个关键能力打动了我:
| 特性 | 说明 |
|---|---|
| 纯客户端运行 | 索引是一个 JSON 文件,浏览器 fetch 后全部在本地搜索,零后端依赖。 |
| 30+ 语言支持 | @orama/tokenizers/mandarin 专门做中文分词,配合 @orama/stopwords/mandarin 停用词过滤。 |
| 从数据源直接建索引 | 不需要像 Pagefind 那样抓取渲染后的 HTML,直接从 Astro 的 Content Collections 拿数据。 |
| 搜索权重可配置 | boost 参数让标题匹配高于正文匹配,搜索结果更符合直觉。 |
@orama/highlight 精准高亮 |
能定位关键词在全文中的位置,摘要自动居中。 |
底层对比:Pagefind 的分片索引 vs Orama 的 BM25 全文检索引擎
介绍完选型理由,来深入扒一扒这两种搜索方案在分词、索引和评分上的根本差异。以下所有分析都基于两边的公开源码——Pagefind 的 Rust 核心(GitHub)和 Orama 的 TypeScript 实现(GitHub)。
说实话,不看源码之前我也有很多想当然的理解,看了之后才发现事实跟我想的差别不小。
一个重要的前提:两者本身都不带中文分词
先说清楚一个很多人(包括之前我)容易忽略的事实:
Pagefind 和 Orama 本质上都是搜索引擎内核——它们负责索引的构建、存储、搜索和打分,但中文文本的分词本身不是它们的职责。中文分词需要额外接入专门的分词包。
| 方案 | 中文支持方式 |
|---|---|
| Pagefind | 编译期开启 extended feature 来启用 charabia(底层 jieba-rs) |
| Orama | 额外安装 @orama/tokenizers/mandarin 包(底层 Intl.Segmenter) |
没有这些额外包,两者对中文的处理方式完全一致:按空白符和标点拆分。这对英文没问题,对中文就是灾难。
Pagefind 的分词逻辑
Pagefind 默认的"分词" 涉及两个文件。pagefind/src/fossick/splitting.rs 里的 get_indexable_words 函数处理单个词单元的归一化:
| # | 处理步骤 | 说明 |
|---|---|---|
| 1 | 字母数字过滤 | 遍历每个字符,只保留 is_alphanumeric() 为 true 的(中文汉字满足这个条件) |
| 2 | 小写化 | 对非 ASCII 大写字母调用 to_lowercase() |
| 3 | 词干提取 | 如果传入了 stemmer(语种相关),对去变音符号后的词做词干提取 |
| 4 | 复合词拆分 | 通过 convert_case crate 的 Case::Lower 拆解驼峰、蛇形、连字符命名(如 camelCase、snake_case、kebab-case) |
但真正决定"怎么把一段文本切成一个个词"的是调用方 pagefind/src/fossick/mod.rs 里的 parse_digest 函数——它先用 split_whitespace() 按空白符切分,再把切出来的每个片段交给 get_indexable_words 处理。也就是说,Pagefind 默认的"分词"就是 split_whitespace()——英文按空格拆,西班牙语按空格拆,中文也按空格拆。一段没有空格的连续中文"全文搜索方案",在索引里就是一个完整的 token。
我翻源码之前一直以为 Pagefind 对中文做了 bigram(二元组)切分,翻了
splitting.rs之后确认:没有。它不做任何字符级 n-gram,也没有 ICU 的中文分词。中文汉字只是通过了is_alphanumeric()的检查被保留下来,但边界识别全靠空格。
这解释了为什么中文搜索体验差——不是匹配不到,而是匹配方式完全不对。find_word_extensions 做的是前缀匹配(key.starts_with(term)),这意味着:
| 场景 | 结果 | 原因 |
|---|---|---|
| 搜"公安"匹配"公安局"、"公安机关" | 匹配 | 它们以"公安"开头 |
| 搜"安全"匹配"公共安全系统" | 不匹配 | 索引里是整词"公共安全系统",不以"安全"开头 |
| 搜"全系"匹配 | 不匹配 | 前缀匹配不支持中间和尾部的子串 |
这种匹配机制对于中文来说是完全不可控的:你的输入词必须是目标词的前缀才能命中,而中文恰恰是一种不以空格分界的语言。
Pagefind 确实有一个可选的 CJK 功能,在 Cargo.toml 中以 extended feature 声明:
[features]extended = ["dep:charabia"]
[dependencies]charabia = { version = "0.9.3", optional = true, default-features = false, features = ["chinese","japanese","thai"] }这个 feature 启用 charabia crate 做中日泰分词。而 charabia 的 Chinese 分词(见其 Cargo.toml)底层依赖的是 jieba-rs v0.8.1,通过 chinese-segmentation feature 激活。
jieba-rs 的分词算法(源码见 jieba.rs 和 sparse_dag.rs):
| # | 步骤 | 说明 |
|---|---|---|
| 1 | 前缀字典 | 使用 Double-Array Trie(Cedar) 存储词频词典,支持非常快的前缀查找 |
| 2 | 构建 DAG | 对输入句子中每个位置,在前缀字典中查找所有可能的词,构建一个有向无环图 |
| 3 | 动态规划 | 从句子末尾向前遍历,计算每个位置的最大对数概率路径:route[i] = max( log(freq(word[i:j])/total) + route[j+1] ) |
| 4 | 按路径分割 | 从位置 0 按最优路径向前推进,取出分词结果 |
Pagefind 调用 charabia 时禁用了 HMM(hmm: false),所以无法处理未登录词(OOV)——词典里没有的词会被切成单个字。这个词典本身来自人民日报等语料库的词频统计,覆盖了绝大多数常见中文词汇。
在 parse_digest 函数中,当 lang 以 zh、ja 或 th 开头时,会用 seg.segment_str()(来自 charabia)对文本做词汇级切分。
但问题是:
| 问题 | 说明 |
|---|---|
| 不在默认构建中 | 这个 feature 不在默认构建中(default = ["serve"]) |
| 预编译二进制不带 extended | astro-pagefind 等 npm 包下载的是预编译的默认二进制 |
| 索引与搜索分词不一致 | 源码明确说浏览器端 WASM 没有同样的分词器,索引时切了词搜索时可能匹配不上 |
"Currently hesitant to run segmentation during indexing that we can't also run during search, since we don't ship a segmenter to the browser."
翻译:浏览器端的 WASM 没有同样的分词器,索引时切了词,搜索时客户端可能无法用同样的方式切分查询词,导致匹配不上。所以这个功能处于一种尴尬的半成品状态。
Orama 这边同样需要额外包。@orama/tokenizers/mandarin 就是那个专门的中文分词包,它的核心代码只有几十行:
const segmenter = new Intl.Segmenter("zh-CN", { granularity: "word" });
function tokenize(text: string): string[] { const segments = segmenter.segment(text); const tokens: string[] = []; for (const segment of segments) { if (segment.isWordLike) { tokens.push(segment.segment); } } return tokens;}底层依赖 浏览器和 Node.js 内置的 Intl.Segmenter,而 Intl.Segmenter 背后是 ICU 的 DictionaryBasedBreakIterator。
ICU 的中文分词走的是 字典驱动的分词算法,以最大匹配(Maximum Matching)为基础,配合更复杂的回溯和规则处理:
| # | 步骤 | 说明 |
|---|---|---|
| 1 | Trie 词典查找 | 使用 Trie(前缀树)结构的编译好的字典,字典大小约 2MB |
| 2 | 最长匹配 | 从每个字符位置开始,在字典中查找最长的匹配词(最长优先) |
| 3 | 词频破平 | 当多个词在相同位置重叠时,用词频权重来破平 |
| 4 | 单字回退 | 字典里找不到的字符保留为单个字 |
这个算法的分词准确率与 ICU 版本相关,在不额外加载词典的情况下表现稳定,优势在于不需要额外加载字典文件——字典已经在浏览器和 Node.js 运行环境里编译好了。
关键优势:Intl.Segmenter 是同一套 API,服务端(Node.js)构建索引时和浏览器查询时分词表现完全一致。不会出现 Pagefind extended 那种索引端用 charabia/jieba 分词、浏览器端用不了同款分词器的不一致问题。
所以回到核心问题:两种方案对中文的感知能力完全不在一个量级上。
| 场景 | Pagefind(默认) | Pagefind(extended) | Orama(mandarin tokenizer) |
|---|---|---|---|
| "搜索" 是否匹配文章中的"搜索引擎" | 不匹配 | 匹配 | 匹配 |
| 索引时如何拆分中文 | 空格/标点分界 | jieba-rs (DAG+DP) | ICU DictionaryBasedBreakIterator |
| 搜索时如何拆分中文 | 空格/标点分界 | 空格/标点分界(无 charabia) | ICU DictionaryBasedBreakIterator |
| 索引与查询分词一致 | 是 | 不一致 | 是 |
| 底层词典 | 无 | Double-Array Trie(Cedar)词频词典 | Trie 编译词典(~2MB,内置) |
| 未登录词处理 | N/A | HMM 禁用 | 无 |
| 歧义消解 | N/A | DP 全局最优路径 | 字典匹配+词频破平 |
索引结构:碎片化分片 vs 单文件倒排索引
Pagefind 的索引组织(源码见 pagefind/src/index/mod.rs):
| # | 步骤 | 说明 |
|---|---|---|
| 1 | 提取词数据 | 解析 HTML 得到每个页面的 word_data(词→位置映射)和 meta_word_data(元数据字段中的词) |
| 2 | 分片 | 所有页面的词表合并后,按位置总数(locs + meta_locs + 1 per page)切分成多个 chunk |
| 3 | 编码词表 | 每个 chunk 包含按字母序排列的词,每个词下是页号(delta 编码)和位置(delta 编码,复合权重编码) |
| 4 | 序列化 | chunk 用 CBOR 二进制格式序列化,输出为 .pf_index 文件 |
| 5 | 元数据索引 | MetaIndex(CBOR)记录每个 chunk 的起始词和结束词、分页信息、排序字段、可筛选字段 |
客户端搜索时,流程是:
| # | 步骤 | 执行方 | 说明 |
|---|---|---|---|
| 1 | 传递查询词 | JS | 把查询词传给 WASM 的 request_indexes 函数 |
| 2 | 定位 chunk | WASM | 根据查询词的前缀,在 chunks 元数据中查找需要加载哪些 chunk |
| 3 | 加载 chunk | JS | fetch 对应的 chunk 二进制文件,传入 load_index_chunk |
| 4 | 搜索打分 | WASM | 所有 chunk 加载完后,调用 search 做 BM25 打分、排序 |
| 5 | 加载片段 | JS | 用结果中的 page_hash 加载对应的页面片段(JSON fragment)用于生成摘要 |
这个"先查元数据→再按需加载 chunk→再查 chunk 内部的倒排索引"的三层架构,设计初衷是让大型站点不用一次性下载所有索引数据。但对于中小博客,这层间接反而增加了搜索延迟。
Orama 的索引组织(源码见 packages/orama/src/components/index.ts):
| 步骤 | 说明 |
|---|---|
| 建倒排索引 | 每个 string 类型属性用 Radix Tree(基数树)存储词到文档 ID 的映射 |
| 记录评分参数 | 同步记录 frequencies、tokenOccurrences、fieldLengths、avgFieldLength |
| 序列化 | 调用 save() 将完整索引序列化为一个 JSON 对象 |
| 客户端加载 | 浏览器 fetch 后用 load() 在内存中重建完整的 Radix Tree + 评分参数 |
Orama 没有分片,所有数据在一个 JSON 文件里。代价是首次加载需要下载整个索引(~4MB / gzip ~800KB),好处是之后的搜索全是内存操作,零网络往返。
搜索评分:两套 BM25 的实现对比
两边的排序算法都基于 BM25,但具体实现和可配置性差别很大。
Pagefind 的评分(源码见 pagefind_web/src/search.rs 的 search_term 和 calculate_bm25_word_score):
| # | 步骤 | 说明 |
|---|---|---|
| 1 | 前缀扩展 | 用 find_word_extensions 找到所有以查询词为前缀的索引词(如搜"search"匹配"searching"、"searcher") |
| 2 | 词长惩罚 | 对每个匹配词计算 word_length_bonus——词越长惩罚越大(高斯衰减) |
| 3 | 位置合并 | 对每个匹配词组合并同一个位置的权重(取最低权重,相同权重则叠加) |
| 4 | BM25 打分 | 对每个匹配词做 BM25 变体计算,带四个可调参数: |
| 参数 | 默认值 | 作用 |
|---|---|---|
term_similarity |
1.0 | 控制词长差异的衰减速度 |
term_saturation |
1.4 | BM25 的 k1 参数 |
page_length |
0.75 | BM25 的 b 参数 |
term_frequency |
1.0 | 控制 BM25 的 TF 和原始加权词频之间的插值比例 |
Pagefind 的评分特别之处在于它对元数据字段有独立的加权系统:代码里 meta_weights 默认给 title 字段 5 倍权重,description、image_alt 等字段也有不同的权重。但这个配置是在 WASM 加载时设死的,不像 Orama 那样可以在搜索请求中动态指定。
Orama 的评分(源码见 packages/orama/src/components/algorithms.ts 的 BM25 函数):
BM25 公式实现很标准,和维基百科上的定义一致:
export function BM25( tf: number, // 词在文档中的频率 matchingCount: number, // 包含该词的文档数 docsCount: number, // 总文档数 fieldLength: number, // 该文档字段长度 averageFieldLength: number, // 平均字段长度 { k, b, d }: Required<BM25Params>,) { const idf = Math.log(1 + (docsCount - matchingCount + 0.5) / (matchingCount + 0.5)); return (idf * (d + tf * (k + 1))) / (tf + k * (1 - b + (b * fieldLength) / averageFieldLength));}参数默认值:k = 1.2(词频饱和度)、b = 0.75(文档长度归一化)、d = 0.5
Orama 还在搜索结果排序上多了一层处理:threshold 参数控制匹配严格程度——threshold = 0 只返回包含所有查询词的结果,threshold = 1 返回包含任意查询词的结果,中间值表示覆盖率阈值。Pagefind 则没有这个机制——只要 find_word_extensions 找到了前缀匹配就会返回,没有"所有词必须匹配"的开关。
对比汇总
| 维度 | Pagefind | Orama(本方案) |
|---|---|---|
| 中文分词(默认) | 只按空格拆分,不做词汇切分 | Intl.Segmenter 中文分词 |
| 中文分词(可选项) | charabia 词典分词(extended feature)但索引和搜索不一致 |
同一 API 保证一致性 |
| 索引结构 | 按位置数分片,CBOR 二进制,按需加载 | 单 JSON 文件,全量加载 |
| 索引树 | BTreeMap(有序映射) | Radix Tree(基数树) |
| 评分算法 | BM25 变体 + 元数据独立加权 + 前缀匹配 | 标准 BM25 + boost 加权 + 阈值控制 |
| 词位置编码 | delta 编码 + 复合权重编码 | 不存储位置(仅 TF) |
| Dev 可用 | 否 | 是 |
| 索引来源 | 渲染后 HTML → data-pagefind-body |
Content Collections 直读 |
补充一句:Orama 其实还有一个 searchVector 方法做向量嵌入搜索(用于 AI 语义搜索场景),但本文的方案用不到。我们用的是传统的 BM25 全文搜索,没有把文章转成 embedding——别误会。
Orama 方案的代价
全量加载索引意味着首次搜索前需要下载 ~800KB(gzip)的数据。对于移动端弱网环境,这个体积可能需要优化(比如渐进式加载或者 Service Worker 缓存)。
踩坑:官方 Astro 插件不能用
Orama 确实有官方插件 @orama/plugin-astro,但装不上。
npm 上最新版 v3.1.18 的 peerDependencies 锁在 astro: ^2.0.4,而当前项目跑的是 Astro 7。即使 --force 强行装上,构建直接崩:
ENOENT: no such file or directory, mkdir '/.../%E6%A1%8C%E9%9D%A2/.../dist/assets'根因是 Astro 5 改了 IntegrationRouteData.distURL 的类型,插件里的 prepareOramaDb 还在用旧 API 拿构建目录,路径中的中文被 URL 编码后透传给了 mkdirSync。
我去翻了 GitHub issues :
| Issue | 状态 |
|---|---|
| #862 — 报告 Astro 5 不兼容 | fix PR #870 已合并 |
| #882 — 要求更新 peer dependency 到 Astro 5 | PR #885 关闭了但没合并 |
也就是说代码修了一部分、peer dep 根本没更新。Astro 7 下仍然不可用。
结论:手写 endpoint 是目前唯一的稳的方案。
实现
1. 构建时生成索引
新建 src/pages/search-index.json.ts,作为 Astro 的静态端点:
import { create, insertMultiple, save } from "@orama/orama";import { createTokenizer } from "@orama/tokenizers/mandarin";import { stopwords as mandarinStopwords } from "@orama/stopwords/mandarin";import { getCollection } from "astro:content";import removeMarkdown from "remove-markdown";
const schema = { id: "string", type: "string", title: "string", description: "string", content: "string", url: "string", date: "string", tags: "string[]",} as const;
export async function GET() { const [posts, aboutPages, wordEntries] = await Promise.all([ getCollection("posts", ({ data }) => !data.draft), getCollection("about"), getCollection("words", ({ data }) => !data.draft), ]);
const documents = [];
for (const post of posts) { const body = typeof post.body === "string" ? post.body : ""; documents.push({ id: `post-${post.data.abbrlink}`, type: "post", title: post.data.title, description: post.data.desc, content: removeMarkdown(body), // 清理 markdown 语法 url: `/posts/${post.data.abbrlink}/`, date: post.data.date ?? "", tags: post.data.tags ?? [], }); }
// ... about 和 words 类似处理
const db = create({ schema, components: { tokenizer: createTokenizer({ // 中文分词 stopWords: mandarinStopwords, // 中文停用词 }), }, }); insertMultiple(db, documents); const index = save(db);
return new Response(JSON.stringify(index), { headers: { "Content-Type": "application/json" }, });}关键设计:
| 设计要点 | 说明 |
|---|---|
| 并行读取 | 用 Promise.all 并行读取多 collection |
| 清理 markdown | remove-markdown 清理正文中的 ##、_text_、[link]() 等语法 |
| 中文分词 | createTokenizer({ stopWords }) 配置中文分词和停用词 |
| 原始正文来源 | post.body 来自 Astro content collection 的 retainBody: true 配置(content.config.ts 中设置),是原始 markdown 字符串 |
2. 客户端搜索组件
改造 src/components/stalux/common/search.astro,保留原有模态框壳子,换掉内部 Pagefind 组件:
<script> import { create, load, search } from "@orama/orama"; import { createTokenizer } from "@orama/tokenizers/mandarin"; import { stopwords as mandarinStopwords } from "@orama/stopwords/mandarin"; import { Highlight, highlightStrategy } from "@orama/highlight";
const schema = { id: "string", type: "string", title: "string", description: "string", content: "string", url: "string", date: "string", tags: "string[]", } as const;
async function initOrama() { const response = await fetch("/search-index.json"); const data = await response.json(); const instance = create({ schema, components: { tokenizer: createTokenizer({ stopWords: mandarinStopwords, }), }, }); load(instance, data); return instance; }
async function performSearch(query: string) { const db = await initOrama(); const results = await search(db, { term: query, limit: 20, threshold: 0, // 只返回包含所有查询词的文档 properties: ["title", "description", "content", "tags"], boost: { title: 2, tags: 1.5 }, }); renderResults(results.hits, query); }
function renderResults(hits, query) { for (const hit of hits) { const dHL = new Highlight({ HTMLTag: "mark", strategy: highlightStrategy.PARTIAL_MATCH, }); dHL.highlight(hit.document.content ?? hit.document.description, query); // trim(200) 自动居中到第一个匹配位置 const snippet = dHL.positions.length > 0 ? dHL.trim(200) : (dHL.HTML ?? "").substring(0, 200); // 设置 innerHTML 显示高亮 } }</script>服务端和客户端同时配置相同的分词器是必须的,否则索引时和搜索时分词不一致会导致匹配失败。
3. 配置清理
astro.config.mjs 中移除 Pagefind 集成;package.json 中:
"astro-pagefind": "^2.0.0""@orama/orama": "^3.1.18""@orama/tokenizers": "^3.1.18""@orama/stopwords": "^3.1.18""@orama/highlight": "^0.1.9""remove-markdown": "^0.6.4"Dev 模式下能搜索,这才是核心
.json.ts 端点被 Astro 作为 API 路由处理——在 astro dev 和 astro build 下都能正常响应。每次请求时动态从 Content Collections 读取并构建索引。
这意味着:
| 场景 | 体验 |
|---|---|
| 搜索 | 本地开发时打开搜索,结果立刻出来 |
| 调参 | 调整分词参数后刷新即生效,不需要重新 build |
| 调试样式 | 调试高亮样式、摘要长度时所见即所得 |
这是整个方案相比 Pagefind 最大的优势。
效果对比
| 特性 | Pagefind | Orama (本方案) |
|---|---|---|
| Dev 模式搜索 | 不可用 | 可用 |
| 中文分词 | 基础支持 | mandarin tokenizer |
| 高亮精度 | 基本 | trim() 自动居中 |
| 索引来源 | 渲染后 HTML | Content Collections 直接读 |
| 搜索权重 | 有限 | boost 自定义 |
| 作用域标注 | data-pagefind-body |
不需要 |
| 索引输出 | 碎片化文件 | 单个 JSON (~4MB, gzip ~800KB) |
参考链接
- pagefind/src/fossick/mod.rs
- ICU DictionaryBasedBreakIterator
- packages/orama/src/methods/search-fulltext.ts
- remove-markdown
小结
搜索是博客体验的最后一公里。一个能在 dev 阶段就调试的搜索系统,不仅能提升读者体验,也让开发流程顺畅得多。Pagefind 是个好工具,但 Orama 在灵活性、中文支持和开发体验上明显更胜一筹。
如果你也用 Astro,不妨试试这个方案。代码都在 stalux 主题仓库里。
告别 Pagefind,用 Orama 实现静态博客的全文搜索
作者:xingwangzhe
本文链接:https://xingwangzhe.fun/posts/orama-search-replace-pagefind/
本文采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。
留言评论