Skip to content

14.19-Hono实现RAG服务

要点

  • 本篇是 RAG 系列的实战收尾——把前 18 讲的概念、算法和接口设计落到 Cloudflare Vectorize + Workers AI + Hono 上,搭出一个最小可用的知识库问答系统
  • 全链路四步走:文本切片 → Embedding 向量化 → Vectorize 写入 → 相似度检索 + LLM 流式生成
  • Vectorize 的 metadata 过滤和 namespace 是多租户隔离的两条路径,选择取决于安全要求
  • Embedding 模型、分块策略和 topK 值没有通用最优解,需要根据实际检索效果迭代调优
  • 系列至此完成从概念到部署的完整链路,下一步是监控、评估和持续优化

1. 本篇要交付什么

前 18 讲逐一拆解了 RAG 的每个组件——文档解析、文本切分、Embedding、向量检索、Rerank、上下文拼接、接口设计。这一篇把它们全部串起来,在 Cloudflare Workers 上跑通一个端到端的 RAG 系统。

最终交付的系统包含三个接口:

  1. 文档写入POST /ingest)——接收文本,生成 Embedding,批量写入 Vectorize
  2. 相似度检索POST /search)——接收查询,返回最相关的文档片段和相似度分数
  3. RAG 对话POST /rag)——检索 + 上下文拼接 + LLM 流式生成,同时返回引用来源

适用场景:公司内部知识库问答、产品文档助手、多租户知识检索。不适用于需要复杂权限控制、实时流式写入或跨Region低延迟的生产环境——这些需要额外的工程投入。

1.1 技术栈和前置条件

组件选型作用
运行时Cloudflare Workers边缘计算,零冷启动
向量数据库Cloudflare Vectorize托管向量索引,免运维
EmbeddingWorkers AI @cf/baai/bge-base-en-v1.5768 维,通用英文场景首选
LLMWorkers AI @cf/meta/llama-3.1-8b-instruct流式生成回答
HTTP 框架Hono类型安全、轻量

你需要:一个 Cloudflare 账号(免费版即可)、wrangler CLI(npm install -g wrangler)、Node.js 22+。

1.2 成本预估

Workers AI 免费版每天 10,000 次推理请求,Vectorize 免费版 100 个索引、每索引 1000 万向量。个人项目和中小应用在内测阶段基本零成本。超过日均 1 万次请求后需要升级到付费计划,按量计费。

2. 从 Embedding 到向量检索:核心机制

在动手写代码之前,需要搞清楚一个核心概念:Embedding 是把文字变成向量,向量检索是在高维空间里找最近的邻居

把一段文字喂给 Embedding 模型,它会返回一个固定长度的浮点数数组(比如 768 个数)。你可以把这个数组想象成文字在高维空间里的坐标——意思相近的文字,坐标距离也近

txt
"今天天气真好" → [0.12, -0.34, 0.88, ..., 0.05]     (768 个数)

"阳光很棒"      → [0.11, -0.32, 0.85, ..., 0.07]     (和上面距离很近)

"今天股票跌了" → [-0.45, 0.67, -0.12, ..., 0.33]    (和上面距离很远)

检索时,把用户问题也转成向量,在数据库里找「距离最近」的那几条就行。这就是 RAG 中「检索」这一步的全部机制。

2.1 为什么不能把整篇文档做成一个向量

一整篇几千字的文档直接算一个向量不行——太长的文本做 Embedding,语义会被"平均化",检索精度很差。就像把一本百科全书压缩成一个数字,你丢失了几乎所有细节。

正确做法是先分块(chunk),每段独立算向量。经验值:按 800~1200 字符切片,切片之间留 100~200 字符重叠(防止关键信息被切断)。太大的切片稀释语义,太小的切片丢失上下文。

2.2 Vectorize 的能力边界

Vectorize 是 Cloudflare 的托管向量数据库,关键限制如下:

数值
最大维度1536
每索引最大向量数10,000,000
每向量 metadata 上限10 KiB
账号索引数上限免费版 100 / 付费版 50,000
最大相似度返回数100(带 metadata 时 50)

这些限制对中小应用足够。如果你的向量维度超过 1536 或需要复杂的事务支持,Vectorize 不是合适的选择——考虑 Qdrant 或 Milvus。

