主题
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 系统。
最终交付的系统包含三个接口:
- 文档写入(
POST /ingest)——接收文本,生成 Embedding,批量写入 Vectorize - 相似度检索(
POST /search)——接收查询,返回最相关的文档片段和相似度分数 - RAG 对话(
POST /rag)——检索 + 上下文拼接 + LLM 流式生成,同时返回引用来源
适用场景:公司内部知识库问答、产品文档助手、多租户知识检索。不适用于需要复杂权限控制、实时流式写入或跨Region低延迟的生产环境——这些需要额外的工程投入。
1.1 技术栈和前置条件
| 组件 | 选型 | 作用 |
|---|---|---|
| 运行时 | Cloudflare Workers | 边缘计算,零冷启动 |
| 向量数据库 | Cloudflare Vectorize | 托管向量索引,免运维 |
| Embedding | Workers AI @cf/baai/bge-base-en-v1.5 | 768 维,通用英文场景首选 |
| LLM | Workers 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,OpenAItext-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_INDEX 和 c.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三步完成:文本 → 向量 → 写库。
几个容易踩坑的点:
upsertvsinsert:upsert在 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 searchquery() 的关键选项:
- topK:返回条数。带 metadata 时上限 50 条。默认 5 是个合理的起点
returnMetadata: 'all':把写入时挂的 metadata 也带回来。不设的话结果只有 id 和 scorereturnValues: 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这段代码做了三件事:
- 检索:把问题向量化后查 Vectorize,拿回 top-3 相关文档
- 拼接 Prompt:系统指令 + 检索到的文档片段 + 用户问题。约束 LLM 只用提供的上下文回答
- 流式生成:通过 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.5 | 384 | 轻量、便宜、英文为主 |
@cf/baai/bge-base-en-v1.5 | 768 | 通用首选,性价比最高 |
@cf/baai/bge-large-en-v1.5 | 1024 | 精度要求高的场景 |
@cf/google/embeddinggemma-300m | 768 | Google 的新选择,多语言好 |
text-embedding-3-small(OpenAI) | 1536 | 多语言通用、质量高,但需走第三方 |
要处理中文内容,bge-m3(社区版)或 OpenAI text-embedding-3-large 更稳。Workers AI 自带的 bge 系列对中文也凑合,但不如中文专用模型。
选型决策条件:
- 英文为主 + 成本敏感 →
bge-base-en-v1.5(本篇选择) - 中文为主 →
bge-m3或 OpenAItext-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 选多少、什么时候该承认"不知道"。这些只能在实践中迭代。