大模型 RAG 实战学习笔记

大模型 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 拆成四个核心环节:

  1. Ingestion:把原始资料处理成系统能检索的形式
  2. Retrieval:根据用户问题找回最相关的片段
  3. Augmentation:把问题和片段组织成给大模型的上下文
  4. Generation:让大模型基于上下文生成答案

如果你只记住这一套四段式结构,后面很多复杂方案都能快速归类。

例如:

  • 切块、清洗、建索引,属于 ingestion
  • BM25、向量检索、Rerank,属于 retrieval
  • Prompt 拼装、引用格式、证据排序,属于 augmentation
  • 回答生成、拒答策略、风格控制,属于 generation

2.3 一次真实提问会经过哪些步骤

举一个企业知识问答的例子。

用户问:

2026 年宁波出差,住宿标准是多少?

系统通常会经历下面这些动作:

  1. 先把制度文件、差旅政策、城市分级表提前处理好,切成合适的知识块并建库。
  2. 用户提问时,系统把"宁波出差住宿标准"转换成一个查询表示。
  3. 检索模块在知识库里找出最相关的政策片段,比如"二线城市住宿上限""宁波所属城市等级"等。
  4. 系统把这些片段和用户问题一起组织成一段给模型的上下文。
  5. 大模型基于这些证据生成答案,并在理想情况下附上引用来源。

这一串动作里,真正决定最终效果的,往往不是"大模型本身够不够强",而是:

  • 资料有没有准备好
  • 切块是不是合理
  • 检索是不是召回了对的内容
  • 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 至少参与两件事:

  1. 把知识库里的每个文本块转成向量,提前存起来
  2. 把用户当前的问题也转成向量,拿去和库里的向量比较

所以 Embedding 不是配角,它是语义检索真正的入口。

4.2 为什么检索常用 Encoder,生成常用 Decoder

这一点如果不讲清楚,后面读者就会一直困惑:为什么同样都属于"大模型家族",有的拿来做向量,有的拿来写答案?

要把这个问题讲明白,最好的方式不是先背术语,而是先回到 RAG 到底要完成哪两类任务

在一个最基本的 RAG 系统里,模型其实要做两件完全不同的事:

  1. 先理解文本:把"用户问题"和"知识库片段"变成可比较的表示,判断谁和谁更相关。
  2. 再生成答案:拿着已经召回的证据,组织成一段自然、完整、可读的回答。

注意,这两件事看起来都和"文本"有关,但底层能力要求并不一样:

  • 第一步更像是在做"读懂"和"比较"
  • 第二步更像是在做"续写"和"表达"

这正是 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 会把文本变成向量。接下来真正的问题才开始出现:

系统拿到这些向量之后,到底是怎么判断"哪段文本更相关"的?

这里至少有三个概念必须讲清楚:

  1. 相似度是什么
  2. 向量空间是什么
  3. 为什么 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 里的共同角色都是:

  1. 离线阶段:给知识库片段生成向量,建立索引
  2. 在线阶段:给用户查询生成向量,用来找最相似的知识片段

所以它们本质上都属于 检索前端的语义表征器。如果这个表征器做得不好,后面向量数据库再快、Prompt 再精致,也是在错误候选上做优化。

真正应该关心的问题是:

  • 它是不是更适合中文
  • 它是不是更适合问答检索
  • 它对短 query 和长 passage 的匹配能力如何
  • 它对领域术语、近义表达、改写问法的区分能力如何
SBERT:把"句子表示"这件事真正推向检索可用

SBERT 的重要性,不在于今天它一定是最强,而在于它是一个非常关键的里程碑。

在它之前,BERT 更常被直接拿去做分类、匹配或下游任务微调。SBERT 让业界开始更系统地接受一个思路:

一句完整的文本,可以被压缩成一个句向量,并且这个句向量可以直接拿去做相似检索。

所以 SBERT 的历史意义是:它让"句向量可用于检索"这件事变得清晰、可行、可复用。很多后续工作,某种程度上都在继续优化这个方向。

SimCSE:把语义空间拉得更开,让"近的更近、远的更远"

SimCSE 常被提到,是因为它把对比学习更直接地引入了句向量训练。

如果用最直观的话说,SimCSE 在做的事就是:

  • 让语义相近的句子更靠近
  • 让语义不同的句子更远离

