不需要服务器,也不需要单独的向量数据库: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/ 目录。欢迎交流。