大多数关于RAG的教程都会向你展示一个可运行的演示示例,然后就认为教学任务完成了。你复制这些代码,在本地环境中运行它们,之后再尝试将它们应用到生产环境中,结果却发现一切都会出问题。

但这个教程有所不同。我运营着一个实际用于处理真实业务流量的RAG系统(vectorize-mcp-worker),其每月的总维护成本仅为5美元。而我评估过的其他替代方案,其维护费用都在100到200美元之间。这种价格差异并非源于什么神奇的因素,而是取决于系统的架构设计。

在这个教程中,你将构建rag-tutorial-simple:这是一个简洁、功能最基础的RAG聊天机器人,它部署在Cloudflare Workers平台上。这个系统不需要使用任何外部API密钥,也不需要支付任何费用来使用向量数据库服务,更无需管理任何服务器。只需要利用Cloudflare提供的免费 tier服务——包括Workers、Vectorize以及Workers AI——就能在边缘计算环境中完成所有核心功能。

目录

  1. 你将构建什么

  2. 先决条件

  3. RAG的工作原理

  4. 如何设置你的项目

  5. 如何构建数据管道

  6. 如何构建查询管道

  7. 如何添加错误处理机制与安全措施

  8. 性能与成本分析

  9. 总结

你将构建什么

完成这个教程后,你将会拥有一个全球范围内可使用的RAG API,它能够:

  • 通过HTTP接收自然语言形式的查询请求

  • 使用Workers AI将这些查询转换为向量表示形式

  • 在Cloudflare Vectorize中存储的知识库中进行搜索

  • 将检索到的结果传递给另一个运行在Workers AI上的大语言模型,以生成相应的答案

  • 返回准确、合理的响应结果(而不会产生错误信息或虚假内容)

完整的源代码可以在这里找到:github.com/dannwaneri/rag-tutorial-simple

先决条件

这是一个面向中级学习者的教程。在开始学习之前,你需要具备以下基础知识:

  • JavaScript/TypeScript:async/await、Promise对象、基本数据类型

  • HTTP API:REST协议、请求与响应机制、JSON格式的数据交换

  • 命令行基础操作:能够运行npm命令、在目录间导航

你还需要准备以下工具:

  • Node.js 18或更高版本:可以通过node --version来检查版本信息

  • 一个Cloudflare账户:使用免费 tier即可,欢迎访问cloudflare.com进行注册

  • 一款代码编辑器

    :推荐使用VS Code,因为它支持TypeScript语法

就是这样。不需要OpenAI的密钥,也不需要信用卡来支付嵌入模型的相关费用。让我们开始构建吧。

RAG的工作原理

在编写任何代码之前,你首先需要对自己所要构建的东西有一个清晰的概念。这一部分将解释RAG系统的三个核心组成部分、数据在这些组件之间的流动方式,以及为什么这种架构能够在大规模应用中发挥作用。

心理模型

可以把传统的大语言模型想象成一位医生:这位医生花费多年时间学习医学知识,但在毕业后就一直生活在没有互联网的偏远地区。他们非常聪明,但所掌握的知识仅限于毕业时的水平。如果问他们关于去年新批准的一种药物的信息,他们要么会说不知道,要么会错误地给出答案。

而RAG让这位医生能够使用最新的医学资料库。在回答你的问题之前,他们可以查阅相关的资料并利用这些信息来给出准确的答案。虽然他们的训练经历仍然很重要(也就是说,他们知道如何阅读和理解这些信息),但他们不再受限于多年前记忆中的内容了。

从技术角度来看,RAG在处理每个请求时都会经过三个步骤:

  1. 检索:从你的知识库中找到最相关的文档

  2. 补充信息:将这些文档作为上下文添加到大语言模型的输入中

  3. 生成答案:让大语言模型结合自身的训练知识以及检索到的信息来生成答案

三个核心组成部分

每一个RAG系统都由三个关键部分构成。了解这些组成部分有助于你在开发过程中解决问题并做出更好的架构决策。

嵌入模型

嵌入模型能够将文本转换为向量——也就是一组数字,这些数字代表了文本的含义。在本教程中使用的模型@cf/baai/bge-base-en-v1.5会为任何输入的文本生成768个数字。

嵌入模型的关键特性在于:语义上相似的文本会生成数值上相近的向量。例如,“如何安装Node.js?”和“设置Node.js的环境需要哪些步骤?”这两个问题生成的向量会非常接近;而“如何安装Node.js?”和“法国的首都是什么?”这两个问题生成的向量则会相差很大。

正是这种机制使得语义搜索成为可能。你并不是在匹配关键词,而是在匹配文本所表达的含义。

有一条规则绝对不能违反:你的文档和查询都必须使用相同的模型进行嵌入处理。如果你用bge-base-en-v1.5模型来处理文档,却用另一个模型来处理查询,那么生成的向量就无法进行比较,从而导致搜索结果出现错误。

向量数据库

向量数据库用于存储这些嵌入向量,并允许你根据文本之间的相似性来进行搜索。在本教程中,你会使用Cloudflare Vectorize这个工具来构建向量数据库。

