RAG: 检索增强生成 —— 让大模型先查资料, 再开口回答

前面几篇文章里,LLM 都是靠 prompt 里塞的材料干活:DAG 把构建日志喂给它做分析, Agent 把工具返回喂给它做决策。但有一个问题一直没回答:材料从哪来? 用户随口一句"我们公司的住宿报销标准是多少",模型 training 数据里怎么可能有你们公司的报销制度。

这篇文章介绍 AI 应用里最常用的一种补料姿势 —— RAG(Retrieval-Augmented Generation,检索增强生成),并给出一个零依赖、单文件、已实际跑通的最小实现(核心 50 行)。

结论先放在前面

  1. RAG 不神秘:先检索、后生成。把用户问题拿去知识库查出最相关的几段资料,连同原问题一起塞进 prompt,让模型"开卷作答";

  2. 它治大模型的三个硬伤:知识截止(训练数据有截止日期, 之后的事不知道)、私有数据(你的公司制度、个人笔记不在训练集里)、幻觉(不知道也会一本正经地编);

  3. 管线就五步:加载 → 分块 → 向量化入库 → 检索 → 拼 prompt。demo 用纯 Python(TF-IDF 当 embedding)全链路跑通, 实测"住宿费报销标准"这个问题精准命中报销制度那一块;

  4. 边界:RAG 的天花板是检索质量 —— 查不到就必然答不出, 垃圾进垃圾出;它是"考试时给模型发参考资料", 不是"把知识灌进模型脑子", 后者靠微调。

  5. 从 demo 到生产,差距不在流程而在用料:embedding 模型(入库查询同一个、换模型重建全库)、按结构分块、混合检索+rerank、元数据与权限;建库里 AI 的刚需只有向量化那一步;向量没有直接质量指标,要用标注评测集算 HitRate@k 间接评估(见 ai/rag_demo/eval_rag.py 的对照实验)。

为什么需要 RAG: 大模型的三个硬伤

直接问模型三个问题, 就能看清问题所在:

问题

模型裸答的表现

根因

"2026 年的新规是什么?"

不知道, 或者把旧规当新规

训练数据有知识截止日期, 截止后的事它天然不知道

"我们公司的年假几天?"

开始编造一个听起来合理的数字

你的私有数据从没进过它的训练集

"XX 库的某个冷门函数怎么用?"

一本正经地给出不存在的参数

幻觉: 语言模型的本职是"续写像人话的话", 不是"保证每句是真的"

三条根因有一条共同点:缺失的知识不是模型的推理能力不行, 是它脑子里没有这些事实。那就有两条路:

  • 把知识灌进去:微调(fine-tuning), 用领域数据继续训练模型 —— 贵、慢、新知识一来就要重训;

  • 用的时候再查:RAG, 回答前临时把相关资料找出来发给它 —— 知识库随时更新, 模型本身不动。

维度

微调

RAG

知识更新

重训一遍, 小时到天级

改知识库文件, 即时生效

私有数据

数据要进训练集, 有泄露面

数据只在检索时用, 不训练

回答可追溯

难, 学了什么说不清

容易, 引用了哪几段就是哪几段

适合补什么

风格、格式、任务模式("怎么说")

事实("说什么")

成本

高(训练)

低(一次向量检索)

一句话:补"说法"靠微调, 补"事实"靠 RAG, 两者不互斥, 生产里经常混着用。

RAG 是什么: 离线建库 + 在线查询两段管线

RAG 全程分两个阶段, 用一张图看清:

        flowchart TB
    subgraph OFFLINE[离线: 建库, 一次或增量做]
        D[文档<br/>PDF/Markdown/网页] --> C[分块<br/>chunking]
        C --> E[向量化<br/>embedding]
        E --> V[(向量库<br/>向量+原文)]
    end
    subgraph ONLINE[在线: 每次提问都做]
        Q[用户问题] --> QE[向量化]
        QE --> R[检索 top-k<br/>向量距离排序]
        V --> R
        R --> P[拼 prompt<br/>资料+问题]
        P --> L[LLM 开卷作答]
    end
    

离线阶段(建库), 把知识预处理成可检索的形态:

  1. 分块(chunking):文档切成几百字的小块。为什么不能整篇入库?——检索的粒度是块, 块太大, 命中一块等于啥也没筛出来;

  2. 向量化(embedding):每块文本送入 embedding 模型, 得到一个高维向量。核心性质:语义相近的文本, 向量距离也近。"住宿报销怎么算"和"差旅住宿标准"用词不同, 但向量很近;

  3. 入库:向量连同原文一起存进向量数据库(Chroma/PGVector/ES 都行, 本质是"带距离索引的表")。

