# RAG: 检索增强生成 —— 让大模型先查资料, 再开口回答 ```{contents} ``` 前面几篇文章里,LLM 都是靠 prompt 里塞的材料干活:[DAG](./dag.md) 把构建日志喂给它做分析, [Agent](./agent.md) 把工具返回喂给它做决策。但有一个问题一直没回答:**材料从哪来?** 用户随口一句"我们公司的住宿报销标准是多少",模型 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 全程分两个阶段, 用一张图看清: ```{mermaid} flowchart TB subgraph OFFLINE[离线: 建库, 一次或增量做] D[文档
PDF/Markdown/网页] --> C[分块
chunking] C --> E[向量化
embedding] E --> V[(向量库
向量+原文)] end subgraph ONLINE[在线: 每次提问都做] Q[用户问题] --> QE[向量化] QE --> R[检索 top-k
向量距离排序] V --> R R --> P[拼 prompt
资料+问题] P --> L[LLM 开卷作答] end ``` **离线阶段(建库)**, 把知识预处理成可检索的形态: 1. **分块(chunking)**:文档切成几百字的小块。为什么不能整篇入库?——检索的粒度是块, 块太大, 命中一块等于啥也没筛出来; 2. **向量化(embedding)**:每块文本送入 embedding 模型, 得到一个高维向量。核心性质:**语义相近的文本, 向量距离也近**。"住宿报销怎么算"和"差旅住宿标准"用词不同, 但向量很近; 3. **入库**:向量连同原文一起存进向量数据库(Chroma/PGVector/ES 都行, 本质是"带距离索引的表")。 **在线阶段(查询)**, 每次提问: 4. **检索**:用户问题同样向量化, 在库里按向量距离取 top-k 块; 5. **增强 + 生成**:把 top-k 块作为"资料"和问题拼成 prompt, 让模型"**根据以下资料回答, 资料里没有的就直说不知道**"。 RAG 三个词各对应一段:**R**etrieval 是第 4 步, **A**ugmented 是第 5 步的拼 prompt, **G**eneration 是模型最后生成的答案。 ## 最小 Demo: 一个文件跑通全链路 `ai/rag_demo/rag_demo.py` 一个文件, 零第三方依赖(用 TF-IDF 代替 embedding 模型演示"文本→向量"), 流程和生产的 RAG 一模一样: ```python """最小 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 ``` 业务侧的使用只有四行, 真实输出附在代码后面: ```python 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`, 未删减): ```text == 知识库分块 == [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 的干扰块), 只换分词方案, 实测: ```text => 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](./dag.md) 里, 它就是"检索"那个节点;搭在 [Agent](./agent.md) 里, 它就是检索工具 —— 典型如"联网搜索"工具, 本质就是拿搜索引擎当知识库的 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 榜单](https://huggingface.co/spaces/mteb/leaderboard): embedding 模型初筛用, 不能替代自建评测集