Skip to content

14.01-RAG基本概念

要点

  • Prompt 的上下文窗口有限,模型无法知道窗口之外的信息——RAG 解决的就是这个问题
  • 核心流程分两个阶段:索引(文档 → 切分 → 向量化 → 入库)和查询(问题 → 检索 → 拼接 → 生成)
  • 向量相似度检索是 RAG 的引擎:找到和用户问题语义最相关的文档片段
  • RAG 和微调是两种互补的知识注入方式,适用场景不同
  • 检索质量决定 RAG 的上限,上下文拼接决定模型能用到多少

1. 把所有文档塞进 Prompt,为什么行不通

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

最直接的想法:把所有文档塞进 Prompt,让模型回答。

typescript
// ❌ 把所有文档塞进 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. 索引阶段:把文档变成可检索的向量

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

3.1 文档解析

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

typescript
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,表格和图表也需要专门处理。

3.2 文本切分(Chunking)

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

typescript
const chunks = chunkText(document.content, {
  chunkSize: 1000,    // 每段 1000 字符
  overlap: 200,       // 段与段之间重叠 200 字符
})
// 一篇 5000 字的文档可能切成 6-8 段

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

3.3 向量化(Embedding)

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

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

Embedding 模型的选择很关键——它决定了向量空间里「什么算相似」。通用模型未必能捕捉你所在领域的语义差异。第 14.06 节讲模型选型。

3.4 存入向量数据库

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

typescript
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 生成回答。

4.1 问题向量化 + 相似度检索

和索引阶段用同一个 embedding 模型,把用户问题转成向量,然后在向量数据库中找最相似的 top-K 个 chunk。

typescript
const [queryVector] = await callEmbeddingAPI(userQuestion)

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

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

4.2 上下文拼接 + 模型生成

把检索到的 chunk 拼进 Prompt,发给模型。

typescript
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 },
]

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

拼接方式会影响模型的回答质量——上下文太长会分散注意力,排列不当会让模型找不到重点。第 14.14 节讲上下文拼接的优化策略。

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

typescript
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,
  })),
})

5. RAG vs 微调:两种知识注入方式的取舍

知识注入有两种主要方式。两者的核心区别不在技术实现,而在知识放在哪里、怎么更新、能不能溯源

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

5.1 什么时候选 RAG

满足以下任一条件,RAG 通常是更好的选择:

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

5.2 什么时候选微调

  • 需要模型学习特定的输出风格或格式
  • 知识相对稳定,不需要频繁更新
  • 不需要溯源,只要回答对就行
  • 任务非常专业,通用模型即使有上下文也做不好(比如特定领域的代码生成或医学诊断)

5.3 什么时候两者都用

很多实际场景会同时使用 RAG 和微调,各司其职:

  • 微调让模型学会「怎么回答」——风格、格式、领域术语
  • RAG 提供「回答什么」——具体事实、最新数据
typescript
const messages = [
  { role: 'system', content: ragContext },          // RAG:提供事实
  // 模型本身经过微调,知道怎么组织回答              // 微调:提供风格
  { role: 'user', content: userQuestion },
]

const response = await callModelAPI({
  model: 'my-fine-tuned-model',  // 微调过的模型
  messages,
})

6. 最小可运行 RAG 服务

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

typescript
// 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) {
    const chunks = chunkText(doc.content, { chunkSize: 1000, overlap: 200 })
    const embeddings = await callEmbeddingAPI(chunks.map((c) => c.text))

    await vectorDB.upsert(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,
      },
    })))
    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
  }>()

  const [queryVector] = await callEmbeddingAPI([question])

  const results = await vectorDB.query(queryVector, {
    topK,
    returnMetadata: 'all',
  })

  const context = results.matches
    .map((m, i) => `[${i + 1}] ${m.metadata.text}`)
    .join('\n\n')

  const systemPrompt = `你是一个根据提供的上下文回答问题的助手。

## 规则
- 只使用下面的上下文回答
- 如果上下文里没有答案,回答「根据现有资料无法回答」
- 回答时引用来源编号,如「根据文档 [1]」

## 上下文
${context}`

  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 系统的实际表现。

7.1 检索质量:RAG 的上限由检索决定

检索到的文档和问题不相关,模型回答就一定偏。检索质量直接决定 RAG 的上限,后面的所有优化都是在逼近这个上限。

常见的失败原因:

  • 切分策略不好——关键信息被切断,比如一个结论和它的前提分属不同 chunk
  • Embedding 模型不合适——通用模型没有捕捉到领域语义差异
  • 用户问题和文档的表述差异太大——「报销流程」vs「差旅费用审批步骤」,语义相近但表述不同

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

7.2 上下文利用:检索到了但模型没用好

检索到了相关文档,模型却没有用好。问题通常出在拼接环节:

  • 上下文太长,模型注意力分散,找不到重点
  • 排列顺序不当,最相关的内容没有放在最前面
  • Prompt 没有让模型理解「只基于上下文回答」的约束

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

7.3 幻觉:有上下文也可能编

即使给了上下文,模型仍然可能编造信息:

  • 上下文里没有答案,但模型硬凑了一个——这是最常见的幻觉来源
  • 上下文里有矛盾信息,模型选了错的那条

引用溯源是对抗幻觉的重要手段:让模型在回答中标注来源编号,回答完检查引用是否真实存在。第 14.15 节展开讲。

7.4 评估:没有指标就没有优化方向

RAG 的质量需要量化评估,否则优化靠感觉:

  • 检索准不准——Recall@K、MRR、NDCG
  • 回答对不对——忠实度、相关性、完整性

第 14.17 节讲 RAG 的评估指标。

7.5 本章在系列中的位置

第 13 章讲 Prompt 工程——怎么让模型在单次调用中更好地完成任务。但模型的知识范围受限于上下文窗口和训练数据。RAG 扩展了模型的知识来源——让它能基于外部知识库回答问题。两者配合:RAG 提供外部知识,Prompt 工程提供怎么用好这些知识。

本章建立了 RAG 的整体心智模型。后续的展开路线:

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,什么情况下不该用。

基于 MIT 协议开源