告别 Pagefind,用 Orama 实现静态博客的全文搜索

发布时间:2026-06-26 18:30:00

🕒 阅读时间:18 分钟📝 字数:5302👀 阅读量:Loading...

本文部分内容(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 。几个关键能力打动了我:

底层对比:Pagefind 的分片索引 vs Orama 的 BM25 全文检索引擎

介绍完选型理由,来深入扒一扒这两种搜索方案在分词、索引和评分上的根本差异。以下所有分析都基于两边的公开源码——Pagefind 的 Rust 核心(GitHub)和 Orama 的 TypeScript 实现(GitHub)。

说实话,不看源码之前我也有很多想当然的理解,看了之后才发现事实跟我想的差别不小。

一个重要的前提:两者本身都不带中文分词

先说清楚一个很多人(包括之前我)容易忽略的事实:

Pagefind 和 Orama 本质上都是搜索引擎内核——它们负责索引的构建、存储、搜索和打分,但中文文本的分词本身不是它们的职责。中文分词需要额外接入专门的分词包。

没有这些额外包,两者对中文的处理方式完全一致:按空白符和标点拆分。这对英文没问题,对中文就是灾难。

Pagefind 的分词逻辑

Pagefind 默认的“分词” 涉及两个文件。pagefind/src/fossick/splitting.rs 里的 get_indexable_words 函数处理单个词单元的归一化:

但真正决定“怎么把一段文本切成一个个词”的是调用方 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):

Pagefind 调用 charabia 时禁用了 HMM(hmm: false),所以无法处理未登录词(OOV)——词典里没有的词会被切成单个字。这个词典本身来自人民日报等语料库的词频统计,覆盖了绝大多数常见中文词汇。

在 parse_digest 函数中,当 lang 以 zh、ja 或 th 开头时,会用 seg.segment_str()(来自 charabia)对文本做词汇级切分。

但问题是:

“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)为基础,配合更复杂的回溯和规则处理:

这个算法的分词准确率与 ICU 版本相关,在不额外加载词典的情况下表现稳定,优势在于不需要额外加载字典文件——字典已经在浏览器和 Node.js 运行环境里编译好了。

关键优势:Intl.Segmenter 是同一套 API,服务端(Node.js)构建索引时和浏览器查询时分词表现完全一致。不会出现 Pagefind extended 那种索引端用 charabia/jieba 分词、浏览器端用不了同款分词器的不一致问题。

所以回到核心问题:两种方案对中文的感知能力完全不在一个量级上。

索引结构:碎片化分片 vs 单文件倒排索引

Pagefind 的索引组织(源码见 pagefind/src/index/mod.rs):

客户端搜索时,流程是:

这个“先查元数据→再按需加载 chunk→再查 chunk 内部的倒排索引”的三层架构,设计初衷是让大型站点不用一次性下载所有索引数据。但对于中小博客,这层间接反而增加了搜索延迟。

Orama 的索引组织(源码见 packages/orama/src/components/index.ts):

Orama 没有分片,所有数据在一个 JSON 文件里。代价是首次加载需要下载整个索引(~4MB / gzip ~800KB),好处是之后的搜索全是内存操作,零网络往返。

搜索评分:两套 BM25 的实现对比

两边的排序算法都基于 BM25,但具体实现和可配置性差别很大。

Pagefind 的评分(源码见 pagefind_web/src/search.rs 的 search_term 和 calculate_bm25_word_score):

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 找到了前缀匹配就会返回,没有“所有词必须匹配”的开关。

对比汇总

补充一句: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 :

也就是说代码修了一部分、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" },

});

}

关键设计:

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 读取并构建索引。

这意味着:

这是整个方案相比 Pagefind 最大的优势。

效果对比

参考链接

  1. pagefind/src/fossick/mod.rs
  2. ICU DictionaryBasedBreakIterator
  3. packages/orama/src/methods/search-fulltext.ts
  4. remove-markdown

小结

搜索是博客体验的最后一公里。一个能在 dev 阶段就调试的搜索系统,不仅能提升读者体验,也让开发流程顺畅得多。Pagefind 是个好工具,但 Orama 在灵活性、中文支持和开发体验上明显更胜一筹。

如果你也用 Astro,不妨试试这个方案。代码都在 stalux 主题仓库里。

Creative Commons