跳转至

第五章 RAG 基础:让生成建立在可追溯证据上

检索增强生成(RAG)把参数化语言模型与外部知识库组合起来。它适合知识频繁变化、需要引用来源或包含私有领域文档的任务。RAG 不能保证绝对真实:检索可能漏掉证据,文档可能过期,模型也可能无视证据。一个合格系统必须同时评估检索与生成。

朴素 RAG 流程

图 5-1 离线阶段解析、切块、向量化和建索引;在线阶段查询、检索、组装上下文并生成。

5.1 为什么不把所有知识都微调进去

先建立直觉。 微调更擅长改变行为和风格,RAG 更擅长提供可更新、可引用的外部事实。两者可以组合,但不能用微调代替权限、版本和证据管理。 读这一节时,先不要急着记缩写,而要不断追问:输入是什么,经过了哪些可观察变换,输出又怎样被验证。

把过程拆开看:

  1. 判断知识是否频繁变化。
  2. 判断是否要求引用。
  3. 判断行为是否需要稳定塑形。
  4. 评估延迟、成本与维护。

最小例子。 企业制度每月更新且回答必须给出处,优先 RAG;固定输出格式和专业语气可用 SFT;两者一起使用时仍以检索证据为事实来源。 例子越小,越容易手算、打印中间量并判断实现是否偏离定义。

容易踩坑。 把训练数据当数据库、认为 RAG 能保证答案正确、把整篇文档直接塞入上下文,都会造成错误。 排错时应保存失败输入和版本,先定位最早出现异常的步骤,再决定是否调整模型或超参数。

动手任务:列出三个业务场景,分别选择 RAG、微调或组合方案,并给出证据。 完成后不要只保存最终输出,还要保存代码、配置、中间张量或检索结果,以及你对结果的解释。

微调擅长改变行为、风格和任务模式,但不适合频繁更新事实,也很难给出精确来源。RAG 可以更新索引而不重训模型,并把证据随请求传入。两者并非互斥:可用 SFT 改善指令遵循,用 RAG 提供动态知识。

原始 RAG 论文把参数记忆与非参数记忆结合。工程系统通常不是端到端训练的单一模型,而是文档管道、检索器、重排器、提示模板、生成模型和评估系统的组合。

5.2 文档解析与切块

从文档页面到可引用证据

图 05-2 RAG 的质量上限常在解析和切块阶段就已经决定。 从问题出发。 解析决定系统实际上看到了什么,切块决定检索的最小证据单位。页面、标题、表格、列表和来源信息应尽量保留,而不是只提取一串纯文本。 先把名词放到一边,沿着输入、状态变化和输出走一遍;只有能指出证据落在哪一步,概念才算真正掌握。

沿数据流逐步检查:

  1. 识别格式与版面。
  2. 清洗页眉页脚和乱码。
  3. 按语义结构切块。
  4. 附加来源、页码和层级元数据。

用小数据走一遍。 一个表格若按行打散而丢失列名,检索到‘30%’也无法知道它对应哪个指标;应把表头与行内容共同编码。 先在纸面预测结果,再让程序打印中间状态;预测与运行不一致的地方,正是需要继续追查的知识缺口。

这里最容易出现的误解。 固定字符切割截断句子、overlap 过大制造重复、扫描 PDF 不做 OCR 质量检查、元数据不随块保存,都会降低召回与引用。 出现异常时先冻结样本、配置和环境,从最靠近输入的环节开始验证;不要同时更换模型、数据和超参数。

小实验:对同一篇含标题和表格的文档实现三种切块,人工比较十个问题的可检索证据。 提交物应包含预期、运行证据和失败记录;只有别人能按同样步骤复现,结果才具有学习价值。

文档进入索引前需要保留结构:标题层级、页码、段落、表格、图片说明和来源。只抽取纯文本会丢失表头、跨页关系和引用位置。每个 chunk 至少应带 doc_id、版本、标题路径、页码、时间、权限和内容哈希。

