惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
P
Palo Alto Networks Blog
S
Securelist
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
NISL@THU
NISL@THU
L
Lohrmann on Cybersecurity
有赞技术团队
有赞技术团队
The GitHub Blog
The GitHub Blog
C
Cisco Blogs
B
Blog
Microsoft Azure Blog
Microsoft Azure Blog
Recent Announcements
Recent Announcements
Simon Willison's Weblog
Simon Willison's Weblog
T
Tenable Blog
Know Your Adversary
Know Your Adversary
Spread Privacy
Spread Privacy
WordPress大学
WordPress大学
月光博客
月光博客
Latest news
Latest news
C
CXSECURITY Database RSS Feed - CXSecurity.com
T
Threat Research - Cisco Blogs
Cisco Talos Blog
Cisco Talos Blog
I
InfoQ
D
Darknet – Hacking Tools, Hacker News & Cyber Security
W
WeLiveSecurity
Hacker News - Newest:
Hacker News - Newest: "LLM"
酷 壳 – CoolShell
酷 壳 – CoolShell
U
Unit 42
C
Cybersecurity and Infrastructure Security Agency CISA
博客园 - 聂微东
人人都是产品经理
人人都是产品经理
Google DeepMind News
Google DeepMind News
Apple Machine Learning Research
Apple Machine Learning Research
Attack and Defense Labs
Attack and Defense Labs
罗磊的独立博客
T
The Exploit Database - CXSecurity.com
I
Intezer
GbyAI
GbyAI
Jina AI
Jina AI
www.infosecurity-magazine.com
www.infosecurity-magazine.com
Blog — PlanetScale
Blog — PlanetScale
博客园 - 司徒正美
Google Online Security Blog
Google Online Security Blog
Engineering at Meta
Engineering at Meta
D
Docker
Recent Commits to openclaw:main
Recent Commits to openclaw:main
小众软件
小众软件
云风的 BLOG
云风的 BLOG
爱范儿
爱范儿
Project Zero
Project Zero

姓王者的博客