这件事对检索非常关键,因为一个检索系统怕的不是"完全无关的东西排进来",而是"几个看起来都差不多像,但真正最相关的没有排在前面"。

SimCSE 的价值就在于,它强化了向量空间的区分能力,让系统在语义检索时更容易把真正相关的句子聚在一起。

CoSENT:把重点从"像不像"推到"排得对不对"

CoSENT 更值得从排序角度理解。

对 RAG 来说,系统最终不是只要判断"相关 / 不相关",而是要在一堆候选里决定:

  • 哪一段排第一
  • 哪一段排第二
  • 哪一段虽然有点相关,但应该排后面

所以 CoSENT 的重要价值在于:

它更强调排序一致性,而不是单纯做静态相似判断。

这对检索任务非常重要,因为用户最终看到的不是"这几段都挺像",而是一个有顺序的 TopK 结果列表。

BGE、M3E:为什么它们才是今天中文 RAG 更常见的落地选择

如果说前面的模型更像是在讲"句向量路线是怎么一步步发展起来的",那 BGE、M3E 这类模型更像是在回答:今天做中文 RAG,工程里到底该优先用什么。

它们之所以更常见,核心原因有三个:

  1. 更贴近检索场景:不是只做通用语义表示,而是更强调 query-document 检索效果。
  2. 中文适配更好:对中文问法、中文知识库、中文领域术语的表现通常更稳定。
  3. 工程可用性更强:在开源生态、部署成本、推理速度、社区使用经验上更贴近真实项目。

特别是在中文知识库问答里,你经常会碰到:

  • 问句很短
  • 文档很长
  • 同义表达很多
  • 专有词密集

这类场景下,BGE、M3E 这类检索模型通常会比"只强调通用文本相似"的模型更稳。

不要把这些模型理解成"名单",要理解成"演化路径"

这一节最容易写成模型点名册,但真正有用的理解方式应该是:

  • SBERT 让句向量检索这件事可行
  • SimCSE 提升语义空间的区分能力
  • CoSENT 更强调排序质量
  • BGE、M3E 更贴近中文 RAG 的实际落地

你可以把它们看成同一类工具的不同阶段与不同侧重点:

它们的共同目标,都是把文本映射到一个更适合检索的语义空间里;

它们的差异,在于这个空间到底是更偏"通用句向量",还是更偏"真实检索排序"。

这也解释了为什么做 RAG 时,模型选择不该只看榜单分数,而要看它是否真正适合你的 query、语料形态和中文场景。

4.5 向量数据库与 ANN 索引是在解决什么问题

到这里就自然能引出向量数据库了。因为前面已经有了三个前提:

  1. 文本已经切成了知识块
  2. 知识块已经被转成了向量
  3. 用户查询也会被转成向量

接下来系统真正面对的问题是:

向量都有了,放哪?怎么查?怎么在百万级候选里快速找到最相似的那几条?

这就是 向量数据库ANN 索引 出现的原因。

先说什么是向量数据库

很多人第一次听到"向量数据库"时,会误以为它只是一个"专门存向量的仓库"。这只说对了一半。

更完整的理解应该是:

向量数据库是一类面向相似度检索设计的存储与检索系统。

它通常不只存向量,还会一起存:

  • 向量对应的原文片段
  • 文档 ID
  • 标题、页码、来源、时间等元数据
  • 检索所需的索引结构

也就是说,向量数据库解决的不是单纯"把数值存起来",而是完整解决这几个问题:

  1. 怎么存:把文本块、向量、元数据关联起来
  2. 怎么查:给一个查询向量,迅速找到最相近的候选
  3. 怎么过滤:按时间、权限、部门、版本等条件筛选
  4. 怎么更新:文档新增、删除、重建索引时如何维护

如果只把向量看成一串数字,而不考虑原文、元数据和过滤能力,那就还没真正进入 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 里的工作流通常是这样的:

  1. 用同一套或兼容的向量模型,把知识块编码成文档向量
  2. 把用户问题编码成查询向量
  3. 在向量空间里找和查询向量最接近的文档向量

它最大的优势,是能跨越字面差异理解"意思是否接近"。

比如:

  • 怎么挑甜西瓜
  • 买西瓜主要看什么特征

