资讯详情

资讯详情

RAG链路优化实战:查询改写、混合检索与父子分块

RAG 技术演进这个系列写到中篇恰好是一个很有意思的位置。上篇我们拆过朴素 RAG 的完整流程把文档按固定长度切成块向量化检索 Top-K拼进 Prompt交给大模型生成。今天这篇不打算再重复那些基础知识而是想认真回答一个问题当你的 RAG Demo 已经能跑通但拿真实文档一测就露馅的时候问题到底出在哪又该按什么顺序去演进。这个问题我倾向于先给一个结论大部分项目卡住的不是模型不够强而是链路不够配合。检索增强生成的关键词虽然是生成但真正决定上限的往往是检索侧——以及为检索服务的索引、查询、评估这些周边环节。所以中篇的内容会集中在四块查询端的改写与路由、检索端的混合召回与精排、索引端的切分与分块策略、生成端的兜底规则与评估闭环。目标读者是那些已经搭过一套基础 RAG、准备把它推向真实业务场景的朋友这篇文章会更贴近你实际会遇到的困境。1. 朴素 RAG 的真正瓶颈链路而不是模型1.1 切块、向量、Top-Kdemo 跑通之后发生了什么先回顾一下朴素 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, k60): 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(), keylambda x: x[1], reverseTrue)参数 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 场景至少准备三类数据单跳问答一个问题对应文档中一个明确片段验证基础召回与问答能力多跳问答需要跨多个片段甚至跨章节整合才能回答验证链路协作反事实问题知识库中根本不存在答案的问题验证拒答与幻觉控制水平。评估指标也分成两组。检索侧看 RecallK也就是标准答案所在片段是否出现在召回结果里生成侧看答案准确率和忠实度。其中忠实度是 RAG 特有且特别重要的指标它衡量的是模型生成的答案是否严格基于检索片段有没有引入检索片段之外的信息。可以用 LLM 充当裁判打分也可以抽一部分人工标注。每次修改完都要跑一遍同样的评测集用表格记录改动前后各项指标指标改动前改动后变化Recall571.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 建索引时顺手把来源文件路径和章节标题写进元数据。将来某天你的系统答错题你能按图索骥很快定位是索引问题、检索问题还是文档本身的问题。这个习惯比你换任何花哨的模型都管用。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →