大模型 RAG 实战学习笔记:从基本流程到检索优化的完整理解
这篇笔记不按"概念百科"来写,而是按一个 RAG 系统真正跑起来的顺序来写。
目标只有一个:即使你没有看过任何参考资料,也能顺着本文理解 RAG 为什么出现、它是怎么工作的、哪些环节最容易出问题,以及工程上应该优先优化什么。
导航目录
- [1. 为什么大模型这么强,RAG 依然重要](#1. 为什么大模型这么强,RAG 依然重要)
- [1.1 大模型的强项,不等于知识系统的强项](#1.1 大模型的强项,不等于知识系统的强项)
- [1.2 RAG 主要在解决哪四类问题](#1.2 RAG 主要在解决哪四类问题)
- [1.3 RAG、长上下文、微调分别适合什么场景](#1.3 RAG、长上下文、微调分别适合什么场景)
- [1.4 哪些业务最适合先上 RAG](#1.4 哪些业务最适合先上 RAG)
- [2. 先看全局:一个 RAG 系统到底是怎么跑起来的](#2. 先看全局:一个 RAG 系统到底是怎么跑起来的)
- [2.1 从一张图看懂最小 RAG 流程](#2.1 从一张图看懂最小 RAG 流程)
- [2.2 用四个词记住主链路:ingestion、retrieval、augmentation、generation](#2.2 用四个词记住主链路:ingestion、retrieval、augmentation、generation)
- [2.3 一次真实提问会经过哪些步骤](#2.3 一次真实提问会经过哪些步骤)
- [2.4 RAG 最容易失败的地方在哪里](#2.4 RAG 最容易失败的地方在哪里)
- [3. 第一步:把知识库准备成系统能用的样子](#3. 第一步:把知识库准备成系统能用的样子)
- [3.1 不是所有数据都适合直接做 RAG](#3.1 不是所有数据都适合直接做 RAG)
- [3.2 数据抽取、清洗、去噪为什么是第一道门槛](#3.2 数据抽取、清洗、去噪为什么是第一道门槛)
- [3.3 为什么必须切块,而不是整篇文档直接入库](#3.3 为什么必须切块,而不是整篇文档直接入库)
- [3.4 常见切块策略应该怎么理解](#3.4 常见切块策略应该怎么理解)
- [3.5 元数据为什么经常比向量本身还重要](#3.5 元数据为什么经常比向量本身还重要)
- [4. 第二步:让机器有能力"找到相关内容"](#4. 第二步:让机器有能力“找到相关内容”)
- [4.1 什么是 Embedding,它在 RAG 里到底负责什么](#4.1 什么是 Embedding,它在 RAG 里到底负责什么)
- [4.2 为什么检索常用 Encoder,生成常用 Decoder](#4.2 为什么检索常用 Encoder,生成常用 Decoder)
- [4.3 相似度、向量空间、非对称检索到底在说什么](#4.3 相似度、向量空间、非对称检索到底在说什么)
- [4.4 常见语义向量模型在 RAG 中分别扮演什么角色](#4.4 常见语义向量模型在 RAG 中分别扮演什么角色)
- [4.5 向量数据库与 ANN 索引是在解决什么问题](#4.5 向量数据库与 ANN 索引是在解决什么问题)
- [5. 第三步:真正把相关文本召回回来](#5. 第三步:真正把相关文本召回回来)
- [5.1 稀疏检索为什么到今天都没过时](#5.1 稀疏检索为什么到今天都没过时)
- [5.2 按难度递进理解:词袋模型、TF-IDF、BM25](#5.2 按难度递进理解:词袋模型、TF-IDF、BM25)
- [5.3 稠密检索擅长什么,为什么不能只靠它](#5.3 稠密检索擅长什么,为什么不能只靠它)
- [5.4 混合检索为什么往往比单路检索更稳](#5.4 混合检索为什么往往比单路检索更稳)
- [5.5 为什么很多系统最后还要加一个 Reranker](#5.5 为什么很多系统最后还要加一个 Reranker)
- [6. 第四步:把召回结果交给大模型,生成可用答案](#6. 第四步:把召回结果交给大模型,生成可用答案)
- [6.1 augmentation 不是拼接文本,而是组织证据](#6.1 augmentation 不是拼接文本,而是组织证据)
- [6.2 一个可靠答案通常需要哪些约束](#6.2 一个可靠答案通常需要哪些约束)
- [6.3 为什么同样的召回结果,答案质量也会差很多](#6.3 为什么同样的召回结果,答案质量也会差很多)
- [6.4 多轮对话为什么会让 RAG 变难](#6.4 多轮对话为什么会让 RAG 变难)
- [7. 从 Demo 到生产:哪些优化最值得优先做](#7. 从 Demo 到生产:哪些优化最值得优先做)
- [7.1 先调切块,再调检索,再调生成](#7.1 先调切块,再调检索,再调生成)
- [7.2 Query Rewrite、MultiQuery、HyDE 到底是什么,分别在补哪类短板](#7.2 Query Rewrite、MultiQuery、HyDE 到底是什么,分别在补哪类短板)
- [7.3 Parent Document、Multi-Vector、Metadata Filter 分别是在重做哪一步](#7.3 Parent Document、Multi-Vector、Metadata Filter 分别是在重做哪一步)
- [7.4 新鲜度、权限、时效性为什么必须单独设计,而不能只靠相似度](#7.4 新鲜度、权限、时效性为什么必须单独设计,而不能只靠相似度)
- [8. 如何判断一个 RAG 系统到底有没有做好](#8. 如何判断一个 RAG 系统到底有没有做好)
- [8.1 先评检索,再评回答](#8.1 先评检索,再评回答)
- [8.2 Hit Rate、MRR 解决的是哪类判断问题](#8.2 Hit Rate、MRR 解决的是哪类判断问题)
- [8.3 除了命中率,还要看回答是否忠于证据](#8.3 除了命中率,还要看回答是否忠于证据)
- [8.4 一套可落地的评估方法应该怎么搭](#8.4 一套可落地的评估方法应该怎么搭)
- [9. 进阶方向:当基础版 RAG 跑通之后,再考虑什么](#9. 进阶方向:当基础版 RAG 跑通之后,再考虑什么)
- [9.1 图谱 RAG、Agentic RAG、多跳检索分别在解决什么](#9.1 图谱 RAG、Agentic RAG、多跳检索分别在解决什么)
- [9.2 多模态 RAG 不是"加图片"这么简单](#9.2 多模态 RAG 不是“加图片”这么简单)
- [9.3 一个最小可用的工程选型建议](#9.3 一个最小可用的工程选型建议)
- [9.4 全文总结:如果你只记住一条主线](#9.4 全文总结:如果你只记住一条主线)
- [10. LangChain 可运行 RAG 客服示例](#10. LangChain 可运行 RAG 客服示例)
- [10.1 为什么示例选这些组件](#10.1 为什么示例选这些组件)
- [10.2 先装哪些依赖](#10.2 先装哪些依赖)
- [10.3 文档切分代码怎么写](#10.3 文档切分代码怎么写)
- [10.4 一个能跑的客服 RAG 示例](#10.4 一个能跑的客服 RAG 示例)
- [10.5 Query Rewrite、MultiQuery、HyDE 的代码怎么接进去](#10.5 Query Rewrite、MultiQuery、HyDE 的代码怎么接进去)
1. 为什么大模型这么强,RAG 依然重要
1.1 大模型的强项,不等于知识系统的强项
很多人第一次接触 RAG 时,都会有一个疑问:大模型上下文都已经这么长了,为什么还要单独搞检索?为什么不直接把资料全部塞进 prompt?
这个问题看起来合理,但它忽略了一件事:大模型擅长的是"语言生成",不天然擅长"长期维护一个持续更新、可追溯、低成本的知识系统"。
换句话说,大模型的强项是:
- 把一句话说顺
- 把一段内容总结好
- 把一类任务泛化到很多问法上
而企业知识问答真正关心的是:
- 答案是不是最新的
- 答案是不是来自可信来源
- 能不能指出证据出处
- 知识一更新,系统能不能立刻跟上
- 成本和延迟能不能接受
这两类能力并不完全重合,所以大模型很强,不代表它天然能替代 RAG。
1.2 RAG 主要在解决哪四类问题
如果把 RAG 的价值说得简单一点,它主要在解决四类问题。
第一,知识时效性问题。
基础模型的知识来自训练语料,而训练语料总有截止时间。模型训练完成之后,它的参数基本就"冻结"了。你问它最近发布的政策、最新的公司制度、刚更新的产品说明,它可能会一本正经地说错。
第二,私有知识缺失问题。
你的公司制度、项目文档、内部 FAQ、采购合同、会议纪要、售后 SOP,不会天然出现在公开训练语料里。模型再聪明,也不知道你企业自己的知识细节。
第三,可解释性与可追溯问题。
纯模型回答经常是"听起来对",但你很难知道它为什么这么回答。RAG 的核心优势之一,就是能把答案和证据片段绑定起来,让系统不仅给出结论,还能给出依据。
第四,成本与延迟问题。
理论上你可以把大量资料直接塞进超长上下文,但这会带来明显的成本上涨、推理变慢以及中间信息被忽略的问题。RAG 的做法是:先筛,再答。 它不是让模型"读完所有材料再回答",而是让模型"只读最相关的几段材料再回答"。
1.3 RAG、长上下文、微调分别适合什么场景
这三者不是互斥关系,但职责不同。
| 方案 | 最擅长解决的问题 | 不擅长的问题 |
|---|---|---|
| RAG | 最新知识、私有知识、证据溯源、低成本更新 | 检索不到就答不出来,系统设计复杂 |
| 长上下文 | 一次性处理长文档、做长摘要、阅读型任务 | 成本高,资料一多就不稳定,不能替代长期知识库 |
| 微调 | 风格、格式、行为约束、领域表达习惯 | 新知识更新慢,事实溯源弱,训练成本高 |
最实用的理解方式是:
- RAG 解决"知道什么"
- 微调解决"怎么说"
- 长上下文解决"一次能看多少"
所以一个成熟系统常常不是三选一,而是组合使用。
1.4 哪些业务最适合先上 RAG
RAG 最适合下面这些问题类型:
- 企业知识库问答
- 文档问答与合同问答
- 产品手册、设备说明书检索
- 内部制度、报销、行政、法务、客服知识助手
- 对答案要求有来源依据的问答系统
如果你的业务核心诉求是"要基于最新资料回答,而且最好给出处",那它几乎天然适合 RAG。
2. 先看全局:一个 RAG 系统到底是怎么跑起来的
2.1 从一张图看懂最小 RAG 流程
先不要急着记模型名词,先把主流程记住。
#mermaid-svg-bHbZgiLsSrqGwGeb{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-bHbZgiLsSrqGwGeb .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-bHbZgiLsSrqGwGeb .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-bHbZgiLsSrqGwGeb .error-icon{fill:#552222;}#mermaid-svg-bHbZgiLsSrqGwGeb .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-bHbZgiLsSrqGwGeb .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-bHbZgiLsSrqGwGeb .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-bHbZgiLsSrqGwGeb .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-bHbZgiLsSrqGwGeb .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-bHbZgiLsSrqGwGeb .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-bHbZgiLsSrqGwGeb .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-bHbZgiLsSrqGwGeb .marker{fill:#333333;stroke:#333333;}#mermaid-svg-bHbZgiLsSrqGwGeb .marker.cross{stroke:#333333;}#mermaid-svg-bHbZgiLsSrqGwGeb svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-bHbZgiLsSrqGwGeb p{margin:0;}#mermaid-svg-bHbZgiLsSrqGwGeb .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-bHbZgiLsSrqGwGeb .cluster-label text{fill:#333;}#mermaid-svg-bHbZgiLsSrqGwGeb .cluster-label span{color:#333;}#mermaid-svg-bHbZgiLsSrqGwGeb .cluster-label span p{background-color:transparent;}#mermaid-svg-bHbZgiLsSrqGwGeb .label text,#mermaid-svg-bHbZgiLsSrqGwGeb span{fill:#333;color:#333;}#mermaid-svg-bHbZgiLsSrqGwGeb .node rect,#mermaid-svg-bHbZgiLsSrqGwGeb .node circle,#mermaid-svg-bHbZgiLsSrqGwGeb .node ellipse,#mermaid-svg-bHbZgiLsSrqGwGeb .node polygon,#mermaid-svg-bHbZgiLsSrqGwGeb .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-bHbZgiLsSrqGwGeb .rough-node .label text,#mermaid-svg-bHbZgiLsSrqGwGeb .node .label text,#mermaid-svg-bHbZgiLsSrqGwGeb .image-shape .label,#mermaid-svg-bHbZgiLsSrqGwGeb .icon-shape .label{text-anchor:middle;}#mermaid-svg-bHbZgiLsSrqGwGeb .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-bHbZgiLsSrqGwGeb .rough-node .label,#mermaid-svg-bHbZgiLsSrqGwGeb .node .label,#mermaid-svg-bHbZgiLsSrqGwGeb .image-shape .label,#mermaid-svg-bHbZgiLsSrqGwGeb .icon-shape .label{text-align:center;}#mermaid-svg-bHbZgiLsSrqGwGeb .node.clickable{cursor:pointer;}#mermaid-svg-bHbZgiLsSrqGwGeb .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-bHbZgiLsSrqGwGeb .arrowheadPath{fill:#333333;}#mermaid-svg-bHbZgiLsSrqGwGeb .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-bHbZgiLsSrqGwGeb .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-bHbZgiLsSrqGwGeb .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-bHbZgiLsSrqGwGeb .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-bHbZgiLsSrqGwGeb .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-bHbZgiLsSrqGwGeb .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-bHbZgiLsSrqGwGeb .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-bHbZgiLsSrqGwGeb .cluster text{fill:#333;}#mermaid-svg-bHbZgiLsSrqGwGeb .cluster span{color:#333;}#mermaid-svg-bHbZgiLsSrqGwGeb div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-bHbZgiLsSrqGwGeb .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-bHbZgiLsSrqGwGeb rect.text{fill:none;stroke-width:0;}#mermaid-svg-bHbZgiLsSrqGwGeb .icon-shape,#mermaid-svg-bHbZgiLsSrqGwGeb .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-bHbZgiLsSrqGwGeb .icon-shape p,#mermaid-svg-bHbZgiLsSrqGwGeb .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-bHbZgiLsSrqGwGeb .icon-shape .label rect,#mermaid-svg-bHbZgiLsSrqGwGeb .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-bHbZgiLsSrqGwGeb .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-bHbZgiLsSrqGwGeb .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-bHbZgiLsSrqGwGeb :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 原始文本 / PDF / 文档库
文本抽取与切块
向量化模型
向量数据库
用户查询
查询向量化
召回相关文本片段
与用户问题一起组织为 Prompt
大模型生成答案
这个流程非常重要,因为后面几乎所有概念,都是在解释这张图里的某一个方框。
比如:
- 切块是在解释为什么要把原始文本拆开
- Embedding 是在解释"向量化模型"到底干了什么
- BM25、稠密检索、混合检索是在解释"怎么召回相关片段"
- Reranker 是在解释"召回之后怎么重新排序"
- Prompt 约束是在解释"怎么让模型基于证据回答"
如果没有这张总图,后面的知识点就很容易变成一堆彼此割裂的术语。
2.2 用四个词记住主链路:ingestion、retrieval、augmentation、generation
很多工程资料会把 RAG 拆成四个核心环节:
- Ingestion:把原始资料处理成系统能检索的形式
- Retrieval:根据用户问题找回最相关的片段
- Augmentation:把问题和片段组织成给大模型的上下文
- Generation:让大模型基于上下文生成答案
如果你只记住这一套四段式结构,后面很多复杂方案都能快速归类。
例如:
- 切块、清洗、建索引,属于 ingestion
- BM25、向量检索、Rerank,属于 retrieval
- Prompt 拼装、引用格式、证据排序,属于 augmentation
- 回答生成、拒答策略、风格控制,属于 generation
2.3 一次真实提问会经过哪些步骤
举一个企业知识问答的例子。
用户问:
2026 年宁波出差,住宿标准是多少?
系统通常会经历下面这些动作:
- 先把制度文件、差旅政策、城市分级表提前处理好,切成合适的知识块并建库。
- 用户提问时,系统把"宁波出差住宿标准"转换成一个查询表示。
- 检索模块在知识库里找出最相关的政策片段,比如"二线城市住宿上限""宁波所属城市等级"等。
- 系统把这些片段和用户问题一起组织成一段给模型的上下文。
- 大模型基于这些证据生成答案,并在理想情况下附上引用来源。
这一串动作里,真正决定最终效果的,往往不是"大模型本身够不够强",而是:
- 资料有没有准备好
- 切块是不是合理
- 检索是不是召回了对的内容
- Prompt 有没有要求模型只依据证据回答
2.4 RAG 最容易失败的地方在哪里
RAG 的错误一般集中在四个位置:
第一类:资料本身有问题。 如果原文就是旧版本、脏数据、重复内容、OCR 错字,再强的模型也救不回来。
第二类:检索不到。 答案明明在知识库里,但因为切块不合理、索引不对、召回方式太单一,正确片段没有进候选集。
第三类:召回到了,但排位不对。 相关内容被召回了,但排得太靠后,被噪声片段挤掉,这时就需要混合检索和重排序。
第四类:模型没有老实使用证据。 明明上下文里已经给了答案,但模型仍然结合自己的常识脑补,导致答非所问或强行扩写。
所以 RAG 从来不是"给模型加个向量库"这么简单,它本质上是一个检索系统、提示系统和生成系统的组合工程。
3. 第一步:把知识库准备成系统能用的样子
3.1 不是所有数据都适合直接做 RAG
RAG 的前提不是"有文档",而是"有适合检索的知识"。
典型适合做 RAG 的数据有:
- PDF 手册
- Word 规章制度
- 产品说明书
- FAQ 文档
- Wiki 页面
- 邮件归档
- 表格说明文档
但不是所有数据都适合一股脑塞进去。以下情况尤其要谨慎:
- 已经过期但还没清理的制度
- 重复版本很多的合同模板
- OCR 质量很差的扫描件
- 结构极度混乱、上下文断裂严重的碎片文档
RAG 很吃数据质量,因为它不是自己创造知识,而是从你给它的材料里"找"知识。
3.2 数据抽取、清洗、去噪为什么是第一道门槛
很多人一上来就关注 Embedding 模型选哪个、向量数据库选哪个,但实际项目里,第一道门槛常常是数据抽取和清洗。
你通常需要处理这些问题:
- PDF 中页眉页脚、页码、目录、版权页是否要去掉
- 表格里的换行、空格、乱码是否要清洗
- 同一制度是否存在多个版本,需要保留哪个
- 标题层级、章节编号是否能抽取出来
- 图片文字是否需要 OCR
如果这一步不做,后面会发生什么?
- 切块会把无意义噪声也切进去
- 检索会召回目录页、页脚、页码
- 模型回答会混入旧版本信息
一句话总结:检索系统的上限,往往在建库前就已经被决定了一半。
3.3 为什么必须切块,而不是整篇文档直接入库
这是 RAG 最容易被误解的一步。
很多初学者会问:为什么不把整篇 PDF 直接当成一个文档做检索?
原因在于:用户的问题通常只对应文档中的某一小段,而不是整篇文档。
如果一整篇几十页的文档只生成一个向量,那么:
- 向量语义过于平均,重点被稀释
- 问题只能匹配"大概主题",很难匹配到具体细节
- 召回回来之后,上下文过长,模型使用成本高
反过来,如果切得太碎,也会出问题:
- 因果关系被切断
- 指代对象丢失
- 法律条款前后约束断开
所以切块的本质是:在"检索粒度足够细"和"语义上下文足够完整"之间找平衡。
3.4 常见切块策略应该怎么理解
常见切块方法可以这样理解。
1. 固定长度切块
最简单,按字符数或 token 数硬切。好处是实现快,坏处是容易把一句话拦腰截断。
2. 递归切块
优先按段落切,切不下再按句子切,再切不下再按逗号切。这是很多通用 RAG 项目的默认做法,因为它兼顾了可控性和自然边界。
3. 句级切块
适合结构很规整的材料,比如英文说明文、较短 FAQ,但如果文档的意义依赖跨句上下文,效果可能不稳定。
4. 语义切块
不是看字数,而是尽量在主题发生变化的地方切。这种方式更贴近人类阅读逻辑,但实现和成本更高。
5. 父子块策略
用小块负责"找",用大块负责"给模型看"。也就是:
- 子块提高召回精度
- 父块保留上下文完整性
这在长合同、规章制度、设备手册里非常有用。
3.5 元数据为什么经常比向量本身还重要
很多新手把注意力全放在"相似度"上,但工程里经常更致命的问题其实是约束条件。
比如用户问的是:
- 2026 年版本,而不是 2024 年版本
- 华东区政策,而不是全国政策
- A 分店价格,而不是 B 分店价格
- 公开权限文档,而不是内部密级资料
这时候,仅靠向量相似度很容易召回"语义相近但业务上错误"的内容。
所以元数据通常要至少保存:
- 文档来源
- 发布时间 / 生效时间
- 章节标题
- 文档类型
- 权限标签
- 部门 / 区域 / 产品线
向量负责"像不像",元数据负责"能不能用、该不该用"。
4. 第二步:让机器有能力"找到相关内容"
到这里,读者终于可以自然地引入 Embedding、Encoder、向量数据库这些概念了。
因为前面已经明确:我们之所以要学这些东西,不是为了背概念,而是因为系统必须有能力把"用户问题"和"知识块"放到一个可比较的空间里。
4.1 什么是 Embedding,它在 RAG 里到底负责什么
Embedding 可以先不理解成复杂模型,先理解成一句话:
它是把一段文本变成一组可计算的数字表示。
为什么要这么做?因为机器没法像人一样直接感知"这两句话是不是差不多一个意思",它需要先把文本转成向量,再在向量空间里比较谁离谁更近。
在 RAG 里,Embedding 至少参与两件事:
- 把知识库里的每个文本块转成向量,提前存起来
- 把用户当前的问题也转成向量,拿去和库里的向量比较
所以 Embedding 不是配角,它是语义检索真正的入口。
4.2 为什么检索常用 Encoder,生成常用 Decoder
这一点如果不讲清楚,后面读者就会一直困惑:为什么同样都属于"大模型家族",有的拿来做向量,有的拿来写答案?
要把这个问题讲明白,最好的方式不是先背术语,而是先回到 RAG 到底要完成哪两类任务。
在一个最基本的 RAG 系统里,模型其实要做两件完全不同的事:
- 先理解文本:把"用户问题"和"知识库片段"变成可比较的表示,判断谁和谁更相关。
- 再生成答案:拿着已经召回的证据,组织成一段自然、完整、可读的回答。
注意,这两件事看起来都和"文本"有关,但底层能力要求并不一样:
- 第一步更像是在做"读懂"和"比较"
- 第二步更像是在做"续写"和"表达"
这正是 Encoder 和 Decoder 在 RAG 里分工不同的根本原因。
先说什么是 Encoder
你可以先把 Encoder 理解成一种更擅长"阅读理解"的模型结构。
它接到一句话之后,重点不是立刻往后生成新文字,而是先把这句话内部的语义关系尽量看清楚,然后输出一个压缩后的表示。
比如一句话是:
宁波出差住宿标准
对于 Encoder 来说,它更关心的是:
- "宁波"是地点
- "出差"是场景
- "住宿标准"是核心查询目标
- 这些词组合在一起,表达的是一个什么需求
也就是说,Encoder 的核心价值不是"会不会写",而是:
- 表达语义
- 比较相似度
- 输出稳定的向量表示
这正好对应了 RAG 检索阶段的需求,因为检索阶段最重要的不是输出一句漂亮的话,而是把问题和文档都变成一个可以比较远近的语义表示。
再说什么是 Decoder
你可以把 Decoder 理解成一种更擅长"根据上下文继续往下写"的模型结构。
它拿到输入之后,最擅长的事情不是把整段话压缩成一个检索向量,而是根据前面已经给出的内容,一步一步预测下一个 token 应该是什么,最终把整段答案写出来。
比如系统已经拿到了这些证据:
二线城市差旅住宿上限为每晚 500 元,宁波属于二线城市。
这时候 Decoder 更擅长做的是:
- 把证据读进来
- 理解用户当前在问什么
- 按自然语言习惯组织回答
- 决定下一句该怎么说,下一词该怎么接
所以 Decoder 更适合做的事是:
- 生成答案
- 补全文本
- 组织表达
- 按指令输出结构化内容
这就正好对应了 RAG 的生成阶段。
它们和 RAG 的关系,到底该怎么一步一步理解
现在把这两个定义放回 RAG 流程里,就很清楚了。
第一步,RAG 要先找资料。
要找资料,系统必须先判断"用户问题"和"哪段知识最相关"。这一步本质上是在做语义理解和相似度比较,所以更适合交给 Encoder 类模型,也就是常见的 Embedding 模型。
第二步,RAG 再基于资料回答。
资料找回来之后,系统要把"问题 + 证据"变成一段自然回答。这一步本质上是在做条件生成,所以更适合交给 Decoder 类模型,也就是常见的大语言模型。
因此,RAG 里的典型分工不是随便定的,而是由任务本身决定的:
- 检索端靠 Encoder / Embedding 模型理解文本
- 生成端靠 Decoder / LLM 组织答案
为什么不反过来用
这里很多读者还会继续追问:那能不能让 Decoder 去做检索,或者让 Encoder 去直接回答?
可以非常简洁地理解:
- Decoder 不是完全不能做向量,但它的强项不在高效、稳定地输出检索表示,做这件事通常成本更高,效果也未必是最优。
- Encoder 也不是完全不能做任务输出,但它更适合分类、匹配、表征,不擅长长段自然语言生成。
所以在工程实践里,大家才逐渐形成了今天这套最常见的 RAG 组合:
- 前面用 Encoder 把文本"看懂"
- 后面用 Decoder 把答案"说出来"
你可以把它记成一句最实用的话:
RAG 不是让一个模型包办一切,而是让最擅长"理解"的模型负责找资料,让最擅长"表达"的模型负责写答案。
4.3 相似度、向量空间、非对称检索到底在说什么
前面已经讲过,Embedding 会把文本变成向量。接下来真正的问题才开始出现:
系统拿到这些向量之后,到底是怎么判断"哪段文本更相关"的?
这里至少有三个概念必须讲清楚:
- 相似度是什么
- 向量空间是什么
- 为什么 RAG 经常面对的是非对称检索问题
先说什么是相似度
在检索系统里,相似度可以理解成一种"打分规则"。
它回答的问题不是"这两段文本是不是完全一样",而是:
如果把用户问题和文档片段都转成向量,它们在数值上到底有多接近?
最常见的做法是 余弦相似度。它的直觉不是看"谁更长",而是看"谁的方向更一致"。
你可以把它想象成两支箭头:
- 如果两支箭头几乎指向同一个方向,说明它们表达的语义更接近
- 如果两支箭头方向差很多,说明它们表达的语义更远
在很多向量检索系统里,还会看到 点积 或 L2 距离。它们本质上也是在回答"谁更接近"的问题,只是计算方式不同。工程上最重要的不是背公式,而是知道:
- 余弦相似度更强调方向是否一致
- L2 距离更像在看空间位置上离得多远
- 点积在很多做了归一化的场景里,可以近似看成和余弦相似度效果接近,但计算更高效
也就是说,所谓"相似度计算",本质上就是在给候选文档排分,看哪几段最值得进下一轮。
再说什么是向量空间
向量空间这个词听上去抽象,但你可以先把它理解成:
一个专门用来摆放文本语义位置的高维地图。
每一段文本在被 Embedding 模型处理之后,都会变成这个"地图"上的一个点。
如果两段文本语义相近,比如:
怎么挑甜西瓜买西瓜主要看什么特征
它们在这个空间里通常就会靠得更近。
如果两段文本主题完全不同,比如:
怎么挑甜西瓜二线城市差旅报销标准
它们在这个空间里通常就会离得更远。
所以,向量空间不是一个额外的花哨概念,它其实是在回答:
系统凭什么能把"语义相近"这件事变成"数值距离相近"。
一旦你接受了"文本被放进一个可比较远近的空间里"这个想法,后面的检索就很好理解了:
- 用户问题先变成查询向量
- 知识库片段已经提前变成文档向量
- 系统在这个空间里找"离查询最近的点"
这就是向量检索最核心的动作。
为什么 RAG 里经常会遇到非对称检索
如果只讲"向量越近越相关",还是不够,因为 RAG 不是一个标准的同句匹配任务。
在很多 NLP 任务里,我们比较的是两段形态差不多的文本,比如:
- 一个问题和另一个问题
- 一句短句和另一句短句
- 一个标题和另一个标题
但在 RAG 里,更常见的情况是:
- 查询很短
- 文档片段更长、更完整、更像解释性文本
这就叫 非对称检索。
例如:
- 查询:
宁波出差住宿标准 - 文档片段:
二线城市差旅住宿上限为每晚 500 元,宁波属于二线城市。
它们不是同一句话,也不共享完全相同的措辞,甚至长度差很多,但系统希望它们在语义空间里离得很近。
非对称检索难就难在这里:
- 查询往往像关键词压缩版
- 文档往往像解释性正文
- 两边的语言风格、长度、信息密度并不对称
所以 RAG 不能随便拿一个"普通文本相似模型"来做检索,而要优先选择那些对 query-passage 场景做过优化的模型。很多检索模型之所以要特别强调"问答检索""query-document""instruction-tuned retrieval",本质上就是在解决这个非对称问题。
这一节和 RAG 的关系,应该怎么落回主线
到这里可以把逻辑收回来:
- 相似度解决的是"怎么打分"
- 向量空间解决的是"为什么语义能被比较远近"
- 非对称检索解释了"为什么 RAG 的检索比普通文本匹配更难"
所以这一节不是在补数学背景,而是在说明:
RAG 的检索之所以有门槛,是因为它不是简单地找相同词,而是要在高维空间里,让一个短问题去匹配一段更长、更完整的知识片段。
4.4 常见语义向量模型在 RAG 中分别扮演什么角色
到这里,读者通常会碰到第二个困惑:既然都是"把文本变成向量",为什么又冒出来这么多模型名字?
这时候一定要先讲一句总括:
语义向量模型并不只是"把文本转成数字"这么简单,它们的差别在于:谁更擅长把真正相关的文本拉近,把容易混淆的文本分开。
也就是说,向量模型之间的区别,主要不在"能不能输出向量",而在"输出出来的语义空间好不好用来做检索"。
先说这类模型在 RAG 里共同扮演什么角色
无论你用 SBERT、SimCSE、CoSENT,还是 BGE、M3E,这些模型在 RAG 里的共同角色都是:
- 离线阶段:给知识库片段生成向量,建立索引
- 在线阶段:给用户查询生成向量,用来找最相似的知识片段
所以它们本质上都属于 检索前端的语义表征器。如果这个表征器做得不好,后面向量数据库再快、Prompt 再精致,也是在错误候选上做优化。
真正应该关心的问题是:
- 它是不是更适合中文
- 它是不是更适合问答检索
- 它对短 query 和长 passage 的匹配能力如何
- 它对领域术语、近义表达、改写问法的区分能力如何
SBERT:把"句子表示"这件事真正推向检索可用
SBERT 的重要性,不在于今天它一定是最强,而在于它是一个非常关键的里程碑。
在它之前,BERT 更常被直接拿去做分类、匹配或下游任务微调。SBERT 让业界开始更系统地接受一个思路:
一句完整的文本,可以被压缩成一个句向量,并且这个句向量可以直接拿去做相似检索。
所以 SBERT 的历史意义是:它让"句向量可用于检索"这件事变得清晰、可行、可复用。很多后续工作,某种程度上都在继续优化这个方向。
SimCSE:把语义空间拉得更开,让"近的更近、远的更远"
SimCSE 常被提到,是因为它把对比学习更直接地引入了句向量训练。
如果用最直观的话说,SimCSE 在做的事就是:
- 让语义相近的句子更靠近
- 让语义不同的句子更远离
这件事对检索非常关键,因为一个检索系统怕的不是"完全无关的东西排进来",而是"几个看起来都差不多像,但真正最相关的没有排在前面"。
SimCSE 的价值就在于,它强化了向量空间的区分能力,让系统在语义检索时更容易把真正相关的句子聚在一起。
CoSENT:把重点从"像不像"推到"排得对不对"
CoSENT 更值得从排序角度理解。
对 RAG 来说,系统最终不是只要判断"相关 / 不相关",而是要在一堆候选里决定:
- 哪一段排第一
- 哪一段排第二
- 哪一段虽然有点相关,但应该排后面
所以 CoSENT 的重要价值在于:
它更强调排序一致性,而不是单纯做静态相似判断。
这对检索任务非常重要,因为用户最终看到的不是"这几段都挺像",而是一个有顺序的 TopK 结果列表。
BGE、M3E:为什么它们才是今天中文 RAG 更常见的落地选择
如果说前面的模型更像是在讲"句向量路线是怎么一步步发展起来的",那 BGE、M3E 这类模型更像是在回答:今天做中文 RAG,工程里到底该优先用什么。
它们之所以更常见,核心原因有三个:
- 更贴近检索场景:不是只做通用语义表示,而是更强调 query-document 检索效果。
- 中文适配更好:对中文问法、中文知识库、中文领域术语的表现通常更稳定。
- 工程可用性更强:在开源生态、部署成本、推理速度、社区使用经验上更贴近真实项目。
特别是在中文知识库问答里,你经常会碰到:
- 问句很短
- 文档很长
- 同义表达很多
- 专有词密集
这类场景下,BGE、M3E 这类检索模型通常会比"只强调通用文本相似"的模型更稳。
不要把这些模型理解成"名单",要理解成"演化路径"
这一节最容易写成模型点名册,但真正有用的理解方式应该是:
- SBERT 让句向量检索这件事可行
- SimCSE 提升语义空间的区分能力
- CoSENT 更强调排序质量
- BGE、M3E 更贴近中文 RAG 的实际落地
你可以把它们看成同一类工具的不同阶段与不同侧重点:
它们的共同目标,都是把文本映射到一个更适合检索的语义空间里;
它们的差异,在于这个空间到底是更偏"通用句向量",还是更偏"真实检索排序"。
这也解释了为什么做 RAG 时,模型选择不该只看榜单分数,而要看它是否真正适合你的 query、语料形态和中文场景。
4.5 向量数据库与 ANN 索引是在解决什么问题
到这里就自然能引出向量数据库了。因为前面已经有了三个前提:
- 文本已经切成了知识块
- 知识块已经被转成了向量
- 用户查询也会被转成向量
接下来系统真正面对的问题是:
向量都有了,放哪?怎么查?怎么在百万级候选里快速找到最相似的那几条?
这就是 向量数据库 和 ANN 索引 出现的原因。
先说什么是向量数据库
很多人第一次听到"向量数据库"时,会误以为它只是一个"专门存向量的仓库"。这只说对了一半。
更完整的理解应该是:
向量数据库是一类面向相似度检索设计的存储与检索系统。
它通常不只存向量,还会一起存:
- 向量对应的原文片段
- 文档 ID
- 标题、页码、来源、时间等元数据
- 检索所需的索引结构
也就是说,向量数据库解决的不是单纯"把数值存起来",而是完整解决这几个问题:
- 怎么存:把文本块、向量、元数据关联起来
- 怎么查:给一个查询向量,迅速找到最相近的候选
- 怎么过滤:按时间、权限、部门、版本等条件筛选
- 怎么更新:文档新增、删除、重建索引时如何维护
如果只把向量看成一串数字,而不考虑原文、元数据和过滤能力,那就还没真正进入 RAG 工程阶段。
它和普通数据库、搜索引擎有什么不同
普通关系型数据库擅长的是精确条件查询,比如:
- 找 ID 等于 123 的记录
- 找时间在某个范围内的数据
传统搜索引擎擅长的是关键词匹配,比如:
- 文档里有没有出现某个词
- 某个词出现了几次
而向量数据库擅长的是另一类任务:
给定一个查询的语义表示,找语义上最接近的内容。
所以它不是在查"有没有这个词",而是在查"哪几段内容在意思上最像这个问题"。
当然,这三类能力在现代系统里并不是完全割裂的。像 Elasticsearch 这种系统,本来是强全文检索引擎,后来也逐步把向量检索、BM25、过滤条件整合在了一起,因此它在很多混合检索场景里很有吸引力。
再说什么是 ANN 索引
如果知识库只有几十条内容,最简单的方法就是:
- 把查询向量和每个文档向量都算一遍相似度
- 最后选出分数最高的前几个
这叫 精确搜索,思路简单,也最准。
但如果你的知识库有:
- 10 万个文档块
- 100 万个文档块
- 甚至更大的规模
那每次查询都全量扫描,延迟和成本都会迅速上升。这时候就需要 ANN(Approximate Nearest Neighbor,近似最近邻)索引。
ANN 的核心思想可以概括成一句话:
不用把全库每个向量都认真比一遍,也能非常快地找到"足够接近正确答案"的候选。
注意这里的关键词是"近似"。
它不是百分之百保证找到全局最优,但会在 召回率、速度、内存占用 之间做工程上的平衡。这正是大规模 RAG 检索系统必须面对的现实取舍。
常见索引到底分别在做什么
Flat
Flat 就是最直接的全量暴力搜索。查询来了,把它和库里每个向量都比一遍。
- 优点:最准,逻辑最简单,适合作为基线
- 缺点:数据一大就慢,内存和计算成本都高
所以 Flat 更像"标准答案"或"小规模场景的朴素解法",而不是大规模线上系统的长期方案。
IVF
IVF 可以理解成"先粗分组,再组内精找"。
它会先把向量空间分成很多簇。查询来了,先判断它最可能落在哪几个簇里,然后只在这些局部区域继续搜索,而不是扫全库。
- 优点:比全量扫描快得多
- 缺点:如果分桶没分好,可能错过真正最优候选
所以 IVF 的本质是:先缩小范围,再在较小候选集里比较。
HNSW
HNSW 是今天工程里非常常见的一类 ANN 索引。它的思路不是"先分桶",而是"建图"。
你可以把它想成:
- 每个向量都是图上的一个点
- 点和点之间会连接一些"邻居"
- 检索时不是全量扫,而是沿着图上的路径一步步逼近最相似区域
它之所以常见,是因为在很多真实场景里,HNSW 在速度和效果之间的平衡非常优秀。
但代价也很现实:
- 建索引可能更慢
- 占用更多内存
所以它很强,但不是零成本。
PQ
PQ 解决的问题和前面几个不完全一样。它更关心的是:
向量太大、太占空间时,怎么压缩。
向量维度高、数据量大时,存储成本会很惊人。PQ 会把向量拆成若干子空间,对每一小段分别量化编码,从而显著减少存储开销。
- 优点:节省内存,适合超大规模场景
- 代价:会有一定精度损失
所以 PQ 往往不是单独理解为"一个更好的搜索算法",而是理解成"为了规模化而做的压缩手段"。
向量数据库和索引的关系要分清
这一点特别容易混淆。
- 向量数据库 是整个系统容器,负责存储、查询、过滤、更新
- ANN 索引 是它内部为了提速使用的关键结构之一
两者不是并列替代关系,而是:
向量数据库经常会在内部提供多种索引选项,让你根据规模、延迟、内存预算来选。
所以你不应该问"我到底是用向量数据库,还是用 HNSW",因为这两个层级不一样。
常见工程选型到底该怎么理解
这部分也不能只停留在"谁轻、谁重"的口号上。
FAISS
FAISS 更像一个高性能向量检索库,而不是一套完整的线上数据库服务。它非常适合:
- 本地实验
- 单机原型
- 快速验证向量检索效果
- 对检索底层结构有较强自定义需求的场景
它强在性能和灵活度,但如果你需要现成的在线服务能力、复杂过滤、运维友好性,还得额外补很多工程工作。
Chroma
Chroma 很受欢迎,是因为上手门槛低,和 Python 生态、原型开发结合方便。它很适合:
- 教学
- Demo
- 轻量 MVP
- 本地知识库工具
它的优势不是极限性能,而是"低门槛把 RAG 跑起来"。
Qdrant
Qdrant 在工程上被很多人喜欢,核心原因是它在这几个维度上比较均衡:
- 向量检索能力成熟
- 元数据过滤体验好
- API 友好
- 更接近线上服务化使用习惯
如果你的场景里经常要做"先按条件过滤,再按语义排序",Qdrant 往往会比纯向量库思维更顺手。
Milvus
Milvus 更偏大规模和分布式。它适合的是:
- 数据量大
- 并发要求高
- 希望把向量检索作为正式基础设施来建设
它不是不能做小项目,而是它的价值通常要在规模上来之后才更明显。
Elasticsearch
Elasticsearch 最值得强调的不是"它也能做向量",而是:
它本身就非常强于全文检索和 BM25,因此在需要关键词检索 + 向量检索 + 复杂过滤一起上场的场景里,非常自然。
尤其是如果你的业务本来就高度依赖:
- 关键词命中
- BM25 相关性
- 条件过滤
- 排序与检索统一管理
那么 Elasticsearch 会是一个很值得考虑的统一搜索底座。
这一节应该怎么落回 RAG 主线
把整节收回来,其实只需要记住一句话:
向量模型解决"怎么把文本表示成可比较的语义点",向量数据库和 ANN 索引解决"这些点变多以后,系统怎么还查得动、查得快、查得稳"。
没有向量模型,系统不知道如何理解语义;没有向量数据库和 ANN 索引,系统就算理解了语义,也撑不起真实规模的检索。
5. 第三步:真正把相关文本召回回来
这一章是 RAG 的核心。
因为生成模型再强,也必须先吃到对的材料。
5.1 稀疏检索为什么到今天都没过时
很多人刚接触 RAG,会误以为"有了向量检索,关键词检索就过时了"。
实际完全不是这样。
当用户问的是:
- 特定合同编号
- 产品型号
- 专有缩写
- 人名、地名、药品名
- 精确版本号
仅靠语义检索非常容易"意思差不多,但对象不对"。
所以关键词检索,也就是稀疏检索,今天依然是很多系统的基础组件。
5.2 按难度递进理解:词袋模型、TF-IDF、BM25
这部分最适合按递进关系来理解。
1. 词袋模型
词袋模型的思想最朴素:一篇文档出现了哪些词,每个词出现了多少次。
它的优点是简单直接,缺点是也很明显:
- 不知道词的重要程度差异
- 不知道"这个词是不是所有文档里都很常见"
- 不考虑文档长度差异
2. TF-IDF
TF-IDF 比词袋模型聪明一步。
它不只看词在当前文档里出现了几次,还看这个词在整个语料库里是不是很常见。
直觉上:
- "的、了、是"这类词出现再多也没什么用
- "HNSW""QLoRA""XZ_974"这类词辨识度更高
所以 TF-IDF 的核心逻辑是:既看局部词频,也看全局稀缺性。
3. BM25
BM25 是今天工程里最有实战感、也最常见的稀疏检索算法。
它比简单词频更聪明,主要考虑了四件事:
- 词出现了多少次
- 这个词是不是到处都常见
- 文档长短差异
- 词频不是越高越好,而是会边际递减
最后这一点特别关键。
也就是说:一个词从出现 1 次增加到 3 次,通常很有信息量;但从 30 次增加到 60 次,对"这篇文档就是在讲它"这件事,并不会带来同等幅度的新信息。
BM25 就把这种词频饱和考虑进去了。
在很多搜索引擎里,BM25 甚至就是默认相关性算法。实践里你常会看到两个参数:
- k1:控制词频增长后多快进入"边际递减"
- b:控制文档长度归一化的强弱
所以 BM25 到今天都没过时,不是因为它"老而经典",而是因为它在大量现实检索问题里,依然非常有效。
5.3 稠密检索擅长什么,为什么不能只靠它
稠密检索如果只写一句"它能理解语义",读者其实还是不知道它到底在做什么。
更准确的理解方式是:
稠密检索(Dense Retrieval)是指用稠密向量来做相似搜索。
这里的"稠密",不是说文本更密,而是说向量表示通常每个维度都有信息,不像词袋或 BM25 那样大部分维度都是 0。
它在 RAG 里的工作流通常是这样的:
- 用同一套或兼容的向量模型,把知识块编码成文档向量
- 把用户问题编码成查询向量
- 在向量空间里找和查询向量最接近的文档向量
它最大的优势,是能跨越字面差异理解"意思是否接近"。
比如:
怎么挑甜西瓜买西瓜主要看什么特征
虽然字面完全不同,但语义非常接近,稠密检索往往能把它们关联起来。
这对下面几类情况尤其有价值:
- 同义表达
- 口语化提问
- 用户问法改写
- 文档里没有完全重复的关键词,但有等价表述
但它的短板也要明确点出来,不然读者会误以为"有向量检索就够了"。
稠密检索最常见的风险有三类:
- 对精确编号不稳定:比如合同号、设备型号、药品编码,这类词只要错一个字符,业务含义可能完全不同。
- 对领域缩写不一定敏感:如果模型没见过这个缩写,或者训练时没有足够多的上下文,它未必理解得准确。
- 容易语义像,但对象错:比如用户问的是"2026 版制度",系统却召回了"2024 版制度",因为两者主题太像。
所以稠密检索很强,但它擅长的是"懂意思",不是"保精确"。
这也是为什么在真实 RAG 里,它通常要和稀疏检索、元数据过滤、Reranker 一起配合,而不是单独统治一切。
5.4 混合检索为什么往往比单路检索更稳
当你已经理解了稀疏检索和稠密检索的长短板,就很容易明白为什么工程上经常会走向 混合检索(Hybrid Retrieval)。
混合检索不是一个神秘新算法,它的本质非常朴素:
让不同检索机制各管自己擅长的那一部分,再把结果融合起来。
最经典的组合就是:
- 让 BM25 负责抓硬词、编号、专有术语
- 让 向量检索 负责抓同义表达、改写问法、隐含语义
为什么这套组合通常更稳?因为真实用户的问题往往同时包含两类信息:
一类是"必须精确匹配"的部分,比如:
- 2026
- XZ_974
- 宁波
- 某部门专用术语
另一类是"允许语义理解"的部分,比如:
- 住宿标准
- 报销上限
- 采购要求
- 处理流程
如果只用 BM25,系统会很依赖字面词汇,容易错过改写问法。
如果只用向量检索,系统又可能在"主题很像"的文档之间混淆版本、编号或对象。
所以混合检索真正稳的地方,不在于它听起来高级,而在于它同时照顾了:
- 字面精确性
- 语义泛化能力
工程上最常见的实现方式有三类:
第一类:双路并行召回
- 一路跑 BM25
- 一路跑向量检索
- 最后把结果合并、去重
第二类:融合排序
把两边结果拉到一起后,不是简单拼接,而是按某种融合规则重新排序。最常见的方式之一就是 RRF(Reciprocal Rank Fusion),它不强依赖不同分数体系的绝对可比性,而是更看重"在各自榜单里排得靠不靠前"。
第三类:多阶段检索
先用一种方式粗召回,再用另一种方式补召回或重排。比如:
- 先用 BM25 找候选,再用向量检索补充语义相关片段
- 或者先做向量召回,再用关键词约束做过滤
所以混合检索并不是"把两种方法都打一遍就完事",而是要思考:
- 两边各自召回多少条
- 如何去重
- 如何融合排序
- 哪一类业务词应更依赖 BM25
- 哪一类问题更依赖语义检索
如果知识库同时包含结构化术语和大量自然语言说明,混合检索通常会比任何单路方案都更稳。这也是很多现代 RAG 系统把它当默认方向的原因。
5.5 为什么很多系统最后还要加一个 Reranker
很多初学者做到这里会问:
既然前面已经有了 BM25、向量检索,为什么还要再加一个 Reranker?
关键在于:
召回和精排,本来就不是一件事。
前面的检索模块,更像是在做"不要漏掉正确答案"。它的目标是把可能相关的候选先尽量找出来。
但"找出来"不等于"排得对"。
在真实场景里,经常会出现这种情况:
- 前 20 条里其实已经有正确片段
- 但真正最该给模型看的那条,没有排在最前面
- 甚至前面挤着几条主题相似、但关键约束不对的噪声片段
这时候就需要 Reranker(重排序模型)。
你可以把它理解成:
- 第一阶段检索是 粗筛
- 第二阶段 Reranker 是 精排
Reranker 到底在重排什么
前面的双塔向量检索,通常是把查询和文档分别编码,再做向量比较。这种方式速度快,适合大规模召回,但它的缺点是:
查询和文档之间的细粒度交互不够充分。
例如下面这两段文档:
宁波属于二线城市,住宿上限 500 元。二线城市住宿上限 500 元,但宁波项目组 2026 年起执行特例标准。
对粗召回来说,它们都很像。
但对最终回答来说,这两段的优先级可能完全不同。
Reranker 的作用,就是在一个更小的候选集上,把查询和文档放在一起做更细的相关性判断,重新给出排序。
为什么它常常是"能用"和"好用"的分水岭
Reranker 特别擅长修复下面这些问题:
- 否定关系没看清
- 数字、版本、对象差一点点但业务含义不同
- 多个候选主题都像,但只有一个真正正面回答了问题
- 查询和文档在局部细节上需要强交互才能分辨
也就是说,Reranker 不负责把答案从零找出来,它更像是在说:
"你前面找回来的这些候选里,到底谁最该排前面?"
为什么不一开始就全用 Reranker
原因也很简单:贵。
Reranker 的细粒度判断能力强,通常意味着计算更重。如果你让它对 10 万条文档逐条重排,成本会非常高,延迟也难以接受。
所以实际工程里最合理的方式通常是:
- 先用 BM25 / 向量检索做大范围粗召回
- 把候选缩到一个较小集合,比如 Top 20、Top 50、Top 100
- 再用 Reranker 做精排
这也是为什么现代 RAG 经常采用 retrieval + rerank 两阶段结构。
所以你可以把这一节记成一句话:
检索负责别漏,Reranker 负责别排错。
6. 第四步:把召回结果交给大模型,生成可用答案
6.1 augmentation 不是拼接文本,而是组织证据
很多人把 augmentation 理解成"把检索结果复制进 prompt 里",这太浅了。
更准确的说法是:augmentation 是把问题和证据组织成大模型最容易正确利用的输入结构。
这里至少要考虑三件事:
- 哪些片段该放前面
- 是否要保留标题、来源、页码
- 是否要把多个片段做简要整理后再送给模型
如果组织不好,就会出现两种典型错误:
- 证据明明召回了,但模型没抓住重点
- 证据太多,噪声把关键信息淹没了
6.2 一个可靠答案通常需要哪些约束
一个更可靠的 RAG Prompt,通常至少包含下面几层要求:
- 明确告诉模型:优先依据给定证据回答
- 如果证据不够,就明确拒答或说明不知道
- 尽可能输出引用来源或证据编号
- 避免把模型常识和检索证据混在一起
一个非常常见的约束模板是:
text
问题:<用户问题>
参考资料:
<检索得到的片段>
要求:
1. 仅根据参考资料回答。
2. 如果参考资料没有答案,明确说明"现有资料不足以回答"。
3. 回答时标注对应的证据来源。
这类约束看起来朴素,但对减少幻觉非常有效。
6.3 为什么同样的召回结果,答案质量也会差很多
即使检索结果一样,不同系统的最终答案也可能差很多,原因通常在生成端:
- Prompt 是否明确
- 证据是否按重要性排序
- 是否保留来源信息
- 模型是否具备足够好的拒答能力
- 是否支持引用和溯源
RAG 的常见误区之一,就是把所有问题都归咎于检索。
实际上很多"答得不像人话"或者"明明找到了还答错"的问题,是生成端组织方式造成的。
6.4 多轮对话为什么会让 RAG 变难
单轮问答里,用户的问题通常比较完整。
但多轮对话里,用户会大量使用:
- 它
- 这个
- 上面那个
- 那宁波呢
- 那第二条怎么理解
这些问法对人类很自然,对 RAG 却很难。
因为检索系统拿到的是当前这一句,而当前这一句往往不完整。
所以多轮 RAG 经常需要增加一个步骤:先做查询改写,再做检索。
也就是把"那宁波呢"改写成更完整的查询,比如:宁波出差住宿标准是多少。
这也是后面 MultiQuery、会话改写等优化策略存在的原因。
7. 从 Demo 到生产:哪些优化最值得优先做
7.1 先调切块,再调检索,再调生成
实际优化顺序非常重要。
很多团队一看到结果不好,就立刻换更大的模型、更贵的模型,甚至想微调。
但大多数时候,更有效的顺序其实是:
- 先看切块是否合理
- 再看检索方式是否单一
- 再看是否需要 Reranker
- 最后才看生成端提示与模型选择
这是因为 RAG 的错误通常首先出在"没把对的材料找回来"。
7.2 Query Rewrite、MultiQuery、HyDE 到底是什么,分别在补哪类短板
这一组方法都属于查询侧优化。
也就是说,它们不是去改文档,不是去换向量数据库,也不是去替代检索,而是在用户问题正式进入检索器之前,先把"查询这一步"处理得更像检索系统能理解的话。
为什么要这么做?因为真实用户的问题经常有三类毛病:
- 太短:比如"那宁波呢""这个怎么退"
- 太口语:比如"售后咋整""积分能提现吗"
- 和文档语言不一致:用户说"取消会员",文档里写的却是"会员订阅注销"
如果直接把这类原始问题做 embedding,再去向量检索,召回效果会很不稳定。原书把这部分叫作"查询内容优化",核心就是在补这个入口短板。
1. Query Rewrite:把问题改写成更适合检索的单次查询
Query Rewrite 的意思,就是先让大模型把原始问题改写成一条更完整、更明确、更适合检索的查询,再拿这条新查询去搜。
例如在多轮对话里:
- 用户上一轮问:
中国的首都在哪儿? - 助手答:
北京。 - 用户继续问:
那里有哪些好玩的景点?
这时直接检索 那里有哪些好玩的景点 基本没法用,因为"那里"这个指代信息丢了。更合理的做法是先改写成:
北京有哪些好玩的景点?
在客服场景里也是一样:
这个能退吗改写成签收后 7 天内是否支持无理由退货会员可以提现吗改写成会员积分是否支持提现或直接抵现
所以它补的是:
- 指代不清
- 语义过短
- 口语噪声太多
它最适合的问题是:原始问题方向是对的,但表达得不完整。
2. MultiQuery:不是改写一次,而是换多个角度一起搜
MultiQuery 可以理解成"单条查询改写"的升级版。
它不是只生成一条更规范的查询,而是围绕同一个问题生成多种不同说法,然后让系统分别检索,再把结果合并去重。
例如用户问:
会员可以提现吗?
系统可能扩展出:
会员积分可以提现吗积分是否支持兑换现金积分可以提现吗或抵扣运费会员权益中的积分使用限制
这样做的原因是:一个问题落到向量空间里,只是一个点;换几种说法一起搜,相当于从多个方向同时逼近目标。
它补的是:
- 同义词很多
- 用户和文档措辞差异大
- 单次检索容易漏掉一部分相关片段
所以 MultiQuery 的重点不是"更聪明地回答",而是扩大召回覆盖面。
代价也很直接:
- 检索次数会变多
- 候选结果要去重
- 往往需要再加一层 rerank
3. HyDE:先生成一段"假想答案",再拿答案去搜
HyDE 全称是 Hypothetical Document Embedding,中文常译为"假设文档嵌入"。
这个方法解决的是另一个更隐蔽的问题:
在 RAG 里,用户给的是问题,但向量库里存的往往是"答案形态"的文档片段。
这两者并不对称。
比如用户问:
宁波出差住宿标准是多少?
而知识库里的真实文本可能写成:
二线城市差旅住宿上限为每晚 500 元,宁波属于二线城市。
问题是"问句形态",文档是"解释形态"。有些 embedding 模型对这种非对称检索并不稳定。于是 HyDE 的思路是:
- 先让大模型写一段"看起来像答案"的假想文本
- 再把这段假想文本拿去做向量检索
- 因为假想文本和真实知识库片段在语言形态上更接近,所以更容易召回真正相关的文档
注意,HyDE 生成的那段"假想答案"不要求事实完全正确,它只是拿来做检索中介。
它最适合:
- 用户提问很口语,但文档写得很正式
- 问题和答案在表达风格上差异很大
- query 很短,而文档是解释性长段落
它不太适合:
- 高度依赖精确编号的检索
- 对版本号、合同号、药品编码特别敏感的场景
因为这时"假想答案"如果写偏,反而可能把检索带歪。
4. 把三者放在一起看,到底怎么区分
你可以把这三种方法按"改写力度"来记:
| 方法 | 做了什么 | 更适合补哪类短板 |
|---|---|---|
| Query Rewrite | 把原问题改写成一条更完整的检索语句 | 指代不清、口语化、问题太短 |
| MultiQuery | 把同一问题扩成多种问法并行检索 | 单次检索覆盖不全、同义表达太多 |
| HyDE | 先生成一段假想答案,再拿"答案形态文本"去检索 | 问句和文档表达风格差异很大 |
所以它们都不是在"替代检索",而是在提高进入检索器之前的查询质量。
7.3 Parent Document、Multi-Vector、Metadata Filter 分别是在重做哪一步
如果说上一节是在优化"问题怎么进来",这一节就是在优化"文档怎么被找、找到后怎么给模型看"。
1. Parent Document:检索时用小块命中,给模型时用大块上下文
Parent Document 解决的是一个经典矛盾:
- 小块更容易检索准
- 大块更适合给大模型读
如果你把制度、合同、手册切得很小,确实更容易命中细节;但把这些很小的碎块直接喂给大模型时,前后约束可能断掉。
Parent Document 的做法是:
- 建库时,把长文档切成很多子块,子块负责向量检索
- 召回时,先命中子块
- 真正送给大模型时,不是只给子块,而是回溯到它所属的父块,把更完整的上下文一起给模型
所以它特别适合:
- 长制度
- 合同条款
- 设备手册
- FAQ 解释依赖上下文的文档
一句话概括:小块负责找,父块负责读。
2. Multi-Vector:同一段文本,不只用一种表示方法建索引
Multi-Vector 的出发点是:一段原始文本未必适合只用"原文自身"生成一个向量。
例如一段售后政策文本:
- 原文可能比较长
- 用户问题可能很短
- 文本里真正关键信息只占其中两三句
这时可以为同一段文档建立多个检索入口,例如:
- 原文小块向量
- 摘要向量
- 主题句向量
- 该段文档适合回答的"假设性问题"向量
当这些"补充向量"中的任意一个被命中时,系统最终返回的仍然是原始文档内容。
它适合的问题是:
- 原文表述啰嗦,不利于直接检索
- 同一段文档可能会被从多个角度提问
- 你希望一段内容既能按主题召回,也能按问题模板召回
所以 Multi-Vector 本质上是在说:不要只给一段文档一个检索身份,而是给它多个可被找到的入口。
3. Metadata Filter:先满足业务硬约束,再做语义排序
Metadata Filter 最容易被初学者低估。
很多时候,问题根本不只是"哪段文本语义最像",而是:
- 先限定
2026 版 - 先限定
华东区域 - 先限定
企业用户 - 先限定
当前用户有权限看
如果这些条件是已知的,就不应该完全交给语义相似度去"猜"。更合理的做法是:
- 先按元数据过滤出符合条件的候选子集
- 再在这个子集里做向量检索和排序
所以它适合:
- 年份 / 版本 / 门店 / 区域 / 部门 / 权限这类硬约束明显的场景
- 语义相近但业务对象不同的场景
- 企业内网和制度问答这类"召回错对象就会出事故"的场景
可以把它记成一句很实用的话:
相似度负责"像不像",元数据负责"该不该让它参加排序"。
7.4 新鲜度、权限、时效性为什么必须单独设计,而不能只靠相似度
很多 Demo 做到这里,已经能在"语义上"回答得像模像样了,但一上线就会出问题。原因就在于:业务正确答案不只取决于相似度。
例如:
- 新闻问答里,用户更关心最新消息,而不是最像旧稿
- 制度问答里,真正有效的是现行版本,而不是历史版本
- 企业知识库里,哪怕某段资料最相关,只要当前用户没权限,也不能返回
所以在生产环境里,通常要把下面这些能力单独抽出来做:
1. 新鲜度控制
相似度不会天然告诉你"哪个结果更新"。
因此你常会增加:
- 时间字段
- 时间衰减
- 最近版本优先
- 最近被更新文档优先
它解决的是:检索结果语义没错,但版本太旧。
2. 版本与生效期控制
制度类、产品说明类文档经常存在:
- 2024 版
- 2025 版
- 临时补丁版
- 尚未生效版
这些文档文字可能极其相似,但业务意义完全不同。所以需要单独维护:
- 生效时间
- 失效时间
- 当前有效版本标记
- 灰度范围
它解决的是:主题对了,但制度版本错了。
3. 权限与组织边界控制
企业 RAG 最容易踩雷的,不是"答不出来",而是"答给了不该看的人"。
所以实际系统经常会有:
- 用户角色过滤
- 部门过滤
- 数据密级过滤
- 租户隔离
- 组织架构隔离
它解决的是:检索很准,但越权了。
4. 为什么这些不能交给 LLM 事后补救
因为一旦不该返回的内容已经被检索进上下文,后面再指望模型"自己别乱说",风险就太高了。
所以更稳妥的顺序应该是:
- 先在检索前做版本 / 权限 / 时间过滤
- 再做语义召回和排序
- 最后才让 LLM 基于被许可的证据回答
这就是为什么很多看起来"只是工程细节"的东西,实际上才决定了 RAG 能不能真正上线。
8. 如何判断一个 RAG 系统到底有没有做好
8.1 先评检索,再评回答
评估 RAG 时最常见的错误,是只看最终答案。
更合理的方式是分两层看:
第一层:检索对不对。
也就是:系统有没有把正确证据找回来。
第二层:回答好不好。
也就是:模型有没有基于证据生成清楚、忠实、可用的答案。
如果检索都没命中,回答再顺也不可信;如果检索命中了但回答仍然偏离,那问题更多在生成端。
8.2 Hit Rate、MRR 解决的是哪类判断问题
检索评估里最常见的两个指标是:
Hit Rate:正确答案对应的证据,是否出现在前 k 个召回结果里。
它解决的是"有没有召回到"的问题。
MRR:正确证据排在第几名。
它解决的是"排得够不够靠前"的问题。
两者不能互相替代。
因为一个系统可能:
- 虽然能召回正确片段,但总排在第 8、第 10
- 最后因为上下文限制,模型根本没机会看到它
所以看 RAG 的召回质量,不能只看有没有,还要看排位。
8.3 除了命中率,还要看回答是否忠于证据
一个答案"说得像",不代表"说得对"。
最终回答至少应从三个维度看:
- 相关性:是不是回答了用户真正的问题
- 忠实性:是不是基于召回证据回答,而不是凭空发挥
- 可用性:表述是否清楚,是否足够直接,是否方便业务使用
很多实际项目里,最大的隐患不是"完全答错",而是:答案大方向对,但掺杂了证据里没有的延伸判断。
这就是为什么企业 RAG 系统往往会强调引用、溯源和拒答。
8.4 一套可落地的评估方法应该怎么搭
最实用的一套做法通常是:
- 准备一批真实问题
- 为每个问题标注参考答案和关键证据
- 先跑检索,记录 Hit Rate、MRR
- 再跑完整问答,人工抽样看忠实性和可用性
- 每改一次切块、检索、重排或 Prompt,都重新回归测试
注意,RAG 评估最好不要只靠"我试了三个问题,感觉不错"。
真正上线前,一定要有稳定的测试集。
9. 进阶方向:当基础版 RAG 跑通之后,再考虑什么
9.1 图谱 RAG、Agentic RAG、多跳检索分别在解决什么
当问题更复杂时,基础版"查一次、答一次"的 RAG 会遇到瓶颈。
图谱 RAG 更适合关系型问题。
比如:
- 某个人隶属于哪个组织
- 某产品依赖哪些模块
- 某条规则和哪些例外情况相关
多跳检索 更适合需要先查 A,再根据 A 去查 B 的问题。
例如:
- 先找到宁波属于哪个城市分级
- 再去查该分级对应的住宿标准
Agentic RAG 更像是在说:
不只是"检索",而是让模型参与决定:
- 先查什么
- 改写成什么查询
- 用哪个工具查
- 是否继续追问
- 是否丢弃某个证据
所以它不是"更高级的向量检索",而是"让检索过程更有调度能力"。
9.2 多模态 RAG 不是"加图片"这么简单
很多资料里不只有文字,还有:
- 图表
- 流程图
- 截图
- 产品结构图
- 图纸
多模态 RAG 的关键点不在于"模型能看图",而在于:你能不能把图像内容和文字内容一起组织进检索系统。
这通常涉及:
- 图文分离与抽取
- 图片描述或 caption 生成
- 图文共同索引
- 模型在回答时同时使用图像证据与文本证据
所以多模态 RAG 的挑战比普通文本 RAG 大得多,它不仅是模型问题,更是数据处理和检索表示的问题。
9.3 一个最小可用的工程选型建议
如果你要从零开始搭一个中文 RAG,最小可用方案可以这样理解:
- 文档抽取:先能稳定处理 PDF / Word / Markdown
- 切块策略:优先用递归切块,必要时再上父子块
- Embedding:优先选中文检索表现成熟的模型,如 BGE、M3E 这类
- 向量库:小规模先用 FAISS / Chroma,工程化再考虑 Qdrant / Milvus
- 检索方案:不要只做向量检索,尽量给 BM25 一个位置
- 重排序:只要候选稍多,就优先评估是否需要 Reranker
- 生成约束:必须支持"仅依据资料回答"和"无答案时拒答"
- 评估方式:尽快建立一套固定问题集
这套方案不一定是终局,但足以让你避开多数初期误区。
9.4 全文总结:如果你只记住一条主线
如果把全文浓缩成一句话,那就是:
RAG 不是一个单点模型技巧,而是一条完整的知识处理链路。
这条链路的核心顺序是:
- 先把资料准备好
- 再把资料切成适合检索的块
- 再把这些块表示成可比较的检索对象
- 再用合适的方式把相关证据找回来
- 最后让大模型基于证据,而不是脱离证据去回答
所以理解 RAG,最重要的不是一开始就背 SimCSE、HNSW、HyDE、Self-RAG 这些名字。
最重要的是先明白:每一个概念,都是为了修复这条链路中的某个具体问题。
当你沿着这条主线去看:
- Embedding 是在解决"怎么表示语义"
- BM25 是在解决"怎么抓住关键词和专有词"
- 混合检索是在解决"语义与字面如何兼顾"
- Reranker 是在解决"候选里谁该更靠前"
- Prompt 约束是在解决"模型如何忠于证据"
- 评估体系是在解决"我们怎么知道系统真的变好了"
这样,RAG 就不再是一堆分散概念,而会变成一套有因果关系的系统认知。
10. LangChain 可运行 RAG 客服示例
这一节补一个可以直接落地的最小示例。为了保证你拿到手就能改,我把代码文件也单独放在了同目录:
d:\期刊\lll\customer_service_kb.mdd:\期刊\lll\rag_customer_service_demo.py
这个示例的目标不是做一个功能特别全的生产系统,而是把 文档切分 -> embedding -> 向量入库 -> 检索 -> 生成 这条主链路完整跑通,并顺手把 Query Rewrite、MultiQuery、HyDE 接进去。
10.1 为什么示例选这些组件
这套示例我故意选得比较"当下主流 + 初学者可复现"。
1. 文档切分:RecursiveCharacterTextSplitter
这是 LangChain 里最常见、也最适合大多数通用文本的切分组件。官方文档把它作为通用文本的推荐起点,因为它会优先按段落、换行等自然边界递归切分,而不是一上来就硬切字符数。
对中文文档来说,最好额外把 。、,、; 这类分隔符也放进 separators 里,否则容易把一句中文从中间切断。
2. 嵌入模型:BAAI/bge-m3
bge-m3 这类中文检索模型在中文 RAG 场景里很常见,适合做知识库问答和语义召回。示例里用的是 langchain_huggingface.HuggingFaceEmbeddings,优点是:
- 不依赖外部 embedding API
- 可以本地直接跑
- 更容易理解"向量化"到底发生了什么
3. 向量数据库:Qdrant
Qdrant 现在在工程实践里非常常见,原因是它不只是能存向量,还很擅长和元数据过滤一起配合。示例里先用它的本地模式跑通,这样你不需要先起一套服务。
4. 生成模型:ChatOpenAI
这里用 ChatOpenAI 不是因为一定要用 OpenAI,而是因为它支持大量 OpenAI-compatible 接口。你可以把它接到:
- OpenAI 官方
- 阿里百炼 / Qwen 的兼容接口
- DeepSeek 的兼容接口
- 其他支持 OpenAI 风格 API 的服务
也就是说,示例把"embedding 本地化,生成模型接口标准化"这两件事分开了,更方便你替换。
10.2 先装哪些依赖
如果你想直接运行同目录的示例文件,可以先安装这些包:
bash
pip install -U langchain langchain-core langchain-community langchain-openai langchain-huggingface langchain-qdrant langchain-text-splitters qdrant-client sentence-transformers python-dotenv pypdf
如果你要读取 PDF,还可以继续使用:
python
from langchain_community.document_loaders import PyPDFLoader
loader = PyPDFLoader("客服手册.pdf")
docs = loader.load()
如果只是想最快把流程跑通,示例文件默认直接读取同目录的 customer_service_kb.md。
10.3 文档切分代码怎么写
下面这段是 LangChain 里最常见的切分写法。你可以把它理解成"通用 RAG 起手式":
python
from langchain_core.documents import Document
from langchain_text_splitters import RecursiveCharacterTextSplitter
text = open("customer_service_kb.md", "r", encoding="utf-8").read()
documents = [
Document(
page_content=text,
metadata={"source": "customer_service_kb.md", "doc_type": "customer_service_kb"},
)
]
splitter = RecursiveCharacterTextSplitter(
chunk_size=500,
chunk_overlap=100,
add_start_index=True,
separators=["\n## ", "\n### ", "\n\n", "\n", "。", ";", ",", " ", ""],
)
chunks = splitter.split_documents(documents)
for idx, chunk in enumerate(chunks):
chunk.metadata["chunk_id"] = idx
这里每个参数都不是随便填的:
chunk_size=500:让每个块不要太大,便于检索命中chunk_overlap=100:避免一句话刚好被切断后语义丢失add_start_index=True:方便后面排查切块位置separators:让中文文档尽量在自然边界上切开
如果你一开始不知道怎么设,最稳妥的方式就是:先从递归切块开始,再根据召回效果回调。
10.4 一个能跑的客服 RAG 示例
下面这套代码会在结尾附上完整的。它做了这几件事:
- 读取客服知识库
- 用
RecursiveCharacterTextSplitter切块 - 用
HuggingFaceEmbeddings(model_name="BAAI/bge-m3")生成向量 - 把向量写入
QdrantVectorStore - 根据问题召回相关 chunk
- 把 chunk 和用户问题拼成 prompt,让大模型回答
它的运行方式如下:
bash
python rag_customer_service_demo.py --question "会员积分可以提现吗?" --mode basic
python rag_customer_service_demo.py --question "会员积分可以提现吗?" --mode rewrite
python rag_customer_service_demo.py --question "会员积分可以提现吗?" --mode multi
python rag_customer_service_demo.py --question "宁波出差住宿标准是多少?" --mode hyde
运行前还需要在 .env 或环境变量中至少配置:
bash
OPENAI_API_KEY=你的密钥
OPENAI_MODEL=gpt-4o-mini
# 如果你接的是兼容接口,还可以再配
OPENAI_BASE_URL=https://你的兼容接口地址/v1
EMBEDDING_MODEL=BAAI/bge-m3
下面摘录示例里的核心索引与检索代码:
python
from uuid import uuid4
from langchain_huggingface import HuggingFaceEmbeddings
from langchain_qdrant import QdrantVectorStore
from langchain_text_splitters import RecursiveCharacterTextSplitter
embeddings = HuggingFaceEmbeddings(
model_name="BAAI/bge-m3",
model_kwargs={"device": "cpu"},
encode_kwargs={"normalize_embeddings": True},
)
splitter = RecursiveCharacterTextSplitter(
chunk_size=500,
chunk_overlap=100,
add_start_index=True,
separators=["\n## ", "\n### ", "\n\n", "\n", "。", ";", ",", " ", ""],
)
chunks = splitter.split_documents(raw_docs)
collection_name = f"customer_service_demo_{uuid4().hex[:8]}"
vector_store = QdrantVectorStore.from_documents(
chunks,
embedding=embeddings,
location=":memory:",
collection_name=collection_name,
)
docs = vector_store.similarity_search("会员积分可以提现吗?", k=4)
然后把检索结果交给生成模型:
python
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
prompt = ChatPromptTemplate.from_template(
"你是电商平台的智能客服助理。\n"
"请严格依据给定资料回答,不要编造制度。\n"
"如果资料不足,请明确回答"当前知识库没有提供足够信息"。\n"
"回答尽量简洁,并在结尾附上你参考的 chunk_id。\n\n"
"用户问题:{question}\n\n"
"参考资料:\n{context}"
)
context = "\n\n".join(doc.page_content for doc in docs)
chain = prompt | llm | StrOutputParser()
answer = chain.invoke({"question": "会员积分可以提现吗?", "context": context})
print(answer)
如果你想把它切成"更像生产系统"的结构,可以继续拆成:
ingest.py:只做建库retrieve.py:只做检索app.py:只做在线问答接口
但对学习阶段来说,先把一个单文件版跑通最重要。
10.5 Query Rewrite、MultiQuery、HyDE 的代码怎么接进去
这也是你前面提到的重点:这些词不能只解释概念,还要知道代码放在哪。
在同目录的 rag_customer_service_demo.py 里,我已经把四种模式都接好了:
basic:原始问题直接检索rewrite:先改写一次,再检索multi:生成多种问法,分别检索,再合并去重hyde:先生成假想答案,再拿假想答案做检索
其中最核心的三段代码如下。
1. Query Rewrite
python
prompt = ChatPromptTemplate.from_template(
"你是 RAG 系统的查询改写助手。"
"请把用户问题改写成更适合检索的单句查询,保留关键约束,去掉口语噪声。"
"只输出改写后的查询。\n\n用户问题:{question}"
)
rewritten_query = (prompt | llm | StrOutputParser()).invoke({"question": question}).strip()
docs = vector_store.similarity_search(rewritten_query, k=4)
2. MultiQuery
python
prompt = ChatPromptTemplate.from_template(
"你是 RAG 系统的查询扩展助手。"
"请围绕同一个问题生成 3 个不同表述,分别覆盖口语说法、业务术语和 FAQ 说法。"
"每行一个问题,不要编号。\n\n原始问题:{question}"
)
queries = [question] + [
line.strip()
for line in (prompt | llm | StrOutputParser()).invoke({"question": question}).splitlines()
if line.strip()
]
all_docs = []
for q in queries:
all_docs.extend(vector_store.similarity_search(q, k=4))
3. HyDE
python
prompt = ChatPromptTemplate.from_template(
"你是知识库检索助手。"
"请针对下面的问题写一段 80 到 120 字的假想答案。"
"这段内容只用于向量检索,不展示给用户,不要求完全准确,但要尽量接近真实知识库语言。\n\n"
"问题:{question}"
)
hypothetical_answer = (prompt | llm | StrOutputParser()).invoke({"question": question}).strip()
docs = vector_store.similarity_search(hypothetical_answer, k=4)
把这三段和前面的基础 RAG 主链路放在一起看,你会更容易明白:
- Query Rewrite 是在改"查询文本本身"
- MultiQuery 是在扩"查询角度"
- HyDE 是在把"问句形态"变成"答案形态"再去搜
它们都没有跳出 RAG 的主链路,只是在检索之前先帮你把入口修好。
完整代码
python
import argparse
import os
from pathlib import Path
from typing import Iterable
from uuid import uuid4
from dotenv import load_dotenv
from langchain_core.documents import Document
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_huggingface import HuggingFaceEmbeddings
from langchain_openai import ChatOpenAI
from langchain_qdrant import QdrantVectorStore
from langchain_text_splitters import RecursiveCharacterTextSplitter
BASE_DIR = Path(__file__).resolve().parent
DEFAULT_KB_PATH = BASE_DIR / "customer_service_kb.md"
def load_knowledge_base(file_path: Path) -> list[Document]:
text = file_path.read_text(encoding="utf-8")
return [
Document(
page_content=text,
metadata={"source": file_path.name, "doc_type": "customer_service_kb"},
)
]
def split_documents(documents: list[Document]) -> list[Document]:
splitter = RecursiveCharacterTextSplitter(
chunk_size=500,
chunk_overlap=100,
add_start_index=True,
separators=[
"\n## ",
"\n### ",
"\n\n",
"\n",
"。",
";",
",",
" ",
"",
],
)
chunks = splitter.split_documents(documents)
for index, chunk in enumerate(chunks):
chunk.metadata["chunk_id"] = index
return chunks
def build_embeddings() -> HuggingFaceEmbeddings:
model_name = os.getenv("EMBEDDING_MODEL", "BAAI/bge-m3")
return HuggingFaceEmbeddings(
model_name=model_name,
model_kwargs={"device": os.getenv("EMBEDDING_DEVICE", "cpu")},
encode_kwargs={"normalize_embeddings": True},
)
def build_vector_store(chunks: list[Document]) -> QdrantVectorStore:
embeddings = build_embeddings()
collection_name = f"customer_service_demo_{uuid4().hex[:8]}"
return QdrantVectorStore.from_documents(
chunks,
embedding=embeddings,
location=":memory:",
collection_name=collection_name,
)
def build_llm() -> ChatOpenAI:
api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
raise ValueError("请先在环境变量或 .env 中设置 OPENAI_API_KEY。")
kwargs = {
"model": os.getenv("OPENAI_MODEL", "gpt-4o-mini"),
"temperature": 0,
"api_key": api_key,
}
base_url = os.getenv("OPENAI_BASE_URL")
if base_url:
kwargs["base_url"] = base_url
return ChatOpenAI(**kwargs)
def rewrite_query(llm: ChatOpenAI, question: str) -> str:
prompt = ChatPromptTemplate.from_template(
"你是 RAG 系统的查询改写助手。"
"请把用户问题改写成更适合检索的单句查询,保留关键约束,去掉口语噪声。"
"只输出改写后的查询。\n\n用户问题:{question}"
)
chain = prompt | llm | StrOutputParser()
return chain.invoke({"question": question}).strip()
def generate_multi_queries(llm: ChatOpenAI, question: str, n: int = 3) -> list[str]:
prompt = ChatPromptTemplate.from_template(
"你是 RAG 系统的查询扩展助手。"
"请围绕同一个问题生成 {n} 个不同表述,分别覆盖口语说法、业务术语和 FAQ 说法。"
"每行一个问题,不要编号。\n\n原始问题:{question}"
)
chain = prompt | llm | StrOutputParser()
content = chain.invoke({"question": question, "n": n})
queries = [line.strip() for line in content.splitlines() if line.strip()]
return queries[:n] if queries else [question]
def generate_hypothetical_answer(llm: ChatOpenAI, question: str) -> str:
prompt = ChatPromptTemplate.from_template(
"你是知识库检索助手。"
"请针对下面的问题写一段 80 到 120 字的假想答案。"
"这段内容只用于向量检索,不展示给用户,不要求完全准确,但要尽量接近真实知识库语言。\n\n"
"问题:{question}"
)
chain = prompt | llm | StrOutputParser()
return chain.invoke({"question": question}).strip()
def deduplicate_documents(documents: Iterable[Document]) -> list[Document]:
seen: set[tuple[str, str]] = set()
result: list[Document] = []
for doc in documents:
key = (doc.page_content, str(doc.metadata.get("chunk_id", "")))
if key not in seen:
seen.add(key)
result.append(doc)
return result
def retrieve_documents(
question: str,
mode: str,
vector_store: QdrantVectorStore,
llm: ChatOpenAI,
top_k: int = 4,
) -> tuple[list[Document], dict]:
debug_info: dict = {"mode": mode}
if mode == "basic":
docs = vector_store.similarity_search(question, k=top_k)
return docs, debug_info
if mode == "rewrite":
rewritten = rewrite_query(llm, question)
debug_info["rewritten_query"] = rewritten
docs = vector_store.similarity_search(rewritten, k=top_k)
return docs, debug_info
if mode == "multi":
queries = [question] + generate_multi_queries(llm, question, n=3)
debug_info["queries"] = queries
merged: list[Document] = []
for q in queries:
merged.extend(vector_store.similarity_search(q, k=top_k))
docs = deduplicate_documents(merged)[:top_k]
return docs, debug_info
if mode == "hyde":
hypothetical_answer = generate_hypothetical_answer(llm, question)
debug_info["hypothetical_answer"] = hypothetical_answer
docs = vector_store.similarity_search(hypothetical_answer, k=top_k)
return docs, debug_info
raise ValueError(f"不支持的 mode: {mode}")
def build_answer_chain(llm: ChatOpenAI):
prompt = ChatPromptTemplate.from_template(
"你是电商平台的智能客服助理。\n"
"请严格依据给定资料回答,不要编造制度。\n"
"如果资料不足,请明确回答"当前知识库没有提供足够信息"。\n"
"回答尽量简洁,并在结尾附上你参考的 chunk_id。\n\n"
"用户问题:{question}\n\n"
"参考资料:\n{context}"
)
return prompt | llm | StrOutputParser()
def format_context(documents: list[Document]) -> str:
parts = []
for doc in documents:
chunk_id = doc.metadata.get("chunk_id", "unknown")
source = doc.metadata.get("source", "unknown")
parts.append(f"[chunk_id={chunk_id} | source={source}]\n{doc.page_content}")
return "\n\n".join(parts)
def answer_question(question: str, mode: str, kb_path: Path) -> None:
load_dotenv()
raw_docs = load_knowledge_base(kb_path)
chunks = split_documents(raw_docs)
vector_store = build_vector_store(chunks)
llm = build_llm()
docs, debug_info = retrieve_documents(question, mode, vector_store, llm)
context = format_context(docs)
answer_chain = build_answer_chain(llm)
answer = answer_chain.invoke({"question": question, "context": context})
print("=" * 80)
print("Retrieval mode:", debug_info["mode"])
if "rewritten_query" in debug_info:
print("Rewritten query:", debug_info["rewritten_query"])
if "queries" in debug_info:
print("Expanded queries:")
for query in debug_info["queries"]:
print("-", query)
if "hypothetical_answer" in debug_info:
print("Hypothetical answer:")
print(debug_info["hypothetical_answer"])
print("\nRetrieved chunks:")
for doc in docs:
print("-" * 80)
print(f"chunk_id={doc.metadata.get('chunk_id')} source={doc.metadata.get('source')}")
print(doc.page_content[:400])
print("\nAnswer:")
print(answer)
print("=" * 80)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="LangChain + BGE-M3 + Qdrant 客服 RAG 示例")
parser.add_argument(
"--question",
required=True,
help="要提问的问题,例如:会员积分可以提现吗?",
)
parser.add_argument(
"--mode",
default="basic",
choices=["basic", "rewrite", "multi", "hyde"],
help="检索模式:basic / rewrite / multi / hyde",
)
parser.add_argument(
"--kb",
default=str(DEFAULT_KB_PATH),
help="知识库文件路径,默认使用 customer_service_kb.md",
)
return parser.parse_args()
if __name__ == "__main__":
args = parse_args()
answer_question(
question=args.question,
mode=args.mode,
kb_path=Path(args.kb),
)
python
customer_service_kb.md文件内容
# 智能客服知识库示例
## 1. 配送时效
### 1.1 发货时间
现货商品在支付成功后 24 小时内发货,大促期间最长不超过 72 小时。
### 1.2 送达时间
华东、华北地区通常在发货后 1 到 3 天送达,西北和偏远地区通常在 3 到 7 天送达。
### 1.3 运费规则
单笔订单实付金额满 99 元包邮;不满 99 元时,标准快递运费为 8 元。
## 2. 退换货
### 2.1 七天无理由
用户签收商品后 7 天内,在商品不影响二次销售的前提下,可申请七天无理由退货。
### 2.2 不支持七天无理由的场景
定制商品、虚拟商品、已拆封且影响二次销售的商品,不支持七天无理由退货。
### 2.3 质量问题
若商品存在质量问题,用户可在签收后 15 天内申请退货或换货,运费由平台承担。
## 3. 发票
### 3.1 开票入口
用户可在订单完成后 30 天内,在 App 的"我的订单"页面申请电子发票。
### 3.2 开票类型
默认支持电子普通发票;企业用户如需专票,需要先完成企业资质认证。
## 4. 会员积分
### 4.1 积分获取
会员每消费 1 元可获得 1 个积分,活动期间可能有双倍积分。
### 4.2 积分有效期
积分自发放之日起 12 个月内有效,到期后系统自动清零,过期积分不可恢复。
### 4.3 积分使用限制
积分只能用于兑换优惠券或指定礼品,不能直接提现,也不能抵扣运费。
## 5. 售后服务
### 5.1 人工客服时间
人工客服服务时间为每天 9:00 到 21:00。
### 5.2 紧急问题
涉及支付失败、重复扣款、账号被盗等紧急问题时,优先引导用户转人工客服处理。
### 5.3 客服升级规则
当用户连续两次表达"不满意""解决不了""转人工"等意图时,系统应主动推荐人工客服入口。
## 6. 账号与隐私
### 6.1 注销账号
用户提交注销申请后,系统会在 7 天冷静期结束后执行正式注销。
### 6.2 注销前提
账号注销前,必须结清所有订单、余额、优惠券和售后单。
### 6.3 隐私说明
客服机器人不得主动展示用户完整手机号、身份证号或支付卡号,只能展示脱敏信息。
一页速记
如果你要临时复习,可以只记下面这 10 句:
- RAG 的本质不是"外挂知识",而是"先检索证据,再基于证据回答"。
- RAG 主要解决的是时效性、私有知识、可溯源和成本问题。
- 主链路永远是:资料准备 → 检索召回 → 上下文组织 → 答案生成。
- 切块是在平衡"粒度够细"和"语义完整"。
- Embedding 负责把文本变成可比较的语义表示。
- Encoder 更适合检索,Decoder 更适合生成。
- BM25 到今天仍然重要,因为它对关键词、编号、术语很稳。
- 混合检索通常比单路检索更稳,Reranker 常常决定最终体验。
- RAG 做不好,很多时候不是模型不够强,而是资料、切块、检索或 Prompt 设计有问题。
- 判断一个 RAG 是否有效,不能只看回答,要分开评检索与生成。