虽然字面完全不同,但语义非常接近,稠密检索往往能把它们关联起来。

这对下面几类情况尤其有价值:

  • 同义表达
  • 口语化提问
  • 用户问法改写
  • 文档里没有完全重复的关键词,但有等价表述

但它的短板也要明确点出来,不然读者会误以为"有向量检索就够了"。

稠密检索最常见的风险有三类:

  1. 对精确编号不稳定:比如合同号、设备型号、药品编码,这类词只要错一个字符,业务含义可能完全不同。
  2. 对领域缩写不一定敏感:如果模型没见过这个缩写,或者训练时没有足够多的上下文,它未必理解得准确。
  3. 容易语义像,但对象错:比如用户问的是"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 到底在重排什么

前面的双塔向量检索,通常是把查询和文档分别编码,再做向量比较。这种方式速度快,适合大规模召回,但它的缺点是:

查询和文档之间的细粒度交互不够充分。

例如下面这两段文档:

  1. 宁波属于二线城市,住宿上限 500 元。
  2. 二线城市住宿上限 500 元,但宁波项目组 2026 年起执行特例标准。

对粗召回来说,它们都很像。

但对最终回答来说,这两段的优先级可能完全不同。

Reranker 的作用,就是在一个更小的候选集上,把查询和文档放在一起做更细的相关性判断,重新给出排序。

为什么它常常是"能用"和"好用"的分水岭

Reranker 特别擅长修复下面这些问题:

  • 否定关系没看清
  • 数字、版本、对象差一点点但业务含义不同
  • 多个候选主题都像,但只有一个真正正面回答了问题
  • 查询和文档在局部细节上需要强交互才能分辨

也就是说,Reranker 不负责把答案从零找出来,它更像是在说:

"你前面找回来的这些候选里,到底谁最该排前面?"

为什么不一开始就全用 Reranker

原因也很简单:贵。

Reranker 的细粒度判断能力强,通常意味着计算更重。如果你让它对 10 万条文档逐条重排,成本会非常高,延迟也难以接受。

所以实际工程里最合理的方式通常是:

  1. 先用 BM25 / 向量检索做大范围粗召回
  2. 把候选缩到一个较小集合,比如 Top 20、Top 50、Top 100
  3. 再用 Reranker 做精排

这也是为什么现代 RAG 经常采用 retrieval + rerank 两阶段结构。

所以你可以把这一节记成一句话:

检索负责别漏,Reranker 负责别排错。


6. 第四步:把召回结果交给大模型,生成可用答案

6.1 augmentation 不是拼接文本,而是组织证据

很多人把 augmentation 理解成"把检索结果复制进 prompt 里",这太浅了。

更准确的说法是:augmentation 是把问题和证据组织成大模型最容易正确利用的输入结构。

这里至少要考虑三件事:

  • 哪些片段该放前面
  • 是否要保留标题、来源、页码
  • 是否要把多个片段做简要整理后再送给模型

如果组织不好,就会出现两种典型错误:

  • 证据明明召回了,但模型没抓住重点
  • 证据太多,噪声把关键信息淹没了

6.2 一个可靠答案通常需要哪些约束

一个更可靠的 RAG Prompt,通常至少包含下面几层要求:

  1. 明确告诉模型:优先依据给定证据回答
  2. 如果证据不够,就明确拒答或说明不知道
  3. 尽可能输出引用来源或证据编号
  4. 避免把模型常识和检索证据混在一起

一个非常常见的约束模板是:

text 复制代码
问题:<用户问题>

参考资料:
<检索得到的片段>

要求:
1. 仅根据参考资料回答。
2. 如果参考资料没有答案,明确说明"现有资料不足以回答"。
3. 回答时标注对应的证据来源。

这类约束看起来朴素,但对减少幻觉非常有效。

6.3 为什么同样的召回结果,答案质量也会差很多

即使检索结果一样,不同系统的最终答案也可能差很多,原因通常在生成端:

  • Prompt 是否明确
  • 证据是否按重要性排序
  • 是否保留来源信息
  • 模型是否具备足够好的拒答能力
  • 是否支持引用和溯源

RAG 的常见误区之一,就是把所有问题都归咎于检索。

实际上很多"答得不像人话"或者"明明找到了还答错"的问题,是生成端组织方式造成的。

