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

推荐订阅源

V2EX - 技术
V2EX - 技术
L
LangChain Blog
IT之家
IT之家
S
SegmentFault 最新的问题
博客园 - 三生石上(FineUI控件)
H
Hackread – Cybersecurity News, Data Breaches, AI and More
T
The Blog of Author Tim Ferriss
Blog — PlanetScale
Blog — PlanetScale
N
Netflix TechBlog - Medium
U
Unit 42
B
Blog RSS Feed
GbyAI
GbyAI
Microsoft Security Blog
Microsoft Security Blog
博客园 - 司徒正美
Apple Machine Learning Research
Apple Machine Learning Research
T
Threatpost
C
CERT Recently Published Vulnerability Notes
Cisco Talos Blog
Cisco Talos Blog
The Register - Security
The Register - Security
Vercel News
Vercel News
S
Schneier on Security
Spread Privacy
Spread Privacy
C
Cyber Attacks, Cyber Crime and Cyber Security
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
博客园 - 叶小钗
雷峰网
雷峰网
博客园_首页
人人都是产品经理
人人都是产品经理
P
Palo Alto Networks Blog
The Hacker News
The Hacker News
T
Tor Project blog
L
Lohrmann on Cybersecurity
Know Your Adversary
Know Your Adversary
D
Darknet – Hacking Tools, Hacker News & Cyber Security
C
Cybersecurity and Infrastructure Security Agency CISA
P
Privacy International News Feed
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
T
Tenable Blog
V
Vulnerabilities – Threatpost
大猫的无限游戏
大猫的无限游戏
博客园 - 【当耐特】
V
V2EX
Security Latest
Security Latest
A
About on SuperTechFans
Cloudbric
Cloudbric
S
Security Affairs
MongoDB | Blog
MongoDB | Blog
Y
Y Combinator Blog
Martin Fowler
Martin Fowler
TaoSecurity Blog
TaoSecurity Blog

BlogFinder

日常漫步 Vol.24 之漫步前山河 - 雅余 周报 #1-聊聊本周的收获 - Edwin's Blog 我的OpenCode必装插件与Skill Write Something 掌中之物未必在掌握之中 · CRIVU PiliNara,一个更顺手的 PiliPlus 分支 「NekoEcho」:做一个必有回响的猫娘主题博客 2026-05 书影音总结 简化博客主题 - 安迪 你要加油呐 我第一次发布 npm 包 拾花小记#45:中考前的二三事 – 小改学习志 黛西花园5月游 #18 枇杷又熟了的五月月报 一些奇奇怪怪的需求?word仿方正书版的几个小操作 - Xiobb's Blog 0419 御温泉之旅 修复了一些bug,网站基本上趋于稳定了 - 新锐博客 又回到四十年前 如何定义成功 迷鹿屋2026已重新上线 科技冰火两重天+一周回顾 ${title} 热度退了,我反而用得更深了-咕咚同学 我到底该不该换个域名? 随身WIFI折腾记 - 安迪 博客撰写体验提升——hexo pro插件 为什么不用相机把屏幕上的接关密码拍下来? 国清寺与天台山 – Ouroboros ★★★★☆《挽救计划》——久违的经济上行感 - Davidの3号基地 删除右键“打开方式”里多余选项 第三周刊_No.53|一切都会被支付两次 安卓APP通话记录与录音上传踩坑记录 - 子舒的博客 天量下跌 inBox 笔记 2.3.8,把工具栏交给了你-咕咚同学 我把小龙虾搬到了微信-咕咚同学 安好 - 响石潭 Compound Engineering Plugin:让每个工程单元都比上一个更容易 MOSS-TTS Family:开源高质量语音与声音生成模型家族深度解析 Crawl4AI:专为 LLM 设计的开源 Web 爬虫与数据抓取工具 Build Your Own X:从零实现你最喜欢的技术——程序员进阶的终极资源清单 Anthropic Skills:用文件夹教 Claude 专业技能的开源框架 1年的去月球(下) - 梅之夏 欢迎回来。 简单讲讲 ASN.1 与 OID DTV - 直播聚合客户端 5.22-5.27 – 不兴江 还没去过鸭川 – 不兴江 张晶晶同学三刷林志颖 关于我 – 不兴江 爱与嫉妒 – 不兴江 港股被持续做空 备案码花了四百块-咕咚同学 一句话生成封面:我给公众号做了4种风格的AI封面生成技能 「官」方認證 再谈费曼学习法 2026-05-28T00:34:11+08:00 2026-05-28T00:28:45+08:00 离谱的英语学习指南:基于AI的英语进阶系统方法论 iii:零集成架构的后端统一运行时 Claude Code Harness:让 Claude Code 工作有迹可循的工程化框架 Heretic:全自动移除大语言模型审查机制的开源工具 MarkItDown:微软开源的万能文档转 Markdown 利器 Harness:让 Claude Code 秒变多智能体协作工厂 这段时间尽折腾AI Agent了,确实极大地提高了效率 近期动态:两个新站点正式上线啦 误判解除!zhouayuan.com 腾讯安全申诉成功 - 周阿源|玩具设计・插画日常・生活随笔 Ralph:让 AI 编码工具自主循环跑完所有 PRD 任务的量产神器 全都违法 – 个人工作记录 关于zhouayuan.com被误判 “含违规信息” 的说明与申诉记录 - 周阿源|玩具设计・插画日常・生活随笔 小米 MiMo v2.5 Pro 白嫖 最大的人间清醒,兜里有钱,但是不花。 夜晚靓歌(12):于文文现场solo - 王志勇的Blog 今日插画:风扬起的倔强 - 周阿源|玩具设计・插画日常・生活随笔 回门习俗 独立网卡 - 忘记了回忆 500亿入股人工智能企业 从命令行到桌面智能体-咕咚同学 第一性原理读书笔记 行者微评论223-加班の守株待兔-博客|政治与时事-风雨行者 ZOZO开源物理接触求解器:GPU加速的可扩展仿真引擎 OpenStock:开源股票市场交易平台技术深度解析 MoneyPrinterTurbo:基于AI的全自动短视频生成工具深度解析 Claude-Mem:为 Claude Code 构建的持久化记忆压缩系统 Twenty:可代码化定制的企业级开源 CRM 平台技术深度解析 2026-05-26T22:59:17+08:00 企业级开源大模型部署平台 GPUStack 实战教程 1年的去月球(上) - 梅之夏 Sevalla - 静态网站托管服务 不用翻墙、不用注册、不用月费,普通人也能用上 Claude Code 装修灯具要注意⚠️ 黄梅天先锋 - 游子微博 公安备案顺利办结,站点备案全部完成 - 周阿源|玩具设计・插画日常・生活随笔 第三次兑换天猫超市卡了宗宗酱-三维狐少儿编程 Don't think, feel. - Rolen's Blog 人这一辈子,到底图个什么 博客迁移 - Edwin's Blog 情感赛道写作模板 再现本轮行情的典型特征 裁员与平常心-咕咚同学 别让“偷懒”,成为隐私泄露的破绽
告别 Pagefind,用 Orama 实现静态博客的全文搜索 | 姓王者的博客
作者:xingwangzhe · 2026-06-26 · via BlogFinder

本文部分内容(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 主题仓库里。