在 Edge 上做全文搜索

博客怎么在无状态的 Cloudflare Workers 里用最小代价做搜索？D1 提供 SQLite FTS5，但我没用。为什么我没选 D1 FTS5 原因是工程栈维护成本。 FTS5 依赖虚拟表（Virtual Table），而我现在用的是 Drizzle。要把 FTS5 接进去，通常要面对这些问题： Schema 里要声明一张“不受正常迁移管理”的虚拟表建表和迁移要混入 raw SQL 以后文章结构变了，普通表迁移和虚拟表迁移要分开维护所以我最后选了一个更粗暴、但对小站更合适的方案：用 Orama 在内存里建索引，序列化后存进 KV；搜索时按版本加载回内存执行。整体思路文章发布 / 更新 → 提取标题、摘要、正文、标签 → 构建 Orama 索引 → gzip 压缩 → 存入 KV → 记录索引版本号搜索请求到来 → 先看当前 Worker 进程内有没有最新版本索引 → 有：直接搜 → 没有：从 KV 读出索引 → 解压 → 加载到内存 → 执行搜索搜索索引当成“可持久化的内存数据结构”存进 KV，对小型内容站很实用。精简的索引结构我给搜索建的 schema 很克制： export const searchSchema = { id: "string", slug: "string", title: "string", summary: "string", content: "string", tags: "string[]", } as const; title 必须搜 summary 必须搜 content 是主要正文 tags 可以提高内容聚类能力先把最关键的几个字段搜准比什么都重要。中文分词是这套方案能用的关键 Orama 默认分词对中文不友好，所以我自己接了 Intl.Segmenter： const segmenter = new Intl.Segmenter("zh-CN", { granularity: "word" }); export const chineseTokenizerConfig: Tokenizer = { language: "chinese", tokenize: (text: string) => { return Array.from(segmenter.segment(text)) .filter((x) => x.isWordLike) .map((x) => x.segment.toLowerCase()); }, normalizationCache: new Map(), }; 利用 Workers 运行时已有的 API 进行分词，不引入重型的第三方中文库，非常适合 Edge 场景。持久化索引：KV + Gzip + 版本号 Orama 的索引本质上是内存对象。要让它在 Workers 的无状态环境里复用，就得序列化后丢到 KV 里。写入时我做了三件事： save(db) 导出原始索引用 CompressionStream("gzip") 压缩把压缩结果和一个 meta 信息一起写入 KV export async function persistOramaDb(env: Env, db: MyOramaDB) { const raw = save(db); const compressed = await compressRaw(raw); await env.KV.put(KV_KEY, compressed); const newVersion = Date.now().toString(); const meta = { version: newVersion, updatedAt: new Date().toISOString(), sizeInBytes: compressed.byteLength, }; await env.KV.put(KV_META_KEY, JSON.stringify(meta)); setOramaDb(db, newVersion); return newVersion; } meta 里的 version 用于读取端快速判断内存索引是否最新。搜索请求不会每次都去读 KV 每次搜都从 KV 拉索引太慢了，加一层进程内缓存： let cachedDb: MyOramaDB | null = null; let cachedVersion: string | null = null; let inflight: Promise | null = null; 读取逻辑是：先读 KV 里的 meta，拿到最新 version 如果当前 Worker 内存里已经有同版本索引，直接返回如果没有，就从 KV 拉一次如果多个并发请求同时撞上冷加载，只让一次真实加载发生，其余请求复用同一个 inflight if (cachedDb && cachedVersion === latestVersion) return cachedDb; if (inflight) return inflight; 这一步能复用 inflight Promise，避免并发请求时重复加载大文件。搜索结果不能只有标题，还得告诉用户“命中在哪” 站内搜索需要准确提示命中词，所以加了一层 snippet 构建： const result = await oramaSearch(db, { term: data.q, limit: Math.min(data.limit, 25), }); return result.hits.map((hit) => { const { document, score } = hit; return { post: { id: document.id, slug: document.slug, title: document.title, summary: document.summary, tags: document.tags, }, score, matches: { title: buildSnippet(...), summary: buildSnippet(...), contentSnippet: buildSnippet(...), }, }; }); buildSnippet 的逻辑：找到命中词位置截取前后文用 包起来另外我还留了一点模糊匹配能力，允许非常轻度的 typo 容错。索引更新不靠全量重建硬顶平时文章发布或更新时，搜索索引走的是 upsert： export async function upsert(context: { env: Env }, data: UpsertSearchDocInput) { const db = await getOramaDb(context.env); try { await remove(db, data.id.toString()); } catch {} const plain = convertToPlainText(data.contentJson ?? null); const content = plain.length > CONTENT_SLICE ? plain.slice(0, CONTENT_SLICE) : plain; await insert(db, { id: data.id.toString(), slug: data.slug, title: data.title, summary: data.summary || content.slice(0, SNIPPET_SLICE), content, tags: data.tags ?? [], }); await persistOramaDb(context.env, db); } 这里有两个我刻意加上的约束：先删后插：因为 Orama 没有原地更新这一套正文截断：最多只索引前 10000 个字符，防止索引体积过快膨胀对于博客正文来说，这个截断已经很够用。但全量重建仍然要保留只靠增量更新不够稳，所以后台还是保留了 rebuildIndex。它的价值不只是“重新生成一次”，更是给你一个明确的修复按钮：数据结构变了早期索引逻辑写错了某次增量更新异常了遇到数据异常或是换数据结构时，全量重建能快速恢复正确状态。这类操作能极大降低系统的维护焦虑感。适用场景 Orama + KV 不是通用搜索架构，它有明显的边界。适合：文章量不大，几十篇到几百篇的小型内容站不想为了搜索单独上一个外部服务可以接受第一次冷加载要读一次 KV 不适合：数据规模很大，索引体积持续膨胀需要复杂 filter、facet、聚合需要真正实时的 search-as-you-type 对我这个博客来说多好。不是最强的，但足够轻、便宜、好维护。这套设计为什么很像“Edge 风格”的工程取舍和之前的缓存、架构设计类似，这种轻量 Edge 方案有几个共性：不为了“理论完整”引入更重的系统复用当前运行时的原生能力把问题压缩到适合个人维护的复杂度里搜索也一样。站点规模不大时，先选一个足够轻、足够稳、足够容易修的方案，往往比追求最正统的搜索架构更实际。