14.01-RAG基本概念

要点

  • 第 13 章讲完了 Prompt 工程——但 Prompt 的上下文窗口是有限的,模型无法知道窗口之外的信息
  • RAG(Retrieval-Augmented Generation)解决的是「让模型基于外部知识回答问题」
  • 核心流程:索引阶段(文档 → 切分 → 向量化 → 入库)+ 查询阶段(问题 → 检索 → 拼接 → 生成)
  • RAG 和微调是两种不同的知识注入方式,适用场景不同
  • 向量相似度检索是 RAG 的核心——找到和用户问题语义最相关的文档片段
  • 这一章从基本概念出发,逐层拆开每个环节的实现细节

1. 从一个真实问题说起

假设你在做一个「公司内部知识库问答」系统。公司有几万份文档——产品手册、技术规范、FAQ、会议纪要。员工想知道「报销流程是什么」「某个 API 的参数怎么填」,不想翻文档,希望直接问 AI 得到答案。

第一反应可能是:把所有文档塞进 Prompt 里,让模型回答。

// ❌ 把所有文档塞进 Prompt
const allDocs = await loadAllCompanyDocuments()  // 可能几百万字
const prompt = `
根据以下文档回答用户的问题。
 
${allDocs}
 
用户问题:${userQuestion}
`

这条路径走不通,原因有三个:

  1. 上下文窗口有限——GPT-4o 的上下文窗口是 128K token,大约相当于 10 万字。几万份文档根本塞不下
  2. 成本爆炸——即使塞得下,每次请求都发送几百万 token 的文档,成本会是天文数字
  3. 信噪比太低——模型需要在海量无关内容中找到相关信息,效果远不如只给它看相关的几段

更好的思路:先找到和用户问题最相关的那几段文档,只把这几段塞进 Prompt

这就是 RAG 的核心思想。

2. RAG 是什么

RAG 全称 Retrieval-Augmented Generation,检索增强生成。名字已经把流程说清楚了:

  1. Retrieval——从外部知识库中检索相关内容
  2. Augmented——用检索到的内容增强 Prompt
  3. Generation——让模型基于增强后的 Prompt 生成回答

和直接把所有文档塞进 Prompt 的区别:RAG 在中间加了一个「检索」步骤,每次只给模型看最相关的内容。

                 ┌──────────────────────────────┐
                 │      知识库(几万份文档)       │
                 └──────────────┬───────────────┘
                                │
                                │ 1. 问题向量化
                                │ 2. 相似度检索
                                ▼
                 ┌──────────────────────────────┐
                 │  检索到 top-K 个相关文档片段    │
                 └──────────────┬───────────────┘
                                │
                                │ 拼进 Prompt
                                ▼
                 ┌──────────────────────────────┐
                 │   增强的 Prompt =             │
                 │   系统指令 + 检索到的文档      │
                 │   + 用户问题                  │
                 └──────────────┬───────────────┘
                                │
                                │ 3. 模型生成回答
                                ▼
                 ┌──────────────────────────────┐
                 │        最终回答                │
                 └──────────────────────────────┘

这个流程分两个阶段:

索引阶段(离线):把知识库里的文档预处理成可检索的形式——切分、向量化、存入向量数据库。这一步只在文档新增或更新时执行。

查询阶段(在线):把用户问题向量化,在向量数据库中检索最相关的文档片段,拼进 Prompt 让模型生成回答。这一步每次用户提问都要执行。

3. 索引阶段:从文档到向量

索引阶段把一个文档变成「可被检索的向量」。流程分四步:

步骤一:文档解析

把各种格式的文档(PDF、Word、HTML、Markdown)转成纯文本。

// 这一步的输入输出
type ParsedDocument = {
  id: string
  title: string
  content: string       // 纯文本内容
  metadata: {
    source: string      // 来源:文件路径、URL
    format: 'pdf' | 'docx' | 'html' | 'markdown'
    createdAt: string
    author?: string
  }
}