固定字符切块简单但可能截断语义;递归切块按段落、句子逐级回退;语义切块根据相邻句向量变化分段;命题切块把复合句拆成原子事实。chunk 越小越容易精确命中,却可能缺背景;越大上下文完整,却降低检索分辨率并占用窗口。

重叠能缓解边界截断,但会增加索引和重复召回。合理做法是先按文档结构切,再用小规模标注查询搜索 chunk 大小与重叠,而不是照抄固定参数。

def recursive_chunks(text: str, max_chars=500, overlap=80):
    paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()]
    chunks, current = [], ""
    for paragraph in paragraphs:
        candidate = f"{current}\n\n{paragraph}".strip()
        if len(candidate) <= max_chars:
            current = candidate
            continue
        if current:
            chunks.append(current)
        # 超长段落退回滑动窗口;生产版本应优先按句子或 token 切分
        if len(paragraph) > max_chars:
            step = max_chars - overlap
            chunks.extend(paragraph[i:i + max_chars]
                          for i in range(0, len(paragraph), step))
            current = ""
        else:
            current = paragraph
    if current:
        chunks.append(current)
    return chunks

5.3 Embedding 与相似度

先看它解决什么。 Embedding 把文本映射到向量空间,相似度只表示模型学习到的语义接近,不等于事实蕴含或答案正确。向量是否归一化决定点积与余弦的关系。 理解的标准不是会复述定义,而是能画出数据流、预测中间结果,并设计一个让错误暴露出来的检查。

可以把实现分成以下环节:

  1. 用同一模型编码查询和文档。
  2. 按模型要求做归一化。
  3. 建立近似最近邻索引。
  4. 返回分数与元数据。

一个可以手算的例子。 归一化后点积等于余弦相似度;未归一化时向量范数会影响排序。不同模型的向量维度和空间不能直接混用。 这里故意不用大模型或大数据,因为小输入能把每个轴、分数和状态变化完整暴露出来。

排错时先看这些地方。 只因维度更大就认为模型更好、跨语言场景未评测、把相似度阈值从一个语料直接搬到另一个语料,都会失败。 修复不能止于‘这次跑通’,还要把失败样本变成自动测试,防止同类问题在下一版本重新出现。

现在动手:构造同义、相关但不回答、完全无关三类文本,观察相似度分布并选择初始阈值。 把关键断言写进测试,并在 README 说明怎样运行、怎样判断正确、当前实现还不支持什么。

Embedding 把文本映射为向量。语义检索通常用余弦相似度或内积;若向量已归一化,两者排序等价。查询和文档可能需要不同前缀或不同编码接口,应遵循模型卡。

选择 Embedding 时考虑语言、领域、最大长度、维度、速度、许可和查询/文档训练方式。MTEB 等榜单提供参考,但你的真实查询集更重要。维度更大不必然更好,还会增加存储和搜索成本。

import numpy as np

def cosine_topk(query_vec, doc_matrix, k=5):
    q = query_vec / (np.linalg.norm(query_vec) + 1e-12)
    docs = doc_matrix / (np.linalg.norm(doc_matrix, axis=1, keepdims=True) + 1e-12)
    scores = docs @ q
    k = min(k, len(scores))
    ids = np.argpartition(-scores, k - 1)[:k]
    ids = ids[np.argsort(-scores[ids])]
    return [(int(i), float(scores[i])) for i in ids]

这段代码是精确搜索,适合小数据和教学。百万级向量常用 FAISS、Milvus 等近似最近邻索引。近似索引用少量召回损失换速度与内存,需调节 HNSW、IVF 等索引参数并测 Recall@K。

5.4 从检索结果到上下文

抓住这一节的主线。 检索结果要经过排序、去重、裁剪和格式化后才能成为上下文。上下文的目标是让模型找到证据边界,而不是把 token 窗口塞满。 遇到新术语,先找它在系统中的位置:它读取什么、保存什么、改变什么,以及失败时会留下什么信号。

真正动手时按这个顺序走:

  1. 过滤权限与版本。
  2. 去除近重复块。
  3. 按相关性与多样性排序。
  4. 加来源标签后控制预算。