3. 创建 Vectorize 索引

wrangler CLI 创建索引。维度必须和你用的 Embedding 模型匹配——bge-base-en-v1.5 输出 768 维,所以索引也设 768。

bash
# 用 cosine 相似度,768 维(对应 bge-base-en-v1.5)
npx wrangler vectorize create docs-index \
  --dimensions=768 \
  --metric=cosine

两个参数需要理解清楚:

  • dimensions:向量维度,必须和 Embedding 模型输出一致。bge-base-en-v1.5 是 768,OpenAI text-embedding-3-small 是 1536。维度不匹配会直接报错
  • metric:相似度算法。cosine(余弦)适合文本场景,euclidean(欧氏距离)适合低维空间,dot-product(点积)在向量已归一化时等价于 cosine

3.1 wrangler 配置绑定

wrangler.jsonc 中绑定 Vectorize 索引和 Workers AI:

jsonc
// wrangler.jsonc
{
  "vectorize": [
    {
      "binding": "DOCS_INDEX",
      "index_name": "docs-index"
    }
  ],
  "ai": {
    "binding": "AI"
  }
}

Workers AI 也一起绑定,因为后续要用它生成 Embedding 和调用 LLM。

3.2 Hono 类型声明

绑定完成后,在类型文件中声明 Env:

typescript
// src/types.ts
export type Bindings = {
  DOCS_INDEX: Vectorize  // Cloudflare Workers 内置的全局类型
  AI: Ai
}

export type AppEnv = {
  Bindings: Bindings
}

类型声明完成后,c.env.DOCS_INDEXc.env.AI 在路由中都有完整的类型提示。

4. 文档写入:文本 → 向量 → 入库

写入流程分三步:批量生成 Embedding → 组装 Vectorize 格式 → 批量 upsert。

typescript
// src/routes/ingest.ts
import { Hono } from 'hono'
import type { AppEnv } from '../types'

const ingest = new Hono<AppEnv>()

ingest.post('/ingest', async (c) => {
  const { docs } = await c.req.json<{
    docs: Array<{ id: string; text: string; source?: string }>
  }>()

  // 1. 批量生成 embedding(Workers AI 一次最多 100 段文本)
  //    返回 { data: [向量1, 向量2, ...] },每个向量是 float[]
  const texts = docs.map((d) => d.text)
  const { data: embeddings } = await c.env.AI.run('@cf/baai/bge-base-en-v1.5', {
    text: texts,
  })

  // 2. 组装成 Vectorize 的写入格式
  const vectors = docs.map((doc, i) => ({
    id: doc.id,
    values: embeddings[i],
    metadata: {
      text: doc.text,
      source: doc.source || 'unknown',
      insertedAt: Date.now(),
    },
  }))

  // 3. 批量写入
  const result = await c.env.DOCS_INDEX.upsert(vectors)

  return c.json({
    ingested: vectors.length,
    mutationId: result.mutationId,
  })
})

export default ingest

三步完成:文本 → 向量 → 写库

几个容易踩坑的点:

  • upsert vs insertupsert 在 id 已存在时会覆盖,insert 会报错。日常更新文档场景用 upsert 更稳
  • mutationId 是异步写入凭证:Vectorize 的写入是异步的,mutationId 可以用来追踪写入状态,真正可查通常需要几秒。实时性要求高的场景要注意这个延迟
  • metadata 里存原文:查询时需要返回原文作为上下文,把 text 存在 metadata 里可以避免再查一次数据库。但 metadata 单条上限 10 KiB,超长文本不要全塞进去

4.1 生产化注意事项

上面的写入接口是「示例可用」级别。要升级到「生产可用」,你还需要:

  • 输入校验:限制单条文本长度、校验 id 格式、拒绝空数组
  • 批量分批:Workers AI 单次最多 100 段文本,超过要分批调用
  • 错误重试:Embedding 生成可能超时,需要指数退避重试
  • 幂等写入:相同的文档 id 重复提交应该覆盖而不是追加
  • 请求鉴权:写入接口必须限制为管理员或内部服务调用

5. 相似度检索:问题向量化 → 最近邻查询

检索是写入的逆过程:把用户问题转成向量,在 Vectorize 中找距离最近的 topK 条。