不同格式的解析难度不同。纯文本和 Markdown 最简单;HTML 需要去掉标签保留内容;PDF 最复杂——扫描件要先 OCR,表格和图表的提取也需要专门处理。

步骤二:文本切分(Chunking)

一篇文档几千上万字,直接算一个向量效果不好——语义会被「平均化」。需要把文档切成小段(chunk),每段独立算向量。

// 切分策略示意
const chunks = chunkText(document.content, {
  chunkSize: 1000,    // 每段 1000 字符
  overlap: 200,       // 段与段之间重叠 200 字符
})
 
// 一篇 5000 字的文档可能切成 6-8 段
// 每段独立成为一个检索单元

切分策略直接影响检索质量。切太大,语义稀释;切太小,丢失上下文。第 14.05 节会展开讲各种切分策略。

步骤三:向量化(Embedding)

把每个 chunk 转成一个高维向量。这个向量捕捉了文本的语义信息——语义相近的文本,向量距离也近。

// 调用 embedding 模型
const embedding = await callEmbeddingAPI(chunk.text)
// 返回类似 [0.12, -0.34, 0.88, ..., 0.05] 的浮点数数组
// 维度通常是 768、1024 或 1536

Embedding 模型的选择很关键——它决定了向量空间里「什么算相似」。第 14.06 节会讲模型选型。

步骤四:存入向量数据库

把 chunk 的向量和元数据存入向量数据库。

// 写入向量数据库
await vectorDB.upsert({
  id: `${document.id}-chunk-${index}`,
  vector: embedding,
  metadata: {
    text: chunk.text,
    documentId: document.id,
    documentTitle: document.title,
    chunkIndex: index,
    source: document.metadata.source,
  },
})

向量数据库专门优化了向量相似度检索的性能。普通的 PostgreSQL 也能做(通过 pgvector 扩展),但数据量大时性能不如专用向量数据库。第 14.07-14.10 节会讲几种主流方案。

4. 查询阶段:从问题到回答

查询阶段是索引阶段的逆过程:把问题变成向量,检索最相似的 chunk,拼进 Prompt 生成回答。

步骤一:问题向量化

和索引阶段用同一个 embedding 模型,把用户问题转成向量。

const queryVector = await callEmbeddingAPI(userQuestion)

步骤二:相似度检索

在向量数据库中找和 queryVector 最相似的 top-K 个 chunk。

const results = await vectorDB.query(queryVector, {
  topK: 5,
  returnMetadata: 'all',
})
 
// results.matches 是 5 个最相似的 chunk
// 每个 match 包含 id、score(相似度分数)、metadata(原文、来源等)

相似度算法通常是余弦相似度(cosine similarity)——两个向量的夹角余弦值,范围 -1 到 1,越大越相似。

步骤三:拼接上下文

把检索到的 chunk 拼进 Prompt。

const context = results.matches
  .map((m, i) => `[${i + 1}] ${m.metadata.text}`)
  .join('\n\n')
 
const systemPrompt = `你是一个根据提供的上下文回答问题的助手。
 
只使用下面的上下文回答。如果上下文里没有答案,明确告知用户。
 
上下文:
${context}`
 
const messages = [
  { role: 'system', content: systemPrompt },
  { role: 'user', content: userQuestion },
]

拼接方式会影响模型的回答质量。第 14.14 节会讲上下文拼接的优化策略。

步骤四:模型生成

把拼好的 Prompt 发给模型,生成回答。

const response = await callModelAPI({
  model: 'gpt-4o',
  messages,
  temperature: 0,  // 基于事实的回答,降低随机性
})
 
const answer = response.choices[0].message.content

同时把检索到的来源也返回给前端,方便用户验证:

return c.json({
  answer,
  sources: results.matches.map((m) => ({
    documentId: m.metadata.documentId,
    documentTitle: m.metadata.documentTitle,
    chunkIndex: m.metadata.chunkIndex,
    score: m.score,
  })),
})

5. RAG vs 微调

知识注入有两种主要方式:RAG 和微调(Fine-tuning)。两者的区别:

维度RAG微调
知识来源外部知识库,运行时检索训练进模型权重
知识更新更新知识库即可,不需要重新训练需要重新训练,成本高
知识溯源可以追溯到具体文档片段无法溯源,模型「凭记忆」回答
适用知识类型事实性知识、最新信息、私有数据风格、语气、特定任务格式
成本每次查询需要检索 + 调用 embedding训练成本高,推理成本和普通模型一样
幻觉风险较低——基于检索到的事实回答较高——模型可能混淆训练数据

什么时候用 RAG

  • 知识会频繁更新(产品文档、技术规范)
  • 需要溯源——用户想知道「这个答案来自哪个文档」
  • 知识量大,无法全部放进上下文窗口
  • 多租户场景——不同用户看到不同知识库

什么时候用微调

  • 需要模型学习特定的输出风格或格式
  • 知识相对稳定,不需要频繁更新
  • 不需要溯源,只要回答对就行
  • 任务非常专业,通用模型即使有上下文也做不好

什么时候两者都用

很多实际场景会同时用 RAG 和微调:

  • 微调让模型学会「怎么回答」(风格、格式、领域术语)
  • RAG 提供「回答什么」(具体事实、最新数据)
// 同时使用 RAG 和微调
const messages = [
  { role: 'system', content: ragContext },          // RAG:提供事实
  // 模型本身经过微调,知道怎么组织回答          // 微调:提供风格
  { role: 'user', content: userQuestion },
]
 
const response = await callModelAPI({
  model: 'my-fine-tuned-model',  // 微调过的模型
  messages,
})

6. 一个最小可运行的 RAG 示例

把上面的流程串起来,写一个完整的 RAG 服务:

// src/services/rag/index.ts
import { Hono } from 'hono'
import { callEmbeddingAPI } from '@/services/embedding'
import { callModelAPI } from '@/services/llm'
import { vectorDB } from '@/services/vector-db'
 
const ragApp = new Hono()
 
// 索引接口:上传文档并建立索引
ragApp.post('/index', async (c) => {
  const { documents } = await c.req.json<{
    documents: Array<{ id: string; title: string; content: string; source: string }>
  }>()
 
  const indexed = []
 
  for (const doc of documents) {
    // 1. 切分
    const chunks = chunkText(doc.content, { chunkSize: 1000, overlap: 200 })
 
    // 2. 向量化(批量)
    const embeddings = await callEmbeddingAPI(chunks.map((c) => c.text))
 
    // 3. 写入向量数据库
    const vectors = chunks.map((chunk, i) => ({
      id: `${doc.id}-chunk-${i}`,
      vector: embeddings[i],
      metadata: {
        text: chunk.text,
        documentId: doc.id,
        documentTitle: doc.title,
        chunkIndex: i,
        source: doc.source,
      },
    }))
 
    await vectorDB.upsert(vectors)
    indexed.push({ documentId: doc.id, chunks: chunks.length })
  }
 
  return c.json({ indexed })
})
 
// 查询接口:检索 + 生成
ragApp.post('/query', async (c) => {
  const { question, topK = 5 } = await c.req.json<{
    question: string
    topK?: number
  }>()
 
  // 1. 问题向量化
  const [queryVector] = await callEmbeddingAPI([question])
 
  // 2. 检索
  const results = await vectorDB.query(queryVector, {
    topK,
    returnMetadata: 'all',
  })
 
  // 3. 拼接上下文
  const context = results.matches
    .map((m, i) => `[${i + 1}] ${m.metadata.text}`)
    .join('\n\n')
 
  const systemPrompt = `你是一个根据提供的上下文回答问题的助手。
 
## 规则
- 只使用下面的上下文回答
- 如果上下文里没有答案,回答「根据现有资料无法回答」
- 回答时引用来源编号,如「根据文档 [1]」
 
## 上下文
${context}`
 
  // 4. 生成回答
  const response = await callModelAPI({
    model: 'gpt-4o',
    messages: [
      { role: 'system', content: systemPrompt },
      { role: 'user', content: question },
    ],
    temperature: 0,
  })
 
  return c.json({
    answer: response.choices[0].message.content,
    sources: results.matches.map((m) => ({
      documentId: m.metadata.documentId,
      documentTitle: m.metadata.documentTitle,
      chunkIndex: m.metadata.chunkIndex,
      score: m.score,
    })),
  })
})
 