6.4 多轮对话为什么会让 RAG 变难

单轮问答里,用户的问题通常比较完整。

但多轮对话里,用户会大量使用:

  • 这个
  • 上面那个
  • 那宁波呢
  • 那第二条怎么理解

这些问法对人类很自然,对 RAG 却很难。

因为检索系统拿到的是当前这一句,而当前这一句往往不完整。

所以多轮 RAG 经常需要增加一个步骤:先做查询改写,再做检索。

也就是把"那宁波呢"改写成更完整的查询,比如:宁波出差住宿标准是多少。

这也是后面 MultiQuery、会话改写等优化策略存在的原因。


7. 从 Demo 到生产:哪些优化最值得优先做

7.1 先调切块,再调检索,再调生成

实际优化顺序非常重要。

很多团队一看到结果不好,就立刻换更大的模型、更贵的模型,甚至想微调。

但大多数时候,更有效的顺序其实是:

  1. 先看切块是否合理
  2. 再看检索方式是否单一
  3. 再看是否需要 Reranker
  4. 最后才看生成端提示与模型选择

这是因为 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 的思路是:

  1. 先让大模型写一段"看起来像答案"的假想文本
  2. 再把这段假想文本拿去做向量检索
  3. 因为假想文本和真实知识库片段在语言形态上更接近,所以更容易召回真正相关的文档

注意,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 版
  • 先限定 华东区域
  • 先限定 企业用户
  • 先限定 当前用户有权限看

如果这些条件是已知的,就不应该完全交给语义相似度去"猜"。更合理的做法是:

  1. 先按元数据过滤出符合条件的候选子集
  2. 再在这个子集里做向量检索和排序

所以它适合:

  • 年份 / 版本 / 门店 / 区域 / 部门 / 权限这类硬约束明显的场景
  • 语义相近但业务对象不同的场景
  • 企业内网和制度问答这类"召回错对象就会出事故"的场景

可以把它记成一句很实用的话:

相似度负责"像不像",元数据负责"该不该让它参加排序"。

7.4 新鲜度、权限、时效性为什么必须单独设计,而不能只靠相似度

很多 Demo 做到这里,已经能在"语义上"回答得像模像样了,但一上线就会出问题。原因就在于:业务正确答案不只取决于相似度。

例如:

  • 新闻问答里,用户更关心最新消息,而不是最像旧稿
  • 制度问答里,真正有效的是现行版本,而不是历史版本
  • 企业知识库里,哪怕某段资料最相关,只要当前用户没权限,也不能返回

所以在生产环境里,通常要把下面这些能力单独抽出来做:

1. 新鲜度控制

相似度不会天然告诉你"哪个结果更新"。

因此你常会增加:

  • 时间字段
  • 时间衰减
  • 最近版本优先
  • 最近被更新文档优先

它解决的是:检索结果语义没错,但版本太旧。

2. 版本与生效期控制

制度类、产品说明类文档经常存在:

  • 2024 版
  • 2025 版
  • 临时补丁版
  • 尚未生效版

这些文档文字可能极其相似,但业务意义完全不同。所以需要单独维护:

  • 生效时间
  • 失效时间
  • 当前有效版本标记
  • 灰度范围

它解决的是:主题对了,但制度版本错了。

3. 权限与组织边界控制

企业 RAG 最容易踩雷的,不是"答不出来",而是"答给了不该看的人"。

所以实际系统经常会有:

  • 用户角色过滤
  • 部门过滤
  • 数据密级过滤
  • 租户隔离
  • 组织架构隔离

它解决的是:检索很准,但越权了。

4. 为什么这些不能交给 LLM 事后补救

因为一旦不该返回的内容已经被检索进上下文,后面再指望模型"自己别乱说",风险就太高了。

所以更稳妥的顺序应该是:

  1. 先在检索前做版本 / 权限 / 时间过滤
  2. 再做语义召回和排序
  3. 最后才让 LLM 基于被许可的证据回答

这就是为什么很多看起来"只是工程细节"的东西,实际上才决定了 RAG 能不能真正上线。


8. 如何判断一个 RAG 系统到底有没有做好

8.1 先评检索,再评回答

评估 RAG 时最常见的错误,是只看最终答案。

更合理的方式是分两层看:

第一层:检索对不对。

