实现 RAG 检索
大语言模型能聊天能写文章,但它只知道训练时见过的内容,问它你公司内部文档里的东西会答不上来,甚至一本正经地编造。RAG(检索增强生成)解决的就是这个问题,先从你的知识库里检索相关片段,再把片段连同问题一起交给模型生成回答。这篇把 Vectorize 和 Workers AI 组起来,从建索引到能问答,搭一个完整的 RAG 系统。
RAG 是什么
RAG 全称 Retrieval-Augmented Generation,检索增强生成。思路是给模型”开卷考试”,回答前先查资料。
它分三步
1. 准备阶段 文档切分成片段 → 每片转向量 → 存进 Vectorize
2. 检索阶段 用户问题转向量 → 在 Vectorize 找最相似的几片
3. 生成阶段 把检索到的片段和问题一起交给 LLM 生成回答
和直接问大模型的对比
| 对比项 | 直接问 LLM | RAG |
|---|---|---|
| 知识来源 | 训练数据,有截止时间 | 你的私有文档,可随时更新 |
| 私有数据 | 不懂 | 能回答 |
| 准确性 | 容易编造 | 基于检索内容,可溯源 |
| 更新成本 | 重新训练或微调 | 重新导入文档即可 |
| 适用场景 | 通用知识问答 | 企业知识库、客服、文档助手 |
RAG 的核心优势是不用重新训练模型就能让 AI 基于你的最新数据回答,且每条回答都能追溯到来源片段。
准备嵌入和生成模型
RAG 要用两类模型,嵌入模型把文字变成向量,生成模型把检索到的内容组织成回答。Cloudflare Workers AI(Cloudflare 的边缘 AI 推理服务)两个都提供,都在 Worker 里通过 env 调用。
模型选择
| 用途 | 模型 | 维度 | 说明 |
|---|---|---|---|
| 文本嵌入 | @cf/baai/bge-m3 | 1024 | 多语言含中文,中文场景首选 |
| 文本生成 | @cf/meta/llama-3.1-8b-instruct | 无 | 通用对话,稳定可靠 |
嵌入模型的维度决定 Vectorize 索引的维度,bge-m3 输出 1024 维,所以索引要建 1024 维。两个模型都要在 wrangler.toml 里绑定。
name = "rag-demo"
main = "src/index.js"
compatibility_date = "2024-09-01"
[ai]
binding = "AI"
[[vectorize]]
binding = "KB_INDEX"
index_name = "knowledge-base"
配置说明
| 段 | 字段 | 作用 |
|---|---|---|
| [ai] | binding = “AI” | 绑定 Workers AI,用 env.AI 调用模型 |
| [[vectorize]] | binding = “KB_INDEX” | 绑定向量索引,用 env.KB_INDEX 检索 |
文本切分
文档通常很长,不能整篇当一个向量。原因有三。嵌入模型有输入长度上限,太长会被截断。整篇一个向量语义太杂,检索不精准。生成模型上下文有限,塞不下整篇。
所以要切成片段,每片几百字。常用按字符数切,带一点重叠保证上下文不断层。
function chunkText(text, size = 500, overlap = 50) {
const chunks = [];
let start = 0;
while (start < text.length) {
const end = Math.min(start + size, text.length);
chunks.push(text.slice(start, end));
start = end - overlap;
}
return chunks;
}
切分参数怎么选
| 参数 | 建议值 | 作用 |
|---|---|---|
| size | 300 到 800 字 | 单片长度,太短丢上下文,太长语义杂 |
| overlap | 50 到 100 字 | 相邻片重叠,避免切断关键句 |
更讲究的做法是按段落或 Markdown 标题切,保证语义完整。简单项目按字符数切够用。
建索引和导入文档
先建 1024 维的索引,度量用 cosine
npx wrangler vectorize create knowledge-base --dimensions=1024 --metric=cosine
然后写一个导入接口,接收一段文本,切分、转向量、存进 Vectorize。原文片段存在 metadata 里,检索时直接拿回来用,省一次额外查库。
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname === "/ingest" && request.method === "POST") {
const { text } = await request.json();
if (!text) return Response.json({ error: "缺少 text" }, { status: 400 });
// 切分文本
const chunks = chunkText(text);
// 批量生成嵌入向量
const embeddings = await env.AI.run("@cf/baai/bge-m3", { text: chunks });
// 组装成向量记录,metadata 存原文片段
const vectors = chunks.map((chunk, i) => ({
id: `doc-${Date.now()}-${i}`,
values: embeddings.data[i],
metadata: { text: chunk },
}));
await env.KB_INDEX.upsert(vectors);
return Response.json({ inserted: vectors.length });
}
return new Response("Not found", { status: 404 });
},
};
嵌入模型一次能批量处理多条文本,比循环单条调用省网络往返和神经元消耗。注意 chunks 数组顺序和 embeddings.data 顺序一一对应,别错位。
检索相关片段
用户提问时,把问题也用同一个嵌入模型转向量,再去 Vectorize 查最相似的几片。
async function retrieve(env, question, topK = 3) {
// 问题转向量
const queryEmbedding = await env.AI.run("@cf/baai/bge-m3", {
text: [question],
});
const queryVector = queryEmbedding.data[0];
// 检索最相似的片段,返回 metadata 里的原文
const matches = await env.KB_INDEX.query(queryVector, {
topK,
returnMetadata: "all",
});
return matches.matches
.filter((m) => m.score > 0.3)
.map((m) => m.metadata.text);
}
检索阶段两个要点。嵌入模型必须和导入时用同一个,否则向量空间不一致,相似度全是错的。returnMetadata 设成 all 把原文片段直接带回来,这样就不用再查 D1 或 R2 拿原文。score 过滤一个阈值能去掉不相关的弱匹配,阈值多少要拿自己的数据试,0.3 到 0.5 之间常用。
生成回答
把检索到的片段拼成上下文,和用户问题一起交给生成模型。
async function generate(env, question, contexts) {
const contextText = contexts.map((c, i) => `片段${i + 1}\n${c}`).join("\n\n");
const response = await env.AI.run("@cf/meta/llama-3.1-8b-instruct", {
messages: [
{
role: "system",
content: `你是一个知识库问答助手。根据下面提供的资料回答用户问题。如果资料里没有相关内容,就说不知道,不要编造。
下面是资料
${contextText}`,
},
{ role: "user", content: question },
],
max_tokens: 512,
});
return response.response;
}
system 提示里明确告诉模型”只基于资料回答,没有就说不知道”,这是压制模型幻觉的关键。把检索片段放进 system 而不是 user,模型更倾向于把它当权威依据。
完整 RAG 接口
把检索和生成串成一个问答接口
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname === "/ingest" && request.method === "POST") {
const { text } = await request.json();
const chunks = chunkText(text);
const embeddings = await env.AI.run("@cf/baai/bge-m3", { text: chunks });
const vectors = chunks.map((chunk, i) => ({
id: `doc-${Date.now()}-${i}`,
values: embeddings.data[i],
metadata: { text: chunk },
}));
await env.KB_INDEX.upsert(vectors);
return Response.json({ inserted: vectors.length });
}
if (url.pathname === "/ask" && request.method === "POST") {
const { question } = await request.json();
if (!question) return Response.json({ error: "缺少 question" }, { status: 400 });
const contexts = await retrieve(env, question);
if (contexts.length === 0) {
return Response.json({ answer: "知识库里没有相关内容" });
}
const answer = await generate(env, question, contexts);
return Response.json({ question, answer, sources: contexts });
}
return new Response("Not found", { status: 404 });
},
};
测试流程。先 POST 一段文档到 /ingest 导入知识库,等几秒让向量索引生效。再 POST 问题到 /ask,拿到基于文档的回答和来源片段。本地调试同样要加 –remote,因为 Workers AI 和 Vectorize 都要连 Cloudflare 网络。
优化要点
跑通之后可以从几个方向优化效果和成本
| 优化点 | 做法 | 收益 |
|---|---|---|
| 切分策略 | 按段落或标题切,保证语义完整 | 检索精度提升 |
| topK 数量 | 从 3 调到 5 到 10 | 召回更全,但上下文变长 |
| score 阈值 | 过滤掉低分匹配 | 减少无关片段干扰生成 |
| 重排 | 取较多候选,用模型重排选最优 | 精度大幅提升,成本增加 |
| 缓存 | 相同问题缓存回答 | 省模型调用,降低延迟 |
| 增量更新 | 用 upsert 增量导入新文档 | 知识库持续更新 |
重排是个进阶技巧。先让 Vectorize 取 20 条候选,再用一个 rerank 模型或小模型重新打分挑出最相关的 3 条送给生成模型。候选多召回准,重排后送生成的更精,整体效果比直接取 3 条好很多。
还有一个常见问题是上下文超长。检索回来 10 条片段每条 500 字就 5000 字,加上 system 和问题可能超出模型的上下文窗口。要么减少 topK,要么缩短切分长度,要么只取 score 最高的几条。
小结
RAG 把检索和生成拼起来,让大模型能基于你的私有知识回答问题。在 Cloudflare 上这套链路特别顺,Workers AI 出嵌入和生成,Vectorize 存和查,全部在一个 Worker 里用 env 调用完成,不用搭任何额外服务。核心是文本切分、嵌入一致性、prompt 工程这三件事。切分决定检索粒度,嵌入模型前后必须一致,system 提示里明确”基于资料回答”压制幻觉。跑通后按表格里的方向逐步优化,效果会越来越稳。
上一篇 元数据过滤与索引管理