当你执行相似性搜索时,需要输入一个查询向量,而“Vectorize”会返回其中K个最为相似的向量,同时还会提供这些向量的元数据以及相似度评分。这种搜索方法被称为“近似最近邻搜索”,而“Vectorize”经过优化,即使面对数百万个向量,也能快速完成这一任务。与像Pinecone这样的外部向量数据库相比,使用Vectorize的主要优势在于数据的高效存储与处理。Vectorize运行在与你使用的Workers AI相同的Cloudflare网络环境中,因此不存在任何外部API调用、认证流程或网络延迟问题。

语言模型

大型语言模型仅负责一项任务:读取用户提供的信息并生成自然语言形式的答案。它不会进行任何搜索操作,也不会判断哪些信息是相关的;它只会根据输入的内容来生成相应的回复。
这种功能分工是经过精心设计的。大型语言模型在处理语言相关任务方面表现优异——它们能够理解问题、整合信息并清晰地表达结果;而向量数据库则擅长快速检索相关信息。RAG技术正是结合了这两者的优势,让它们各自发挥出最大的作用,而无需要求其中任何一方去做超出其设计范围的任务。
在本教程中,你可以通过Workers AI使用@cf/meta/llama-3.3-70b-instruct-fp8-fast这个模型。使用该模型不需要API密钥。

关于视觉嵌入的说明

如果你打算将这个系统扩展到图像搜索领域,你可能会考虑使用像CLIP这样的视觉语言模型来生成视觉嵌入——这些嵌入向量能够代表图像本身,而不仅仅是文本描述。虽然这种方法听起来很巧妙,但实际上在RAG场景中效果并不好。
视觉嵌入是基于像素相似性进行匹配的,因此它们非常适合用于“查找与某张图片外观相同的图片”这类任务;但对于“找到登录界面”或“找出显示错误率的仪表盘”这样的查询来说,它们的效果就差得多了,因为这些查询实际上关注的是图像所表达的含义,而不是像素本身。
在实际应用中,更好的方法是让图像通过像Llama 4 Scout这样的多模态模型进行处理,这类模型能够生成详细的文本描述,并通过OCR技术提取出可见的文字内容。然后你可以使用与处理其他文档时相同的BGE模型来生成这些文字的视觉嵌入向量。
这样得到的结果会被存储在一个统一的索引系统中,可以与你现有的查询流程无缝配合使用,而且对于RAG场景来说,这种方法的搜索效果要比使用视觉嵌入要好得多。
无论如何,Cloudflare Workers AI并不支持CLIP模型。即便它支持,基于文本描述的搜索方式在语义检索方面的表现也会优于CLIP。

查询在系统中的处理流程

当用户向你的Workers AI模型提交“什么是RAG?”这个查询时,系统会按照以下步骤进行处理:

  1. 第一步——生成问题对应的嵌入向量(20-30毫秒):你的Workers AI模型会接收这个问题文本,并使用相应的嵌入模型生成一个768维的向量,该向量代表了问题的含义。

  2. 第二步——在Vectorize数据库中进行搜索(30-50毫秒):你的Workers AI模型会将这个嵌入向量传递给Vectorize数据库,数据库会在其中检索相关信息,并返回3份相似度最高的文档及其相似度分数。

  3. 第三步——过滤结果并构建上下文信息(<1毫秒):那些相似度低于0.5的文档会被剔除,剩下的文档文本会被合并成一段连续的上下文字符串。

  4. 第四步——生成最终答案(500-1500毫秒):你的Workers AI模型会将生成的上下文信息和原始问题一起传递给大型语言模型,该模型会根据这些信息生成一个合理的答案。

  5. 第五步——将结果返回给用户:最终答案以及相关的元数据会以JSON格式返回给用户。

总耗时:通常为600至1600毫秒。其中,大语言模型的生成步骤所占时间最长,其余环节的速度都很快。

为何这种方案在大规模应用中能够有效运行

人们对Cloudflare RAG的一个常见质疑是,它无法满足小于200毫秒的查询响应时间要求。这一质疑源于一个特定的架构缺陷:人们试图将整个RAG处理流程——包括复杂的嵌入信息生成和排序操作——都放在同一个同步请求中完成。这种架构设计是错误的。

在本教程中介绍的架构方案,将数据加载环节(这个环节速度较慢,且只需执行一次)与查询环节分开;查询环节速度很快,每次用户发起请求时都会被执行。因此,当用户提出问题时,相关文档已经完成了嵌入处理并已被存储起来。查询流程只需要对问题内容进行嵌入处理、执行一次向量搜索,然后调用大语言模型即可,这三个步骤的速度都很快。

我的生产系统(vectorize-mcp-worker)就是采用这种架构设计的,每月只需5美元的成本就能处理实际业务流量。详细的性能数据可以在这里查看。Cloudflare RAG确实是有效的,只要构建方式正确,就能取得理想的效果。

如何搭建您的项目

在本节中,您将创建一个Cloudflare Worker实例,生成用于存储嵌入信息的Vectorize索引,并配置好各组件之间的连接关系。

如何创建项目

打开终端,为该项目创建一个新的目录。

在Mac/Linux系统中:

mkdir rag-tutorial-simple && cd rag-tutorial-simple

在Windows PowerShell系统中:

mkdir rag-tutorial-simple
cd rag-tutorial-simple

接下来运行Cloudflare的模板生成工具:

npm create cloudflare@latest

按照提示进行操作,具体内容如下:

  • 目录名称/项目名称rag-tutorial-simple

  • 想从哪个示例开始入手?“Hello World”示例

  • 是否使用TypeScript编写代码?

  • 是否进行部署?

