banner
约 1,500 字
5 分钟

在 Edge 上做全文搜索

摘要

给博客做搜索时,我没有走 D1 FTS5,而是选了 Orama + KV + 进程内缓存。本文重点讲这个选择背后的约束、索引持久化方式,以及它对小型内容站为什么足够实用。

博客怎么在无状态的 Cloudflare Workers 里用最小代价做搜索?D1 提供 SQLite FTS5,但我没用。

为什么我没选 D1 FTS5

原因是工程栈维护成本。

FTS5 依赖虚拟表(Virtual Table),而我现在用的是 Drizzle。要把 FTS5 接进去,通常要面对这些问题:

  • Schema 里要声明一张“不受正常迁移管理”的虚拟表

  • 建表和迁移要混入 raw SQL

  • 以后文章结构变了,普通表迁移和虚拟表迁移要分开维护

所以我最后选了一个更粗暴、但对小站更合适的方案:

用 Orama 在内存里建索引,序列化后存进 KV;搜索时按版本加载回内存执行。

整体思路

纯文本
文章发布 / 更新
  → 提取标题、摘要、正文、标签
  → 构建 Orama 索引
  → gzip 压缩
  → 存入 KV
  → 记录索引版本号

搜索请求到来
  → 先看当前 Worker 进程内有没有最新版本索引
    → 有:直接搜
    → 没有:从 KV 读出索引
      → 解压
      → 加载到内存
      → 执行搜索

搜索索引当成“可持久化的内存数据结构”存进 KV,对小型内容站很实用。

精简的索引结构

我给搜索建的 schema 很克制:

TypeScript
export const searchSchema = {
  id: "string",
  slug: "string",
  title: "string",
  summary: "string",
  content: "string",
  tags: "string[]",
} as const;
  • title 必须搜

  • summary 必须搜

  • content 是主要正文

  • tags 可以提高内容聚类能力

先把最关键的几个字段搜准比什么都重要。

中文分词是这套方案能用的关键

Orama 默认分词对中文不友好,所以我自己接了 Intl.Segmenter

TypeScript
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 里。

写入时我做了三件事:

  1. save(db) 导出原始索引

  2. CompressionStream("gzip") 压缩

  3. 把压缩结果和一个 meta 信息一起写入 KV

TypeScript
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 拉索引太慢了,加一层进程内缓存:

TypeScript
let cachedDb: MyOramaDB | null = null;
let cachedVersion: string | null = null;
let inflight: Promise<MyOramaDB> | null = null;

读取逻辑是:

  • 先读 KV 里的 meta,拿到最新 version

  • 如果当前 Worker 内存里已经有同版本索引,直接返回

  • 如果没有,就从 KV 拉一次

  • 如果多个并发请求同时撞上冷加载,只让一次真实加载发生,其余请求复用同一个 inflight

TypeScript
if (cachedDb && cachedVersion === latestVersion) return cachedDb;
if (inflight) return inflight;

这一步能复用 inflight Promise,避免并发请求时重复加载大文件。

搜索结果不能只有标题,还得告诉用户“命中在哪”

站内搜索需要准确提示命中词,所以加了一层 snippet 构建:

TypeScript
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 的逻辑:

  • 找到命中词位置

  • 截取前后文

  • <mark> 包起来

另外我还留了一点模糊匹配能力,允许非常轻度的 typo 容错。

索引更新不靠全量重建硬顶

平时文章发布或更新时,搜索索引走的是 upsert

TypeScript
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 方案有几个共性:

  • 不为了“理论完整”引入更重的系统

  • 复用当前运行时的原生能力

  • 把问题压缩到适合个人维护的复杂度里

搜索也一样。站点规模不大时,先选一个足够轻、足够稳、足够容易修的方案,往往比追求最正统的搜索架构更实际。

END
© 2026 阿旷. All Rights Reserved. / RSS / Sitemap
Powered by Tanstack Start & Flare Stack Blog