ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

RAG检索优化:从Chunking到混合检索再到Rerank的工程实践

RAG检索优化:从Chunking到混合检索再到Rerank的工程实践 1. 先别急着上 Rerank把 RAG 当检索系统来工程化1.1 一条能跑的基线链路在 Spring AI 2.0 里长什么样最早我把 RAG 搭起来时走的是最典型的四步流程读文档、切块、写向量库、问答时检索增强。用 Spring AI 2.0 的 API 写出来非常简洁看起来就像这样// 1. 读取 PDF 文档 DocumentReader reader new PagePdfDocumentReader( new FileSystemResource(knowledge.pdf)); ListDocument docs reader.read(); // 2. 切块 TokenTextSplitter splitter TokenTextSplitter.builder() .withChunkSize(500) .withOverlap(50) .build(); ListDocument chunks splitter.apply(docs); // 3. 写入向量库 vectorStore.add(chunks); // 4. 问答时自动检索 ChatClient chatClient ChatClient.builder(chatModel).build(); String answer chatClient.prompt() .advisors(new QuestionAnswerAdvisor(vectorStore)) .user(实习生入职流程是什么) .call() .content();这套链路在 Demo 阶段完全看不出问题文档干净、问题典型、答案模板化怎么跑怎么顺。但一旦数据源换成真实业务资料比如几百份产品文档、工单总结、技术方案、售后记录情况立刻就不一样了。我大概归纳成三个让人头疼的现象第一个是答不上来。资料库里明明有这句话模型却说“没有找到相关信息”。这不是模型能力问题而是检索环节根本没有把正确答案召回。第二个是答非所问。上下文里确实塞进了相关文档但相关文档的片段是“部分相关”模型被这些干扰信息带偏回答变得模棱两可。第三个是溯源困难。就算模型答对了我追问一句“这个结论在原文哪一段”它给出的引用经常落到不相关的位置因为分块时把上下文切断引用粒度太粗。如果你也碰到过这些问题那说明 RAG 链路已经不再是“demo 能通”的层面而是进入检索质量的工程优化阶段了。1.2 为什么 Demo 效果还行一换业务文档就崩我后来把问题想明白了RAG 本质上不是“AI 功能”而是一个被 AI 包装过的检索系统。检索系统的评价指标是查准率和查全率这两个指标没做好后面模型生成得再漂亮也没用。Demo 里的文档往往只有几十页主题集中每个问题几乎都可以在同一个页面上找到答案业务文档则是主题分散的同一件事分散在多个章节同一个术语在不同地方含义不同这直接挑战了切块和召回的底线。具体到 Spring AI 这条链路上四个环节全都可能成为瓶颈读文档环节PDF、Word、Markdown 混在一起不同格式解析出来的结构完全不同表格和代码块特别容易乱。切块环节固定 token 切分不关心段落语义一个完整的知识点被切成两半检索时哪一半都召回不到完整答案。索引环节Embedding 模型只能表示“语义相似”处理不了精确术语匹配。检索环节单路向量检索对同义词、口语描述、精确型号的覆盖能力有限漏召回几乎是必然的。这也是我最终确定优化主线的依据。这三个杠杆的顺序是有讲究的先保证索引端“有话可查”再保证召回端“尽量不 miss”最后才轮到排序端“把对的提到前面”。如果 Chunking 做得稀烂Rerank 再强也只是在一堆残缺片段里挑一个相对完整的天花板很低。1.3 优化主线从 Chunking 到混合检索再到 Rerank为什么是这个顺序我见过不少团队一上来就折腾 Rerank觉得“重排序是 RAG 优化的银弹”。实际做完一轮评测后我发现Rerank 的提升幅度虽然明显但它是建立在召回集合质量足够好的基础上的。如果检索阶段 top20 里根本没有正确答案Rerank 就是在“矮子里拔将军”属于无效优化。正确的顺序应该是先把 Chunking 做对确保每个切片都是一个尽量完整的语义单元这是召回质量的地基。再把混合检索加上用关键词精确匹配和向量语义检索互相补位扩大召回覆盖。最后上 Rerank在候选集合上用更精细的模型做二次排序把真正相关的片段提到最前面。这个顺序背后还有一层成本考虑。Chunking 优化基本不增加线上延迟混合检索增加少量的并发开销Rerank 则会实打实地引入额外的模型推理时间。让每一步都建立在上一步的可靠基础上才不会出现“优化了半天线上响应慢得没法用”的尴尬局面。2. Chunking召回质量的第一道分水岭值得花半天调参2.1 固定 Token 切块的问题语义容易被拦腰截断很多网上教程喜欢用固定 token 数来切块比如每 500 token 切成一块再留 50 token 的重叠。这种做法简单但对业务文档伤害很大。我举个实际遇到的例子。有一份合同条款文档原文是“在合同纠纷中如果甲方未按期支付款项则乙方有权要求甲方支付违约金”。如果切块在“未按期支付”和“款项”之间断开那么第一块只保留了“甲方未按期支付”第二块只保留了“款项则乙方有权要求甲方支付违约金”。用户问“乙方在什么情况下可以要求违约金”两块都可能被召回但没有一块包含完整的条件与结论模型只能靠猜。这个问题的本质是Embedding 模型是把整个片段“压缩”成一个向量如果片段内部包含多个独立语义向量表示就会变得模糊。你大概率会得到这样的结果这个片段和任何问题都“有点相关”但和哪个问题都不“强相关”最终检索出来的 topK 里混了一堆似是而非的文档LLM 回答质量自然直线下降。还有一类问题是中文特有的。Token 切分如果按照英文空格或标点做很容易把中文长句切成不完整的片段。比如产品文档里“弹性计算服务提供按量付费和包年包月两种计费方式”这句话按标点切没问题但如果是 500 字符级别的大块中间夹杂了大量无关信息检索时语义会被稀释。2.2 按结构切分和语义感知比调大 chunkOverlap 更有效我自己尝试过各种切分策略后得出的结论是别依赖一个全局的固定 chunk 大小而是先看文档结构再做滑动窗口。主流办公文档通常具备天然语义边界标题、小节、有序列表、表格行。先按标题切到小节级别再在每个小节内判断是否需要继续切分这样每一块都是一个相对完整的主题。Markdown 文档和 Word 文档都能解析出标题层级PDF 文档用 Tika 解析后也能保留段落边界关键是切分器要利用这些信息而不是直接对纯文本流暴力切分。另一个关键点是元数据。切块后每一块都要带上原始来源、所在章节、页码和版本号。这不仅仅是方便溯源更是为了后续做检索过滤。比如一个知识库里有多个版本的产品手册用户查询时应该只检索当前版本如果元数据里不带版本号你只能回退到“所有文档一起查”旧版本内容很容易污染答案。关于 chunk 大小的选择我的实践参考是这样文档类型建议策略理由标准操作流程按步骤切每步一块每步本身就是完整语义单元产品说明/规格书按小节切超标后再叠 10% 重叠标题结构即知识结构合同/条款按条款编号切不跨条跨条容易断裂条件与结论表格数据整个表格作为一块或者按行转成描述性文本拆开表格会丢失列对应关系代码文件按函数/类切不按行切函数内部逻辑完整2.3 一段可落地的 Spring AI 分块代码在 Spring AI 中官方提供的TokenTextSplitter其实支持不少参数但默认行为就是滑动窗口它不会感知标题结构。所以我通常的做法是自己实现一个DocumentTransformer在切分前先做结构级拆分再交给TokenTextSplitter控制长度。下面这段代码是我在项目里实际使用过的结构虽然不是成品但思路可以作为参考Component public class StructuredChunkTransformer implements DocumentTransformer { private final TokenTextSplitter splitter; public StructuredChunkTransformer() { this.splitter TokenTextSplitter.builder() .withChunkSize(450) // 按 token 计 .withOverlap(45) // 10% 重叠 .build(); } Override public ListDocument apply(ListDocument documents) { ListDocument result new ArrayList(); for (Document doc : documents) { // 1. 先按标题结构切出语义单元 ListString sections splitByHeading(doc.getText()); // 2. 每个语义单元内部再做 token 级别控制 for (String section : sections) { ListDocument sectionDocs splitter.apply( List.of(new Document(section))); for (Document sectionDoc : sectionDocs) { // 3. 继承原始元数据方便溯源和过滤 sectionDoc.getMetadata().putAll(doc.getMetadata()); sectionDoc.getMetadata().put(sourceFile, doc.getMetadata().get(source)); result.add(sectionDoc); } } } return result; } private ListString splitByHeading(String text) { // 这里可以按 Markdown 的 # 标题、Word 大纲级别、PDF 书签来拆 // 我在工程里用的是正则匹配“^#{1,3}\\s”, // 配合 Tika 抽取出的段落结构做二次处理 ... } }写这块代码时最需要注意的是第三步。切出来的子文档如果直接丢掉父文档的metadata前面用filterExpression做版本过滤、来源过滤时就全废了。我第一版就是没做继承结果检索时想只查某个产品线都做不到后来花了不少时间返工。2.4 分块调优中的实操心得与常见误区第一不要盲信“chunk 越大信息越全”。有个误区是认为答案越长chunk 就应该越大这样模型能看到的上下文才够。但你要理解检索阶段的目标是找到“答案所在的语义单元”而不是把整篇文章塞给模型。如果答案需要跨 chunk 拼接那说明你的业务答案边界本身就应该被设计成独立的检索单元。第二重叠设置别超过 20%。我见过有人把 overlap 设到 100 token 甚至 200 token理由是“防止切断语义”。实际效果是同一段内容出现在多个 chunk 里检索时会看到大量重复的片段topK 里一半是重复信息上下文被严重浪费。我的实践是 5%~15% 足够一般 45 token 对 450 token 这样。第三表格数据一定要特殊处理。Tika 解析出的表格往往是一堆没有表头的文本效果很差。我后来会先把表格行转换成“列名值”的描述性文本比如“内存32G\n磁盘SSD 1T”这样 Embedding 模型才能理解哪列对哪列、哪行对哪行检索效果比直接丢原始字符串好很多。3. 混合检索用“关键词精确匹配 语义召回”覆盖互补信号3.1 单路检索为什么容易漏稀疏与稠密的信号差异向量检索的本质是语义检索。它把问题和文档分别 Embedding 成向量然后算余弦相似度。好处是“换一种说法也能匹配”比如用户问“有便宜的云主机吗”文档里写的是“低成本计算资源”它能匹配上。坏处是它对精确词面匹配不敏感尤其是遇到专有名词、产品型号、错误拼写、罕见术语时Embedding 模型可能根本没学好这些 token 的语义向量之间的距离就拉不远。关键词检索比如 BM25则完全相反。它靠词频和逆文档频率打分数只要文档里出现了用户问题中的词就能给一个不低的分数。代价是它不理解同义词也不理解语境用户说“服务器”而文档写“ECS”它就匹配不上。真实业务场景里这两种信号缺一不可。我举一个实际例子用户问“ECS 4核8G的实例现在还有货吗”文档里可能写的是“ecs.g6.4xlarge 库存状态”向量检索很难想起来“4核8G”和“ecs.g6.4xlarge”是同一个东西但如果你把“ECS”“库存”“4核8G”作为关键词去搜文档大概率能被检索到因为产品文档里大概率会出现这些词。维度稠密向量Embedding稀疏/关键词BM25匹配逻辑语义近似词面匹配擅长问题同义词、口语化描述、抽象问题精确型号、产品名、代码标识容易失败专有名词、罕见 token、拼写错误同义改写、语序变化典型例子“帮我找个能管理容器的东西”“kubectl 查看 pod 日志”3.2 在 Spring AI 里落地混合检索的几种姿势严格来说Spring AI 的VectorStore抽象本身只负责“向量检索”但很多向量数据库或搜索引擎已经提供了混合检索能力。比如ElasticsearchVectorStore、OpenSearchVectorStore、MongoDBAtlasVectorStore等在 Spring AI 的封装中都支持用稀疏向量和稠密向量同时召回再由搜索引擎做分数融合。如果你的后端用这几种底座可以直接看官方文档会省掉不少自研工作。但我在很多项目里的情况是向量库用的是纯向量数据库PGVector、Milvus它本身不支持全文检索或者词面检索能力很弱。这种情况下我会自己做两层召回再在应用层融合。我选择的方式是在 Spring AI 实现一个自定义DocumentRetriever里面同时调用向量检索和关键词检索引擎。关键词索引可以用 Lucene 在 Java 进程内建也可以直接调用 Elasticsearch 的 BM25 查询看你的部署条件。我项目里用的是 Lucene 内存索引因为当时不想再引入一个中间件如果之后数据量大了再迁到 ES 也可以。大致的实现思路是这样public class HybridDocumentRetriever implements DocumentRetriever { private final VectorStore vectorStore; private final KeywordSearcher keywordSearcher; Override public ListDocument retrieve(RetrievalQuery query) { // 第一路向量召回 ListDocument denseDocs vectorStore.similaritySearch( SearchRequest.builder() .query(query.text()) .topK(15) .similarityThreshold(0.0) // 别过滤太狠 .build()); // 第二路关键词召回 ListDocument sparseDocs keywordSearcher.search(query.text(), 15); // 融合并返回 return rrfMerge(List.of(denseDocs, sparseDocs), 60); } }我特意把similarityThreshold设为 0.0 而不是默认阈值。原理很简单既然已经决定用混合检索那向量这一路就不应该过早过滤否则本来靠关键词能救回来的文档可能在向量路就被 threshold 拦掉了。让两路都尽量放宽召回把判断交给融合策略和 Rerank。3.3 RRF 融合权重与 topK 配置两路召回各自的分数体系完全不一样直接相加毫无意义。我采用的是 RRFReciprocal Rank Fusion它不关心各个检索结果的原始分数只看每个文档在结果列表里的排名。核心公式很简单[ score(doc) \sum_{list} \frac{1}{k rank_i} ]其中 rank 从 1 开始k 是平滑常数通常取 60。这样设计的好处是某个文档在向量路排第 2、在关键词路排第 30它依然能得到一个不低的分数不会被某一路的极端分数淹没。我自己的实现大概长这样private ListDocument rrfMerge(ListListDocument candidates, int k) { MapString, Double scoreMap new HashMap(); MapString, Document docMap new LinkedHashMap(); for (ListDocument docs : candidates) { for (int i 0; i docs.size(); i) { Document doc docs.get(i); String id doc.getId(); scoreMap.merge(id, 1.0 / (k i 1), Double::sum); docMap.putIfAbsent(id, doc); } } return docMap.entrySet().stream() .sorted((a, b) - Double.compare( scoreMap.get(b.getKey()), scoreMap.get(a.getKey()))) .map(e - docMap.get(e.getKey())) .collect(Collectors.toList()); }这里有一个很容易被忽略的细节如果某一路的 topK 数量远大于另一路融合结果会被那一路主导。所以两路的 topK 要尽量保持一致或者至少在融合前做一次归一切块。我实际用的 topK 是向量 15、关键词 15融合后保留前 20这样 Rerank 阶段才有足够多的候选。3.4 混合检索的延迟与线程模型混合检索是两路查询如果串行执行延迟就是两路之和。我在第一版实现里直接串行调用结果接口平均延迟从 0.9 秒涨到了 1.6 秒非常难受。后来改成用CompletableFuture并发执行两路检索延迟基本回到单路的水平。CompletableFutureListDocument denseFuture CompletableFuture.supplyAsync(() - vectorStore.similaritySearch(...)); CompletableFutureListDocument sparseFuture CompletableFuture.supplyAsync(() - keywordSearcher.search(...)); ListDocument denseDocs denseFuture.join(); ListDocument sparseDocs sparseFuture.join();并发执行引入的另一个问题是要注意线程池隔离。我当时用的是公共 ForkJoinPool线上偶尔出现检索任务阻塞拖垮其他操作的情况后来换成了一个单独的ExecutorService核心线程数控制在 CPU 核数左右效果稳定很多。4. Rerank粗召回之后的精排代价与收益要一起看4.1 为什么 Embedding 相似度不够从双塔到交叉编码在讲 Rerank 之前得先明白它和 Embedding 检索的本质区别。Embedding 检索是在索引阶段把文档离线编码成向量查询阶段再把问题编码成向量计算两个向量的相似度。这种结构叫双塔模型问题和文档在编码时彼此看不到对方靠的是“各自先压缩语义再比较压缩后的结果”。问题在于语义压缩是有损失的。两个完全相关的文本如果表达方式差异很大压缩后的向量可能离得很远两个只是字面相似但语义无关的文本反而可能靠得很近。Rerank 阶段使用的交叉编码器则不同它会同时输入问题和文档让模型在 token 级别上直接观察两者的交互相当于两个人互相看着对方讨论而不是背对背各自写读后感精准度天然更高。代价是速度。交叉编码器无法预先计算文档向量它必须对每个“问题-文档对”实时推理一次。所以 Rerank 只能用于粗召回之后的精排阶段不可能替代第一轮检索。4.2 Spring AI 接入 Rerank 的代码路径在 Spring AI 里接入 Rerank最自然的位置是自定义DocumentRetriever。也就是把原来“混合检索后直接返回 topK”的逻辑改成“混合检索后先取一个较大的候选集再经过 Rerank 精排选出最终 topK”。我项目里的实现大致是这样public class RerankDocumentRetriever implements DocumentRetriever { private final VectorStore vectorStore; private final KeywordSearcher keywordSearcher; private final RerankClient rerankClient; Override public ListDocument retrieve(RetrievalQuery query) { // 1. 粗召回拿到 20~30 个候选 ListDocument candidates hybridSearch(query.text(), 30); // 2. 构造问题和文档片段 ListString contents candidates.stream() .map(doc - doc.getText().substring(0, Math.min(500, doc.getText().length()))) .toList(); // 3. 调用 Rerank 模型拿到每个候选的分数 ListDouble scores rerankClient.rerank(query.text(), contents); // 4. 按分数降序取 topK ListDocument ranked IntStream.range(0, candidates.size()) .boxed() .sorted(Comparator.comparingDouble((Integer i) - scores.get(i)).reversed()) .map(candidates::get) .limit(5) .toList(); return ranked; } }这段代码里的RerankClient可以有不同的实现方式。如果你的团队已经部署了单独的 Rerank 模型服务比如基于 bge-reranker 或 Qwen3-Reranker 封装的 HTTP 接口就直接用 WebClient 调用它。如果你想先低成本验证 Rerank 是否有效也可以用 ChatClient 让大模型给每个候选打分。后者不需要额外部署模型但分数稳定性差一些适合做 POC。用大模型打分时我的 prompt 大致是这样的private double llmScore(String query, Document doc) { String content doc.getText().substring(0, Math.min(500, doc.getText().length())); String prompt 给定问题%s 给定资料%s 请只输出一个 0 到 10 的整数表示该资料对回答问题是否有用。 不要输出任何其他内容。 .formatted(query, content); String resp chatModel.call(new Prompt(prompt)) .getResult() .getOutput() .getText() .trim(); try { return Double.parseDouble(resp); } catch (NumberFormatException e) { return 0; } }我特别提醒一下这个方案有两个坑。一是文档内容太长时LLM 的推理时间会猛增所以截断到 500 字符以内是必要的。二是 LLM 很可能输出“8 分因为……”这种带解释的内容parseDouble会直接失败返回 0等于把这篇文档排到底部。我后来改成先正则提取数字再解析稳定了很多。4.3 Rerank 窗口、超时与模型选型Rerank 的窗口大小也就是“给精排模型多少个候选”需要在效果和延迟之间做平衡。我的经验是候选 5 个以下时Rerank 几乎没有必要直接取粗召回前 5 就行。候选 10~20 个是性价比最高的区间Rerank 能明显改善排序。候选超过 50 个延迟会变得不可接受特别是用交叉编码器时。我做压测时的数据是用 bge-reranker 对 30 个候选做精排单次请求耗时大约 200~400ms用 LLM 打分方式30 个候选可能要 3 秒以上基本只适合离线评测。所以生产环境我强烈建议部署专用 Rerank 模型而不是让 ChatClient 做重排序。模型选型方面中文场景我用过 bge-reranker-v2-m3 和 Qwen3-Reranker效果都不错。英文场景常见的是 Cohere Rerank 3.5 或 voyage-rerank。部署方式一般是 Python 侧封装一个 HTTP 服务Java 侧通过 WebClient 调用两边用 JSON 交换数据。如果机器资源允许也可以在本地用 ONNX Runtime 加载量化后的模型这样省一次网络开销但集成成本会高一些。4.4 别把 Rerank 变成第二层幻觉来源Rerank 能提升排序质量但它也有自己的问题最明显的是“分数不可解释”。交叉编码器给出的分数是一个相对值不是概率你不能直接拿它当置信度。我在调优时遇到过一种情况Rerank 模型对某个长文本片段给了很高分数但因为片段太长混入了一堆和问题无关的信息模型生成答案时反而被干扰了。后来的处理方案是Rerank 之后设置一个最低分数阈值低于阈值的文档即使排名靠前也直接丢弃宁可让上下文更精简也不要“看起来相关但实际是噪音”。另外Rerank 模型的训练数据通常和你的业务领域不完全一致它擅长判断“相关问题”但未必清楚你的业务流程里哪些信息更重要。比如用户问“退款流程是什么”Rerank 模型可能对“退款规则”和“退款接口报错”都打高分但业务上你可能只希望返回标准退款流程。这种场景下光靠 Rerank 模型不够还需要设计好检索时的元数据过滤或者加入业务规则层面的加权。5. 一个完整检索管线的工程复盘5.1 最终 Pipeline 结构与调用链经过几轮调整我最终落地的 RAG 检索管线大致是这样的用户问题进入ChatClient触发自定义 Advisor。Advisor 先做可选的查询改写比如把“它的价格”改写成“ECS 实例的价格”。HybridDocumentRetriever并发执行向量检索和关键词检索各取 top15。两路结果通过 RRF 融合保留 top20。RerankClient对 top20 精排选出 top5。注入上下文交给 LLM 生成回答。在 Spring AI 里可以通过自定义DocumentRetriever并组装到 Advisor 链中实现这个过程。大致结构是var advisor RetrievalAugmentationAdvisor.builder() .documentRetriever(new RerankDocumentRetriever( vectorStore, keywordSearcher, rerankClient)) .build(); ChatClient chatClient ChatClient.builder(chatModel) .defaultAdvisors(advisor) .build();如果你的 Spring AI 版本里类名不一样也不用慌思路是一致的把文档检索、排序这部分逻辑替换成自己的实现让 ChatClient 在生成回答前拿到经过精排的上下文。5.2 我内部测试集上的指标对比为了不凭感觉判断优化效果我搭了一个小型评测集120 条业务问答覆盖产品功能、故障排查、流程规范和版本差异五类。每条问题标记了标准答案所在文档 ID方便我做召回评估。人工评估时重点看模型答案是否正确、引用是否落到了正确的段落。评测结果很有代表性方案Recall5答案准确率人工平均延迟仅向量检索topK50.640.570.8s向量 关键词 RRFtopK50.790.710.9s向量 关键词 RRF ReranktopK50.860.841.3s可以看到混合检索显著提升了召回率因为很多业务问题带有明确的专有名词BM25 能把向量检索漏掉的部分找回来。Rerank 对答案准确率的提升最大因为它能把最相关的段落挑出来减少 LLM 被低质量上下文干扰的概率。代价是延迟增加了约 400ms在我的业务场景里可以接受但如果你的接口对延迟非常敏感可以缩小 Rerank 窗口比如从 top30 缩到 top15。5.3 从零开始我建议按这个节奏推进如果你正在做一个新的 Java RAG 项目我建议不要一上来就搞 Rerank而是按下面这个节奏迭代第一阶段先跑通基线链路用默认的TokenTextSplitter和QuestionAnswerAdvisor建立你的测试集。别急着优化先搞清楚哪些问题答得不好、哪些召回是错的。第二阶段优化 Chunking。重点检查召回列表里是否有“语义被切断”的片段以及同一个问题在不同文档里的表述差异。这个阶段应该能解决 20% 左右的召回问题。第三阶段加混合检索。选一个能支持 BM25 的检索方式或者自己用 Lucene 建关键词索引把召回率提上去。这一步是解决问题数量最多的一个环节。第四阶段上 Rerank。在候选集稳定的基础上精排把最终的答案准确率推到可用水平。每个阶段都要跑一遍评测集记录变化不要凭一两次直觉判断就改参数。RAG 优化的本质是“检索质量工程”评测集和数据积累比调模型重要得多。5.4 一些我踩过的坑写在这里帮你排掉最后分享几个我在工程里真实踩过、且不太容易在官方文档里找到答案的坑。第一个坑是知识库更新后没有做增量索引。线上跑了一段时间后业务文档更新了但向量库里还留着旧版本用户问新功能系统永远只召回旧文档。我的解决方案很简单在文档元数据里增加version字段检索时用filterExpression只查当前版本同时在写入新文档时先按sourceFile删除旧记录。如果你用的是 PGVector 或 Milvus删除操作要按 metadata 条件完成别遗漏。第二个坑是混合检索里两路检索的 topK 差距过大。如果向量路取 top5、关键词路取 top50RRF 融合时关键词路会明显占优结果里全是词面匹配的文档语义匹配反而被淹没了。我的经验是把两路的召回数量控制在相近水平融合效果最好。第三个坑是 Rerank 的输入内容过长。交叉编码器对输入长度有上限超出部分会被截断或报错。我在代码里对每个候选片段做了 500 字符截断算是经验和性能的折中。如果你发现某些答案依赖长段落的后半部分可以适当调大这个值但也要同步评估延迟。第四个坑是 LLM 打分式 Rerank 的稳定性。我明确说这个方案只适合 POC 或离线评测直接上生产会面临分数漂移、输出格式不稳定、并发成本高等一堆问题。如果决定在正式环境用 Rerank还是老老实实部署一个专用模型服务。这套从 Chunking 到混合检索再到 Rerank 的优化链路我前后迭代了大概三周才跑稳。要说最大的体会就是 RAG 项目里真正费时间的不是写代码而是建立评测集、分析坏例、反复验证参数。希望这篇文章能帮你少走一些弯路把有限的精力花在真正影响结果的地方。
返回列表