操作完成后,您就会得到一个已经配置好了相关组件的TypeScript Worker实例。

如何创建Vectorize索引

Vectorize是Cloudflare提供的向量数据库。它与您的Worker实例位于同一网络环境中,因此在进行查询时不会产生额外的API调用延迟或性能瓶颈。

npx wrangler vectorize create rag-tutorial-index --dimensions=768 --metric=cosine

这里有兩点需要注意:

--dimensions=768这个参数指定了每个嵌入向量由多少个数值组成。这个数值必须与您所使用的嵌入模型的输出结果相匹配。您将使用的模型(@cf/baai/bge-base-en-v1.5)生成的嵌入向量具有768个维度,如果这个数值不一致,查询操作就会失败。

--metric=cosine,这就是 Vectorize 用来衡量向量之间相似性的方法。余弦相似度关注的是两个向量之间的夹角,而非它们之间的距离。对于文本嵌入来说,这种测量方式比其他指标能更准确地捕捉到语义信息。

如何配置 wrangler.toml

打开 wrangler.toml 文件,将其内容替换为以下内容:

name = "rag-tutorial-simple"
main = "src/index.ts"
compatibility_date = "2026-02-25"

[[vectorize]]
binding = "VECTORIZE"
index_name = "rag-tutorial-index"

[ai]
binding = "AI"

[[vectorize]] 部分用于将你的 Worker 与你刚刚创建的索引连接起来。[ai] 部分则允许你的 Worker 使用 Workers AI——无论是生成嵌入数据,还是运行用于生成答案的语言模型。

需要注意的是,这里并没有任何 API 密钥。因为你的 Worker、Vectorize 以及 Workers AI 都是在同一个账户下运行的,所以 Cloudflare 会内部处理身份验证相关事宜。

如何更新 src/index.ts

打开 src/index.ts 文件,将其内容替换为以下代码:

export interface Env {
  VECTORIZE: VectorizeIndex;
  AI: Ai;
  LOAD_SECRET: string;
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    return new Response("RAG tutorial worker is running", { status: 200 });
  },
};

Env 接口告诉 TypeScript,在你的 Worker 中有哪些可用的功能。VectorizeIndexAi 是 Cloudflare 提供的类型定义。

如何验证你的配置是否正确

首先启动本地开发服务器:

npx wrangler dev

然后打开浏览器,访问 http://localhost:8787。你应该会看到如下提示:

RAG tutorial worker is running

在终端中你会看到两条警告信息,但这些都是正常的。

第一条警告表示 Vectorize 不支持本地模式。这意味着在本地开发环境下使用 Vectorize 时,除非使用 --remote 参数,否则相关操作将无法正常进行。在测试整个数据管道时,你需要使用这个参数。

第二条警告说明 AI 相关功能总是会访问远程资源。因此,在本地开发模式下,生成嵌入数据和调用大型语言模型时,数据都会传输到 Cloudflare 的服务器上。不过这并没有问题,因为在免费使用范围内进行这些操作是不需要支付任何费用的。

此时你的项目结构应该是这样的:

rag-tutorial-simple/
├── scripts/
│   └── knowledge-base.ts
├ ├── src/
│   └── index.ts
├ ├── wrangler.toml
├ ├── package.json
└── tsconfig.json

如何构建数据管道

数据管道负责两件事:为知识库中的每份文档生成嵌入向量,并将这些嵌入向量存储在Vectorize系统中。你可以在Worker内部通过/load接口来完成这两步操作。

这种方法的最大优势在于:你不需要API令牌、账户ID或任何外部工具。所有流程都依赖于你在wrangler.toml中配置的设置。

如何创建知识库

在项目中创建一个scripts/文件夹,并添加一个名为knowledge-base.ts的文件:

mkdir scripts

将你的文档内容添加到scripts/knowledge-base.ts文件中:

export const documents = [
  {
    id: "1",
    text: "Cloudflare Workers在全球300多个数据中心中运行JavaScript代码。请求处理过程发生在离用户较近的位置,因此与单区域服务器相比,延迟显著降低。",
    metadata: { source: "cloudflare-docs", category: "workers" },
  },
  {
    id: "2",
    text: "Vectorize是Cloudflare提供的向量数据库。它用于存储嵌入向量,并允许你根据语义相似性进行搜索。由于Vectorize运行在与Worker相同的网络环境中,因此无需进行任何外部API调用。",
    metadata: { source: "cloudflare-docs", category: "vectorize" },
  },
  {
    id: "3",
    text: "Workers AI使你能够在Cloudflare的基础设施上直接运行机器学习模型。你可以生成嵌入向量,并在不出离Cloudflare网络的情况下进行大语言模型的推理操作。",
    metadata: { source: "cloudflare-docs", category: "workers-ai" },
  },
  {
    id: "4",
    text: "RAG代表“检索增强生成”。与仅依赖大语言模型训练数据的不同,RAG会从知识库中检索相关内容,并将其添加到提示信息中,然后再生成答案。",
    metadata: { source: "ai-concepts", category: "rag" },
  },
  {
    id: "5",
    text>嵌入向量是一种文本的数值表示形式。相似的文本会产生相似的嵌入向量。正是这种机制使得语义搜索成为可能——你可以通过意义来进行搜索,而不仅仅是依靠精确的关键词。

metadata: { source: "ai-concepts", category: "embeddings" }, }, { id: "6", text>BGE模型(bge-base-en-v1.5)可通过Workers AI使用。该模型能够生成768维的嵌入向量,非常适合用于英语语义搜索任务。

metadata: { source: "cloudflare-docs", category: "workers-ai" }, }, { id: "7", text>余弦相似度用于衡量两个向量之间的夹角。对于文本嵌入向量而言,这种测量方法能够忽略文本长度的因素,从而准确反映语义上的相似性,因此比欧几里得距离更为可靠。

metadata: { source: "ai-concepts", category: "embeddings" }, }, { id: "8", text>Cloudflare Workers提供免费套餐,每天可使用10万次请求次数。Vectorize既适用于免费套餐,也适用于付费套餐。免费套餐适合用于原型开发和实验;而付费套餐的月费为5美元起,能为生产环境中的工作负载提供更高的资源配额。

metadata: { source: "cloudflare-docs", category: "pricing" }, }, ];

