资讯详情

RAG检索优化实战:分块、混合召回与重排核心方案

📅 2026/10/23 3:21:34 | 华诺云谱 👁 阅读
RAG检索优化实战:分块、混合召回与重排核心方案
做 RAG 这行当demo 阶段人人都觉得“已经成了”真正上线跑一两周才会撞见那个经典场面模型能力没问题prompt 也调了十几版可用户一问到具体业务回复要么“抱歉我没有找到相关信息”要么把两件相似但完全不同的文档内容缝在一起。我在 ACP-LLM 项目里把检索增强生成的链路从零搭起来这个系列写到第二篇最想聊的反而不是生成而是检索。原因很简单生成的质量上限基本被检索拿捏死了。第一篇文章我讲了 RAG 的整体架构和选型思路这一篇专门拆检索。分块、索引、混合召回、重排、评估以及一堆只有真实数据才能教你的坑。如果你正在做自己的 RAG 落地或者已经在跑但效果不稳定这篇文章应该能帮你把问题定位到具体环节。1. 先把检索这件事想通RAG 的天花板在这里1.1 一个“demo 能用、上线拉垮”的典型现象做过知识库问答的人应该都有同感内部文档百来篇示例问题二三十个演示的时候检索结果基本靠谱一旦把全部文档灌进去、让真实用户自由提问各种怪问题就来了。比如用户问“报销单审批流程是怎样的”系统召回的第一名却是一篇关于“差旅报销标准”的制度文件标题里都有“报销”语义上也不是完全无关但根本不是用户要的答案。这种问题很不幸的是怎么调 prompt 都没用。因为大模型拿到的上下文片段就是偏的它只能在一个错误地基上“合理发挥”。我后来把问题往前面查发现召回环节的 top 文档压根不具备回答条件生成端再厉害也只能将错就错。1.2 检索质量由什么决定三个环节一条链RAG 里真正决定“能不能答对”的是下面这条链分块与索引决定了一个知识片段以什么粒度存在能被什么条件命中。召回与排序决定了与 query 相关的片段能不能进入候选集以及谁排在前面。上下文压缩与组装决定了喂给模型的片段是否干净、是否聚焦、是否有引用来源。很多人优化 RAG 只盯着“向量模型”换了一个又一个效果却提升有限。其实多数项目的检索瓶颈不在 embedding 本身而在分块粒度不合理、索引字段没设计好、召回方式单一、排序只看相似度没有业务校准。这一篇就是围绕这条链逐层展开的。1.3 本文要解决哪些问题结合 ACP-LLM 落地过程中的实际数据这篇重点回答几个问题文档到底该怎么切才能兼顾召回率和上下文完整性纯向量检索为什么会在垂直领域翻车混合检索的分数融合怎么定才稳重排序该在什么环节做、怎么做。最后我会给出一套能落地的检索评估思路以及真实项目里踩过的几个坑。2. 分块和索引入口错了后面全是白费2.1 分块粒度与召回上限的关系分块是 RAG 的入口也是很多人最随意处理的一步。一个很反直觉的事实是召回率的上限在分块那一刻就锁死了。如果一块切成 200 token而某个问题的完整答案分布在 800 token 的段落里那无论检索模型多强都不可能靠单块直接命中完整答案后续只能靠 LLM 去“拼凑”拼凑就容易出错。分块太大会有另一个问题块里塞进了大量无关内容向量检索算出来的相似度自然会被“平均”掉排在前面的块虽然整体相关但真正关键的句子被淹没在噪音里。所以分块本质是在“语义完整性”和“信息密度”之间做权衡。我的经验是通用文档用 512 token 左右作为块的默认上限overlap 控制在 50 到 100 token技术手册、规范条文这类结构工整的文档可以适当调大而 FAQ、聊天记录这类单条信息密度高的内容反而要调小。2.2 元数据、父子分块、多路索引怎么搭只把文档切成小条扔进向量库是很多 RAG 早期版本的通病。切片之后文档的“身份信息”就丢了检索时只能靠相似度硬碰。更合理的做法是给每个块挂上结构化的元数据至少包括文档编号、一级目录、章节标题、更新时间、来源路径这几个字段。有了这些字段你可以在检索前做过滤比如“只看 2024 年后的制度文档”“只看某个产品线的手册”召回精度会明显提升。还有一个非常实用的套路父子分块。父块切得大一些负责给 LLM 提供完整上下文子块切得小一些负责参与向量召回。检索时拿 query 去打子块命中后把对应的父块内容返回给模型。这样既保证了召回的精确性又避免了上下文被切得七零八落。2.3 一套可以抄作业的分块参数模板我整理了一份在 ACP-LLM 里用过并且稳定的参数组合可以参考但不要照搬因为你的文档结构一定不同文档类型分块策略块大小重叠长度说明制度规章、流程文档按章节 子块递归切分600~800 token80 token答案往往跨多段父块完整度高产品手册、操作指南固定大小分块400~512 token50 token信息密度高小块利于精确命中FAQ、问答对按条切不跨条整条不设 overlap一条一答语义边界清晰代码片段、配置说明按代码块切按代码块边界0不能把代码从注释里切断代码层面一个通用的递归分块流程可以这样写def split_document(text, max_tokens, overlap_tokens): # 这里先按段落做粗切避免从句子中间硬切 raw_paras [p for p in text.split(\n\n) if p.strip()] blocks [] buffer for para in raw_paras: # 按 token 估算长度中文场景可以用字符数近似 if len(buffer) len(para) max_tokens: buffer \n\n para else: if buffer: blocks.append(buffer) # 保留尾部一小段作为和下一块的 overlap overlap buffer[-overlap_tokens:] if overlap_tokens else buffer overlap \n\n para if buffer: blocks.append(buffer) return blocks这段逻辑很朴素但规避了两个高频问题不在句子中间硬切、不把独立段落强行拼进一块。你在生产环境里还需要把这个函数和元数据抽取逻辑串起来保证每个 block 都能追溯回父文档。3. 混合检索别让“语义相似”替你扛所有召回3.1 纯向量检索在专业领域的失灵现场做 RAG 第一版时大家都会优先上 embedding因为它好理解把 query 和文档块都变成向量算相似度取 top K。这套方案在开放域、长文本语义匹配上确实能打但一进垂直领域就露出短板。我在 ACP-LLM 里遇到过很典型的例子某文档里写了“型号 A-200 只支持 220V 供电”用户 query 是“A-200 的供电电压是多少”。embedding 模型对这句话的理解没问题能召回。但换一种 query“请把 A-200 和 A-210 的供电要求对比一下”向量检索很容易把两个型号的向量混在一起因为它们在语义空间里太接近了。更麻烦的是精确匹配类信息零件编号、订单号、报错码、身份证件号这类字符串语义相似度几乎没有区分度。用户搜“ERR-4012”embedding 可能检索出“ERR-4013”的文档并排在前面因为它们在向量空间里确实“长得像”。可用户要的就是那个精确字符串字面一致就是唯一正确答案。这时候必须引入词法检索。3.2 词法 向量一个最小可用的混合检索实现混合检索的基本思路是同一路 query 同时走两条召回通道一条用 BM25 这类词法匹配负责精确命中一条用向量相似度负责语义泛化。两条通道的候选取并集再统一做排序。之所以用 BM25 而不是简单的“包含匹配”是因为它能处理词频和文档长度归一化比单纯的字符串包含更稳。一个最小实现可以是def hybrid_search(query, bm25_index, vector_index, top_k20): # 词法通道对 query 做分词走 BM25 打分 lexical_hits bm25_index.search(query, top_k) # 向量通道对 query 做 embedding走向量检索 query_vec embedding_model.encode(query) vector_hits vector_index.search(query_vec, top_k) # 取并集保留每个 doc 在两个通道的得分 merged {} for hit in lexical_hits: merged[hit.doc_id] {lexical: hit.score, vector: 0.0} for hit in vector_hits: merged.setdefault(hit.doc_id, {lexical: 0.0, vector: 0.0}) merged[hit.doc_id][vector] hit.score # 最终排序交给融合打分函数 ranked sorted(merged.items(), keylambda x: fused_score(x[1]), reverseTrue) return ranked[:top_k]两条通道各取多少候选直接决定召回上限。我常用的配置是词法取 50、向量取 50然后做 50 到 80 的并集重排后再给下游。如果并集数量太少混合检索的意义就不大。3.3 分数融合不是简单求和权重和归一化要讲逻辑混合检索最容易被低估的是分数融合。直接把 BM25 分数和向量余弦相似度相加是常见错误因为两种分数根本不在一个量纲上。BM25 分数可能从 0 到 20 几余弦相似度只有 -1 到 1加完之后 BM25 完全主导。比较稳的做法有两种。第一种是 RRFReciprocal Rank Fusion不看分数看排名把两条通道的排名倒数相加def rrf_score(rank_lexical, rank_vector, k60): score 0.0 if rank_lexical is not None: score 1.0 / (k rank_lexical) if rank_vector is not None: score 1.0 / (k rank_vector) return score这种方式对分数尺度不敏感稳定但粗糙。第二种是先做 min-max 归一化再加权求和def normalized_weighted_score(lex_score, vec_score, w_lex0.3, w_vec0.7): lex_norm (lex_score - lex_min) / (lex_max - lex_min) vec_norm (vec_score - vec_min) / (vec_max - vec_min) return w_lex * lex_norm w_vec * vec_norm权重怎么定我在 ACP-LLM 里按业务域做了两套默认值对制度问答、合同审查这类术语密集的场景词法权重高一些w_lex0.4, w_vec0.6对开放式问答向量权重明显占主导w_lex0.2, w_vec0.8。调权重的依据不是拍脑袋而是靠后面第 5 章的评测集对比不同权重下的 RecallK。4. 重排序把候选集从“相关”筛成“能用”4.1 为什么两阶段检索几乎成了标配召回阶段的目标不是“准”而是“全”。为了让真正相关的片段不被漏掉召回时会故意把条件放宽、多取一些候选。但宽松召回必然带来噪音如果把 50 个片段直接塞给 LLM一是上下文窗口装不下二是模型被大量无关信息干扰回答质量断崖式下降。所以需要两阶段召回负责“海选”重排负责“精选”。重排阶段通常把候选控制在 20 到 30 个以内用更强的模型逐对计算 query 和片段的关联度再取 top 5 到 10 给下游。这个策略已经是业内的通用做法因为它把“成本高的精细打分”限制在小范围里整体延迟可控。4.2 重排模型选择与运行成本的控制重排模型的选型思路和向量模型不同。向量模型是“双塔”结构query 和文档各自编码计算快但交互少重排常用的是“交叉编码器”结构把 query 和文档拼在一起过模型能捕捉更细的交互信号准确率更高代价是慢。上线时要把成本算清楚。假如一个 query 需要重排 30 个片段每个片段和 query 拼接后都要过一次模型100 次查询就是 3000 次推理。应对办法有三条第一严格控制重排候选数量超过 30 个就分批处理第二在重排前先做一个基于向量分数的粗过滤把明显不相关的候选丢掉第三给重排加缓存相同的 query 在一小段时间内直接返回历史结果。4.3 重排后的上下文组装窗口是稀缺资源重排只解决了“哪些片段更靠前”还没解决“怎么把这些片段喂给模型”。实际操作中我踩过这样一个坑重排后取了 top 5结果其中两个片段高度重复等于浪费了上下文窗口另一个片段虽然排在前五但它只是某个大问题的一个分支单独看和 query 沾边放在答案里却没有支撑力。我后来在上下文组装里加了三道处理第一按片段来源去重同一个文档里连续切出来的相邻块在候选里只保留覆盖范围更大的父块第二检查片段之间的连续性如果检索出来的片段在原文里是连续段落拼接时把它们合并成一块避免上下文中间“断气”第三设置一个总预算比如允许 3000 token 的上下文重排后的片段按顺序填充超出预算的直接丢弃不要让某个超长块把整个窗口占满。5. 检索评估没有评测集的优化都是自我感动5.1 评估指标怎么选从 RecallK 到答案命中率RAG 的评估很容易做成“凭感觉”。改了一版分块策略跑几个例子觉得“好像好一点”然后就没有然后了。这样优化的方向是不可持续的。一套可用的检索评估体系至少应该分两层检索层和生成层。检索层的基础指标是 RecallK意思是正确答案有没有出现在召回的前 K 条里。K 一般取 5 或 10。但光看 Recall 不够还要看 MRR也就是正确答案排在第几位。假设同样是 top 10 里包含正确答案一个排在第二一个排在第九LLM 拿到的上下文质量完全不同所以 MRR 比 Recall 更能反映排序质量。生成层我会额外看一个指标答案是否引用了正确的文档来源。引用错了就算答案措辞通顺也是错的。5.2 用一周时间攒一个可用的评测集没有评测集后面所有调优都是空转。攒评测集不需要一开始就做很大200 到 500 条 query 足够发现多数问题。来源我就地取材从线上日志里挑用户真实问过的问题再补一批内部同事拍脑袋想出来的边界问题。每条 query 标注出“答案应该来自哪几个文档片段”这就是一条 gold-standard。标注过程里有一条要注意不要只标“相关文档”要标“能回答该问题的文档”。这两个要求差别很大。因为很多文档和 query 主题相关却不直接包含答案如果把它们标成正样本评测指标会虚高优化方向也会被带偏。5.3 让评估结果能指导优化评测的价值不在于给个分数而在于定位问题在哪一环。我在 ACP-LLM 里每轮检索优化之后都会跑一遍同样的评测集然后专门统计“bad case 来自哪一层”。分类可以这样分问题来源典型表现优先排查方向分块问题答案被切散没有单块能覆盖完整答案调整分块粒度和 overlap召回问题相关片段压根没进候选集检查索引字段、增加混合检索通道排序问题相关片段在候选集里但排太靠后调重排模型、调整融合权重上下文问题片段相关但组装后信息冗余/割裂优化去重和连续性检查每次跑完评测把 bad case 归到某一层再去调那一层而不是盲目换模型。这是在 RAG 优化里性价比最高的行动方式。6. 落地阶段最容易翻车的五个坑6.1 中文环境下的分句和分词问题分块看似简单一旦处理中文就会发现一堆细节。直接用split(。)切句遇到“国标编号 GB/T 一类带点号的文本”会被错误切开用英文空格分词中文术语全挤在一起。更隐蔽的是中文的“自然段”经常是一大段包含多个语义点如果按段落切块会过大如果按句切又会丢掉上下文。我的处理习惯是先把原文按空行拆成段落再用标点符号做二次边界判断。只有当一个段落明显超过块大小上限时才在句子边界处切如果句子本身就超过上限再切在子句边界。这个规则不复杂但能显著减少“切断答案”的概率。6.2 向量维度、相似度指标和阈值选择向量检索的参数细节容易被忽略。最常见的坑是向量没做归一化就用内积相似度导致分数受向量长度影响。我的建议是统一用余弦相似度所有向量入库前做 L2 归一化这样后续无论用内积还是余弦结果都一致。还有一个问题是阈值。很多人会给检索加一个“相似度低于 0.7 就返回无结果”的规则但不同文档域、不同 embedding 模型的分数分布差异很大固定阈值很容易误杀。我后来不再设全局相似度阈值而是只拿分数做排序让重排模型或 LLM 判断“这个片段是否足够支撑回答”。如果你必须设阈值务必基于自己评测集上的分数分布来确定不要直接抄网上别人给的数字。6.3 延迟预算下的缓存设计RAG 全链路延迟往往不是大头在 LLM而是被检索和重排拖长。向量检索本身快但重排阶段如果不加控制延迟会线性上升。我的做法是把检索链路分成“可缓存”和“不可缓存”两层相同 query 在短期内直接返回缓存的检索结果同一批相关文档的向量检索结果也可以缓存。对于高频但措辞略有差异的 query先用一个轻量的改写服务把 query 做归一化再查缓存命中率能提升不少。6.4 引用溯源与压缩的交互这个坑容易被忽略但非常重要。检索出来的原始片段里明明带着文档编号和段落位置但经过上下文压缩之后标签被压缩模型“吃了”LLM 输出的答案就失去了引用来源。用户一旦追问“这个说法依据是什么”系统拿不出出处信任感直接崩塌。我的建议是压缩和溯源不要分成两个独立步骤。压缩时保留每个片段的结构化编号并在送给 LLM 的 prompt 里显式约定“回答时引用[文档编号, 段落编号]”。这里我吃过亏改完压缩策略之后引用率明显下降后来又花了不少精力把编号链路重新接上。6.5 多轮对话场景下的检索上下文单轮问答里检索对象就是用户最新的一句话多轮场景就没这么简单了。用户说“那它的功耗呢”如果你拿这句原话去检索几乎什么都召不回来因为“它”没有实体上下文在历史里。如果直接把整段历史都塞给检索模型又会引入大量噪音而且历史里的错误答案可能被反复检索出来。我的处理是对当前 query 做“改写”把多轮历史压缩成一句独立的、包含实体和限定条件的检索 query再拿去匹配。另外还要注意绝不要把上一轮 LLM 生成的答案原封不动地放进检索上下文因为生成内容可能本来就是错的检索到它只会让错误滚雪球。7. 收尾之前再分享一点个人体会RAG 优化做到最后越来越像“数据工程”而不是“模型工程”。小到一个 overlap 参数、大到混合检索权重几乎每一项都能通过评测数据找到最优答案而不是靠灵感。ACP-LLM 的 RAG 篇写到第二篇我在检索这层最深的感触是模型选型能帮你赢在起跑线上但分块、索引、召回融合、重排和引用链路才是让你赢在终点线的东西。如果你也想在项目里推一遍这些优化我建议按这个顺序来先搭评测集再调分块和索引然后引入混合检索最后处理重排和压缩。每一步都拿同一组 query 对比前后效果你很快会看到整体回答质量的变化。下一个阶段我大概率会写检索结果如何和业务规则做融合回头有结论了再来更新这个系列。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