Administrator
发布于 2024-06-04 / 2854 阅读
50

RAG 效果优化实战:切分、召回、重排三板斧

我们的内部知识库 RAG 上线第一周,产品拿了个真实问题测试:"年假怎么休?"模型一本正经地答了去年的旧政策。查下来,召回的文档里既有新政策也有旧政策,模型没分清哪个是现行有效的。RAG 的难点从来不在"接上模型",而在"召回准不准、排得对不对"。

第一板斧:切分策略

最初按固定 500 字符硬切,结果一条完整政策被拦腰斩断,召回到的片段语义残缺。我们换了语义切分:按 Markdown 标题层级切,一个章节一块;再叠加滑动窗口重叠 100 字符,保证跨边界的语义连续。对代码类文档,按函数/类切而非按字符。

// 用标题层级切分的简化逻辑
Pattern h = Pattern.compile("^#{1,3}\\s+(.+)$", MULTILINE);
// 每个标题到下一个标题之间作为一个 chunk,保留标题作为上下文前缀

切得太碎(100 字)召回噪声多、拼回 prompt 重复;切得太粗(2000 字)单块塞不下几块、覆盖不全。我们在 400-800 字区间做了网格搜索:

块大小召回@5 相关率prompt 占用量
200 字0.62
600 字0.81
1200 字0.78

最终选 600 字,相关率和成本平衡最好。

第二板斧:混合检索

纯向量召回有个毛病:专业术语(如"五险一金")语义模糊时容易召回不相关。我们加了 BM25 关键词检索做互补:

List<Doc> v = vectorStore.search(embed(q), 10);  // 语义
List<Doc> k = bm25Store.search(q, 10);          // 关键词
// 互惠融合 RRF 合并
Map<Doc, Double> score = new HashMap<>();
v.forEach(d -> score.merge(d, 1.0/(rank+60), Double::sum));
k.forEach(d -> score.merge(d, 1.0/(rank+60), Double::sum));
List<Doc> merged = score.entrySet().stream()
    .sorted(byValue).limit(10).map(Entry::getKey).toList();

混合检索后,含确切政策条款的 query 召回相关率从 0.81 提到 0.89。"年假"这种词,BM25 能精准命中带"年假"字样的现行条款,补上了向量召回的模糊。

第三板斧:Rerank 模型

粗排保"不漏",精排保"精准"。合并后的 top10 里,排第 1 的不一定最相关。我们接了一个轻量 Rerank 模型(cross-encoder),对 query 和每段重新打分:

List<ScoredDoc> reranked = reranker.rerank(q, merged);
// rerank 后最相关的政策条款稳定排到前 2

引入 Rerank 后,最终进 prompt 的 top3 命中率从 0.83 升到 0.94。代价是每段多一次模型推理,单请求增加约 300ms,但答案质量提升明显值回票价。

效果评估指标:别靠感觉

我们建了 120 条标注 query 的测试集,每轮改动都跑一遍,盯三个指标:

  • 召回@5 相关率:粗排质量,目标 > 0.88;
  • 命中率(Hit Rate):正确答案是否在前 k 个里,目标 > 0.92;
  • MRR(平均倒数排名):正确答案排得越靠前越高,优化后从 0.71 到 0.86。

没有量化,每次"感觉好点了"都是自我安慰。测试集让我们看清:切分改动能带来 +0.05,Rerank 带来 +0.11,钱要花在刀刃上。

就写到这。如果哪天你也被《RAG 效果优化实战:切分、召回、重排三板斧》里同一个坑绊住,回来翻这篇,能省半小时。

参考