每份文档都包含三个字段。`id`是一个唯一的字符串,Vectorize使用它来识别相应的向量数据;`text`则是会被转换成嵌入向量的部分;而`metadata`则会与向量数据一起被存储起来,并在搜索结果中显示出来。稍后你可以通过这些元数据来展示每个答案的来源。

理解嵌入向量

在编写加载代码之前,先了解自己实际上正在生成什么内容是非常有帮助的。

嵌入向量是一个由768个数字组成的数组,它用来表示一段文本的含义。模型会读取一条句子,并输出这768个数字;相似的句子会产生类似的数字数组。

当用户提出一个问题时,你会使用相同的模型将这个问题转换成嵌入向量,然后让Vectorize去查找那些与之最为接近的已存储嵌入向量。这些嵌入向量所对应的文档就是与问题最相关的内容。

这就是为什么模型选择非常重要:你的文档和查询必须使用同一个模型进行嵌入处理,否则相似度评分就会失去意义。

如何构建加载端点

打开`src/index.ts`文件,并在其中添加一个 `/load` 路由。目前这个文件的完整内容如下:

import { documents } from "../scripts/knowledge-base";

export interface Env {
  VECTORIZE: VectorizeIndex;
  AI: Ai;
  LOAD_SECRET: string;
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);

    if (url.pathname === "/load" && request.method === "POST") {
      return handleLoad(env, request);
    }

    return new Response("RAG教程工作进程正在运行", { status: 200 });
  },
};