先做最小实验。 同一段在五个版本中重复出现时,应优先保留当前有效版本,否则模型可能引用已废止政策。 把随机性固定并保留中间量,这个例子就能成为后续优化时的回归基线。

别被表面现象带偏。 把检索分数当可信度、丢失来源 id、上下文顺序随机、截断时切掉标题或单位,都会损害回答。 先区分定义错误、实现错误、数据问题和性能瓶颈。四类问题需要的证据不同,不能靠盲目调参混在一起处理。

验证任务:写 build_context 函数,按 token 预算选择块,并保证每块含唯一证据编号。 实验结束后写三句话:观察到了什么、这些证据支持什么结论、还有哪种解释尚未排除。

召回的 chunk 不能直接无脑拼接。先按权限过滤,再去重、限制同一文档占比、按重排分数选择,并保留稳定引用编号。提示模板应告诉模型只根据证据回答,证据不足时明确拒答,同时要求引用对应编号。

def build_context(hits, max_chars=5000):
    blocks, used = [], 0
    for idx, hit in enumerate(hits, 1):
        block = (f"[证据{idx}] 来源={hit['title']} 页={hit.get('page', '?')}\n"
                 f"{hit['text'].strip()}")
        if used + len(block) > max_chars:
            break
        blocks.append(block)
        used += len(block)
    return "\n\n".join(blocks)

引用必须能回到原文位置。模型生成的 [证据3] 还要验证是否真的存在、是否支撑对应陈述。引用格式正确不等于内容忠实。

5.5 LlamaIndex 等框架的角色

先把概念落到可观察对象上。 框架负责连接加载器、切块器、索引、Retriever 和生成器,但不会替你定义正确的数据边界、评估集和权限模型。先手写最小链路,再使用框架更容易定位问题。 这一节的重点是建立因果链。先说明问题,再看计算或流程,最后用可重复实验验证结论。

把抽象概念还原成操作:

  1. 确认组件接口与数据结构。
  2. 显式记录每步输入输出。
  3. 替换单个组件做对照。
  4. 锁定版本并保留原始样本。

把它缩小到能逐项检查。 当最终回答错误时,若能独立调用 Retriever 查看 top-k,就能区分是召回失败还是生成器忽略证据。 如果这个小例子还不能解释清楚,扩大数据只会让错误更难发现。

需要特别守住的边界。 把框架默认参数当最佳实践、升级后不跑回归、深层 callback 隐藏异常和成本,都会使系统难以解释。 保留完整 trace 比猜原因更重要。找到第一次偏离预期的位置,通常比分析最终错误输出更高效。

本节练习:用同一评估集比较手写检索器与框架检索器,记录结果差异而非只比较代码行数。 除代码外,请保留一份结果说明,标明环境、随机种子、输入规模和你主动检查过的边界。

LlamaIndex 把加载器、节点、索引、Retriever、QueryEngine 与 Response Synthesizer 等组件标准化,适合快速组装。学习时要能画出真实数据流,不要把 query_engine.query() 当作魔法。遇到质量问题,应能分别替换解析器、切块器、Embedding、向量库、重排器和生成器。

框架升级较快,示例 API 可能变化。先掌握组件契约:输入是什么、输出是什么、元数据是否保留、是否异步、是否可观测,再看当前官方文档。

5.6 一个从零 RAG 的最小接口

先建立直觉。 最小 RAG 接口应明确查询、检索结果、上下文和回答四个对象,保留证据链。即使不接真实模型,也能用确定性函数测试检索与拼接。 读这一节时,先不要急着记缩写,而要不断追问:输入是什么,经过了哪些可观察变换,输出又怎样被验证。

把过程拆开看:

  1. search 返回结构化 Chunk。
  2. build_context 生成编号证据。
  3. generate 只接收问题与证据。
  4. response 同时返回答案和引用。

最小例子。 Chunk 至少包含 id、source、page、text、score;Response 至少包含 answer、citations、trace_id,便于评估和排错。 例子越小,越容易手算、打印中间量并判断实现是否偏离定义。

