在 Edge 上做全文搜索
摘要
给博客做搜索时,我没有走 D1 FTS5,而是选了 Orama + KV + 进程内缓存。本文重点讲这个选择背后的约束、索引持久化方式,以及它对小型内容站为什么足够实用。
博客怎么在无状态的 Cloudflare Workers 里用最小代价做搜索?D1 提供 SQLite FTS5,但我没用。
为什么我没选 D1 FTS5
原因是工程栈维护成本。
FTS5 依赖虚拟表(Virtual Table),而我现在用的是 Drizzle。要把 FTS5 接进去,通常要面对这些问题:
Schema 里要声明一张“不受正常迁移管理”的虚拟表
建表和迁移要混入 raw SQL
以后文章结构变了,普通表迁移和虚拟表迁移要分开维护
所以我最后选了一个更粗暴、但对小站更合适的方案:
用 Orama 在内存里建索引,序列化后存进 KV;搜索时按版本加载回内存执行。
整体思路
搜索索引当成“可持久化的内存数据结构”存进 KV,对小型内容站很实用。
精简的索引结构
我给搜索建的 schema 很克制:
title必须搜summary必须搜content是主要正文tags可以提高内容聚类能力
先把最关键的几个字段搜准比什么都重要。
中文分词是这套方案能用的关键
Orama 默认分词对中文不友好,所以我自己接了 Intl.Segmenter:
利用 Workers 运行时已有的 API 进行分词,不引入重型的第三方中文库,非常适合 Edge 场景。
持久化索引:KV + Gzip + 版本号
Orama 的索引本质上是内存对象。要让它在 Workers 的无状态环境里复用,就得序列化后丢到 KV 里。
写入时我做了三件事:
save(db)导出原始索引用
CompressionStream("gzip")压缩把压缩结果和一个 meta 信息一起写入 KV
meta 里的 version 用于读取端快速判断内存索引是否最新。
搜索请求不会每次都去读 KV
每次搜都从 KV 拉索引太慢了,加一层进程内缓存:
读取逻辑是:
先读 KV 里的 meta,拿到最新
version如果当前 Worker 内存里已经有同版本索引,直接返回
如果没有,就从 KV 拉一次
如果多个并发请求同时撞上冷加载,只让一次真实加载发生,其余请求复用同一个
inflight
这一步能复用 inflight Promise,避免并发请求时重复加载大文件。
搜索结果不能只有标题,还得告诉用户“命中在哪”
站内搜索需要准确提示命中词,所以加了一层 snippet 构建:
buildSnippet 的逻辑:
找到命中词位置
截取前后文
用
<mark>包起来
另外我还留了一点模糊匹配能力,允许非常轻度的 typo 容错。
索引更新不靠全量重建硬顶
平时文章发布或更新时,搜索索引走的是 upsert:
这里有两个我刻意加上的约束:
先删后插:因为 Orama 没有原地更新这一套
正文截断:最多只索引前
10000个字符,防止索引体积过快膨胀
对于博客正文来说,这个截断已经很够用。
但全量重建仍然要保留
只靠增量更新不够稳,所以后台还是保留了 rebuildIndex。它的价值不只是“重新生成一次”,更是给你一个明确的修复按钮:
数据结构变了
早期索引逻辑写错了
某次增量更新异常了
遇到数据异常或是换数据结构时,全量重建能快速恢复正确状态。这类操作能极大降低系统的维护焦虑感。
适用场景
Orama + KV 不是通用搜索架构,它有明显的边界。
适合:
文章量不大,几十篇到几百篇的小型内容站
不想为了搜索单独上一个外部服务
可以接受第一次冷加载要读一次 KV
不适合:
数据规模很大,索引体积持续膨胀
需要复杂 filter、facet、聚合
需要真正实时的 search-as-you-type
对我这个博客来说多好。不是最强的,但足够轻、便宜、好维护。
这套设计为什么很像“Edge 风格”的工程取舍
和之前的缓存、架构设计类似,这种轻量 Edge 方案有几个共性:
不为了“理论完整”引入更重的系统
复用当前运行时的原生能力
把问题压缩到适合个人维护的复杂度里
搜索也一样。站点规模不大时,先选一个足够轻、足够稳、足够容易修的方案,往往比追求最正统的搜索架构更实际。