Linux用户Secure Boot自主维护指南 | 姓王者的博客 MAD Bugs 已经开始——关于信息安全的军备竞赛 | 姓王者的博客 解决钉钉Dingtalk无法在Linux新版内核上启动问题-修复可执行栈错误 | 姓王者的博客 突发:GitHub 正遭受大规模 Issue 赌博广告轰炸 | 姓王者的博客 Ubuntu26.04-beta体验:坚毅浣熊! | 姓王者的博客 fakeclaw装作龙虾发贴吧 | 姓王者的博客 找回12年前的QQ记忆 | 姓王者的博客 在Linux上玩Flash网页游戏-洛克王国 | 姓王者的博客 Copilot将使用交互数据来训练 | 姓王者的博客 重要通知-请更新我的GPG公钥 | 姓王者的博客 为了自由Android | 姓王者的博客 GPL"2,3"事 | 姓王者的博客 短文-对VitePlus的一点🤏小贡献 | 姓王者的博客 Bing收录没了?亲测有效的快速恢复指南 | 姓王者的博客 解决桌面设备二维码快速识别的工具-ClipQR | 姓王者的博客 解决 Nautilus 自定义终端插件安装依赖问题 | 姓王者的博客 OpenClaw 该熄火了 | 姓王者的博客 Vite8 - 统一的基建开始 | 姓王者的博客 Astro 6 推出啦 | 姓王者的博客 ubuntu的openvpn异常暂停推送更新 | 姓王者的博客 Ubuntu 24.04 安装 Win10 虚拟机 | 姓王者的博客 ESA-后记:热爱阿里云 | 姓王者的博客 Moonbit 0.8.0 重大发布,我也要改一下我的包 | 姓王者的博客 ESA Pages 边缘开发大赛获奖 | 姓王者的博客 Astro: 优化katex,mermaid和灯箱使用 | 姓王者的博客 从edgeone迁移到esa | 姓王者的博客 出租人类:AI时代的荒诞与真实 | 姓王者的博客 Astro 5.17构建性能优化实践:从18s到13s | 姓王者的博客 Moonbit License Checker 开发使用 | 姓王者的博客 Stalux Astro博客主题自荐 | 姓王者的博客 把Hexo永久链接迁移到Astro | 姓王者的博客 再见👋 LeanCloud | 姓王者的博客 2025年终总结 | 姓王者的博客 许可合规-fancybox | 姓王者的博客 博客主题的软著下来了 | 姓王者的博客 友链图谱 - 汇聚千丝万缕的联系 | 姓王者的博客 chen-er 专为Chen式ER图打造的npm包 | 姓王者的博客 为什么我推荐你使用GPG来加密你的邮件 | 姓王者的博客 2025第三方客户端登录东北大学邮箱 | 姓王者的博客 好久没更新了,过去与未来 | 姓王者的博客 1024 重要的日子 | 姓王者的博客 再也不见Windows10 | 姓王者的博客 偷梁换柱,解决Ubuntu24.04安装Packet Tracer缺失依赖问题 | 姓王者的博客 中秋-来试试Moonbit吧 | 姓王者的博客 Obsidian使用体验 | 姓王者的博客 猪猪侠·一只老猪的逆袭 | 姓王者的博客 国庆日纪念 | 姓王者的博客 GNU 42周年,AI时代的自由精神 | 姓王者的博客 解决Linux上启动游戏总是默认English的情况 | 姓王者的博客 7x24:运维使命 | 姓王者的博客 Tauri2.x实现系统菜单导航Vue路由 | 姓王者的博客 计算机图形学-基本图形生成算法 | 姓王者的博客 数据库原理-关系数据 | 姓王者的博客 数据库原理-设计技巧 | 姓王者的博客 数据库原理E-R模型 | 姓王者的博客 旧忆 - 我曾玩过的游戏 | 姓王者的博客 再谈自由软件 | 姓王者的博客 可能解决Tauri多窗口应用阻塞问题 | 姓王者的博客 Xingwangzhe! Z-Library We miss you and we need your help | 姓王者的博客 计算机组成原理第二章 - 定点数与浮点数 | 姓王者的博客 计算机组成原理第一章 | 姓王者的博客 不小心写死循环窗口弹出了 | 姓王者的博客 美化Grub界面 | 姓王者的博客 计算机图形学-图形的表示与数据结构 | 姓王者的博客 计算机图形学绪论 | 姓王者的博客 为什么说,大学教育与社会脱节 | 姓王者的博客 VSCode Remote 远程连接服务器记录 | 姓王者的博客 解决Tauri2.x拖拽事件问题 | 姓王者的博客 新学期第一课《计算机图形学》报告 | 姓王者的博客 Tauri在GNOME46+上通知无效的临时解决方法 | 姓王者的博客 窃文者:未经授权转载我文章 | 姓王者的博客 GPG公钥分享文化 | 姓王者的博客 解决在ubuntu上,打包vscode插件问题 | 姓王者的博客 伪造squaremap的玩家显示 | 姓王者的博客 爆,沉浸式翻译泄露敏感信息 | 姓王者的博客 读书:《Free as in Freedom》——若为自由故 | 姓王者的博客 首页文章列表懒加载优化 | 姓王者的博客 Ubuntu 24.04 安装 Vivado 2018.3 | 姓王者的博客 腾讯Edgeone免费版体验 | 姓王者的博客 在 Ubuntu 上实现 Thetis FIDO U2F 密钥登录 | 姓王者的博客 Thetis物理密钥,为什么我们应该使用物理密钥 | 姓王者的博客 高考生过来看!教你精准转换录取位次! | 姓王者的博客 ubuntu无法访问windows磁盘问题 | 姓王者的博客 收信有感,防范钓鱼邮件 | 姓王者的博客 自由不止软件-记录一次zlib上传书籍 | 姓王者的博客 时隔两年,通关夺命邮差2 | 姓王者的博客 博客一周年了,竟然坚持了下来 | 姓王者的博客 Minecraft大电影:不建不散! | 姓王者的博客 是时候了解docker了! | 姓王者的博客 编译原理:LL(1)文法 | 姓王者的博客 编译原理:文法转换 | 姓王者的博客 离散数学:子群的陪集及拉格朗日定理 | 姓王者的博客 离散数学:半群,独异点 | 姓王者的博客 《人工智能生成合成内容标识办法》与个人博客--我们应该做什么? | 姓王者的博客 通识学习:形式语言与自动机,布尔代数与数进制 | 姓王者的博客 Webmapview:一个我的世界内置网页地图浏览Fabric模组 | 姓王者的博客 海岛机器人农场试玩 | 姓王者的博客 正则表达式学习 | 姓王者的博客 抓取个人博客文章目录到github主页 | 姓王者的博客 制作github贪吃蛇贡献图 | 姓王者的博客
告别 Pagefind,用 Orama 实现静态博客的全文搜索 | 姓王者的博客
作者:xingwangzhe · 2026-06-26 · via 姓王者的博客

本文部分内容(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 拆解驼峰、蛇形、连字符命名(如 camelCasesnake_casekebab-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.rssparse_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 函数中,当 langzhjath 开头时,会用 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 的映射
记录评分参数 同步记录 frequenciestokenOccurrencesfieldLengthsavgFieldLength
序列化 调用 save() 将完整索引序列化为一个 JSON 对象
客户端加载 浏览器 fetch 后用 load() 在内存中重建完整的 Radix Tree + 评分参数

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


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

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

Pagefind 的评分(源码见 pagefind_web/src/search.rssearch_termcalculate_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 倍权重,descriptionimage_alt 等字段也有不同的权重。但这个配置是在 WASM 加载时设死的,不像 Orama 那样可以在搜索请求中动态指定。

Orama 的评分(源码见 packages/orama/src/components/algorithms.tsBM25 函数):

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 devastro build 下都能正常响应。每次请求时动态从 Content Collections 读取并构建索引。

这意味着:

场景 体验
搜索 本地开发时打开搜索,结果立刻出来
调参 调整分词参数后刷新即生效,不需要重新 build
调试样式 调试高亮样式、摘要长度时所见即所得

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

效果对比

特性 Pagefind Orama (本方案)
Dev 模式搜索 不可用 可用
中文分词 基础支持 mandarin tokenizer
高亮精度 基本 trim() 自动居中
索引来源 渲染后 HTML Content Collections 直接读
搜索权重 有限 boost 自定义
作用域标注 data-pagefind-body 不需要
索引输出 碎片化文件 单个 JSON (~4MB, gzip ~800KB)

参考链接

  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 主题仓库里。