容易踩坑。 函数直接返回拼接字符串、索引与生成共用全局状态、没有空召回处理、引用由模型自由编造,都会让接口失真。 排错时应保存失败输入和版本,先定位最早出现异常的步骤,再决定是否调整模型或超参数。

动手任务:扩展最小接口,加入 no_answer 状态、检索耗时和引用存在性验证。 完成后不要只保存最终输出,还要保存代码、配置、中间张量或检索结果,以及你对结果的解释。

from dataclasses import dataclass

@dataclass
class Chunk:
    text: str
    source: str
    page: int | None
    vector: np.ndarray

class TinyRetriever:
    def __init__(self, chunks: list[Chunk]):
        self.chunks = chunks
        self.matrix = np.stack([c.vector for c in chunks])

    def search(self, query_vector: np.ndarray, k=5):
        return [(self.chunks[i], score)
                for i, score in cosine_topk(query_vector, self.matrix, k)]

这个类没有负责 Embedding、持久化或权限,恰好说明模块边界。生产系统中查询向量要记录模型版本;文档更新要能增量重建;删除文档必须同步删除索引与缓存。

5.7 评估检索,而不是只看最终回答

从问题出发。 最终回答正确可能掩盖检索失败,因为模型可能凭参数记忆猜对。必须单独标注相关文档并计算 Recall@K、MRR、nDCG 等检索指标。 先把名词放到一边,沿着输入、状态变化和输出走一遍;只有能指出证据落在哪一步,概念才算真正掌握。

沿数据流逐步检查:

  1. 定义查询与相关块集合。
  2. 冻结索引和评估版本。
  3. 计算排名指标。
  4. 按问题类型分析失败。

用小数据走一遍。 若正确块在第 8 名,Recall@10 为 1 但 Recall@5 为 0;MRR 还能反映首次命中的位置。 先在纸面预测结果,再让程序打印中间状态;预测与运行不一致的地方,正是需要继续追查的知识缺口。

这里最容易出现的误解。 只有一个参考块、相关性标注含糊、用生成答案反推相关块、调参后仍在同一测试集反复选择,都会污染指标。 出现异常时先冻结样本、配置和环境,从最靠近输入的环节开始验证;不要同时更换模型、数据和超参数。

小实验:手工标注至少 30 个查询,计算 BM25 和向量检索的 Recall@5 与 MRR。 提交物应包含预期、运行证据和失败记录;只有别人能按同样步骤复现,结果才具有学习价值。

先构建小而可信的评估集:查询、相关文档/片段、参考答案和不可回答标记。检索指标包括 Recall@K、Precision@K、MRR、nDCG;生成指标包括答案正确性、证据忠实度、引用准确率、拒答质量。还要记录延迟、成本和失败类型。

Recall@K 回答“相关证据是否出现在前 K 个结果中”;MRR 重视第一个相关结果的位置。没有标注相关文档时,可先人工标注几十到几百条高价值查询。用另一个大模型做评委可以扩展规模,但要用人工样本校准偏差。

5.8 常见失败

先看它解决什么。 RAG 失败应沿数据摄取、索引、查询、召回、重排、上下文和生成逐层定位。每层都要能输出可检查证据。 理解的标准不是会复述定义,而是能画出数据流、预测中间结果,并设计一个让错误暴露出来的检查。

可以把实现分成以下环节:

  1. 确认目标文档确实入库。
  2. 确认切块含完整答案。
  3. 确认查询表达与过滤。
  4. 确认模型引用与回答一致。

一个可以手算的例子。 答案缺失可能不是 embedding 不好,而是 PDF 表格解析丢列、权限过滤过严或当前版本被错误标记过期。 这里故意不用大模型或大数据,因为小输入能把每个轴、分数和状态变化完整暴露出来。

排错时先看这些地方。 一遇到 bad case 就更换大模型、只调 top-k、没有保存 trace、修复后不建回归集,会导致问题迁移而非解决。 修复不能止于‘这次跑通’,还要把失败样本变成自动测试,防止同类问题在下一版本重新出现。