在线阶段(查询), 每次提问:

  1. 检索:用户问题同样向量化, 在库里按向量距离取 top-k 块;

  2. 增强 + 生成:把 top-k 块作为"资料"和问题拼成 prompt, 让模型"根据以下资料回答, 资料里没有的就直说不知道"。

RAG 三个词各对应一段:Retrieval 是第 4 步, Augmented 是第 5 步的拼 prompt, Generation 是模型最后生成的答案。

最小 Demo: 一个文件跑通全链路

ai/rag_demo/rag_demo.py 一个文件, 零第三方依赖(用 TF-IDF 代替 embedding 模型演示"文本→向量"), 流程和生产的 RAG 一模一样:

"""最小 RAG: 分块 -> 向量化(TF-IDF) -> 索引 -> 检索 -> 拼 prompt。零依赖, 直接 python3 rag_demo.py"""
import math
import re


def chunk(text, size=40, overlap=10):
    """按句切, 滑窗分块, 块间保留 overlap 个字的重叠上下文"""
    chunks, cur = [], ""
    for s in re.findall(r"[^。;]+[。;]?", text.strip()):
        if len(cur) + len(s) > size and cur:
            chunks.append(cur)
            cur = cur[-overlap:]          # 尾部重叠, 防止答案恰好被切断
        cur += s
    if cur.strip():
        chunks.append(cur)
    return chunks


def tokenize(doc):
    # 演示级分词: 英文按单词, 中文按单字。生产里换成正经分词器/embedding 模型
    return re.findall(r"[a-zA-Z]+", doc.lower()) + re.findall(r"[\u4e00-\u9fff]", doc)


class Store:
    """向量库: 文档入库时统计词频, 查询时套用同一份 IDF 表, 按余弦相似度取 top-k"""

    def __init__(self):
        self.docs, self.df = [], {}

    def _vec(self, tokens, n):
        tf = {}
        for w in tokens:
            tf[w] = tf.get(w, 0) + 1
        return {w: c * math.log((n + 1) / (self.df.get(w, 0) + 1)) for w, c in tf.items()}

    def add(self, doc):
        self.docs.append(doc)
        for w in set(tokenize(doc)):
            self.df[w] = self.df.get(w, 0) + 1

    def search(self, query, k=2):
        qv = self._vec(tokenize(query), len(self.docs))
        scored = [cosine(qv, self._vec(tokenize(d), len(self.docs))) for d in self.docs]
        top = sorted(enumerate(scored), key=lambda x: x[1], reverse=True)[:k]
        return [(self.docs[i], round(s, 3)) for i, s in top]


def cosine(a, b):
    dot = sum(v * b.get(w, 0) for w, v in a.items())
    na = math.sqrt(sum(v * v for v in a.values()))
    nb = math.sqrt(sum(v * v for v in b.values()))
    return dot / (na * nb) if na and nb else 0.0

业务侧的使用只有四行, 真实输出附在代码后面:

KNOWLEDGE = """
公司报销制度(2025 版): 差旅住宿一线城市每晚不超过 500 元, 二线城市不超过 350 元。
交通费按职级补贴, 总监以下每月 800 元, 总监及以上每月 1500 元。
发票必须在费用发生后 30 天内提交, 超期不予受理。
年假按工龄计算, 满 1 年 5 天, 满 10 年 10 天, 满 20 年 15 天。
服务器部署规范: 生产环境所有变更必须走工单审批, 变更窗口为每周三凌晨。
数据库慢查询超过 2 秒的必须提交优化说明, 由 DBA 团队复核。
"""

QUESTION = "住宿费报销标准是多少?"

store = Store()
for c in chunk(KNOWLEDGE):
    store.add(c)

hits = store.search(QUESTION)

prompt = f"""根据以下资料回答用户问题, 资料里没有的就直说不知道。

资料:
{chr(10).join(doc for doc, _ in hits)}

问题: {QUESTION}
"""

实测输出(直接运行 python3 rag_demo.py, 未删减):

== 知识库分块 ==
[0] 公司报销制度(2025 版): 差旅住宿一线城市每晚不超过 500 元, 二线城市不超过 350 元。
[1] 不超过 350 元。
交通费按职级补贴, 总监以下每月 800 元, 总监及以上每月 1500 元。
[2] 每月 1500 元。
发票必须在费用发生后 30 天内提交, 超期不予受理。
[3] 交, 超期不予受理。
年假按工龄计算, 满 1 年 5 天, 满 10 年 10 天, 满 20 年 15 天。
[4] 20 年 15 天。
服务器部署规范: 生产环境所有变更必须走工单审批, 变更窗口为每周三凌晨。
[5] 更窗口为每周三凌晨。
数据库慢查询超过 2 秒的必须提交优化说明, 由 DBA 团队复核。

