用 Cloudflare 全家桶实现游戏站 RAG
作者使用 Workers、Workers AI、Vectorize 和 KV,为多语言游戏库实现语义搜索与流式问答。单个 Worker 承担数据同步、向量与 BM25 索引更新、混合检索及 SSE 回答。
AI 深度解读
游戏名记不住,只记得「骑越野摩托的那个」,传统搜索框就容易失灵。NESBox 的作者给自己的复古游戏站加了一套 RAG 搜索与问答:本地名称、拼音搜索先接住明确需求,语义检索补充模糊描述,问答再根据游戏资料生成回答。
这套方案用一个 Cloudflare Worker 串起 Workers AI、Vectorize 和 KV。最值得借鉴的,是如何整理多语言资料、融合两种检索结果,以及用小规模测试决定哪些能力暂时不开。
搜索能不能找对,先看资料怎么整理
NESBox 有一百多款游戏,介绍混着中文、英文和日文。作者从站点自己的 GraphQL API 取数据,把每款游戏拆成两部分:名称、平台、类型、系列构成头部,游戏介绍构成正文。
头部复用前端 i18n 的中英文标签。例如「越野机车」同时带上 Nes、Racing、Excitebike、Famicom、NES 等信息。用户搜中文类型或英文名称,都有可匹配的内容。正文则去掉 Markdown 图片,把链接压成文字;frontmatter 如果有参考链接,还会抓取页面并追加内容。Wikipedia 走 MediaWiki 的 action=parse API,减少导航栏、侧边栏混进资料的问题。
切块使用 Intl.Segmenter('zh', { granularity: 'sentence' }),默认 maxChars = 800、overlap = 100,相邻块按完整句子重叠,每款游戏最多保留 24 块。每块都带游戏头部,避免单独召回一段介绍后,不知道它属于哪款游戏。
这里有个未交代清楚的边界:原文同时承诺每块最多 800 字符、句子不截断,但没有给出完整实现。遇到单句超过上限,或者加上头部后超长,实际如何处理,需要看代码。
向量 ID 使用 gameId#chunkIndex,KV 记录每款游戏的块数。更新后块数减少,就用 deleteByIds 删除多余旧块,避免已经删掉的资料继续被搜到。
RAG 的第一步,是让每个被检索出来的片段,都有足够完整的身份和上下文。
为什么同时保留向量搜索和关键词搜索
Embedding 使用多语言模型 @cf/baai/bge-m3,输出 1024 维向量,每次提交 50 段文本,并开启 truncate_inputs,超长输入会截断。向量按每批 500 条写入 Vectorize,正文放进 metadata,召回后可以直接使用。
同一轮处理还会生成 BM25 倒排索引。BM25 是按词项匹配和统计权重排序的方法,适合补充名称、标签等明确词语的命中。作者用词粒度的 Intl.Segmenter 分词,把整个索引存成一个 KV 值;全量同步重建,单款更新只替换对应游戏的块。
查询时,向量检索和 BM25 并行,各取前 20 个块。两边分数不能直接相加,作者采用 RRF(Reciprocal Rank Fusion),只根据名次融合:
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 }));
}
要看的是 1 / (k + i + 1):靠前的结果贡献更高,两路都命中的结果可以累加。默认 k = 60。仅被 BM25 找到的块,再用 getByIds 补取正文。
融合后也能调用 cross-encoder 模型 @cf/baai/bge-reranker-base 重排,即把问题和候选文本一起交给模型重新打分。但作者默认关闭,只在 rerank=1 时启用。
依据是 12 条中英文标注查询:在这组混合语言资料上,重排没有改善排序,还增加一次模型调用。作者保留 mode=vector|bm25,方便单独比较两条检索路径。参数都进入 URL,而缓存按 URL 区分,因此不同组合可以分别缓存。
这个实验支持当前默认值,却不足以证明重排普遍无效。原文没有给出具体指标、完整结果和查询分布。
一个 Worker 如何接住搜索与流式问答
后端只有四个端点:/sync、/update 负责入库,/search 返回搜索结果,/completions 负责问答。wrangler.jsonc 声明 GAMES_SEARCH、KV、AI 三个 binding,向量索引名为 nesbox-games-m3,服务调用不需要另外管理 API key。
全量同步通过 TransformStream 持续输出进度,使用 curl -N https://<worker>/sync 可以直接观察。原文示例显示处理 164 款游戏、223 个块;这也解释了为什么作者认为单个 KV 值存放 BM25 索引已经够用。
/search 先取 20 个块,再按游戏去重,因此最终可能不足 20 款。结果缓存一小时。前端在全局搜索框 ⌘K 中先匹配名称和拼音,本地结果不超过 5 个时,才防抖请求语义搜索,并追加尚未展示的游戏。每次输入用 AbortController 取消上一次未完成请求。
问答由 ? 开头的问题触发,本地帮助没有命中才进入 AI 流程。最相关的 5 个块放入 system prompt,要求简短回答并使用当前 UI 语言,生成模型是 @cf/nvidia/nemotron-3-120b-a12b,调用时设置 stream: true。
下面这段解决的是流式输出与答案缓存同时进行的问题:
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' } });
关键是 tee() 和 ctx.waitUntil:一份流直接返回用户,另一份在后台读完并以 expirationTtl: 3600 存入 KV。相同问题再次到来,可以返回缓存的 SSE 内容。
前端使用 Gem(Web Components)。<m-sse> 等待用户停止输入 2 秒,再调用 /completions;异步生成器逐行解析 data: ,遇到 [DONE] 结束,过程中用 marked 渲染 Markdown。接口地址来自 AI_BASE,默认生产环境,本地运行 wrangler dev 时可以把它指向本地 Worker,通过真实前端联调。
我的判断
这套方案适合资料量小、已有结构化内容、希望减少运维工作的站点。Vectorize 承担向量数据库职责,所谓「没有单独的向量数据库」,指的是不用再维护另一套服务。相比接入独立检索系统,组件更集中;代价是更依赖 Cloudflare 的绑定、模型与存储接口。
我认同作者把能力开关留在 URL 上,用测试决定默认值。但现有证据仍然偏小:12 条查询只能支持局部选择,原文也没有给出延迟、命中率或问答正确率的对照数据。「效果不错」仍有经验判断的成分。
费用也要分开看。原文称 Workers AI 在 Free 和 Paid 计划均提供每日 10,000 免费 Neurons,并据几百个块的规模判断 embedding 成本很低;它没有列出完整账单,不能据此认定整套系统免费。
问答可信度还有缺口。展示的 prompt 要求根据上下文回答,却没有明确规定资料不足时如何拒答;来源引用还在计划中。缓存命中后的回答是否因资料更新而失效,也没说清。整份 BM25 索引存一个 KV 值,在当前规模方便,扩大后则需要重新评估。
另外,作者把 /sync、/update 的鉴权列为下一步,说明运维入口的访问控制仍待补齐。服务间免管 API key,不等于这些入口已经受到保护。
能拿来做什么
- 给已有名称、拼音搜索的游戏站增加语义补充时,可以沿用「本地先返回,结果不足再请求」的交互,同时保留防抖、去重和请求取消。
- 整理多语言游戏资料时,先复用前端已有的 i18n 标签,把名称、平台和类型带进每个块,并记录块数以便清理旧数据。
- 比较检索方案时,可以保留
mode=vector|bm25与rerank=1这样的开关,用真实模糊描述和明确名称组成测试集,再决定是否承担额外模型调用。 - 实现流式问答缓存时,可以参考
tee()配合ctx.waitUntil的方式,并补充原文未说明的资料更新失效规则、回答来源和资料不足时的处理方式。