现在动手:为三个失败样例写故障树,每个假设设计一个最便宜的验证实验。 把关键断言写进测试,并在 README 说明怎样运行、怎样判断正确、当前实现还不支持什么。

  • 查询与文档措辞差异大:尝试查询改写、多查询或混合检索。
  • 召回内容正确但答案错误:检查上下文排序、冲突证据、提示和生成模型。
  • 表格问答失败:保留表头与行列结构,必要时走结构化查询。
  • 新旧制度冲突:用生效时间、版本和权威级别过滤。
  • 权限泄漏:权限过滤必须在检索或数据层完成,不能只在提示中声明。

练习:为同一批文档比较 200/500/1000 字符切块的 Recall@5;加入页码和标题路径;设计五条“知识库没有答案”的查询并检查拒答。

延伸阅读:RAG 原始论文Milvus 快速开始LlamaIndex 评估文档

本章配套代码

下面的脚本与正文使用相同符号。建议先在代码中打印形状和中间量,再运行断言;若依赖尚未安装,至少先阅读入口函数、输入输出和测试部分。

本章端到端实验:把知识变成可复现证据

本实验不是把本章代码重新抄一遍,而是把概念、实现、测试和解释串成一个小型工程。请新建独立目录,保存 README.md、环境文件、源代码、测试、运行日志和结果图。README 至少说明任务、输入输出、运行命令、预期现象、已知限制和复现条件。

实验步骤

  1. 列出三个业务场景,分别选择 RAG、微调或组合方案,并给出证据。
  2. 对同一篇含标题和表格的文档实现三种切块,人工比较十个问题的可检索证据。
  3. 构造同义、相关但不回答、完全无关三类文本,观察相似度分布并选择初始阈值。
  4. 写 build_context 函数,按 token 预算选择块,并保证每块含唯一证据编号。
  5. 用同一评估集比较手写检索器与框架检索器,记录结果差异而非只比较代码行数。
  6. 扩展最小接口,加入 no_answer 状态、检索耗时和引用存在性验证。

每完成一步,先写下预期,再运行代码。若结果与预期不一致,不要覆盖旧日志;建立 failures.md,记录现象、假设、证据、修复和回归测试。这样得到的不是一次性 Demo,而是一份能证明你真正理解本章内容的实验档案。

验收标准

  • 全新环境能够按照 README 从头运行,依赖和随机种子已记录。
  • 关键函数至少有正常、边界和错误输入三类测试;涉及数值计算时检查有限值与合理误差。
  • 结果包含一个基线和至少一个受控改动,能够说明变化来自哪里。
  • 日志保留输入规模、耗时、内存或显存、软件版本和失败样本。
  • 结论区分“实验直接证明的事实”“根据事实做出的推断”和“仍未验证的猜想”。

本章自测

  1. 不看正文,用自己的话解释“微调更擅长改变行为和风格,RAG 更擅长提供可更新、可引用的外部事实”,并给出一个可以证伪的测试。
  2. 不看正文,用自己的话解释“解析决定系统实际上看到了什么,切块决定检索的最小证据单位”,并给出一个可以证伪的测试。
  3. 不看正文,用自己的话解释“Embedding 把文本映射到向量空间,相似度只表示模型学习到的语义接近,不等于事实蕴含或答案正确”,并给出一个可以证伪的测试。
  4. 不看正文,用自己的话解释“检索结果要经过排序、去重、裁剪和格式化后才能成为上下文”,并给出一个可以证伪的测试。
  5. 不看正文,用自己的话解释“框架负责连接加载器、切块器、索引、Retriever 和生成器,但不会替你定义正确的数据边界、评估集和权限模型”,并给出一个可以证伪的测试。
  6. 不看正文,用自己的话解释“最小 RAG 接口应明确查询、检索结果、上下文和回答四个对象,保留证据链”,并给出一个可以证伪的测试。

回答时先画图或写形状,再给结论。若只能说出术语而不能给出最小例子、边界条件和验证方法,说明这一节仍需要回到代码中重做。