== 检索结果 (top-2) ==
0.186  公司报销制度(2025 版): 差旅住宿一线城市每晚不超过 500 元, 二线城市不超过 350 元。
0.030  每月 1500 元。
发票必须在费用发生后 30 天内提交, 超期不予受理。

== 拼好的 prompt(传给 LLM 的就是它) ==
根据以下资料回答用户问题, 资料里没有的就直说不知道。

资料:
公司报销制度(2025 版): 差旅住宿一线城市每晚不超过 500 元, 二线城市不超过 350 元。
每月 1500 元。
发票必须在费用发生后 30 天内提交, 超期不予受理。

问题: 住宿费报销标准是多少?

几个值得注意的观察, 全是踩过坑才写出来的:

  • 分块重叠不能省:看分块 [0] 和 [1], 接界处"不超过 350 元"出现了两次。如果不重叠, 一条规则恰好被切断, 两块各自只剩半句话, 检索命中哪块都读不懂。生产里 chunk size / overlap 是核心调参旋钮;

  • 检索不是匹配:问题里没有一个字和"报销制度"四字相同, 但它还是排到了第一 —— 这就是向量检索的价值, 比的是语义不是字面;

  • top-2 的第二名是噪声(0.030 vs 0.186, 分差很大, 是"补贴/费"这类通用字撞出来的)。真实系统里这一步靠**重排(rerank)**和拉高阈值过滤掉, demo 为了极简没做;

  • 给模型的"资料里没有就说不知道"那句 prompt 很关键。不写这句, 检索失手时模型照样现场编造, RAG 就白装了。

RAG 的天花板: 检索质量

demo 里第二名是噪声这件事, 指向 RAG 最重要的一个认知:整条链路是"检索先行"结构, 生成质量的上限在检索那一刻就定了。

  • 相关资料没被检索出来 → 模型根本没见过事实, 只能编;

  • 相关资料排进了 top-k 但被噪声淹没 → 模型可能采信错的;

  • 资料本身就没有(用户问的知识库外的全新问题)→ RAG 无能为力, 该走 Agent 去现查。

所以生产级 RAG 的优化, 大头都不在"生成"环节, 而在检索环节:混合检索(向量 + 关键词 BM25 并行, 互补语义近和精确匹配)、查询改写(把口语问题改成好检索的查询)、重排(用一个专精模型对 top-50 重排序取 top-5)。这套组合拳做完, 才轮到调 prompt 和换更强的 LLM。

从 demo 到生产: 算法重要吗、建库要不要 AI、向量怎么评

demo 跑通了流程, 真上生产前还有四个问题要答清楚。先放结论:

追问

一句话回答

向量化算法重要吗?

重要。TF-IDF 只是演示替身, 生产用神经网络 embedding 模型;三条铁律——入库查询同一个模型、换模型重建全库、模型升级按换模型对待

生产环境一般怎么处理?

五步管线不变, 每步换用料:正经解析器、按结构分块、embedding 模型、向量库+元数据、混合检索+rerank

建库要先用 AI 分析资料吗?

向量化本身就是 AI(神经网络), 这步绕不开;LLM 预处理(摘要/假问题/元数据)是可选增强, 贵 10~100 倍, 按需加

向量好坏怎么判断?

向量本身没有直接指标, 用标注评测集算 HitRate@k / MRR 间接评估;失败 case 按"查不到/查错/被淹没"三类归因

向量化算法重要吗: 重要, 但要分清"换参数"和"换家族"

rag_demo.py 里的"向量化"是 TF-IDF——词袋统计, 没有理解。它不知道"出差住酒店"和"差旅住宿"是一回事, 只知道哪些字常一起出现;它甚至不算真正的语义向量——每个块是一张稀疏词频表, "向量"只是演示口径。

生产用的是 embedding 模型, 本身就是一个小型神经网络(BERT 系的 sentence-transformers、OpenAI text-embedding-3、BGE-M3、Qwen3-Embedding 等), 训练目标就是"语义相近的文本, 向量距离近"。词袋和它的差距集中在两处:

  • 同义词: "出差住酒店" vs "差旅住宿", 词袋的交集约等于零, embedding 很近;

  • 一词多义: "苹果发布了新手机"和"苹果富含维生素", 词袋算出几乎相同的向量, embedding 模型会给出两个不同的向量。

