只用 Cloudflare 全家桶,给游戏站做一个 RAG 搜索和问答

不需要服务器,也不需要单独的向量数据库:Workers + Workers AI + Vectorize + KV,一个 Worker 文件就实现了入库、混合检索和流式问答。

做了什么

NESBox 是我做的一个复古游戏在线联机平台。游戏库里有一百多款游戏,简介中文、英文、日文都有。用户常常记不住游戏名,只记得"那个骑摩托越野的"、"两个人一起打的格斗游戏"。

所以我给它加了两个 AI 功能,都放在全局搜索框(⌘K)里:

  • 语义搜索:用自然语言描述来找游戏,结果和本地按名称、拼音匹配的结果合并显示。
  • AI 问答 :输入 ? 开头的问题,本地帮助条目都没匹配上时,基于游戏库流式生成回答,用用户当前的界面语言回复。

后端是一个 Cloudflare Worker,提供四个接口:

接口 作用
/sync 全量同步:拉取所有游戏,重建向量和 BM25 索引,以流的形式逐行返回进度
/update 单条更新:只更新某一个游戏
/search 语义搜索,返回按相关度排序的游戏 id
/completions RAG 问答,以 SSE 流式输出

整体架构

bash 复制代码
                 ┌──────────── 入库(/sync、/update)────────────┐
GraphQL 游戏数据 ─┤ 解析简介 + 拼标签 + 抓维基 → 分块 → bge-m3 向量化 ├─→ Vectorize(向量 + 原文)
                 │                              └→ 分词统计 ──────┼─→ KV(BM25 倒排索引)
                 └──────────────────────────────────────────────┘

用户输入 → 向量召回 ┐
        → BM25 召回 ┴→ RRF 融合 →(可选 rerank)→ 前 N 块
                                                 ├─→ /search:按游戏去重,返回 id
                                                 └─→ /completions:拼进 prompt → LLM → SSE
                                                       (检索和生成结果都用 KV 缓存)

每部分用到的服务:

组件 用途
Workers 接口和全部业务逻辑
Workers AI @cf/baai/bge-m3 生成向量、@cf/baai/bge-reranker-base 精排(可选)、@cf/nvidia/nemotron-3-120b-a12b 生成回答
Vectorize 存向量,metadata 里同时存块的原文
KV 接口结果缓存、BM25 倒排索引、每个游戏的块数

在 wrangler.jsonc 里声明这几个 binding 就能用,不需要管理任何密钥:

jsonc 复制代码
{
  "vectorize": [{ "binding": "GAMES_SEARCH", "index_name": "nesbox-games-m3" }],
  "kv_namespaces": [{ "binding": "KV", "id": "..." }],
  "ai": { "binding": "AI" }
}

一、入库

1. 拼出每个游戏的文本

游戏数据从站点自己的 GraphQL 接口拉取。每个游戏的文本由两部分组成:

  • 头部 :游戏名,加上平台、类型、系列的中英文标签 ,例如 越野机车 红白机 Nes 赛车竞速 Racing。标签直接复用前端的 i18n 文案,所以中文提问和英文提问都能匹配上。
  • 正文 :游戏简介(Markdown,去掉图片,链接只保留文字)。如果简介的 frontmatter 里有参考链接,还会把那个页面抓下来转成 Markdown 补进去。维基百科的页面走 MediaWiki 的 action=parse 接口,拿到的是干净的正文 HTML,不用自己处理整个页面的导航栏和侧边栏。

2. 分块

分块用 Intl.Segmenter 按句切分。它是 Workers 运行时内置的 ICU 分句器,一个实例就能处理中日英混排的文本,不用自己写标点正则:

ts 复制代码
const sentenceSegmenter = new Intl.Segmenter('zh', { granularity: 'sentence' });

export function chunkText(text: string, maxChars = 800, overlap = 100) {
  const sentences = text
    .split(/\n+/)
    .flatMap((p) => Array.from(sentenceSegmenter.segment(p), (s) => s.segment.trim()))
    .filter(Boolean);
  // 按句合并成不超过 maxChars 的块;
  // 开新块时,把上一块末尾不超过 overlap 字符的整句带过来,作为重叠部分
  // ...
}

