第3章 检索增强生成 RAG
让模型"知道"你的资料:这是 RAG 一章要解决的核心问题。大模型再聪明,也不知道你公司的制度、你产品的文档、你私有的数据 - 它要么答不上来,要么一本正经地编。RAG(Retrieval-Augmented Generation,检索增强生成)的思路朴素而有效:回答之前,先去你的资料库里查一查。本章从"为什么"讲到"怎么落地":完整的索引-检索-生成流水线、chunk 切分与向量化、向量库选型、检索质量优化、生成模板,以及一套可量化的评估方法。学完本章,你就能搭出一个"看着资料说话"的知识库问答系统。
3.1 为什么需要 RAG
上一章我们把模型调通:给它问题,它就能回答。但当你把它用到真实业务时,第一个坑马上出现 - 模型的知识来自训练数据,而训练数据有截止日期。你问它"本公司年假几天""这台设备的保修流程",它要么说不知道,要么一本正经地编一个。后者更可怕:幻觉(Hallucination)。模型对不确定的问题,会给出流畅但错误的内容,因为它本质上是在"续写最像样的答案",而不是"查证之后再回答"。
问题的根源在于:你的私域资料(制度文档、产品手册、售后记录)从来没有进入过模型的训练语料,模型根本"不知道"这些信息存在。要让模型懂你的资料,有两个方向:一是微调,把知识"灌进"模型的参数里 - 成本高、周期长、更新慢;二是 RAG,把知识放在模型之外,回答之前先去查。RAG 的一句话总结:先查再答 - 问题到来时,先从你的资料库里检索出最相关的几段,连同问题一起交给模型,让模型"看着资料回答"。
RAG 的本质
RAG 不改变模型本身,而是改变"喂给模型什么"。知识放在外部存储(可随时更新),模型只负责"阅读理解 + 组织语言"。资料一更新,答案立即跟上 - 不需要重新训练,这是它比微调更"轻"的根本原因。
类比:开卷考试
纯对话像闭卷考试:全凭脑子里的记忆,没学过的就瞎编;RAG 像开卷考试:先翻书(检索),找到对应章节(top-k),再照着书答(生成)。开卷考永远不怕题超纲 - 只要书里有。
3.2 RAG 全流程:索引、检索、生成
RAG 系统拆开看,是一条清晰的三步流水线:索引、检索、生成。前两步解决"找到资料",第三步解决"用好资料"。
① 离线索引(Indexing):把资料库变成可检索的形态
原始文档(PDF、Word、网页)先加载、清洗,再切分成大小合适的 chunk,每段文本用 Embedding 模型转成向量,连同原文和来源信息一起写入向量数据库。这一步是离线的:只做一次,资料更新时增量重做。慢一点没关系,因为它不发生在用户等待的路径上。
② 在线检索(Retrieval):把问题变成查询
用户提问到达时,把问题用同一个 Embedding 模型转成向量,在向量库里找与它最相似的 top-k 个 chunk(相似度怎么算,见 3.4 节)。
③ 生成(Generation):让模型看着资料说话
把检索到的资料 + 用户问题 + 输出指令组装成 Prompt,交给大模型生成最终回答,并要求它只依据资料作答、标注引用(模板见 3.7 节)。
为什么拆成"离线 + 在线"
两者的成本与延迟要求完全不同:索引要处理全部文档,慢一点没关系;检索和生成发生在用户等待的几百毫秒里,必须快。工程上最重要的一个直觉:能预先算好的,一律提前算好。
把这三步写成代码,就是 RAG 的全部骨架(embed、kb、llm_complete 三个函数分别来自 3.4、3.5、第 2 章):
# RAG 主流程:检索 → 组装 → 生成(3.2 节总览,细节见后文)
def rag_answer(question, top_k=3):
# 1) 检索:问题向量化,去向量库找最相似的 chunk
q_vec = embed([question])[0] # 同一个 Embedding 模型
hits = kb.query(query_embeddings=[q_vec], n_results=top_k)
# 2) 组装:资料 + 问题 + 指令,拼成一段 Prompt
context = "\n\n".join(
f"[{i+1}] {doc}" for i, doc in enumerate(hits["documents"][0]))
prompt = RAG_PROMPT.format(context=context, question=question)
# 3) 生成:把 Prompt 交给大模型,流式返回答案
return llm_complete(prompt) # 见第 2 章 llm_complete
一句话记住 RAG
索引把资料变成"可查的",检索把"最相关的"捞出来,生成让模型"看着资料说话"。三者缺一,都不叫 RAG。
3.3 文档加载与切分:chunk 的艺术
资料进入系统后第一件事是加载与清洗:PDF 要抽文本(版面、表格可能丢失)、Word/HTML 要剥掉样式、网页要去掉导航与广告。清洗完的是一大段连续文本 - 它不能直接进向量库,因为检索的最小单位是 chunk,一个 chunk 就是一个"候选答案片段"。整份文档作为一个向量,检索时"一查就是整本书",毫无区分度。
切分要决定两件事:chunk 的大小与边界。
- 大小:常见 256–512 token。太小,单个 chunk 承载的上下文不足,答案信息被拦腰截断;太大,一个 chunk 里混入大量无关内容,向量被"稀释",相似度区分度下降,还会挤占模型的上下文窗口。
- 重叠(overlap):相邻 chunk 让出一段公共内容(约 10–20%),避免"答案恰好被切在边界上"而两头都搜不到。
比"切多长"更重要的是在哪切。最简单的是按固定 token 数硬切 - 实现容易,但可能在句子中间、标题和正文之间下刀。更好的做法是按结构切:先按标题/章节把文档分层,再对每层按段落切,保证一个 chunk 内部主题连贯。Markdown、HTML、PDF 的标题信息都能被解析器提取出来。
| 切分策略 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 固定长度(按 token 数硬切) | 实现简单、速度快、长度均匀 | 可能在句子中间或主题中间下刀 | 纯文本、日志、快速原型 |
| 按段落 / 句子 | 语义基本完整、实现也简单 | 段落长短不一,长段会超限 | 文章、说明书、规范文档 |
| 按标题结构(Markdown / 大纲感知) | 主题连贯、天然分层、可追溯章节 | 依赖文档自带结构,解析有成本 | Markdown / HTML / 带目录的文档 |
| 语义切分(模型判断边界) | 边界贴合语义,质量最高 | 慢、要额外模型、成本高 | 长文、知识密度高的精品资料 |
类比:切菜
切得太碎,营养(上下文)流失,一夹就散;切得太整,一口塞不下(超出窗口),还得回锅。重叠区就像"接缝处多留的边",保证哪一刀下去都切不断关键信息。
切分决定检索上限
检索器只能找回 chunk 里存在的信息 - 答案被切成两半,再好的向量也救不回来。所以切分是 RAG 里性价比最高的优化点:先保证"答案完整地待在某个 chunk 里",再谈检索算法。
3.4 向量化:Embedding 把文字变成坐标
chunk 准备好了,怎么让计算机"理解"语义相似?靠 Embedding。Embedding 模型把一段文字映射成一个固定长度的向量 - 一串浮点数,比如 768 维、1536 维。第 1 章提过,向量就是"有方向的箭头",点积衡量两个箭头同向的程度。Embedding 的核心性质:语义相近的文本,它们的向量方向也相近(余弦相似度高);主题无关的文本,向量方向差得很远。于是"检索"的本质变成:把问题也转成向量,在向量空间里找距离最近的那些 chunk 向量。词 → 向量 → 语义相近距离近,这就是整个 RAG 的基石。
\[ \cos(A,B)=\frac{A\cdot B}{\lVert A\rVert\,\lVert B\rVert}\in[-1,1] \]
选 Embedding 模型看三点。一是语言:中文场景优先选中文友好的模型,如 BGE 系列(bge-small-zh / bge-large-zh)、通义 text-embedding-v3、M3E;英文或中英混合可用 OpenAI 的 text-embedding-3-small / 3-large。二是维度:通常 768–3072 之间。维度高、表达能力强,但占存储、算得慢;小模型(如 bge-small-zh,512 维)对多数业务已经够用。三是一致性:索引和查询必须用同一个模型,且模型版本变了要重建索引 - 两个模型产出的向量不在同一个坐标系里,比不了相似度。
# 用 OpenAI 兼容接口生成向量(text-embedding-3-small 为例)
from openai import OpenAI
client = OpenAI() # 自动读取环境变量中的 API Key
def embed(texts):
"""把一组文本转成向量,返回顺序与输入一致。"""
resp = client.embeddings.create(
model="text-embedding-3-small", # 1536 维
input=texts,
)
return [item.embedding for item in resp.data]
v = embed(["深度学习是机器学习的一个分支"])[0]
print(len(v)) # 1536
print(v[:3]) # 示例:[0.0123, -0.0456, 0.0789, ...]
一条铁律
换 Embedding 模型 = 重建整个索引。索引时用 bge-large-zh、查询时换成了 text-embedding-3,两套向量坐标系不同,检索结果会莫名其妙地变差 - 先查日志,多半是这里。
3.5 向量数据库:存与查
向量算出来了,存哪?向量数据库。它干两件事:存(向量 + 原文 + 元数据)和查(给定查询向量,快速返回最相似的 top-k)。"快"从哪来?全库精确比对在数据量大时太慢,向量库普遍用 ANN(近似最近邻)索引,如 HNSW、IVF - 用极小的精度损失换几个数量级的速度提升,业务上几乎无感。查询时还有个细节:很多库返回的是"距离"(越小越近,如 L2)或"余弦相似度"(越大越近),排序前先归一化,别搞反。
| 方案 | 定位 | 适合规模 | 注意点 |
|---|---|---|---|
| Chroma | 本地嵌入式向量库 | 原型、单机万级 chunk | 零配置,pip 即用,自动持久化到目录 |
| FAISS | 高性能 ANN 索引库 | 单机百万级向量 | 不是完整数据库,持久化与过滤要自己管 |
| Milvus | 分布式向量数据库 | 千万级、多租户生产环境 | 部署与运维成本高,规模到了再上 |
| Qdrant | 独立向量数据库 | 生产级、过滤条件丰富的场景 | 可用 Docker 单机起步,再横向扩展 |
| pgvector | PostgreSQL 扩展 | 已有 PG 的系统 | 复用现有数据库,少一套组件,性能适中 |
# Chroma 本地起步:零配置,数据持久化到 ./kb 目录
import chromadb
client = chromadb.PersistentClient(path="./kb")
col = client.get_or_create_collection("docs") # 一个集合 = 一个知识库
# 1) 存:chunk 原文、来源、向量一起写入
col.add(
ids=[f"doc-{i}" for i in range(len(chunks))],
documents=chunks, # 原文,生成时用来引用
metadatas=[{"source": s} for s in sources], # 来源等元数据
embeddings=vectors, # 3.4 节算好的向量
)
# 2) 查:问题用同一个模型向量化,取 top-3
q_vec = embed(["公司年假制度是什么"])[0]
hits = col.query(query_embeddings=[q_vec], n_results=3)
for doc, meta, dist in zip(hits["documents"][0],
hits["metadatas"][0],
hits["distances"][0]):
print(f"{dist:.3f} | {meta['source']} | {doc[:40]}")
起步建议
别一上来就上 Milvus:数据量在十万 chunk 以内,Chroma / FAISS 完全够用。先跑通,再谈规模 - 向量库选型最怕的不是选错,而是为不存在的规模提前付出运维成本。
3.6 检索质量优化:混合检索与重排
向量检索按"语义"找资料,但它有一个盲区:精确的术语、编号、人名、缩写。问"HR-2024-031 号文件的处理流程",语义相近的句子未必包含这些字符,向量检索可能漏掉;反过来,包含这些精确字符的文档,BM25 这类关键词检索一查一个准。而 BM25 的短板是:换个说法就搜不到 - "年假怎么请"和"休假申请流程"字面不重叠,语义却相同。于是有了混合检索:两路并行,各取所长。
① 混合检索:BM25 关键词 + 向量语义,两路并行
BM25 路负责精确匹配术语、编号;向量路负责抓住同义改写、长句意图。两路结果合并,常用 RRF(倒数排名融合):对每个候选文档,把它在每路结果中的 1/(k+rank) 求和,再排序。
② 重排:交叉编码器精排
合并后的候选集可能有几十条,但最后只喂给模型三五条 - 中间加一道 Rerank。重排用交叉编码器(cross-encoder),把"问题 + 候选文档"拼在一起整体编码打分,比向量检索用的双塔式更精细,但慢一到两个数量级,所以只对粗筛后的少量候选精排。
③ 查询改写:把问题变清晰
问题太模糊、太短时,先用大模型把问题改写/扩展成更利于检索的形态,甚至生成多路查询分别检索再合并。比如"年假"→"年假 规定 天数 申请 条件"。
# RRF:把两路检索结果合并成一个候选排序
def rrf_merge(bm25_hits, vec_hits, k=60):
scores = {}
for rank, doc_id in enumerate(bm25_hits): # 关键词路
scores[doc_id] = scores.get(doc_id, 0) + 1.0 / (k + rank)
for rank, doc_id in enumerate(vec_hits): # 语义路
scores[doc_id] = scores.get(doc_id, 0) + 1.0 / (k + rank)
return sorted(scores, key=scores.get, reverse=True) # 融合后按分排序
检索质量三件套
混合检索(召回更全)、重排(排序更准)、查询改写(问题更清楚)。按顺序做:先混合检索,效果不够再加重排,最后再考虑改写。还有一条黄金法则:把优化放在检索侧,而不是生成侧 - 生成模型再强,也答不好"没检索到"的问题。
3.7 生成优化:模板、引用与风格
资料到位,最后一公里是生成。同样的资料,Prompt 写法不同,答案质量天差地别。生成阶段的模板要回答四个问题:你是谁(角色)、你能用什么(资料)、你要答什么(问题)、你怎么答(规则)。
- 角色与任务:"你是公司知识库助手",让模型进入状态;
- 资料区:把 top-k 个 chunk 编号后整齐放入,与问题用分隔符明确隔开,并声明"仅依据以下资料";
- 输出规则:只依据资料回答;资料里没有就明说"资料中未找到",不要编造;需要时标注引用 [1][2];控制风格(简洁/详细、是否用列表);
- 兜底条款:"如果资料与问题无关,请直接说明",防止模型硬答。
# 生成阶段 Prompt 模板:资料 + 问题 + 规则
RAG_PROMPT = """你是一名严谨的知识库助手。请只依据下面提供的资料回答问题,不要使用资料之外的知识。
【资料】
{context}
【问题】
{question}
【规则】
1. 只依据【资料】回答,不得编造;
2. 资料中没有答案时,直接回答"资料中未找到相关信息";
3. 回答时在相关句子末尾标注来源编号,如 [1]、[2];
4. 语言简洁,先给结论,再给依据。"""
引用(Citation)是生产环境的刚需:回答里带 [1] 上标,用户点开能看到原文出处,既增加可信度,也便于事后审计 - "模型这句话是哪来的"。风格控制也不是玄学:要详细就要求"先给结论,再给理由";要表格就指定"用 Markdown 表格";要口语就声明"避免书面语"。规则写得越具体,输出越稳定。
资料不是越多越好
上下文窗口有限,塞满无关资料反而稀释模型注意力。top_k 取 3–5,配合 3.6 节的重排,比盲目多塞资料更有效。资料区与问题之间要有清晰的分隔符(如【资料】【问题】),模型才能分清"该读什么、该答什么"。
3.8 评估 RAG:三个关键指标
搭好一个 RAG 系统只完成了一半,另一半是:怎么知道它好不好?没有评估的 RAG 是盲人摸象 - 感觉"好像还行",却说不清哪里不行,改起来全靠猜。评估要把系统拆成两段分别看:检索段(有没有找到对的资料)和生成段(有没有把资料用好)。
- 检索命中率(Recall@k / Hit Rate):对每个测试问题,正确答案所在的 chunk 是否出现在检索返回的 top-k 里。命中率低 → 问题出在索引与检索侧(切分、embedding、检索策略),回查 3.3–3.6 节。
- 生成忠实度(Faithfulness / Groundedness):回答中的每句话是否都能在检索到的资料里找到依据。忠实度低 → 模型在自由发挥,查模板的"只依据资料"约束是否够硬。
- 答案有用性(Usefulness / Correctness):综合正确性、完整性、相关性,回答到底有没有解决用户的问题。有用性低但前两项正常 → 问题可能出在 prompt 风格或 top_k 数量。
| 指标 | 衡量什么 | 怎么测 | 低了查哪里 |
|---|---|---|---|
| 检索命中率 Recall@k | 正确答案是否在 top-k 里 | 测试集"问题 ↔︎ 期望 chunk"比对 | 切分、Embedding、检索策略(3.3–3.6) |
| 生成忠实度 Faithfulness | 回答每句是否有资料依据 | LLM 判分 / 人工抽检 | 模板约束、引用规则(3.7) |
| 答案有用性 Usefulness | 是否真正解决了用户问题 | 人工评分 / LLM-as-a-Judge | Prompt 风格、top_k、上下文组织 |
评估数据的准备:离线造一个测试集 - 几十到几百条"问题 → 参考答案 → 所在 chunk"三元组。用 LLM 当裁判(LLM-as-a-Judge,第 7 章展开)可以批量打分,但先人工抽检 50 条校准口径。工程节奏:先保证命中率 ≥ 80%,再优化忠实度,最后调有用性。指标要盯趋势,不要盯单次结果 - 同一测试集上反复跑,改一处看一处变化。
先有测试集,再谈优化
没有评估集,所有"优化"都是玄学。花半天搭一个 100 条左右的测试集,是整个 RAG 项目性价比最高的一笔投入 - 之后每改一处,都能用数字回答"变好了还是变差了"。
评估不是上线的最后一步,而是优化的第一步。每一个"感觉不对"的地方,都要先变成一条能复现、能计数的失败用例。
本章要点
- RAG = 先查再答:知识放在外部存储,模型只做"阅读理解",解决私域知识与幻觉问题;
- 三步流水线:离线索引(切分 → 向量化 → 入库)、在线检索(问题向量化 → top-k)、生成(资料 + 问题 → 模型);
- 切分决定检索上限:256–512 token、10–20% 重叠、优先按标题结构切;
- Embedding 把文字变坐标:语义相近距离近;索引与查询必须用同一模型;
- 向量库按规模选型:Chroma / FAISS 起步,Milvus / Qdrant 上生产;
- 检索质量三件套:混合检索(BM25 + 向量)、重排(交叉编码器)、查询改写;
- 模板四要素:角色、资料、问题、规则,加上"不知道就说不知道"与引用标注;
- 三个指标:检索命中率、生成忠实度、答案有用性 - 先有测试集,再谈优化。




