第3章 检索增强生成 RAG

让模型"知道"你的资料:这是 RAG 一章要解决的核心问题。大模型再聪明,也不知道你公司的制度、你产品的文档、你私有的数据 - 它要么答不上来,要么一本正经地编。RAG(Retrieval-Augmented Generation,检索增强生成)的思路朴素而有效:回答之前,先去你的资料库里查一查。本章从"为什么"讲到"怎么落地":完整的索引-检索-生成流水线、chunk 切分与向量化、向量库选型、检索质量优化、生成模板,以及一套可量化的评估方法。学完本章,你就能搭出一个"看着资料说话"的知识库问答系统。

3.1 为什么需要 RAG

上一章我们把模型调通:给它问题,它就能回答。但当你把它用到真实业务时,第一个坑马上出现 - 模型的知识来自训练数据,而训练数据有截止日期。你问它"本公司年假几天""这台设备的保修流程",它要么说不知道,要么一本正经地编一个。后者更可怕:幻觉(Hallucination)。模型对不确定的问题,会给出流畅但错误的内容,因为它本质上是在"续写最像样的答案",而不是"查证之后再回答"。

问题的根源在于:你的私域资料(制度文档、产品手册、售后记录)从来没有进入过模型的训练语料,模型根本"不知道"这些信息存在。要让模型懂你的资料,有两个方向:一是微调,把知识"灌进"模型的参数里 - 成本高、周期长、更新慢;二是 RAG,把知识放在模型之外,回答之前先去查。RAG 的一句话总结:先查再答 - 问题到来时,先从你的资料库里检索出最相关的几段,连同问题一起交给模型,让模型"看着资料回答"。

图 3-1 纯对话 vs 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 节)。

注记

为什么拆成"离线 + 在线"
两者的成本与延迟要求完全不同:索引要处理全部文档,慢一点没关系;检索和生成发生在用户等待的几百毫秒里,必须快。工程上最重要的一个直觉:能预先算好的,一律提前算好

图 3-2 RAG 三步流水线:离线索引入库,在线检索与生成实时执行

把这三步写成代码,就是 RAG 的全部骨架(embedkbllm_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%),避免"答案恰好被切在边界上"而两头都搜不到。

图 3-3 chunk 切分示意:固定 300 token、重叠 50 token 的切法

比"切多长"更重要的是在哪切。最简单的是按固定 token 数硬切 - 实现容易,但可能在句子中间、标题和正文之间下刀。更好的做法是按结构切:先按标题/章节把文档分层,再对每层按段落切,保证一个 chunk 内部主题连贯。Markdown、HTML、PDF 的标题信息都能被解析器提取出来。

切分策略 优点 缺点 适用场景
固定长度(按 token 数硬切) 实现简单、速度快、长度均匀 可能在句子中间或主题中间下刀 纯文本、日志、快速原型
按段落 / 句子 语义基本完整、实现也简单 段落长短不一,长段会超限 文章、说明书、规范文档
按标题结构(Markdown / 大纲感知) 主题连贯、天然分层、可追溯章节 依赖文档自带结构,解析有成本 Markdown / HTML / 带目录的文档
语义切分(模型判断边界) 边界贴合语义,质量最高 慢、要额外模型、成本高 长文、知识密度高的精品资料
提示

类比:切菜
切得太碎,营养(上下文)流失,一夹就散;切得太整,一口塞不下(超出窗口),还得回锅。重叠区就像"接缝处多留的边",保证哪一刀下去都切不断关键信息。

重要

切分决定检索上限
检索器只能找回 chunk 里存在的信息 - 答案被切成两半,再好的向量也救不回来。所以切分是 RAG 里性价比最高的优化点:先保证"答案完整地待在某个 chunk 里",再谈检索算法。

3.4 向量化:Embedding 把文字变成坐标

chunk 准备好了,怎么让计算机"理解"语义相似?靠 Embedding。Embedding 模型把一段文字映射成一个固定长度的向量 - 一串浮点数,比如 768 维、1536 维。第 1 章提过,向量就是"有方向的箭头",点积衡量两个箭头同向的程度。Embedding 的核心性质:语义相近的文本,它们的向量方向也相近(余弦相似度高);主题无关的文本,向量方向差得很远。于是"检索"的本质变成:把问题也转成向量,在向量空间里找距离最近的那些 chunk 向量。词 → 向量 → 语义相近距离近,这就是整个 RAG 的基石。

图 3-4 Embedding 向量示意:语义相近的文本映射到相近的坐标

\[ \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),把"问题 + 候选文档"拼在一起整体编码打分,比向量检索用的双塔式更精细,但慢一到两个数量级,所以只对粗筛后的少量候选精排。

③ 查询改写:把问题变清晰

问题太模糊、太短时,先用大模型把问题改写/扩展成更利于检索的形态,甚至生成多路查询分别检索再合并。比如"年假"→"年假 规定 天数 申请 条件"。

图 3-5 混合检索与重排:BM25 与向量两路并行,RRF 合并后再精排

# 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 + 向量)、重排(交叉编码器)、查询改写;
  • 模板四要素:角色、资料、问题、规则,加上"不知道就说不知道"与引用标注;
  • 三个指标:检索命中率、生成忠实度、答案有用性 - 先有测试集,再谈优化。
警告

衔接 · 下一站
第 5 章 Agent 会把检索封装成"工具",让模型自己决定何时查知识库(工具调用);第 6 章实战项目会用完整的 RAG 流程搭建公司知识库问答系统,把本章的索引、检索、模板与评估串成一条生产链路。前置课第 1 章的向量与点积,是本章 Embedding 与余弦相似度的数学底子;第 7 章会用 LLM-as-a-Judge 把这套评估体系自动化。