RAG 技术演进这个系列写到中篇,恰好是一个很有意思的位置。上篇我们拆过朴素 RAG 的完整流程:把文档按固定长度切成块,向量化,检索 Top-K,拼进 Prompt,交给大模型生成。今天这篇不打算再重复那些基础知识,而是想认真回答一个问题:当你的 RAG Demo 已经能跑通,但拿真实文档一测就露馅的时候,问题到底出在哪,又该按什么顺序去演进。
这个问题我倾向于先给一个结论:大部分项目卡住的不是模型不够强,而是链路不够配合。检索增强生成的关键词虽然是"生成",但真正决定上限的往往是检索侧——以及为检索服务的索引、查询、评估这些周边环节。所以中篇的内容会集中在四块:查询端的改写与路由、检索端的混合召回与精排、索引端的切分与分块策略、生成端的兜底规则与评估闭环。目标读者是那些已经搭过一套基础 RAG、准备把它推向真实业务场景的朋友,这篇文章会更贴近你实际会遇到的困境。
1. 朴素 RAG 的真正瓶颈:链路而不是模型
1.1 切块、向量、Top-K:demo 跑通之后发生了什么
先回顾一下朴素 RAG 的标准动作:加载文档,按固定长度切成若干块,每块过一遍嵌入模型变成向量,灌进向量库;用户提问时,把问题向量化,在向量库里做相似度检索,取出 Top-K 个块,按顺序拼接成上下文,连同问题一起交给大模型生成答案。
这套流程放在干净的测试文档上,效果往往还不错。但一旦换成真实业务文档——比如几十页的操作手册、跨部门的制度文件、含代码片段的技术 wiki——问题就接踵而来。最常见的三类现象:
第一类是召回质量不稳定。用户问"怎么处理登录超时",向量库确实召回了几个包含"超时"和"登录"的片段,但最关键的、真正写了超时参数调整方法的那一页始终排不进前三。第二类是答案引用混乱。模型把来自不同章节的信息揉在一起,甚至自己补了一段看似合理但原文根本没有的配置项。第三类是知识更新滞后。文档已经改版了,索引里还是旧内容,模型照着过期文档一本正经地胡说。
这三类问题看起来像是"某一环不够好",但实际排查下来,你会发现每个环节单独看都合理。切块是按常规尺寸切的,向量库召回也符合预期的相似度分布,生成模型也没有明显越界。真正的问题在于:这些环节之间没有相互适应。切块没有考虑文档的语义边界,检索没有考虑用户提问的表达习惯,生成又没有一套强制的"只能使用检索片段"约束。整条链路像三个各自合格的零件硬拼在一起,谁都没有错,凑在一起就是不好用。
打个比方,把 RAG 想象成去图书馆借书。朴素方案相当于把每本书拆成散页,每页写一个关键词摘要贴到柜子里,管理员只根据摘要标签帮你找页。问题来了:你问的是"一本讲了某个偏门技巧的书在哪一类",管理员手里只有一页页散页标签,很难跨页整合信息;又或者你问的是书里某段话的出处,管理员按标签找到好几处相近内容,却不知道该给你哪一页。索引结构、检索入口、输出约束三个层面都存在信息损耗,这才是朴素 RAG 被真实场景击穿的本质。
1.2 演进的三条主线:查询、索引、生成
既然问题是链路层面的,演进就不能只靠换一个更强的嵌入模型或者加一个更大的向量库。我的做法是把整个 RAG 拆成三条主线,分别对应三个不同的问题域:
- 查询端(Query):解决"入口不对"的问题。用户提问往往是口语化、含指代、缺上下文的,直接拿原始 query 去检索,未必匹配得上文档里的表达方式。
- 索引端(Index):解决"存储不对"的问题。文档本身的层级结构、语义边界、元数据没有被充分利用,切块方式决定了检索单元的信息完整度。
- 检索与生成端(Retrieval + Generation):解决"匹配与输出不对"的问题。单通道召回容易顾此失彼,生成阶段又缺少对模型输出的约束和兜底。
三条主线不是孤立推进的,而是有先后关系。我的经验是:先优化查询端,因为改造成本低、收益直观;再动索引端,把文档切分和元数据做好;接着上混合检索和重排序,把召回的精度和准度拉上去;最后补生成端的兜底规则和评估闭环。顺序反过来会比较痛苦——如果你的切分和召回本身就是乱的,生成端加再多约束也只是在垃圾输入上做精致加工。
所以中篇的内容就按这条主线展开。下面先讲查询端,这是整个链路上最便宜、却最容易被忽略的一环。
2. 查询端改造:从原样检索到问题重写
2.1 为什么原封不动的 query 常常不好使
很多团队搭建 RAG 的第一步,是把用户的问题直接丢给向量检索,得到的结果不理想就开始怀疑嵌入模型不行。说实话,嵌入模型确实有责任,但它只是替罪羊。真正的问题是:用户提问和文档表述之间的"语义距离",比我们想象中大得多。
举个例子。用户问:"上次说的那个报错怎么解决?"这个 query 在向量空间中能匹配到什么呢?它包含"上次""那个报错""怎么解决"这些词,但文档里根本不这么写。文档里写的是"登录接口返回 500 错误,请检查认证服务配置"。想要靠向量检索把这两者关联起来,难度相当大。如果用户再带上一些行业黑话、英文缩写、部门内部代号,召回基本靠运气。
更麻烦的是指代消解。真实对话中,用户经常在上一句已经提到了具体对象,下一句用"它""这个""刚才那个"来承接。RAG 面对的是单轮问题,根本没有上文可参考,于是这些代词全成了噪音。
从评估数据看也会很直观:把原始 query 和改写后的 query 分别跑一遍召回,在同样的文档库上,改写后的命中率经常能提升 10 到 20 个百分点。这不是模型多强,而是因为检索入口终于和文档的表达方式对齐了。所以查询改写不应该是锦上添花,而应该是搭建 RAG 之后第一件要做的事。
2.2 三件套:查询改写、多查询扩展与 HyDE
查询端改造有三个最常用的手段,我习惯按复杂度从低到高排列。
第一件是查询改写。让大模型基于原始问题生成一个更完整、更适合检索的自然语言查询。核心是补全实体、消除指代、把口语变成文档常用的书面表达。比如原始问题"怎么改数据库连接池配置",改写结果可以是"数据库连接池 maxActive 参数配置方法"或者"后端服务数据库连接池调优配置"。这两个改写版都比原句更贴近技术文档的用词习惯。
第二件是多查询扩展。一次生成一个改写版本还不够,干脆生成多个不同角度的查询,并行检索后再合并结果。比如对"服务器内存占用过高"这个问题,可以生成"服务器内存占用排查步骤""内存泄漏定位方法""JVM 堆内存调优参数"三个子查询。每个子查询各自召回一批候选文档,最后把所有结果去重、按相关度合并。这个方案对覆盖面广、答案分散在不同文档的问题特别有效,代价是检索次数成倍增加,需要注意延迟预算。
第三件是 HyDE,也叫假设性文档嵌入。思路很有意思:先让大模型针对用户问题"凭空"写一段假设答案,再用这段假设答案去做向量检索,而不是用原始问题。原理也好理解:问题文本通常信息量很少,但一段假设答案通常包含了更多与目标文档重叠的词汇和语义,把它当作检索 query,能在向量空间中更准地命中真实文档。HyDE 适合概念解释、方案推荐这类开放式问题,能明显提升召回的语义匹配度;但不适合强时效性问题或精确数字查找,因为假设答案很可能和事实不符,反而把检索带偏。
这三种手段可以叠加使用,但也不是越多越好。我给一个实用建议:先做查询改写,评估命中率;如果答案分散的问题仍然突出,再加多查询扩展;如果开放性问题召回不理想,再考虑 HyDE。每一步都要靠评测数据判断,不要一次性全上,否则出了问题根本不知道是哪个环节引入的。
2.3 查询路由:按意图分发到不同检索器
查询端还有另外一个容易被低估的组件:查询路由。它的目标不是改善某个 query 的召回质量,而是把不同类型的 query 分发到最适合的检索通道。
RAG 场景里的问题类型差异其实非常大。用户可能问"什么是内存泄漏"(概念类,适合向量检索)、"XG-2000 型号的上架日期"(实体精确匹配,适合关键词检索)、"总结一下上季度的故障报告"(全文概括,可能根本不适合向量检索)。如果用同一个检索通道去处理所有问题,必然会让一部分问题用"错误的方式"检索。
路由的实现有三档复杂度。最轻的是规则路由:用正则或关键词把 query 分类,比如含"型号""编号""日期"的走关键词通道,含"如何""为什么"的走向量通道。中间档是训练一个小分类模型,适合问题类别比较稳定、样本充足的场景。最重的是让大模型做意图判断,灵活但增加一次模型调用延迟。
路由之后还可以顺便决定融合策略。比如代码类问题走精确匹配为主、向量召回为辅,概念类问题反过来。一个简单的伪代码示例:
def route_query(query): if contains_code_pattern(query): return "code_bm25_index", {"alpha": 0.7, "beta": 0.3} if contains_spec_number(query): return "entity_meta_index", {"alpha": 0.9, "beta": 0.1} return "vector_index", {"alpha": 0.3, "beta": 0.7}这里的 alpha 和 beta 分别代表精确匹配和向量召回在最终融合时的权重。路由的价值在于:它承认了"没有一种检索策略能通吃所有问题"这个事实,而演进到中期,这个意识非常重要。
3. 检索端进阶:混合检索、重排序与上下文压缩
3.1 为什么单靠向量检索不够
查询端修好之后,下一个瓶颈通常出现在检索通道本身。纯向量检索的问题,用一句话概括:它擅长语义匹配,却不擅长精确匹配。
向量检索的原理是把文本映射到高维语义空间,相近意思的文本距离更近。但某些信息在向量空间里恰恰是被"模糊化"的。比如产品型号"XG-2000"、接口名"send_message_v2"、错误码"ERR_AUTH_TIMEOUT",这些字符串在向量化之后,可能和别的型号、别的接口混在一起,因为嵌入模型更关注上下文语义,而不是字符串的精确组成。用户精确搜索"XG-2000 说明书"的时候,向量检索可能返回一堆"XG 系列设备维护文档",看起来相关,却唯独少了那篇唯一提到 2000 型号的文档。
这种场景就是关键词检索的天下。BM25 这类传统稀疏检索算法,对精确字符串、编号、罕见词非常敏感。所以混合检索几乎是所有生产级 RAG 系统的标配:一路向量召回负责语义泛化,一路 BM25 召回负责精确锁定,最后把两路结果融合。
打个比方,向量检索像按"意思"找朋友,你描述一下个性,它能帮你找到气质相近的人;BM25 像查通讯录,只要你知道对方姓名的准确写法,一翻就能找到。真实找人的时候,这两种方式都离不开。
3.2 从向量召回与 BM25 并行:融合策略怎么选
两路召回结果放一起之后,第一个问题是排序融合。向量检索给出的相似度分数和 BM25 给出的相关度分数不在同一个尺度上,直接相加就是拿苹果的分数和橙子的分数比较。
融合策略常见的有三种:
| 融合策略 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 加权和 | 分别归一化后按权重相加 | 简单直观 | 分数归一化方式影响大 |
| Borda Count | 按名次赋分 | 忽略分数绝对值 | 只关心顺序,损失强度信息 |
| RFF 倒数排名融合 | 每个文档得分是各列表中排名的倒数之和 | 对分数尺度不敏感,效果好 | 需要调参数 k |
在实践中,RFF 往往是默认选择。它的计算非常简单:假设一个文档在某个召回列表中排在第 r 位,那它对总分的贡献就是 1 / (k + r)。把所有召回列表的贡献加到一起,就是最终分数。核心代码如下:
def rff_scores(rank_lists, k=60): scores = {} for rank_list in rank_lists: for rank, doc_id in enumerate(rank_list): scores[doc_id] = scores.get(doc_id, 0) + 1.0 / (k + rank + 1) return sorted(scores.items(), key=lambda x: x[1], reverse=True)参数 k 一般取 60 左右。k 太小会让排在前面的文档权重过大,融合结果几乎只看单路头部;k 太大会把所有文档的差距压平,失去区分度。具体取值可以用一小批人工标注的验证集去扫一遍,选效果最好的。
真实项目里,我还会在这上面加一个修正:如果两路召回的文档池重叠率很低,说明两个通道覆盖的是不同维度的信息,融合时应该给叠加上来的文档更多机会;反之,如果重叠率很高,则说明融合只是从"双保险"变成了"重复计算",需要考虑去掉一路。
3.3 重排序(Rerank)不是可选项:候选集上的精细把关
混合召回做完之后,Top-K 个候选文档的排序仍然是"粗排"级别的。粗排模型通常采用双塔结构,问题和文档各自编码,最后算一个相似度。这种结构效率高,但问题在于:查询和文档在编码阶段是独立的,没有做细粒度的交互匹配。于是排序结果里经常出现"看起来相关,实际上答案不对"的片段。
要解决这个问题,需要在候选集上做一次精排,也就是重排序(Rerank)。重排模型一般使用交叉编码器结构,把查询和文档拼在一起输入模型,让模型看到两者之间的完整交互。这种方式的精度远高于双塔粗排,但推理成本也高很多。所以要控制候选集大小:先粗召回 100 到 200 条,重排模型只在这批候选里挑出最相关的 5 到 10 条,作为最终上下文。
一个典型的检索流程是:混合召回 100 条,Rerank 取前 8 条,生成答案。这样既保证了召回广度和精度,又控制了精排的计算量。有些团队觉得"文档库才几千条,Recrank 没必要",但如果你的场景里存在大量相似文档,或者用户对答案准确性要求很高,Rerank 带来的收益通常远超它的成本。
还要提醒一点:Rerank 模型的打分和粗排打分也不是一个尺度,不能直接混用。实际工程里,Rerank 的结果会直接覆盖粗排的排序,而不是去融合它们的分数。
3.4 上下文压缩与去重:少而精优于多而杂
检索端还有一项容易被忽视的工作:整理喂给大模型的上下文。混合召回加 Rerank 之后,拿到的 Top-K 片段质量已经不错,但仍然存在几个常见问题。
首先是重复。同一个信息可能被多个片段以不同措辞覆盖,尤其是多路召回的场景,向量路和 BM25 路可能各自返回同一段内容的变体。去重可以用文本哈希,也可以用 embedding 相似度做聚类,把相似片段合并。这能在不损失信息的前提下减少冗余,也避免模型被不同措辞干扰。
其次是冗余上下文。一个按固定长度切成的块,可能包含两三个主题,只有一句话和当前问题相关。把这整块拼进 Prompt,既浪费 token,又可能让模型被无关内容带偏。处理方式有两种:一是用 LLM 对每个片段做压缩,提取与问题相关的关键句;二是直接对片段做句子级相关性打分,只保留得分高的句子。第二种方式的延迟更低,适合在线场景。
还有一个原则值得刻在墙上:上下文长度并不是越多越好。5 个精炼且高度相关的片段,通常比 20 个粗糙片段效果更好。因为大模型面对噪声过多的上下文,容易学会"从所有材料里找答案",而不是"从真正相关的材料里找答案"。控制输入的精确度,本身就是在帮助模型减少幻觉。
4. 索引端重构:切分策略、父子分块与元数据
4.1 固定长度切块的问题:语义被拦腰截断
索引端的第一个坑,往往就是我们最开始用的固定长度切块。按 512 或 1024 个字符一刀切下去,实现简单,但代价极大。最常见的问题是:一个完整操作步骤被切成两半,第二半的开头是"然后"、"接着"这类承上启下的词。检索时命中了第二半,模型只看到结论看不到前提,只能自行脑补,答案自然就偏了。
块大小本身也是一个矛盾体。块越小,语义越聚焦,召回精度高,但单块信息量不足,模型需要更多块才能拼出完整答案;块越大,上下文越完整,但噪声也随之增加,而且大块消耗的 token 更多。在对某套技术文档做过测试后,我倾向于把块大小控制在 200 到 500 字左右,并设置 10% 到 20% 的重叠,避免句子在边界被截断。当然,具体数值要结合你的文档类型和嵌入模型的最大长度来定。
比块大小更重要的是切分策略。真正能显著改善质量的,是按文档结构切分:优先按标题、章节、列表来划分边界,其次按语义段落。Markdown 或 HTML 格式的文档天然自带结构,解析时先把标题层级提取出来,再沿着标题边界做切块,保留标题作为块元数据。这样做出来的块,至少是一个意思完整的单元。
4.2 父子分块:先用大块找、再用小块读
在切分策略里,父子分块是我最推荐在中期引入的结构。它的核心思路是:索引用小粒度块保证精确命中,生成用大粒度块保证上下文完整。
具体来说,先把文档按章节切成"父块",每个父块再进一步切成若干"子块"。向量索引只对子块建,检索时用户在子块里召回,但最终拼进 Prompt 的不是子块本身,而是子块对应的父块。举例:一篇操作手册按章节切成父块,每节再按说明步骤切成一到两个句子作为子块。用户问"第二步的参数怎么填",向量检索精确命中描述参数的那一两个子句;但模型真正阅读的,是包含了完整操作流程的整个父块。这样既保证了召回精度,又避免了只见树木不见森林。
实现链路也不复杂:
- 按章节或语义段落划分父块;
- 父块内部再切子块,并建立子块到父块的映射表;
- 子块向量化后写入向量库,父块可以随子块一起存储或单独存储;
- 检索时命中子块,通过映射表找到父块;
- 把父块作为上下文交给大模型。
父子分块还有一个好处,就是它天然兼容多跳问题。很多真实问题是跨章节的:"先看 A 章节确定配置项,再看 B 章节了解报错含义"。子块级召回能分别命中两个章节的相关句子,再通过父块恢复各章节的上下文。在我参与过的一个内部知识库问答项目里,引入父子分块后,多跳问题的端到端回答完整度提升了接近一倍。这是整个演进过程中效果最明显的一步。
如果父块本身还是太长,还可以进一步做"父块摘要":把父块先交给 LLM 生成一段摘要,模型读到的是摘要加命中的子块。摘要能压缩无关信息,但会损失细节,适合大块确实过大的场景。
4.3 元数据过滤与多路索引:让检索不再大海捞针
索引端最后要谈的是元数据。给每个块挂上元数据,是让检索系统从"全库模糊搜"变成"定向精确找"的关键一步。
常见的元数据包括:文档来源、章节路径、更新时间、作者部门、文档类型、权限级别、标签。检索阶段,先用元数据做过滤,再做向量相似度匹配。例如业务场景中只允许查询 2024 年之后的文档,就可以在检索前加一个日期过滤条件;或者只查某个模块的文档,就按模块标签过滤。这个操作能大幅缩小检索范围,提升召回精度,同时也能实现权限控制——用户只能搜到他有权访问的文档。
多路索引是另一个进阶方案。同一份文档,可以按不同策略建多套索引:一套按章节切分,适合概念类问题;一套按句子切分,适合证据型问题;一套专门针对代码块或表格提取,适合技术细节问题。查询路由决定走哪一套索引或并行检索后融合。代价是索引存储成本和更新成本增加,需要结合文档库规模和检索频率评估。
不同场景下切分策略的选择,可以参考这个对照表:
| 切分策略 | 适用场景 | 主要缺点 |
|---|---|---|
| 固定长度 | 快速起步、通用文档 | 切断语义、上下文不完整 |
| 按段落切分 | 博客文章、说明文档 | 段落过长时会引入噪声 |
| 按章节/标题切分 | 手册、规章制度、wiki | 章节过大时块粒度过粗 |
| 句子级切分 | 证据型、精确查找类问题 | 上下文缺失,需要父块补充 |
| 父子分块 | 多跳问题、长文档问答 | 映射逻辑稍复杂 |
核心原则只有一条:切分策略要跟着文档结构和问答类型走,而不是跟着某个固定的字符数走。
5. 生成端的兜底工程与评估闭环
5.1 提示词模板与引用溯源:把规则写进生成流程
检索做到位之后,生成端也不能放任自流。很多人直接把检索拼接的上下文塞给模型,不加约束,结果模型自由发挥。实际上,生成阶段需要一组显式规则,而这些规则最好固化在提示词里。
我常用的提示词模板分两层:system 层设定角色和边界,user 层给出带编号的检索片段和问题。模板大致是这样的:
system: 你是一个知识库问答助手。只能使用"检索片段"中提供的信息回答用户问题。 如果检索片段中没有足够的信息来回答问题,请直接回复:知识库中没有相关信息。 回答时请在引用具体信息的位置标注来源编号,例如 [1]、[2]。 user: 检索片段: [1] (片段内容) [2] (片段内容) [3] (片段内容) 问题:(用户问题)这段提示词做了三件事:第一,明确"只能使用检索片段"这个硬边界,从规则上抑制模型编造;第二,给模型提供"拒答"的出口,避免它强行作答;第三,要求标注引用编号,这样答案的每一句话都能回溯到具体的知识文档,方便人工核对和追责。
一个容易被忽略的细节是,检索片段本身要带上编号。这部分信息对模型来说就是"证据链",当多条片段互相矛盾时,模型有依据指出出处,而不是自己默默挑一个"看起来更合理"的答案。
5.2 把"无答案"当成一种正常输出
RAG 系统有一个很常见的坏毛病:不管检索到的材料靠不靠谱,都要硬挤出一个答案。用户问一个知识库里完全没有的问题,系统也能编得像模像样。这大概是 RAG 幻觉最重要、也最容易被轻视的来源之一。
解决思路很简单:给系统一个拒答机制。在生成之前,先对检索结果做一个置信度判断:如果最高相关的片段得分低于某个阈值,就直接返回"知识库中没有找到相关信息",而不进入生成环节。这个阈值不是拍脑袋定的,而是先准备一批验证问题,统计每个问题检索到的最高得分,观察"能正确回答的问题"和"无法回答的问题"之间的分数分布,然后在两者之间取一个分位点作为阈值。
实际效果方面,这个机制把"硬答"变成了"合理拒答",虽然牺牲了一部分回答率,但用户对系统的信任度大大提升。因为错误答案的危害远大于"不知道"三个字。一个诚实的 RAG 系统,比一个假装全知的系统更有工程价值。
5.3 评估指标与回归测试:没有数据支撑的演进都是盲调
演进到中期,最关键的其实不是某个具体优化手段,而是有没有一套能评价"改完到底变好还是变坏"的体系。没有评估的演进,本质上就是靠感觉调参,今天调了切块,明天换了检索通道,后天再改提示词,每步都觉得自己优化了,但加起来不知道是变好还是变坏。
所以我强烈建议在动手做任何优化之前,先建立一个评测集。针对 RAG 场景,至少准备三类数据:
- 单跳问答:一个问题对应文档中一个明确片段,验证基础召回与问答能力;
- 多跳问答:需要跨多个片段甚至跨章节整合才能回答,验证链路协作;
- 反事实问题:知识库中根本不存在答案的问题,验证拒答与幻觉控制水平。
评估指标也分成两组。检索侧看 Recall@K,也就是标准答案所在片段是否出现在召回结果里;生成侧看答案准确率和忠实度。其中忠实度是 RAG 特有且特别重要的指标,它衡量的是:模型生成的答案是否严格基于检索片段,有没有引入检索片段之外的信息。可以用 LLM 充当裁判打分,也可以抽一部分人工标注。
每次修改完,都要跑一遍同样的评测集,用表格记录改动前后各项指标:
| 指标 | 改动前 | 改动后 | 变化 |
|---|---|---|---|
| Recall@5 | 71.2% | 78.5% | +7.3% |
| 答案准确率 | 66.8% | 74.1% | +7.3% |
| 忠实度 | 82.4% | 87.6% | +5.2% |
| 拒答准确率 | 60.0% | 81.0% | +21.0% |
只有让每一步演进都有数据反馈,你才知道当前瓶颈到底在哪个环节,这才是"演进"而不是"折腾"。
写到这里,想分享一点个人体会。我见过不少团队在 RAG 优化上花了大量时间,反复换嵌入模型、调块大小、试各种检索库,但一直缺一个评测集,最后谁的方案更好都说不清。我自己后来养成一个习惯:任何改动之前,先把评测集跑一遍作为基线;改完再跑一遍,对比差异,判断到底是正向收益还是负向波动。这个流程看起来多花了一点时间,却能让所有优化都建立在可验证的基础上。另外一个私藏小技巧是:在给每个 chunk 建索引时,顺手把来源文件路径和章节标题写进元数据。将来某天你的系统答错题,你能按图索骥,很快定位是索引问题、检索问题,还是文档本身的问题。这个习惯,比你换任何花哨的模型都管用。