typescript
// src/routes/search.ts
import { Hono } from 'hono'
import type { AppEnv } from '../types'

const search = new Hono<AppEnv>()

search.post('/search', async (c) => {
  const { query, topK = 5 } = await c.req.json<{
    query: string
    topK?: number
  }>()

  // 1. 把问题转成向量
  const { data: queryEmbeddings } = await c.env.AI.run(
    '@cf/baai/bge-base-en-v1.5',
    { text: [query] }
  )
  const queryVector = queryEmbeddings[0]

  // 2. 查最相似的 topK 条
  const result = await c.env.DOCS_INDEX.query(queryVector, {
    topK,
    returnMetadata: 'all',
  })

  return c.json({
    matches: result.matches.map((m) => ({
      id: m.id,
      score: m.score,       // 相似度分数,0~1 之间
      text: m.metadata?.text,
      source: m.metadata?.source,
    })),
  })
})

export default search

query() 的关键选项:

  • topK:返回条数。带 metadata 时上限 50 条。默认 5 是个合理的起点
  • returnMetadata: 'all':把写入时挂的 metadata 也带回来。不设的话结果只有 id 和 score
  • returnValues: true:把向量本身也返回——一般不需要,浪费带宽

预期输出类似:

json
{
  "matches": [
    { "id": "doc-1", "score": 0.92, "text": "报销流程需要...", "source": "internal-wiki" },
    { "id": "doc-3", "score": 0.87, "text": "差旅报销...", "source": "faq" }
  ]
}

score 越接近 1 表示越相似。通常 score > 0.8 的结果是高质量的,低于 0.6 的可能已经是噪声。

6. RAG 接口:检索 + 生成一步到位

把检索和 LLM 生成串起来,就是完整的 RAG。核心流程:问题向量化 → 检索 top-K → 拼接 Prompt → 流式生成

typescript
// src/routes/rag.ts
import { Hono } from 'hono'
import { streamSSE } from 'hono/streaming'
import type { AppEnv } from '../types'

const rag = new Hono<AppEnv>()

rag.post('/rag', async (c) => {
  const { question } = await c.req.json<{ question: string }>()

  // 1. 检索相关文档
  const { data: [queryVec] } = await c.env.AI.run(
    '@cf/baai/bge-base-en-v1.5',
    { text: [question] }
  )

  const { matches } = await c.env.DOCS_INDEX.query(queryVec, {
    topK: 3,
    returnMetadata: 'all',
  })

  // 2. 把检索到的片段拼进 prompt
  const context = matches
    .map((m, i) => `[${i + 1}] ${m.metadata?.text}`)
    .join('\n\n')

  const systemPrompt = `你是一个根据提供的上下文回答问题的助手。
只使用下面的上下文回答。如果上下文里没有答案,就回复"根据现有资料无法回答"。

上下文:
${context}`

  // 3. 调用大模型流式生成
  return streamSSE(c, async (stream) => {
    const llmStream = await c.env.AI.run(
      '@cf/meta/llama-3.1-8b-instruct',
      {
        messages: [
          { role: 'system', content: systemPrompt },
          { role: 'user', content: question },
        ],
        stream: true,
      }
    )

    const reader = (llmStream as ReadableStream).getReader()
    const decoder = new TextDecoder()

    while (true) {
      const { done, value } = await reader.read()
      if (done) break

      await stream.writeSSE({
        data: decoder.decode(value, { stream: true }),
        event: 'delta',
      })
    }

    // 把引用来源也发给前端,方便展示"回答依据"
    await stream.writeSSE({
      data: JSON.stringify(matches.map((m) => ({ id: m.id, score: m.score }))),
      event: 'sources',
    })
  })
})

export default rag

这段代码做了三件事:

  1. 检索:把问题向量化后查 Vectorize,拿回 top-3 相关文档
  2. 拼接 Prompt:系统指令 + 检索到的文档片段 + 用户问题。约束 LLM 只用提供的上下文回答
  3. 流式生成:通过 SSE 逐字推送 LLM 输出,最后发送引用来源

最后发送的 sources 事件让前端可以展示「回答依据」——用户不仅看到答案,还能看到答案来自哪些文档、相似度多少。

6.1 为什么用流式而不是同步