几条规则:

  • 每块最多 800 字,相邻块之间重叠整句,避免一句话被切成两半。
  • 每块开头都拼上前面的头部。 只检索到某一块时,也能知道它属于哪个游戏、是什么类型。
  • 每个游戏最多 24 块,防止很长的维基页面拉高推理成本。
  • 向量 id 用 游戏id#块序号。每个游戏的块数记在 KV 里,重新入库时块数变少,就用 deleteByIds 删掉多出来的旧块,不留过期内容。

3. 向量化和写入

  • embedding 用 @cf/baai/bge-m3:多语言,1024 维,对中日英混排的内容效果很好。每 50 条文本请求一次,开 truncate_inputs,超长时自动截断,不会报错。
  • 写 Vectorize 时每 500 条一批。块的原文放在 metadata 里,检索时直接取出来用,不需要再查一次数据库。
  • BM25 倒排索引在同一时间构建,用 Intl.Segmenter 的 word 粒度分词,整个索引存成一个 KV 值。全量同步时重建,单条更新时只替换这个游戏的块。数据量小,这样做足够了。

/sync 的执行时间比较长,所以它返回一个 TransformStream,边执行边一行一行写进度:

bash 复制代码
$ curl -N https://<worker>/sync
{"status":"start"}
{"status":"info","total":164}
{"status":"info","chunks":223}
{"status":"info","terms":...}
{"status":"done"}

二、检索

混合召回 + RRF 融合

向量检索和 BM25 并行召回,各取前 20 块,再用 RRF(Reciprocal Rank Fusion)融合:

ts 复制代码
const [vectorMatches, bm25Matches] = await Promise.all([
  embedding(env.AI, [query]).then(async ([values]) =>
    (await env.GAMES_SEARCH.query(values, { topK: 20, returnMetadata: 'all' })).matches),
  env.KV.get<Bm25Index>('bm25:index', 'json').then((index) => (index ? bm25Search(index, query, 20) : [])),
]);
const fused = rrf([vectorMatches.map((e) => e.id), bm25Matches.map((e) => e.id)]);

RRF 只看每一路结果的名次,不看分数,所以不用操心余弦相似度和 BM25 分数量纲不同、没法直接相加的问题:

ts 复制代码
export function rrf(lists: string[][], k = 60) {
  const scores = new Map<string, number>();
  for (const list of lists) list.forEach((id, i) => scores.set(id, (scores.get(id) || 0) + 1 / (k + i + 1)));
  return [...scores].sort((x, y) => y[1] - x[1]).map(([id, score]) => ({ id, score }));
}

只被 BM25 命中的块,向量查询的结果里没有它的 metadata,需要再用 getByIds 补取一次。

可选的 rerank

在融合结果之后,还可以接 @cf/baai/bge-reranker-base(cross-encoder)做精排。但默认是关闭的 ,请求带上 rerank=1 才会开启。

原因是我用一组带标准答案的小测试集(12 条中英文查询)比较过几种方式。在这批中日英混排的数据上,加了 reranker 之后排序并没有变好,还多了一次模型调用。为了方便以后数据变多时再比较,代码保留了,同时提供了 mode=vector|bm25 参数,可以只走其中一路检索。这些参数都在 URL 里,而缓存的 key 就是 URL,所以不同组合的结果会分开缓存,不会互相干扰。

三、接口

/search:语义搜索

一个游戏可能有多块同时命中,所以要先按游戏去重,再返回游戏 id:

ts 复制代码
const seen = new Set<string>();
const matches = (await retrieve(env, q, 20, { mode, useRerank }))
  .filter(({ gameId }) => !seen.has(gameId) && seen.add(gameId))
  .map(({ gameId, score }) => ({ id: gameId, score }));

结果以 URL 为 key,在 KV 里缓存 1 小时。

/completions:RAG 问答

取相关度最高的 5 块拼进 system prompt,要求模型只根据上下文回答,并用指定的语言回复:

ts 复制代码
const messages = [
  {
    role: 'system',
    content: `You are an application assistant.

Requirements:
1. Respond briefly to user input based on context.
2. Language: respond in (${l}).

Context:
${chunks.map((e) => e.text).join('\n\n---\n\n')}`,
  },
  { role: 'user', content: q },
];
const stream = await env.AI.run('@cf/nvidia/nemotron-3-120b-a12b', { messages, stream: true });

缓存流式结果时用了一个小技巧:把流 tee() 成两份,一份直接返回给用户,另一份在 ctx.waitUntil 里读完后写进 KV。用户不用等缓存写完,下一次问同样的问题时,就直接回放缓存的 SSE 内容:

ts 复制代码
const [stream1, stream2] = stream.tee();
ctx.waitUntil(new Response(stream1).text().then((text) => env.KV.put(req.url, text, { expirationTtl: 3600 })));
return new Response(stream2, { headers: { 'Content-Type': 'text/event-stream; charset=utf-8' } });

四、前端接入

前端是 Gem(Web Components)写的,接入代码很少。

语义搜索 作为本地搜索的补充:先按游戏名、拼音在本地匹配;本地结果不超过 5 条时,防抖后请求 /search,把本地结果里没有的游戏追加在后面。每次输入变化时,用 AbortController 取消上一次还没完成的请求。

问答 是一个 <m-sse> 元素。它等 2 秒、确认用户停止输入后,才请求 /completions,然后用一个小的异步生成器逐行解析 SSE,边收边用 marked 渲染成 Markdown:

ts 复制代码
export async function* eventStream(input: string, init?: RequestInit) {
  const res = await fetch(input, init);
  const reader = res.body!.getReader();
  const decoder = new TextDecoder();
  let buffer = '';
  while (true) {
    const { done, value } = await reader.read();
    if (done) return;
    buffer += decoder.decode(value, { stream: true });
    const lines = buffer.split('\n');
    buffer = lines.pop() || '';
    for (const line of lines) {
      if (!line.startsWith('data: ')) continue;
      const data = line.slice(6);
      if (data === '[DONE]') return;
      yield JSON.parse(data);
    }
  }
}

Worker 的地址可以用环境变量 AI_BASE 指定,默认是线上地址。本地用 wrangler dev 改 Worker 时,把它指向本地,就能先用前端联调再部署。

成本

Workers AI 每天有 10,000 Neurons 的免费额度,Free 和 Paid 计划都有。按 Cloudflare 的定价:

模型 价格
bge-m3(embedding) 每百万 token 1075 Neurons
bge-reranker-base 每百万 token 283 Neurons(默认不调用)
生成回答的大模型 远高于前两者,是主要开销

全量同步一次只有两百多块,embedding 的消耗可以忽略。检索结果和生成结果都缓存 1 小时,重复的问题不会再消耗额度。

小结

这套实现的几个特点:

  • 全部跑在 Cloudflare 上,一个 Worker 文件,没有服务器,也不需要额外的向量数据库。
  • 面向多语言内容 :多语言 embedding,加上中英文双语标签,Intl.Segmenter 负责分句和分词。
  • 该缓存的都缓存:检索结果、流式回答、BM25 索引都放在 KV 里。
  • 增强手段可以开关:rerank、单路检索都有 URL 参数,用测试集比较之后再决定默认值,而不是默认就把"最佳实践"全部打开。

还可以继续做的:回答时带上出处(在 SSE 开头推一个 sources 事件,前端就能把引用的游戏做成链接),以及给 /sync、/update 加上鉴权。

代码在 mantou132/nesbox 的 packages/webapp/cf/ 目录。欢迎交流。

相关推荐
AI前端工程狮14 分钟前
服务端已经死了,客户端却报"超时"
人工智能·程序员
阿屿15 分钟前
从零手搓 AI 伴侣(二):好感度通胀危机,与它的「货币政策」
java·人工智能
师夷长技以自愈31 分钟前
第 2 篇:AI 点菜,程序端菜——第一次学会"工具调用"
人工智能
数据集_深度学习34 分钟前
果园红绿苹果目标检测数据集介绍和论文参考
人工智能·目标检测
武子康36 分钟前
H3-World 的 11.3 小时训练数据,是怎样对齐成动作监督的?
大数据·人工智能·深度学习
泯泷1 小时前
大模型会吃掉 Harness 吗?Agent 天空上的“两朵乌云”
人工智能·llm·agent
吴名氏.1 小时前
Java转AI Agent教程(第十二周)检索与切分
java·开发语言·人工智能·ai·ai agent
LightBreeze20021 小时前
深入理解 Leaf-Spine:从网络拓扑到 AI 数据中心 Fabric
人工智能·fabric·spine·叶脊网络
Elastic 中国社区官方博客2 小时前
更少的位数,相同的召回率:DiskBBQ 更保守的自动校准扫描
大数据·运维·开发语言·人工智能·elasticsearch·搜索引擎