// 切分函数(简化版,第 14.05 节会展开)
function chunkText(text: string, options: { chunkSize: number; overlap: number }) {
  const chunks: Array<{ text: string; start: number }> = []
  let start = 0
  while (start < text.length) {
    const end = Math.min(start + options.chunkSize, text.length)
    chunks.push({ text: text.slice(start, end), start })
    start += options.chunkSize - options.overlap
  }
  return chunks
}
 
export default ragApp

这个示例覆盖了 RAG 的核心流程:

  • 索引:切分 → 向量化 → 写入
  • 查询:问题向量化 → 检索 → 拼接 → 生成

后续章节会逐层拆开每个环节,讨论优化策略。

7. RAG 的挑战

RAG 看起来简单,但做好不容易。几个常见的挑战:

挑战一:检索质量

如果检索到的文档和问题不相关,模型的回答就会偏。检索质量直接决定 RAG 的上限。

可能的原因:

  • 切分策略不好——关键信息被切断
  • Embedding 模型不合适——语义空间没有捕捉到领域知识
  • 用户问题的表述和文档的表述差异太大——语义匹配失败

第 14.11-14.13 节会讲怎么提升检索质量。

挑战二:上下文利用

检索到了相关文档,但模型没有用好。可能原因:

  • 拼接方式不好——模型找不到重点
  • 上下文太长——模型注意力分散
  • Prompt 设计不好——模型没有理解「只基于上下文回答」

第 14.14 节会讲上下文拼接的优化。

挑战三:幻觉问题

即使有上下文,模型仍然可能编造信息。可能的原因:

  • 上下文里没有答案,但模型硬凑了一个
  • 上下文里有矛盾信息,模型选了错的

第 14.15 节会讲怎么通过引用溯源降低幻觉风险。

挑战四:评估困难

RAG 的质量怎么评估?检索准不准、回答对不对,都需要量化指标。没有评估就没有优化方向。

第 14.17 节会讲 RAG 的评估指标。

8. 本章在整体中的位置

第 13 章讲 Prompt 工程——怎么让模型在单次调用中更好地完成任务。但模型的知识范围受限于上下文窗口和训练数据。

第 14 章的 RAG 扩展了模型的知识来源——让它能基于外部知识库回答问题。

两者配合:RAG 提供「外部知识」,Prompt 工程提供「怎么用好这些知识」。

本章的展开顺序:

01 基本概念(本篇) → RAG 是什么、流程、与微调的区别
02 适用场景 → 什么时候该用 RAG
03-06 索引链路 → 文档上传 → 解析 → 切分 → 向量化
07-10 向量数据库 → 选型 + 三种主流方案实践
11-13 检索优化 → 相似度检索 → 混合检索 → Rerank
14-16 查询链路 → 上下文拼接 → 引用返回 → 权限控制
17 评估 → 怎么度量 RAG 质量
18 接口设计 → 完整 API 设计
19 实战 → Cloudflare Vectorize 实现(已有内容)

从基本概念到工程实践,覆盖 RAG 的完整链路。

总结

回顾这一节的要点:

  • RAG 解决的是「让模型基于外部知识回答问题」
  • 核心流程:索引阶段(文档 → 切分 → 向量化 → 入库)+ 查询阶段(问题 → 检索 → 拼接 → 生成)
  • RAG 和微调是两种互补的知识注入方式
  • 向量相似度检索是 RAG 的核心——找到和用户问题语义最相关的文档片段
  • 主要挑战:检索质量、上下文利用、幻觉问题、评估困难
  • 第 13 章的 Prompt 工程 + 第 14 章的 RAG = 让模型既有知识又能用好知识

下一篇讲 RAG 的适用场景——什么情况下该用 RAG,什么情况下不该用。