RAG 的完整链路(Embedding + 向量检索 + LLM 生成)可能需要 5-30 秒。同步 JSON 响应会让用户面对一个漫长的加载状态。

流式 SSE 的好处:用户先看到检索到的来源(通常 1-2 秒内),然后逐字看到回答生成。体感延迟从「等 30 秒」变成「1 秒后开始出字」

6.2 系统 Prompt 的设计

系统 Prompt 里有一句关键约束:「只使用下面的上下文回答。如果上下文里没有答案,就回复"根据现有资料无法回答"」。

这不是可选的装饰语句,而是 RAG 正确性的防线。没有这条约束,LLM 会用自己的训练数据回答用户问题,检索就成了摆设。这条指令把 LLM 限制在「基于证据回答」的模式,大幅降低幻觉风险。

7. Metadata 过滤:按条件缩小检索范围

真实场景里你不会只对「全库」做检索。按部门、按时间、按用户做过滤是基本需求。Vectorize 支持索引 metadata 字段,然后在查询时做条件过滤

7.1 创建 metadata 索引

bash
# 允许按 source 字段过滤
npx wrangler vectorize create-metadata-index docs-index \
  --property-name=source \
  --type=string

每个 Vectorize 索引最多 10 个 metadata 索引。字段类型支持 string / number / boolean

7.2 带过滤条件的查询

typescript
// src/routes/search.ts
const result = await c.env.DOCS_INDEX.query(queryVector, {
  topK: 5,
  returnMetadata: 'all',
  filter: {
    source: { $eq: 'internal-wiki' },
  },
})

过滤语法:$eq(等于)、$ne(不等于)、$in(在列表中)、$gt(大于)、$lt(小于)。多字段可以组合使用。

对多租户应用,最实用的用法是按 tenantId 过滤——一个索引承载所有租户的数据,查询时自动隔离:

typescript
const result = await c.env.DOCS_INDEX.query(queryVector, {
  topK: 5,
  returnMetadata: 'all',
  filter: {
    tenantId: { $eq: c.get('user').tenantId },
  },
})

8. Namespace:物理级数据隔离

如果数据之间需要物理隔离——比 metadata 过滤更强的安全保障——用 namespace。每条向量写入时指定一个 namespace 字符串,查询时带上同一个 namespace,不同 namespace 的数据彼此完全不可见。

typescript
// 写入时指定 namespace
await c.env.DOCS_INDEX.upsert(
  vectors.map(v => ({ ...v, namespace: 'tenant-42' }))
)

// 查询时限定 namespace
const result = await c.env.DOCS_INDEX.query(queryVec, {
  namespace: 'tenant-42',
  topK: 5,
})

8.1 Metadata 过滤 vs Namespace

维度Metadata 过滤Namespace
隔离方式查询时软过滤物理隔离
安全性依赖代码正确性——忘了加 filter 就泄漏即使代码 bug 也不会串数据
灵活性可以组合多字段、动态条件只能按单一维度隔离
适用场景按类型、时间、标签等软分类多租户 SaaS、合规要求严格的数据隔离

多租户 SaaS 优先用 namespace。即使代码里忘了加过滤条件,不同 namespace 的数据也不会串——这是安全兜底。Metadata 过滤更灵活,但安全性完全依赖开发者每次都写对了 filter。

9. Embedding 模型选型

模型选择直接决定检索质量。以下是 Workers AI 可用模型的对比:

模型维度适用场景
@cf/baai/bge-small-en-v1.5384轻量、便宜、英文为主
@cf/baai/bge-base-en-v1.5768通用首选,性价比最高
@cf/baai/bge-large-en-v1.51024精度要求高的场景
@cf/google/embeddinggemma-300m768Google 的新选择,多语言好
text-embedding-3-small(OpenAI)1536多语言通用、质量高,但需走第三方

要处理中文内容bge-m3(社区版)或 OpenAI text-embedding-3-large 更稳。Workers AI 自带的 bge 系列对中文也凑合,但不如中文专用模型。

选型决策条件:

  • 英文为主 + 成本敏感 → bge-base-en-v1.5(本篇选择)
  • 中文为主 → bge-m3 或 OpenAI text-embedding-3-large
  • 多语言混合 → embeddinggemma-300m 或 OpenAI
  • 换模型后必须重建索引——维度不同,旧索引和新向量不兼容