也就是:系统有没有把正确证据找回来。

第二层:回答好不好。

也就是:模型有没有基于证据生成清楚、忠实、可用的答案。

如果检索都没命中,回答再顺也不可信;如果检索命中了但回答仍然偏离,那问题更多在生成端。

8.2 Hit Rate、MRR 解决的是哪类判断问题

检索评估里最常见的两个指标是:

Hit Rate:正确答案对应的证据,是否出现在前 k 个召回结果里。

它解决的是"有没有召回到"的问题。

MRR:正确证据排在第几名。

它解决的是"排得够不够靠前"的问题。

两者不能互相替代。

因为一个系统可能:

  • 虽然能召回正确片段,但总排在第 8、第 10
  • 最后因为上下文限制,模型根本没机会看到它

所以看 RAG 的召回质量,不能只看有没有,还要看排位。

8.3 除了命中率,还要看回答是否忠于证据

一个答案"说得像",不代表"说得对"。

最终回答至少应从三个维度看:

  • 相关性:是不是回答了用户真正的问题
  • 忠实性:是不是基于召回证据回答,而不是凭空发挥
  • 可用性:表述是否清楚,是否足够直接,是否方便业务使用

很多实际项目里,最大的隐患不是"完全答错",而是:答案大方向对,但掺杂了证据里没有的延伸判断。

这就是为什么企业 RAG 系统往往会强调引用、溯源和拒答。

8.4 一套可落地的评估方法应该怎么搭

最实用的一套做法通常是:

  1. 准备一批真实问题
  2. 为每个问题标注参考答案和关键证据
  3. 先跑检索,记录 Hit Rate、MRR
  4. 再跑完整问答,人工抽样看忠实性和可用性
  5. 每改一次切块、检索、重排或 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 不是一个单点模型技巧,而是一条完整的知识处理链路。

这条链路的核心顺序是:

  1. 先把资料准备好
  2. 再把资料切成适合检索的块
  3. 再把这些块表示成可比较的检索对象
  4. 再用合适的方式把相关证据找回来
  5. 最后让大模型基于证据,而不是脱离证据去回答

所以理解 RAG,最重要的不是一开始就背 SimCSE、HNSW、HyDE、Self-RAG 这些名字。

最重要的是先明白:每一个概念,都是为了修复这条链路中的某个具体问题。

当你沿着这条主线去看:

  • Embedding 是在解决"怎么表示语义"
  • BM25 是在解决"怎么抓住关键词和专有词"
  • 混合检索是在解决"语义与字面如何兼顾"
  • Reranker 是在解决"候选里谁该更靠前"
  • Prompt 约束是在解决"模型如何忠于证据"
  • 评估体系是在解决"我们怎么知道系统真的变好了"

这样,RAG 就不再是一堆分散概念,而会变成一套有因果关系的系统认知。


10. LangChain 可运行 RAG 客服示例

这一节补一个可以直接落地的最小示例。为了保证你拿到手就能改,我把代码文件也单独放在了同目录:

  • d:\期刊\lll\customer_service_kb.md
  • d:\期刊\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 示例

下面这套代码会在结尾附上完整的。它做了这几件事:

  1. 读取客服知识库
  2. RecursiveCharacterTextSplitter 切块
  3. HuggingFaceEmbeddings(model_name="BAAI/bge-m3") 生成向量
  4. 把向量写入 QdrantVectorStore
  5. 根据问题召回相关 chunk
  6. 把 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 句:

  1. RAG 的本质不是"外挂知识",而是"先检索证据,再基于证据回答"。
  2. RAG 主要解决的是时效性、私有知识、可溯源和成本问题。
  3. 主链路永远是:资料准备 → 检索召回 → 上下文组织 → 答案生成。
  4. 切块是在平衡"粒度够细"和"语义完整"。
  5. Embedding 负责把文本变成可比较的语义表示。
  6. Encoder 更适合检索,Decoder 更适合生成。
  7. BM25 到今天仍然重要,因为它对关键词、编号、术语很稳。
  8. 混合检索通常比单路检索更稳,Reranker 常常决定最终体验。
  9. RAG 做不好,很多时候不是模型不够强,而是资料、切块、检索或 Prompt 设计有问题。
  10. 判断一个 RAG 是否有效,不能只看回答,要分开评检索与生成。