选型看四个维度:语言(中文为主选 BGE-M3 / Qwen3-Embedding 这类开源双语模型, 或云厂商的 embedding API)、维度(512~4096, 维度越高越占存储, 精度提升趋缓)、领域(代码/医疗/法律都有专用模型)、部署(API 省心 vs 本地开源, 数据不出域)。

三条铁律, 全是踩坑换来的:

  1. 入库和查询必须用同一个模型。两个模型的向量空间互不兼容, 混用等于随机排序;

  2. 换模型 = 重建整个库。旧向量全部作废, 千万级块的库重算一遍是小时到天级的活;

  3. 模型版本升级按"换模型"对待。哪怕厂商说"效果更好", 向量空间也可能变了——升级前先小流量评测(方法见下文最后一节)。

"算法重要"到底重要到什么程度?ai/rag_demo/eval_rag.py 做了个对照实验:同一份知识库(12 块)、同一套标注问题(12 题——前 5 题字面提问, 后 7 题口语/同义改写, 库里故意放了 发票×3、住宿×2、过期×2 的干扰块), 只换分词方案, 实测:

=> HitRate @1=10/12  @3=12/12  (单字分词)
=> HitRate @1= 9/12  @3=11/12  (双字分词)

三个读法:

  • 词袋家族内部调参, 收益就是"±1 题"这个量级——单字双字互有胜负, 不值得纠结。真正的数量级差距在"词袋 → 神经网络 embedding"这一跳;

  • 字面提问两边都稳(前 5 题全对), 一改写就开始翻车:双字方案把"发票过期了还能报销吗"的正确块排到第 5——它分不清"过期"在"发票超期"和"调休过期"两个语境里谁相关, 词袋永远只会数字面;

  • 字面题也没全对:"住宿费报销标准是多少"在两个方案里都没排第一——库变大、干扰块变多后, 字面撞车是常态不是意外。这就是上一节"天花板是检索质量"的量化版, 也是生产要加 rerank 的直接动机。

把 eval_rag.py 里的 TF-IDF 换成 embedding API, 评测集一行不用改——这套脚本就是"新模型到底好不好"的验收工具。

生产环境一般怎么处理: 五步不变, 每步换用料

环节

demo

生产

加载解析

一段写死的字符串

PDF/Word/网页/Markdown 解析器;表格转文本、图片 OCR;解析丢字漏表, 后面全白搭

分块

按句+固定字数滑窗

按文档结构切(标题层级、语义段落), 块大 200~500 token、overlap 10~15%;先清洗(去页眉页脚、归一化全半角)

向量化

TF-IDF 现算

embedding API 或本地开源模型;批量+并发+断点续传, 块和向量一起落库

入库

一个 list

向量库:专用(Qdrant/Milvus/Weaviate)或 PGVector/ES 插件;距离索引用 ANN(HNSW/IVF);每条存 向量+原文+元数据(来源/部门/生效时间/权限)

检索

余弦相似度 top-2

混合检索:向量 + BM25 关键词并行、RRF 合并(向量管语义, BM25 管精确词和编号);再 rerank(bge-reranker / Cohere)对 top-50 重排取 top-5

生成

一句"不知道就说不知道"

同左, 外加引用标注、追问澄清、按权限过滤可见块

更新

一次性

增量:文档变更检测 → 只重算变化的块 → 旧向量连带删除

权限

无

检索时按用户权限过滤元数据——不做这步, 等于把全员资料发给每个人

建库要先用 AI 分析资料吗: 一半是刚需, 一半是可选增强

先拆开"AI 分析"这个词, 它指两件不同的事:

刚需的那半, 就是向量化本身。 embedding 模型是神经网络, 分块后的每一块都要过它——这是 RAG 建库里 AI 唯一绕不开的介入点。所以"要不要 AI 参与建库"的答案是:要, 但向量化这一步是唯一的刚需。demo 用 TF-IDF 替它演示了流程, 生产换上真模型即可, 管线形状不变。

可选的那半, 是用 LLM 预处理文档, 生产里常见三种:

增强

做法

解决什么

LLM 生成元数据

给每块抽标题、部门、生效时间、文档类型

检索可按字段过滤("只要 2025 版制度");权限控制也靠它

LLM 生成假问题

让每个块附带几个"用户可能怎么问"(HyDE 思路)

口语问题和正式条文的措辞 gap, 检索锚点变多

LLM 结构化改写