async function handleLoad.env: Env, request: Request): Promise<Response> {
  const authHeader = request.headers.get("X-Load-Secret");
  if (authHeader !== env.LOAD_SECRET) {
    return Response.json({ error: "未经授权" }, { status: 401 });
  }

  const results: { id: string; status: string }[] = [];

  for (const doc of documents) {
    const response = await env.AI.run("@cf/baai/bge-base-en-v1.5", {
      text: [doc.text],
    }) as { data: number[][] };

    await env.VECTORIZE.upsert([
      {
        id: doc.id,
        values: response.data[0],
        metadata: {
          ...doc.metadata,
          text: doc.text,
        },
      },
    ];

    results.push({ id: doc.id, status: "loaded" });
  }

  return Response.json({ success: true, loaded: results });
}

需要注意的是,`env.AI.run()`和`env.VECTORIZE.upsert()`这两个方法并不需要任何认证信息。因为这些操作是在你的Cloudflare账户内部进行的,所以系统会自动处理身份验证流程;对于内部服务之间的通信来说,也不存在需要管理的密钥或密码。

在`metadata`字段中,`text: doc.text`这一项非常重要。Vectorize会保存嵌入向量的数值以及你提供的元数据,但不会单独存储原始文本。通过将文本包含在元数据中,你就可以在后续的搜索结果中检索并显示这些文本了。

as { data: number[][] }这种类型转换是必要的,因为TypeScript为Workers AI定义的类型尚无法准确反映各个模型的返回数据结构。实际上,所有响应结果都包含一个data数组,而这种类型转换能让TypeScript识别并接受这一数据结构。

如何部署和加载你的知识库

首先,设置用于保护你的数据加载端点的密钥:

npx wrangler secret put LOAD_SECRET

系统会提示你输入一个强密码。输入完成后,进行部署:

npx wrangler deploy

最后,触发数据加载端点。这个操作只需执行一次,或者在更新知识库后随时重新执行:

curl -X POST https://rag-tutorial-simple..workers.dev/load \
  -H "X-Load-Secret: your-secret-value"

在Windows PowerShell中:

注意:PowerShell使用反引号(`)来表示行续接,而不是反斜杠。

Invoke-WebRequest `
  -Uri "https://rag-tutorial-simple..workers.dev/load" `
  -Method POST `
  -Headers @{"X-Load-Secret"="your-secret-value"} `
  -UseBasicParsing

你应该会看到如下响应:

{
  "success": true,
  "loaded": [
    { "id": "1", "status": "loaded" },
    { "id": "2", "status": "loaded" },
    { "id": "3", "status": "loaded" },
    { "id": "4", "status": "loaded" },
    { "id": "5", "status": "loaded" },
    { "id": "6", "status": "loaded" },
    { "id": "7", "status": "loaded" },
    { "id": "8", "status": "loaded" }
  ]
}

现在,你的知识库已经以向量的形式存储在Vectorize系统中了。在下一节中,你将构建查询流程来检索这些向量并生成答案。

如何构建查询流程

查询流程是RAG系统的核心。当用户提出问题时,该流程会依次执行四个步骤:嵌入问题内容、在Vectorize系统中进行搜索、根据搜索结果生成上下文信息,最后利用大语言模型生成答案。

在你的fetch处理函数以及完整的handleQuery函数中添加/query路由路径。以下是更新后的src/index.ts文件内容:

import { documents } from "../scripts/knowledge-base";

export interface Env {
  VECTORIZE: VectorizeIndex;
  AI: Ai;
  LOAD_SECRET: string;
}

export default {
  async fetch(request: Request, env: Env): Promise {
    const url = new URL(request.url);

    if (url.pathname === "/load" && request.method === "POST") {
      return handleLoad(env, request);
    }

    if (url.pathname === "/query" && request.method === "POST") {
      return handleQuery(request, env);
    }

    return new Response("RAG教程工作器正在运行", { status: 200 });
  },
};

async function handleLoad(env: Env, request: Request): Promise {
  const authHeader = request.headers.get("X-Load-Secret");
  if (authHeader !== env.LOAD_SECRET) {
    return Response.json({ error: "未经授权" }, { status: 401 });
  }

  const results: { id: string; status: string }[] = [];

  for (const doc of documents) {
    const response = await env.AI.run("@cf/baai/bge-base-en-v1.5", {
      text: [doc.text],
    }) as { data: number[][] };

    await env.VECTORIZE.upsert([
      {
        id: doc.id,
        values: response.data[0],
        metadata: {
          ...doc.metadata,
          text: doc.text,
        },
      },
    ];

    results.push({ id: doc.id, status: "loaded" });
  }

  return Response.json({ success: true, loaded: results });
}

async function handleQuery(request: Request, env: Env): Promise {
  const body = await request.json() as { question: string };

  if (!body.question) {
    return Response.json({ error: "问题内容是必填项" }, { status: 400 });
  }

  // 第一步:使用与文档相同的模型对问题进行嵌入处理
  const embeddingResponse = await env.AI.run("@cf/baai/bge-base-en-v1.5", {
    text: [body.question],
  }) as { data: number[][] };

  // 第二步:在Vectorize系统中搜索与问题内容最相似的3份文档
  const searchResults = await env.VECTORIZE.query(
    embeddingResponse.data[0],
    {
      topK: 3,
      returnMetadata: "all",
    }
  );

  // 第三步:从搜索结果中筛选出符合要求的上下文信息
  const context = searchResults.matches
    .filter((match) => match.score > 0.5)
    .map((match) => match.metadata?.text as string)
    .filter(Boolean)
    .join("\n\n");

  if (!context) {
    return Response.json({
      answer: "我无法找到相关的信息来回答这个问题。",
      sources: [],
    });
  }

  // 第四步:利用筛选出的上下文信息生成答案
  const aiResponse = await env.AI.run("@cf/meta/llama-3.3-70b-instruct-fp8-fast", {
    messages: [
      {
        role: "system",
        content: "你是一个乐于助人的助手。请仅根据提供的上下文来回答这个问题。如果上下文信息不足,请说明原因。",
      },
      {
        role: "user",
        content: `上下文:\n\({context}\n\n问题: \){body.question}",
      },
    ],
    max_tokens: 256,
  }) as { response: string };

  // 第五步:将答案及其来源信息一起返回
  const sources = searchResults.matches
    .filter((match) => match.score > 0.5)
    .map((match) => match.metadata?.source as string)
    .filter(Boolean);

  return Response.json({
    answer: aiResponse.response,
    sources: [...new Set(sources)],
  });
}

每一步的具体操作如下:

  1. 步骤1 – 将问题转换为向量:您会使用在加载文档时所使用的相同模型,将用户提出的问题转换成一个768维的向量。这一点非常重要:问题和文档必须使用相同的模型进行转换,否则相似度评分将会毫无意义。

  2. 步骤2 – 进行搜索并生成向量结果:您会将处理后的问题向量传递给相关系统,该系统会返回三个与这个问题最为相似的文档。returnMetadata: "all"这一设置会让系统在返回结果时同时包含每份文档的元数据,其中包括原始文本。

  3. 步骤3 – 组合相关信息形成上下文:您会过滤掉那些相似度评分低于0.5的结果,然后将剩余文档的文本内容合并成一条连续的上下文字符串。设置0.5这个阈值是为了防止大型语言模型因为没有更匹配的结果而收到一些无关的文档。

  4. 步骤4 – 生成答案:您会将生成的上下文信息以及问题本身,以聊天对话的形式传递给大型语言模型。messages参数用于指定这些输入内容。系统提示会明确要求模型仅使用所提供的上下文来生成答案,这样才能确保答案的准确性和相关性。如果没有这一限制,模型很可能会忽略上下文信息,而是根据自身的训练数据来回答问题。

  5. 步骤5 – 显示文档来源:您会在响应结果中包含文档的元数据,这样用户就能知道答案是来源于哪些文档的。Set命令用于去除重复的文档来源信息,以防同一份文档被多次引用。

如何测试查询流程

首先部署您的Worker节点:

npx wrangler deploy

然后发送一个问题进行测试:

curl -X POST https://rag-tutorial-simple..workers.dev/query \
  -H "Content-Type: application/json" \
  -d '{"question": "What is RAG?"}'

在Windows PowerShell中,可以使用以下命令:

Invoke-WebRequest `
  -Uri "https://rag-tutorial-simple..workers.dev/query" `
  -Method POST `
  -ContentType "application/json" `
  -Body '{"question": "What is RAG?"}' `
  -UseBasicParsing

您应该会收到如下形式的响应:

{
  "answer": "RAG代表‘Retrieval Augmented Generation’,这是一种通过从知识库中检索相关内容,并将其添加到生成答案的提示信息中,从而提升答案质量的方法。",
  "sources": ["ai-concepts"]
}

可以看到,这个答案是来源于您的知识库,而不是大型语言模型的训练数据。这正是RAG技术的核心优势:它能够提供有根据、可验证的答案,并且这些答案的来源都是可以追溯的。

如何添加错误处理机制与安全防护措施

如果一个教程只展示了正常运行时的情况,那么它还不足以在实际环境中使用。在这一部分,您将为查询流程的每一步添加错误处理机制,并保护/load接口不被未经授权的访问者利用。

如何保护负载端点

/load端点会生成嵌入向量并将其写入你的Vectorize索引中。如果没有采取任何保护措施,任何知道你的Worker URL的人都可以反复调用该端点,从而消耗你的AI使用额度并覆盖你的数据。

你之前添加到Env中的LOAD_SECRET配置,以及你执行的wrangler secret put命令,就可以解决这个问题。handleLoad函数开头进行的检查会拒绝任何不包含正确秘密头信息的请求:

const authHeader = request.headers.get("X-Load-Secret");
if (authHeader !== env.LOAD_SECRET) {
  return Response.json({ error: "未经授权" }, { status: 401 });
}

如果请求中不包含这个秘密头信息,系统会返回{"error":"未经授权"},并且状态码为401。这个秘密本身是以加密形式存储在你的Worker中的,它永远不会出现在你的代码或wrangler.toml文件中。

要想调用负载端点,你必须在请求头信息中包含这个秘密:

curl -X POST https://rag-tutorial-simple..workers.dev/load \
  -H "X-Load-Secret: your-secret-value"

如何处理查询错误

请用以下加强版的handleQuery函数替换原来的版本:

async function handleQuery(request: Request, env: Env): Promise<Response> {
  // 检查请求体是否格式正确
  let body: { question: string };
  try {
    body = await request.json() as { question: string };
  } catch {
    return Response.json({ error: "请求体中的JSON格式不正确" }, { status: 400 });
  }

  if (!body.question || typeof body-question !== "string" || bodyquestion.trim() === "") {
    return Response.json({ error: "问题内容必须是一个非空字符串" }, { status: 400 });
  }

  // 对问题内容进行清洗:去除空白字符并限制长度
  const question = body.question.trim().slice(0, 500);

  // 第一步:生成嵌入向量
  let embeddingResponse: { data: number[][] };
  try {
    embeddingResponse = await env.AI.run("@cf/baai/bge-base-en-v1.5", {
      text: [question],
    }) as { data: number[][] };
  } catch (err) {
    console.error("生成嵌入向量失败:", err);
    return Response.json({ error: "无法处理你的问题" }, { status: 503 });
  }

  // 第二步:在Vectorize数据库中搜索
  let searchResults: Awaited<ReturnType<typeof env.VECTORIZE.query>>;
  try {
    searchResults = await env.VECTORIZE.query(
      embeddingResponse.data[0],
      { topK: 3, returnMetadata: "all" }
    );
  } catch (err) {
    console.error("在Vectorize数据库中搜索失败:", err);
    return Response.json({ error: "无法查询到相关信息" }, { status: 503 });
  }

  // 第三步:构建上下文信息
  const context = searchResults.matches
    .filter((match) => match.score > 0.5)
    .map((match) => match.metadata?.text as string)
    .filter(Boolean)
    .join("\n\n");

  if (!context) {
    return Response.json({
      answer: "我找不到相关的信息来回答这个问题。请重新表述问题或尝试其他问题。",
      sources: [],
    });
  }

  // 第四步:生成答案
  let aiResponse: { response: string };
  try {
    aiResponse = await env.AI.run("@cf/meta/llama-3.3-70b-instruct-fp8-fast", {
      messages: [
        {
          role: "system",
          content: "你是一个有帮助的助手。请仅使用提供的上下文信息来回答这个问题。如果上下文信息不足,请说明。",
        },
        {
          role: "user",
          content: `上下文信息:\n\({context}\n\n问题:\){question}",
        },
      ],
      max_tokens: 256,
    }) as { response: string };
  } catch (err) {
    console.error("生成AI答案失败:", err);
    return Response.json({ error: "无法生成答案" }, { status: 503 });
  }

  // 第五步:返回答案及相关参考信息
  const sources = searchResults.matches
    .filter((match) => match.score > 0.5)
    .map((match) => match.metadata?.source as string)
    .filter(Boolean);

  return Response.json({
    answer: aiResponse.response,
    sources: [...new Set(sources)],
  });
}

各种错误处理措施的含义如下:

  • try/catch 语句被用于 request.json() 方法周围:如果传入的数据不是有效的 JSON 格式,request.json() 方法会抛出异常。如果没有使用这种错误处理机制,格式错误的请求会导致 Worker 过程崩溃,并出现未处理的 500 错误;而使用了这种机制后,调用者会收到明确的 400 错误提示,从而了解问题所在。

  • 在处理数据之前进行输入验证:在调用任何外部服务之前,会先检查 question 变量是否存在、是否为字符串类型以及是否为空。这样就可以避免因无效输入而导致 AI 算法被浪费性地调用。

  • 对输入数据执行 .slice(0, 500) 操作:在将数据传递给嵌入模型之前,会限制输入数据的长度。如果不进行这种处理,恶意用户可能会发送过长的字符串,从而增加 AI 算法的计算负担或导致 Worker 的 CPU 资源耗尽。

  • 当 AI 计算或向 Vectorize 服务请求数据失败时,返回 HTTP 503 错误:HTTP 503 表示“服务暂时不可用”,这告诉调用者错误发生在服务器端,因此可以重新尝试发送请求。

  • context 变量执行 .filter(Boolean) 操作:在处理 match.metadata?.text 后,如果某些元数据的 text 字段为空,对应的结果就会变为 undefined。通过这种过滤操作,可以确保在将结果发送给 LLM 时,context 字符串中不会包含 undefined 值。

如何测试错误处理机制

首先部署更新后的 Worker 实例:

npx wrangler deploy

然后测试各种错误情况:


# 如果加载请求端点时缺少密钥,应返回 401 错误
curl -X POST https://rag-tutorial-simple..workers.dev/load

# 如果传入的 JSON 数据无效,应返回 400 错误
curl -X POST https://rag-tutorial-simple..workers.dev/query \
  -H "Content-Type: application/json" \
  -d 'not json'

# 如果输入的查询内容为空,应返回 400 错误
curl -X POST https://rag-tutorial-simple..workers.dev/query \
  -H "Content-Type: application/json" \
  -d '{"question": ""}'

性能与成本分析

本节使用了我在 vectorize-mcp-worker 项目中收集的实际生产数据。这些数据是针对我在尼日利亚的 Port Harcourt 地点到 Cloudflare 边缘服务器之间的网络环境进行测量的。

实际性能数据

以下是每次请求时整个处理流程所消耗的时间:

操作类型 耗时
嵌入模型生成 142 毫秒
向量搜索 223 毫秒
响应格式化 <5 毫秒以内
总耗时 约 365 毫秒

这里仅讨论了嵌入生成和向量搜索这两个环节,也就是检索层。而使用大语言模型进行生成会额外增加500到1500毫秒的处理时间,因此端到端的响应时间通常会在600到1600毫秒之间。

在整体系统中,嵌入生成和向量搜索这两个步骤所占的比例最大,其他环节的影响都可以忽略不计。作为参考,如果使用OpenAI的嵌入技术和Pinecone服务,还会额外增加两次外部API调用,这会使得总延迟轻松超过1秒钟。

这些数据是基于单一地区的测试结果得出的。实际上,你的延迟值会根据你的地理位置以及Cloudflare在请求时刻的实际负载情况而有所变化。但无论如何,这种架构设计的核心理念是正确的:将所有相关组件都部署在边缘计算节点上,就可以消除服务之间的网络传输环节,而传统检索系统中的大部分延迟恰恰来源于这些环节。

实际成本构成

对于每天进行10,000次搜索(每月共300,000次搜索),并且使用10,000个存储好的向量的情况来说:

这种架构组合的成本为:

服务名称 月成本
Workers 约3美元
Workers AI 约3至5美元
Vectorize 约2美元
总计 8至10美元

对于相同查询量而言,传统解决方案的成本如下:

方案名称 月成本
Pinecone Standard 50至70美元
Weaviate Serverless 25至40美元
自托管的pgvector 40至60美元

相比之下,这种新架构的成本可以降低85%到95%。对于一家正在发展中的初创企业来说,如果使用这种方案来实现语义搜索功能,每年能够节省1,500至2,000美元的费用。

为什么成本差异会如此之大

传统检索系统存在三个相互叠加的成本问题。

第一个问题是闲置计算资源的浪费。即使没有进行任何搜索操作,专门用于运行嵌入生成服务的服务器或容器也会产生费用。而Cloudflare Workers只按实际执行时间计费。

第二个问题是服务之间的数据传输成本。每当应用程序先调用外部服务来生成嵌入向量,然后再调用另一个服务来进行搜索时,就需要支付两次外部API调用的费用。而在这种新架构中,这两项操作都在Cloudflare的内部网络中完成,因此不会产生额外的传输成本。

第三个问题是最低配置方案的高昂价格。Pinecone的Standard方案无论使用量多少,每月的费用都是50美元;而Cloudflare的Workers Paid方案则从每月5美元起计费。

当内置资源分配足够使用时

对于使用量较小的情况来说,你的月费用很可能不会超过50美元这个最低标准:

  • Workers:每月提供1,000万次请求的处理能力

  • Workers AI:每天都会分配一定数量的计算资源供使用

  • Vectorize:在免费和付费方案中都可用,免费方案也包含一定的资源分配量

如果是一个副项目、内部工具,或者每天搜索量少于3,000次的小型业务,那么其所需的资源很可能会完全被包含在分配的范围内。