10. 部署验收与系列收尾

10.1 部署清单

把三个路由挂载到主应用后,部署和验收按以下步骤进行:

bash
# 1. 确认 Vectorize 索引已创建
npx wrangler vectorize list

# 2. 部署 Workers
npx wrangler deploy

# 3. 测试写入
curl -X POST https://your-worker.workers.dev/ingest \
  -H "Content-Type: application/json" \
  -d '{"docs": [{"id": "doc-1", "text": "Cloudflare Vectorize is a managed vector database", "source": "docs"}]}'
# 预期:返回 { "ingested": 1, "mutationId": "..." }

# 4. 测试检索
curl -X POST https://your-worker.workers.dev/search \
  -H "Content-Type: application/json" \
  -d '{"query": "vector database", "topK": 3}'
# 预期:返回 matches 数组,doc-1 应在第一位,score > 0.8

# 5. 测试 RAG 对话
curl -X POST https://your-worker.workers.dev/rag \
  -H "Content-Type: application/json" \
  -d '{"question": "What is Vectorize?"}'
# 预期:流式返回回答,最后发送 sources 事件

10.2 验收检查项

  • [ ] Vectorize 索引维度与 Embedding 模型输出维度匹配
  • [ ] 写入接口返回 mutationId,几秒后检索可以查到新数据
  • [ ] 检索接口返回的 score 合理(相关文档 > 0.8,无关文档 < 0.5)
  • [ ] RAG 接口流式输出正常,sources 事件包含引用信息
  • [ ] LLM 回答基于检索到的上下文,而非自由发挥
  • [ ] metadata 过滤和 namespace 隔离各测试通过

10.3 常见错误排查

错误原因修复
dimension mismatch索引维度与模型输出不一致检查模型维度和 --dimensions 参数
index not found索引未创建或 binding 名称错误wrangler vectorize list 确认索引存在
检索结果为空写入是异步的,还没可查等几秒后重试,或检查 mutationId
LLM 回答与文档无关系统 Prompt 缺少约束确保包含「只使用上下文回答」的指令
metadata 过滤不生效未创建 metadata 索引wrangler vectorize create-metadata-index

10.4 从示例到生产的差距

本篇搭建的是「示例可用」级别的 RAG 系统。要升级到「生产可用」,还需要:

  • 文档处理流水线:异步处理上传的 PDF/Word/HTML,自动切分和向量化
  • Rerank 重排序:检索后增加一层重排序模型,提升 top-K 的精度
  • 缓存层:高频查询的 Embedding 和检索结果缓存,减少重复计算
  • 监控和告警:追踪检索延迟、LLM 生成质量、空结果率
  • 评估体系:定期评估检索召回率和回答准确率,驱动分块策略和模型调优

这些主题在前面的章节中已经详细讨论过,你可以按需接入。

11. 系列回顾与下一步

至此,RAG 系列 19 讲完成了一条完整链路:

基本概念(01) → 文档解析(02) → 文本切分(03) → Embedding(04) → 向量数据库选型(05)
→ 相似度检索(06) → 混合检索(07) → Rerank(08) → 上下文拼接(09) → 引用来源(10)
→ 权限控制(11) → 评估指标(12) → 评估实践(13) → LLM 选型(14) → Prompt 工程(15)
→ 流式生成(16) → 缓存优化(17) → API 接口设计(18) → Vectorize 实战(19)

向量数据库本身不复杂——写入时把文本转成向量存进去,查询时把问题转成向量找最近的。搞定写入、查询、过滤这三件事,RAG 的核心就跑通了。

下一步方向

  • 如果你的检索准确率不满意,回到第 12-13 讲的评估体系,用数据驱动调优分块策略和 topK
  • 如果你需要处理中文内容,参考第 9 讲的模型选型,换成中文效果更好的 Embedding 模型
  • 如果你要做多租户隔离,根据第 8 节的对比选择 namespace 或 metadata 过滤
  • 如果检索质量到了瓶颈,考虑引入第 7 讲的混合检索和第 8 讲的 Rerank

RAG 没有通用最优配置。真正决定效果的是你对自己业务数据的理解——什么该检索、什么该过滤、topK 选多少、什么时候该承认"不知道"。这些只能在实践中迭代。

基于 MIT 协议开源