六维教程

实现 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 提示里明确”基于资料回答”压制幻觉。跑通后按表格里的方向逐步优化,效果会越来越稳。

上一篇 元数据过滤与索引管理

上一篇
元数据过滤与索引管理
下一篇
文本生成入门