需要了解的权衡因素

这种成本优势带来一个在开发之前就必须了解的操作限制:Vectorize在本地开发模式下是无法使用的。

当你运行wrangler dev时,虽然Worker会在本地运行,但Vectorize的相关调用会失败。因此,你必须将系统部署到Cloudflare上才能测试向量搜索功能。对于大多数开发流程来说,这意味着首先需要使用模拟响应在本地测试查询逻辑,然后再将其部署到预发布环境中进行全面的集成测试。

这是一个实实在在的障碍。不过,这也是拥有一个无需自行管理基础设施的托管型向量数据库所必须接受的权衡。

结论

通过本教程,你已经在Cloudflare的边缘网络上构建并部署了一个可用于生产环境的RAG系统。现在让我们来看看你实际构建了什么,以及运行这个系统需要付出多少成本。

你构建了什么

你最终完成的系统包含三个终端点:

  • GET /:用于检查Worker是否正在运行

  • POST /load:将知识库数据加载到Vectorize中,这一操作会受到保密头信息的保护

  • POST /query:接收用户输入的问题,检索相关内容,并返回包含来源信息的答案

对于每一个请求,完整的查询流程会分四步完成:

  1. 问题会被转换为768维的嵌入向量,这一转换是通过@cf/baai/bge-base-en-v1.5实现的

  2. Vectorize会找出三个在语义上最为相似的文档

  3. 那些相似度超过0.5的文档会被组合起来,形成用于生成答案的上下文

  4. Llama 3.3会仅使用这些上下文来生成最终答案

整个系统的运行完全依赖于Cloudflare的基础设施。不需要任何外部API密钥,也不需要单独订阅向量数据库服务,更无需管理任何服务器。

接下来该做什么

本教程介绍了RAG模式的核心内容。接下来,你可以从四个方向进一步深入探索这个技术。

添加更多文档

在本教程中,知识库仅包含了8份文档。而在实际应用中,一个真正的知识库可能会包含数千份文档。添加文档的方法是一样的:只需将新文档添加到knowledge-base.ts文件中,然后使用保密密钥调用/load接口,剩下的工作就交由Vectorize来完成。

对于规模非常大的知识库,你可以修改handleLoad函数,使其能够批量处理20到50份文档,而不再是一次只添加一份。

优化文档分块方式

在本教程中,每份文档都只是一段简短的文字。而在现实世界中,PDF文件、文章或文档页面等通常需要被分割成多个部分才能进行嵌入处理。应该按照段落或句子这样的自然界限来划分文档,每个分块的长度应控制在200到400个词素之间,并且确保相邻分块之间有50个词素的重叠内容,这样就能保证上下文信息在分块之间得到妥善保留。

添加对话历史记录

当前系统将每个查询视为独立的请求。为了支持后续问题,需要将之前的消息存储在Cloudflare的KV命名空间中,并将最后2-3条交流内容与检索到的上下文信息一起包含在LLM的messages数组中。

流式传输响应结果

对于较长的回答,用户需要等待很长时间才能看到完整的输出结果。不过Cloudflare Workers支持通过TransformStream函数来实现响应结果的流式传输。采用这种传输方式后,前几个字符可以在不到100毫秒的时间内显示出来,而其余内容则会在之后生成。

权衡维度数量与重新排序机制的利弊

本教程使用了维度数为768的bge-base-en-v1.5模型;而我的生产环境则使用维度数为384的bge-small-en-v1.5模型。测试结果显示,将维度数从384增加到768仅使准确率提高了约2%,但成本和延迟却翻了一番。

引入重新排序机制(@cf/baai/bge-reranker-base)所带来的准确率提升幅度要大于增加维度数带来的效果,而且所需成本也更低。具体的提升效果会因应用领域和查询内容的分布情况而有所不同,在做出决定之前,请先在您实际的数据上进行测试。如果您是在为生产环境优化系统,那么请在增加维度数之前先引入重新排序机制。

完整的项目实现

只需执行以下五个命令即可完成克隆和部署过程:

git clone https://github.com/dannwaneri/rag-tutorial-simple
cd rag-tutorial-simple
npm install
npx wrangler vectorize create rag-tutorial-index --dimensions=768 --metric=cosine
npx wrangler secret put LOAD_SECRET
npx wrangler deploy

之后,您需要加载知识库数据:

curl -X POST https://.workers.dev/load \
  -H "X-Load-Secret: your-secret"

如果您觉得这个教程很有用,那么它所基于的生产系统是开源的,地址为github.com/dannwaneri(vectorize-mcp-worker)。该生产系统在基础框架的基础上进行了进一步扩展:它结合了向量搜索和BM25算法进行混合搜索;支持使用AI视觉技术来查询图像;引入了重新排序机制以提升搜索结果的准确性;同时还提供了实时监控面板。整个系统运行在您刚刚搭建的Cloudflare技术栈上——包括Workers、Vectorize、Workers AI,以及用于存储文档的D1服务。

您会注意到一个区别:生产环境使用的模型是维度数为384的bge-small-en-v1.5,而不是本教程中使用的768维模型。这种设计其实是一种有意识的权衡:重新排序机制带来的准确率提升幅度大于增加维度数所带来的成本增加。从您今天搭建的系统到生产环境中的系统,其技术差异其实并没有看起来那么大。

Comments are closed.