RAG工程优化实战:Chunking、混合检索与Rerank全解析
1. RAG 工程优化的整体设计思路1.1 为什么“能跑通”和“能上线”之间隔着一整套工程优化很多人第一次搭 RAG流程大概是这样文档丢进去、按固定字数切一刀、向量化、存进向量库、用户提问时拿 top-k 相似片段拼进 prompt然后交给大模型回答。Demo 阶段这套东西确实能跑甚至效果看着还不错。但只要文档量上到几千篇、问题稍微绕一点你就会发现回答开始“飘”要么答非所问要么把不相关的段落硬塞进上下文要么明明知识库里有正确答案却检索不出来。这不是模型不行而是检索质量没跟上。RAG 的本质是“先找对资料再让模型组织语言”找资料这一步做不好后面再强的模型也是巧妇难为无米之炊。所以工程优化的核心目标只有一个在有限的上下文窗口里塞进最相关、最完整、最不冗余的知识片段。围绕这个目标我把优化拆成三个递进的层次这也是本文的主线Chunking切块决定知识被切成什么样是检索的地基。切得不好后面检索和重排都救不回来。混合检索Hybrid Retrieval单一向量检索有盲区用关键词检索补上两路召回合并。Rerank重排召回阶段追求“不漏”重排阶段追求“精准”用交叉编码模型把真正相关的片段顶到最前面。这三步是层层递进的关系切块决定了召回的上限混合检索扩大了召回面重排则把精度拉回来。任何一步偷懒最终效果都会打折。1.2 技术选型为什么是 Spring AI 2.0选 Spring AI 做 RAG 工程最直接的理由是生态契合。如果你的后端本来就是 Spring Boot 体系引入 Spring AI 几乎是无缝的——依赖注入、配置管理、可观测性这些基础设施全都现成不用为了接一个大模型再单独维护一套 Python 服务。Spring AI 2.0 在 RAG 这块提供了几个关键抽象这是它能撑起工程化优化的基础DocumentReader/DocumentTransformer/DocumentWriter三段式的 ETL 管道切块逻辑可以插在 Transformer 环节替换成自定义实现。VectorStore接口统一了不同向量库的操作换库成本低。Advisor机制尤其是QuestionAnswerAdvisor把检索增强的逻辑封装成可插拔组件混合检索和重排可以在这里做文章。ChatClient的流式与结构化输出方便把重排后的上下文组织成稳定的 prompt。提示Spring AI 的版本迭代比较快2.0 阶段部分 API 还在演进。生产项目里建议锁定具体小版本升级前先跑一遍回归测试集别盲目跟最新。1.3 一个容易被忽略的前提先把评测集建起来在动手优化之前我强烈建议先做一件事建一个 50 到 100 条的小评测集。每条包含一个问题、标准答案、以及答案应该来自哪几个文档片段。没有这个你所有的“优化”都是凭感觉改完不知道是变好还是变坏。评测指标不用太复杂初期看两个就够指标含义关注点召回率 RecallK正确答案所在片段是否出现在前 K 个召回结果里衡量“漏没漏”命中精度 PrecisionK前 K 个结果里有多少是真正相关的衡量“准不准”有了这两个数你才能判断切块参数调大调小、要不要加重排、重排后 top-n 取多少。这是整个优化工作的“仪表盘”后面每一步调整都要回头看它。2. Chunking决定检索上限的地基工程2.1 固定长度切块为什么在真实文档上会翻车最省事的切块方式是按固定字符数切比如每 500 字一块块间留 50 字重叠。这个方法在结构规整的文档上勉强能用但真实文档往往长这样一个标题下面跟着三段说明中间夹一个表格表格后面又接一段结论。你按 500 字硬切很可能把标题和它的正文切开把表格切成两半把一句完整的话拦腰截断。后果很直接语义被破坏。向量模型编码的是“块”的整体语义一个被切碎的块它的向量表示本身就是模糊的检索时自然匹配不准。更糟的是即使这个块被召回了模型拿到的也是残缺信息容易产生幻觉。所以切块的第一原则是优先按文档的自然结构切其次才考虑长度。2.2 结构化切块按标题层级和语义边界来我的做法是分两层处理。第一层按文档结构切第二层对超长块做语义再切。第一层用 Markdown 标题或文档的段落结构作为切分点。比如一篇技术文档##一级标题下的内容作为一个父块###子标题下的内容作为子块。这样每个块天然带着自己的标题上下文语义是完整的。第二层针对那些超过阈值比如 800 字的块用语义切分。语义切分的核心思路是计算相邻句子的向量相似度在相似度骤降的地方断开——那个位置通常就是话题转换的边界。// 语义切块的简化思路伪代码实际需结合具体分词与向量模型 public ListDocument semanticSplit(Document doc, int maxLen, double threshold) { ListString sentences splitBySentence(doc.getText()); ListDocument chunks new ArrayList(); ListString buffer new ArrayList(); int bufferLen 0; for (int i 0; i sentences.size(); i) { buffer.add(sentences.get(i)); bufferLen sentences.get(i).length(); boolean overLen bufferLen maxLen; boolean topicShift i 1 sentences.size() cosineSim(embed(sentences.get(i)), embed(sentences.get(i 1))) threshold; if (overLen || topicShift) { chunks.add(buildChunk(buffer, doc.getMetadata())); buffer.clear(); bufferLen 0; } } if (!buffer.isEmpty()) { chunks.add(buildChunk(buffer, doc.getMetadata())); } return chunks; }这里有个关键细节每个块都要继承父级的元数据。比如它属于哪个文档、哪个章节、原文位置在哪。这些元数据在后续重排和引用溯源时非常有用。2.3 块大小与重叠的取舍没有万能参数块大小到底取多少这个问题没有标准答案但有几个经验规律可以参考块太小200 字语义不完整向量表示偏窄容易召回一堆碎片模型拼不出完整答案。块太大1000 字一个块里混了多个主题向量被“平均”掉检索精度下降还浪费上下文窗口。常用区间中文技术文档 300 到 600 字是比较舒服的区间英文可以适当放大。重叠overlap的作用是防止关键信息正好落在切分边界上被割裂。一般取块大小的 10% 到 20%。但重叠不是越多越好重叠太多会导致召回结果高度冗余重排时一堆相似块互相挤占名额。注意块大小和重叠这两个参数一定要在你的评测集上实测。我见过同一个参数在 A 项目效果好、在 B 项目一塌糊涂的情况因为文档类型和问题类型完全不同。2.4 元数据设计让每个块“自带身份证”切块时顺手把元数据补全是性价比极高的一件事。我通常会给每个块带上这些字段字段用途doc_id溯源到原文档doc_title提供文档级上下文section_path章节路径如“第3章 3.2 配置”chunk_index块在文档中的顺序用于相邻块扩展source_type文档类型便于按类型过滤updated_at时效性判断有了section_path检索时可以把章节标题拼进块的文本再向量化相当于给块加了一层语义标签召回准确率会有肉眼可见的提升。有了chunk_index还能做“相邻块扩展”——召回一个块后把它前后各一个块也带上给模型更完整的上下文。3. 混合检索向量与关键词的双路召回3.1 纯向量检索的三个盲区向量检索擅长语义匹配用户问“怎么配置数据库连接”它能召回“数据源设置方法”这种字面不同但语义相近的内容。但它有三个明显盲区第一专有名词和代号。比如产品型号、错误码、函数名这些词的语义向量往往很接近向量检索分不清ERR_1001和ERR_1002的区别但关键词检索一匹配一个准。第二精确短语。用户搜一个完整的配置项名称向量检索可能返回一堆语义相关但名字不对的段落。第三低频词。训练语料里少见的词向量表示质量差检索容易失准。关键词检索BM25 是经典实现恰好补上这些短板它基于词频和逆文档频率打分对精确匹配极其敏感。两者结合就是混合检索。3.2 双路召回的实现与分数融合混合检索的工程实现分两步并行召回然后融合排序。并行召回很直接向量库查一次关键词索引查一次各取 top-NN 通常取 20 到 50比最终需要的多。关键词索引可以用 Elasticsearch、OpenSearch也可以用轻量的 Lucene 自建。融合排序是重点。最简单的做法是加权求和final_score α * vector_score (1 - α) * bm25_score但这里有个坑向量相似度通常在 0 到 1 之间BM25 分数却是没有上界的实数直接加权会被量纲差异带偏。所以要先做归一化把两路分数都映射到 0 到 1。更稳妥的做法是RRFReciprocal Rank Fusion倒数排名融合。它不看具体分数只看排名RRF_score(d) Σ 1 / (k rank_i(d))其中k是平滑常数常用 60rank_i(d)是文档 d 在第 i 路召回中的排名。RRF 的好处是完全规避了量纲问题而且对某一路召回质量波动不敏感工程上非常稳。// RRF 融合示例 public ListScoredDocument rrfFuse(ListListScoredDocument rankedLists, int k) { MapString, Double scoreMap new HashMap(); MapString, ScoredDocument docMap new HashMap(); for (ListScoredDocument list : rankedLists) { for (int rank 0; rank list.size(); rank) { ScoredDocument doc list.get(rank); String id doc.getId(); docMap.putIfAbsent(id, doc); scoreMap.merge(id, 1.0 / (k rank 1), Double::sum); } } return scoreMap.entrySet().stream() .sorted(Map.Entry.String, DoublecomparingByValue().reversed()) .map(e - docMap.get(e.getKey()).withScore(e.getValue())) .collect(Collectors.toList()); }3.3 查询改写让召回更懂用户用户的问题往往口语化、有指代、缺上下文。直接拿原问题去检索效果会打折扣。查询改写是提升召回率的低成本手段常见做法有三种指代消解把“它怎么配置”补全成“数据库连接池怎么配置”结合对话历史补全主语。多查询扩展用大模型把一个问题改写成 3 到 5 个不同角度的查询分别召回后合并。比如“RAG 怎么优化”可以扩展成“RAG 检索精度提升”“RAG 切块策略”“RAG 重排方法”。HyDE让模型先“假装”写一段答案再用这段答案去检索。因为答案的措辞通常比问题更接近文档内容召回效果往往更好。多查询扩展会增加检索次数和延迟建议只在召回率不达标时启用或者对复杂问题才触发。3.4 元数据过滤先缩小范围再检索如果知识库里有多个来源、多个版本、多个业务域的内容检索前先做元数据过滤能大幅提升精度。比如用户明确问“最新版接口文档”那就先按updated_at过滤掉旧版本再在剩余内容里检索。Spring AI 的VectorStore支持在SearchRequest里带过滤表达式关键词检索那边也能加 filter 子句。把过滤条件前置等于把检索空间缩小了一个数量级既快又准。实操心得过滤条件不要设得太死。我踩过一次坑用户问“上季度的销售数据”我按季度精确过滤结果文档里标注的季度格式不统一过滤后一条都没剩。后来改成宽松匹配加时间范围兜底才稳定下来。4. Rerank把真正相关的顶到最前面4.1 召回与重排的分工一个求全一个求精混合检索解决了“召回面”的问题但召回结果里必然混着噪声。top-20 里可能只有 3 到 5 个是真正相关的其余都是语义相近但答非所问的。如果直接把这 20 个塞给模型不仅浪费上下文还会干扰模型判断。Rerank 的作用就是在这批候选里做精排。它和召回阶段用的是完全不同的模型架构召回阶段用的是双编码器Bi-Encoder问题和文档分别编码成向量算余弦相似度。优点是快可以预先建索引适合海量检索。缺点是问题和文档没有交互精度有限。重排阶段用的是交叉编码器Cross-Encoder把问题和文档拼在一起送进模型让模型逐字逐句地比对。精度高得多但计算量大没法预建索引只能对少量候选做。所以标准流程是召回取 top-50重排后取 top-5。用双编码器保证不漏用交叉编码器保证精准。4.2 Rerank 模型的部署与调用重排模型可以本地部署也可以调 API。本地部署常见的选择是开源的交叉编码器模型通过推理服务暴露接口。部署时要注意几点显存占用交叉编码器比双编码器吃显存batch size 要按显存调别一上来就拉满。批处理重排是对一批候选打分尽量把同一批候选打包成一次请求减少网络往返。超时与降级重排服务挂了不能拖垮整个链路要有降级策略——比如超时就直接用召回分数排序。// 重排调用示例伪代码 public ListScoredDocument rerank(String query, ListScoredDocument candidates, int topN) { try { ListRerankPair pairs candidates.stream() .map(d - new RerankPair(query, d.getText())) .collect(Collectors.toList()); ListDouble scores rerankClient.score(pairs); return IntStream.range(0, candidates.size()) .mapToObj(i - candidates.get(i).withRerankScore(scores.get(i))) .sorted(Comparator.comparingDouble(ScoredDocument::getRerankScore).reversed()) .limit(topN) .collect(Collectors.toList()); } catch (Exception e) { log.warn(Rerank 失败降级为召回排序, e); return candidates.stream().limit(topN).collect(Collectors.toList()); } }4.3 重排后的上下文组织顺序和数量都有讲究重排选出 top-n 之后怎么把它们拼进 prompt 也有讲究。顺序方面有研究表明模型对上下文开头和结尾的信息更敏感中间容易“迷失”。所以可以把最相关的放开头次相关的放结尾中间放一般的。这个技巧叫“lost in the middle”的应对实测在长上下文场景下有效。数量方面不是越多越好。top-n 取 3 到 5 通常够用取太多反而引入噪声。具体取多少还是回到评测集上看——如果 Recall5 已经很高就没必要取 10。去重方面重排后要检查有没有高度相似的块比如重叠切块导致的重复重复的只保留一个省下的名额给其他相关块。4.4 重排的收益与成本权衡重排不是免费的。它增加了一次模型推理延迟会上升尤其是候选多、模型大的时候。所以要不要上重排取决于你的场景场景是否建议重排理由知识库小、问题简单可选召回本身够准重排收益有限知识库大、问题复杂强烈建议召回噪声多重排收益明显对延迟极敏感谨慎需评估重排耗时或改用轻量模型对准确率要求高强烈建议重排是精度提升最直接的手段我的经验是只要召回 top-20 里相关片段占比低于 50%重排的收益就非常明显。先用评测集量一下这个占比再决定投不投重排。5. 常见问题与排查技巧实录5.1 检索不准的排查顺序遇到“答非所问”别急着换模型按这个顺序排查先看切块把召回结果对应的原文块打印出来看语义是否完整。如果块本身是残缺的问题在切块。再看召回正确答案所在的块有没有出现在召回列表里如果没出现是召回问题考虑加混合检索或查询改写。最后看重排正确答案在召回列表里但没进 top-n那是重排问题检查重排模型是否适配你的领域。这个顺序能帮你快速定位问题层次避免盲目调参。5.2 常见问题速查表现象可能原因排查方向召回结果全是相似块切块重叠过大降低 overlap专有名词检索不到纯向量检索盲区加关键词检索答案残缺不完整块被切断调大块或做相邻块扩展重排后延迟飙升候选太多或模型太大减少候选数或换轻量模型新旧版本内容混淆缺元数据过滤加版本/时间过滤多轮对话指代不清缺查询改写加指代消解5.3 几个踩过的坑坑一切块时把表格切碎了。表格的语义是整体性的切碎后每一块都读不懂。后来我对表格做了特殊处理——整表作为一个块或者转成文字描述再切。坑二重排模型和向量模型领域不匹配。用通用重排模型处理医疗、法律这类垂直领域文本效果会打折。有条件的话用领域数据微调一下重排模型收益很大。坑三忽略缓存。高频问题的检索和重排结果完全可以缓存尤其是重排这种计算密集的操作。加一层结果缓存QPS 上来后延迟能降一大截。坑四评测集和线上分布不一致。评测集里都是“标准问法”线上用户却各种口语、错别字、省略。评测集要定期用真实日志补充否则优化方向会跑偏。5.4 上线前的检查清单切块参数在评测集上验证过召回率达标混合检索两路召回都正常融合逻辑有单测重排有超时降级不会拖垮主链路元数据过滤条件经过边界测试上下文拼接顺序和数量有依据有缓存层高频问题不重复计算有监控能观测召回率、重排耗时、端到端延迟这套东西搭下来RAG 才算从“能跑”走到“能上线”。我个人在实际操作中的体会是优化没有终点但评测集是方向盘。每次改动都回头看指标改对了就固化改错了就回滚慢慢就能把效果磨到一个稳定的水平。后面如果知识库继续膨胀还可以考虑引入更细粒度的路由——按问题类型走不同的检索策略那是下一个阶段的活了。