表格转文字叙述、图片内容描述

解析器啃不动的内容, LLM 兜底

成本账要算清:一块过一次 embedding 是毫厘级, 过一次 LLM 改写贵 10~100 倍。所以默认管线 = 解析+分块+embedding, LLM 预处理只在 bad case 分析发现"这类问题查不到"时定向补, 不做全系标配。

最后一个直觉校准, 很重要:AI 并不是先"读懂"整篇文档再编一份索引——那是人脑编目录。它是把每一块独立压成向量, 块与块的语义关系全靠向量距离涌现, 没有全局理解, 也没有"重点"概念。所以分块切断上下文是 RAG 的经典损失点(上面 12 块的库, 块 7 就把"采购"和"招待"两个主题缝进了一块), 结构感知分块就是在补这个。

向量好坏怎么判断: 没有直接指标, 只有间接评估

先破除一个执念:单个向量"好不好"不存在直接度量。一百个数字排成一列, 没有标签告诉你它"语义够不够准"。向量的质量只能通过"好问题能不能查到对的块"间接体现——评估对象是检索效果, 不是向量本身。由轻到重四层:

第 1 层: 公开榜单(选模型时用)。 MTEB / C-MTEB 把上百个 embedding 模型放在统一任务集上打分, 初筛好用。但记住:榜单分高 ≠ 在你的数据上分高——榜单语料是百科和网页, 你的知识库是报销制度和运维手册。榜单只负责缩小候选, 最终要用第 2 层在自己的数据上复测定生死。

第 2 层: 离线评测集(最常用、最该建)。 标注一批"问题 → 正确答案所在块"(20~50 题就能起步, 字面问和口语问都要有), 对检索算:

  • HitRate@k / Recall@k: 正确块有没有进 top-k。k 取你实际拼 prompt 的块数, 不是取得越大越好;

  • MRR: 正确块排得多靠前——排第 1 和排第 5 虽然都"进了 top-5", 喂给 LLM 的可信度完全不同。

eval_rag.py 就是这一层的可跑样板:两个方案同库同题对比, 谁好谁坏一目了然;把分词器换成 embedding API, 就是新模型的验收单。

第 3 层: bad case 归因(调优时用)。 失败 case 只可能是三类, 处方各不同:

症状

根因

处方

查不到

库里没有 / 分块切断 / 没进 top-k

补文档;调分块和 overlap;上混合检索

查错

字面撞车("过期"撞"调休过期")

换更强 embedding;BM25+向量双路;上 rerank

查到了被淹没

正确块在 top-k 但排名低, LLM 采信噪声

rerank;相似度阈值过滤

第 4 层: 在线指标(上线后盯)。 检索命中率、答案忠实度(LLM-as-judge 打"答案是否只来自所给资料")、该答"不知道"时硬答的比例。

两个误区别踩:一是拿相似度绝对值当质量——它不能跨模型比(各家模型打分松紧完全不同), 能跨方案比的是"同一套评测集上的命中率";二是只看 HitRate 不看 MRR——进了 top-5 但垫底, 和排第 1, 对最终答案是两回事。

边界: 三个常见追问

1. 上下文窗口越来越长, 直接把全部文档塞进去不行吗?

小库可以, 大了不行, 而且贵。1 万页的文档库就算装得进 128K 窗口, 每问一次都要全量读一遍 —— 成本、延迟、注意力稀释全是问题。RAG 本质是用一次几毫秒的距离计算, 替代几十万 token 的重复阅读。

2. RAG 和微调怎么选?

前面给过:补事实用 RAG, 补"说法/风格/任务模式"用微调。比如"让模型学会按我司格式写周报"是微调, "让模型知道我司年假制度"是 RAG。

3. RAG 和本仓库其他文章什么关系?

RAG 是喂料的一种方式, 不是工作流形态。搭在 DAG 里, 它就是"检索"那个节点;搭在 Agent 里, 它就是检索工具 —— 典型如"联网搜索"工具, 本质就是拿搜索引擎当知识库的 RAG。

参考

  • 提出 RAG 的论文: Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks(Lewis et al., 2020), arXiv:2005.11401

  • 本文 demo: ai/rag_demo/rag_demo.py, python3 rag_demo.py 直接可跑, 上文输出为实测未删减

  • 检索质量对照实验: ai/rag_demo/eval_rag.py, python3 eval_rag.py, 两套向量化方案在同一评测集上比 HitRate@k, 上文数字为实测

  • MTEB / C-MTEB 榜单: embedding 模型初筛用, 不能替代自建评测集