11 道题给 RAG 装了个仪表盘,读数推翻了我三个想当然

11 道题给 RAG 装了个仪表盘,读数推翻了我三个想当然

智能文档问答这个助手我改了很久,RAG 效果好坏一直靠"感觉答得还行"判断。上周加了一套离线评测,一轮跑完,三个我想当然的地方一个没剩下。这套评测只有 11 道固定题,打一次拿 recall@K 和 MRR;同一份语料、同一套题,每次只改一个配置开关。

只做一件事:每题存一句标准答案里必不可少的话,把问题丢给检索链路,看这句话在不在返回的前 K 条里、排第几。生成的答案它不碰------材料没到手,prompt 再漂亮也补不回来。

题集就是 src/main/resources/rag-golden-set.json,一条用例三个字段,判指标只看中间那个。文件开头第一条长这样,referenceAnswer 那句完整有 48 字、这里截了尾,往下还有 10 条同结构的:

json 复制代码
{
  "cases": [
    {
      "question": "七天无理由退货需要满足什么条件?",
      "expectedContextContains": "不影响二次销售",
      "referenceAnswer": "商品需保持完好、不影响二次销售,..."
    }
  ]
}

第三个字段 referenceAnswer 是留给人核对的参考答案,includeAnswer=true 时才会连答案一起交给 LLM 裁判。

评测接口是个 GET,返回体里的 recallAt1 / recallAt3 / recallAt5 / mrr / totalCases 就是那四个指标加一个题数,抄进对照表就行。环境是 LangChain4j 1.22.0 配实验模块 1.22.0-beta32,JDK 21;检索层这一轮验证在 2026-10-28 做完(答案层沿用早先记录,本轮没重跑)。先起本地服务(端口 8090),再打这条:

ini 复制代码
curl "http://localhost:8090/api/rag/evaluate?includeAnswer=false"

includeAnswer=false 只测检索层、不生成答案,离线秒级、不需要模型 Key,所以改一个开关就能重跑一轮。默认配置(父子分块 + 混合检索 + 重排 top5)打出来是这样:

json 复制代码
{"recallAt1":0.4545,"recallAt3":0.8182,"recallAt5":1.0,
 "mrr":0.6515,"totalCases":11,...}

这四个数全是 0 到 1 的比率,越大越好,1 是满分。recall@K 只问"在不在前 K 条",MRR 问"第一个命中的排在第几"。

指标 它在问什么 默认那行怎么读
recall@1 关键句有没有出现在返回的第 1 条 0.455 = 11 题里 5 题第一条命中
recall@3 有没有出现在前 3 条 0.818 = 9 题进了前 3
recall@5 有没有出现在前 5 条 1.000 = 11 题全在
MRR 首个命中的名次取倒数,再对 11 题平均 0.652 = 11 个倒数加起来 ÷ 11

上面那行返回值的原始出处是下面这张逐题表。语料和题集都没动,父子分块(默认)和扁平分块各自建库------父子切出 10 块、扁平切出 7 块,除 chat.rag.parent-child 这一个开关外其余配置全同------各跑一遍,每题那句关键句第一次出现在第几条,"---"是前五条里根本没有:

题目 判命中的关键句 默认(父子) 扁平分块
七天无理由退货需要满足什么条件? 不影响二次销售 第 1 第 2
退款一般多久能到账? 3 个工作日内原路退回 第 1 第 1
支持哪些支付方式? 支付宝、微信支付、银行卡、花呗分期 第 2 第 3
什么是 RAG? Retrieval-Augmented Generation 第 4 第 4
pgvector 是什么? PostgreSQL 的向量检索扩展 第 4 ---
Spring Boot 的核心思想是什么? 约定优于配置 第 1 第 1
咖啡机多久除垢一次? 每月:运行除垢程序 第 3 第 4
K-3000 开箱清单里都有哪些物品? 主机 x1、水箱 x1、电源线 x1 第 1 第 2
K-3000 咖啡机坏了怎么报修? 在线报修:关注官方公众号 第 1 ---
做一杯美式咖啡大概要多久? 约 90 秒出杯 第 3 第 4
K-3000(只问型号) 智能咖啡机 K-3000 第 2 第 2

左边那列数一下就有答案:第 1 名 5 个,recall@1 = 5 ÷ 11 = 0.455;前三以内 9 个,recall@3 = 0.818;前五 11 个,recall@5 = 1.000。MRR 是 11 个倒数求平均,5 个 1.0、2 个 0.5、2 个 0.333、2 个 0.25,加起来 7.166,除以 11 得 0.6515。右边那列同法:2 个第 1、6 个进前三、9 个进前五,倒数和 4.583 ÷ 11 = 0.4167。这篇文章后面反复出现的 0.652 和 0.417,就是这两列各自数出来的。

后面重排截断那一行也能从这张表推出来:top2 就是把第 3 名往后全扔掉,左边那列只剩 5 个 1.0 和 2 个 0.5,倒数和 6.0 ÷ 11 = 0.5455,落在前三的也是这 7 题、0.636。这一行仓库里还单独跑过一轮实测,0.455 / 0.636 / 0.636 / 0.545 四个值俱全,和我从表里推的逐位一致。指标本来就是名次的函数,题集在手,读者不用信我。

代码在评测器里挨着写,一段是逐题判命中:

java 复制代码
            // 检索指标:Recall@K + MRR
            String needle = c.expectedContextContains().toLowerCase();
            boolean h1 = containsAt(texts, needle, 1);
            boolean h3 = containsAt(texts, needle, 3);
            boolean h5 = containsAt(texts, needle, 5);
            double mrr = reciprocalRank(texts, needle);
            hit1 += h1 ? 1 : 0;
            hit3 += h3 ? 1 : 0;
            hit5 += h5 ? 1 : 0;
            mrrSum += mrr;

containsAt 把前 K 条片段拼成一段文本,查那句关键句在不在里面,忽略大小写------"只问在不在"就是这几行:

java 复制代码
    /** topK 个片段的拼接文本中是否包含关键句(忽略大小写) */
    private boolean containsAt(List<String> texts, String needle, int k) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < Math.min(k, texts.size()); i++) {
            sb.append(texts.get(i)).append("\n");
        }
        return sb.toString().toLowerCase().contains(needle);
    }

三个 hit 各累加一遍、除以题数就是那三个 recall,MRR 累的是倒数。K 一放宽,containsAt 里 Math.min(k, texts.size()) 的窗口就往后开,命中只会更容易,所以同一轮里 recall@5 ≥ recall@3 ≥ recall@1;打出 1.000 只说明 containsAt(texts, needle, 5) 十一题全返 true,不说明管线好。

只改一个变量,四行跑完

三个开关都在配置文件里,一次只动一个:

ini 复制代码
--chat.rag.parent-child=false
--chat.rag.keyword.enabled=false
--chat.rag.reranker.max-results=2

它们各管管线的一段。parent-child 管入库粒度:开着时进向量库的是子块(chat.rag.child-chunk-size,400 字),命中后按子块 metadata 里的 parentId 展开成父块(1200 字)再送进上下文;关掉就退回按 document-pipeline.chunk-size 的 500 字一切,切出来那段既当命中单位又当上下文单位。keyword.enabled 管混合检索的第二路,那一路不看语义、只按词面精确匹配,和向量那一路各查一次,两边的名次再由 ReciprocalRankFuser 按 RRF(k=60)融合成一份。reranker.max-results 管重排之后送进上下文的条数,默认 5。这四个数全是名次的函数,同时动两处就分不清涨跌归谁,所以一轮只动一个开关。

同一份干净语料、同一套 11 题,四行结果:

配置 recall@1 recall@3 recall@5 MRR
默认(父子分块 + 混合检索 + 重排 top5) 0.455 0.818 1.000 0.652
扁平分块(500/50) 0.182 0.545 0.818 0.417
重排截断 top2 0.455 0.636 0.636 0.545
关掉关键词检索那一路 0.455 0.818 1.000 0.652

四行里只有分块那一行重建了库,另三行和默认行共用同一份库存量,所以每一行的涨跌只归那一个开关。

重排截断那一行也得说一句:chat.rag.reranker.max-results 从 5 砍到 2,掉的全是语义题------"什么是 RAG""pgvector 是什么""咖啡机多久除垢一次""做一杯美式咖啡大概要多久"四题的 reciprocal rank 归零,三道型号题分别在表格第 1、第 1、第 2 名,都还在 top2 里,名次照旧。这一路我用的是双编码器重排(chat.rag.reranker.provider=embedding,复用现成的 embedding 给每段片段各自算余弦),它看不见"这个问题配这段文本对不对",所以顶不动座次;看得见配对关系的是交叉编码器,代价是每排一次就要调一次外部接口。那四题原先排在第 3 和第 4,重排没能把它们顶进前 2,截断于是直接把它们扔了出去。

用例和结果在代码里就是两条 record,题集里那句"必不可少的话"叫 expectedContextContains,它是所有指标的唯一判据。结果里的 hitAt1 / hitAt3 / hitAt5 / mrr 是四个指标的逐题形态,faithful 和 correct 两列属于答案层,这一轮没动它们:

java 复制代码
    /** golden set 一条用例 */
    public record GoldenCase(String question,
                             String expectedContextContains,
                             String referenceAnswer) {}

    /** 单条用例的评估结果(context 字段是一串检索片段,这里没展开) */
    public record CaseResult(String question, boolean hitAt1,
                             boolean hitAt3, boolean hitAt5,
                             double mrr, String answer,
                             Boolean faithful, Boolean correct) {}

0.417 到 0.652 不是"优化前后"

我在仓库 README 里原先写过一行"MRR 0.417 → 0.652",这话容易读成"我优化了一轮,从 0.417 涨到 0.652"。真实情况是这两行分别是扁平分块和父子分块,同一天、同一套题、同一份语料测出来的对照,只不过各自按自己的切法建库。0.235 的差是换分块方式换来的,不是我调参调出来的运气(表里 0.652 减 0.417,用未四舍五入的原始值算是 0.6515 减 0.4167 = 0.2348)。写成"优化前后",读者会以为照抄一遍配置就能涨 0.235;写成"两种分块的对照",这一行就是可复测的证据,换了你自己的文档还得重跑。

机制上也能解释这 0.235 从哪来。题集里新加的"K-3000 咖啡机坏了怎么报修?"这一题,扁平模式下 reciprocal rank 是 0,前五条全是空的;父子模式直接 1.0。原因是这份手册整篇都在讲一个产品,按 500 字切完之后,"报修"那一段孤零零地跟全文比相似度,查询里的"咖啡机""坏了"把手册其他段落全都排到了它前面。换成父子,负责被匹配的是那段更小的子块,喂给模型的是它背后完整的父块,小块负责被找到、大块负责讲清楚,这道题就满分了。

关掉关键词检索那一路,四个指标一个没变

我第一反应是混合检索白搭了双倍复杂度。

反例在另一组单路由实验里:同一份干净语料,向量路和关键词路各查一次、只看前三条。裸型号"K-3000"这种查询,纯向量第一条端出来的是技术手册,正确的那本掉到第二,关键词路一发正中。

我就把型号题补进题集,开箱清单、报修、裸型号三道都加了。完整管线上四个指标照旧一格没动。这事的缘由在指标本身的分辨率上:干净父子库里向量路自己就能把手册排进前二,recall@3 早把它算成命中了,关键词路多送进来的那个第 1 名经过 RRF 融合还是落回原位,名次变了,三个 recall 和 MRR 一位小数都动不了。

所以关键词路我留着,留的理由跟这一节标题是反的。单路由对照能看出它在精确匹配上确实更硬,管线上的 recall@K 和 MRR 照不出这个便宜。要让它显形,题集得压成一片型号题和数字题,我没打算这么出题,真实用户问的不是这种话。这一路的结论只能停在机制上成立、指标上测不到。

垃圾内容会抬高指标,不会拉低

这一条我原本想当然是反过来:库里混着垃圾,指标肯定难看,清掉就该涨。更早一轮(题集只有 6 道、解析错配还没修)我同时测了两个库,启动默认库里那份 PDF 是被当纯文本啃进去的 16 块垃圾,目录重建的干净库里它被正确解析成咖啡机手册的真实文本。

指标 库 A:启动默认库,26 块(含 16 块 PDF 垃圾) 库 B:目录重建干净库,10 块
recall@3 1.000 0.667
MRR 0.694 0.667

垃圾块把指标抬高了。那 16 块相似度只有 0.60 左右,压根进不了 top5,也就没挤过任何正确答案;而正确解析出来的真实文本块第一次进了候选池,语义上恰好比"技术手册里讲 RAG 的段落"更接近某些泛化查询,把"什么是 RAG""pgvector 是什么"两题的正确答案从第 3 名挤到第 4 名。

系统没变坏,是库里的内容变了、题目没变。指标是"内容 × 题目 × 题集版本"的函数,我现在的基线记录一律把这三样捆在一起写,不然就像上面那样,一个 0.6666 能被当成事故查一轮。另外对一下账:早期章稿里写过一版 MRR 0.6666,后来在启动库复现出 0.6944,我一度以为前者测错了,真相是两个数字分别属于库 B 和库 A,都真实存在。

11 道题还是太小

修复当天我把题集从 6 条扩到 11 条(4 条咖啡手册用例加 1 条裸型号题),前面那张四行表已经是扩完之后的数。但这只是缓解,小样本的麻烦在逐题名次的权重:前面库 A 到库 B 那两题名次从第 3 掉到第 4,recall@3 按题数掉了 0.333,MRR 只掉 0.028。两个指标对同一次名次变化的敏感度差了一个量级------recall@3 只问前三有没有,两题掉出前三就是六分之二没了;MRR 问的是掉到第几,第 3 到第 4 只是三分之一变四分之一。所以我拿聚合值当入口,出问题得翻 cases 明细逐题对。

MRR 的实现短到一个 for 循环,也正因为短,它对名次格外敏感:

java 复制代码
    /** 首个包含关键句的片段排名的倒数(MRR);未命中返回 0 */
    private double reciprocalRank(List<String> texts, String needle) {
        for (int i = 0; i < texts.size(); i++) {
            if (texts.get(i).toLowerCase().contains(needle)) {
                return 1.0 / (i + 1);
            }
        }
        return 0.0;
    }

每题的命中情况我会逐条打出来,看的就是这个:

java 复制代码
            log.info("评估用例[{}] hit@1={} hit@3={} hit@5={} mrr={}",
                    c.question(), h1, h3, h5, String.format("%.3f", mrr));

比样本量更容易出问题的是题集里那句"关键句"的选法。关键句写宽了(比如只留一个"退货"),指标虚高;写严了(要求整段逐字命中),指标虚低。我的口径是取该问题标准答案里必不可少的那一句,宁严勿宽------"K-3000 开箱清单里都有哪些物品?"这道题存的那句就是"主机 x1、水箱 x1、电源线 x1"。改了关键句就等于换了一套题,新旧指标不可比。生产规模我建议 30 到 100 条起,这条是经验判断、不是实测结论------我手上跑得通的只有 11 题,撑不起一个更大的数。

阈值不能拍脑袋

想把评测接进 CI 时,我随手写了 0.9。这个线连修复后干净库 0.818 的 recall@3 基线都过不了,接进去会天天红。

阈值是从基线长出来的:先跑一轮基线,把线定在"基线减去允许波动"上。11 道题的 recall@3 只能是 1/11 的整数倍,0.818 是 9 题命中,0.727 是 8 题。所以线画在 0.8,意思是"掉出前三的题目不许超过 2 道";想给一道题留抖动余量就得画到 0.72。MRR 那条线同理,我定在 0.6。我打算卡的就是这两条:

css 复制代码
recall@3 >= 0.8
MRR >= 0.6

另外阈值、golden set 版本、库的构建方式这三件套要写进同一处配置或文档,否则半年后没人记得 0.652 是在哪套题、哪个库上跑出来的。

复现与边界

换框架版本、换模型、换你自己的文档,请按同样的方法重跑一轮基线再对比,别直接引用我的数字。评测实现、11 题集和三组开关都在配套仓langchain4j从零到生产:11 题逐题 reciprocal rank 在 chapters/ch12-advanced-rag.md 的 12.3.4,重排截断与关键词路在 12.4.4 / 12.5.5;库 A/B 对照与阈值推导在 chapters/ch13-rag-evaluation.md 的 13.3.2 / 13.3.3 / 13.3.4;代码是 smart-doc-assistant/src/main/java/com/example/assistant/service/RagEvaluator.java,题集是 smart-doc-assistant/src/main/resources/rag-golden-set.json。教程全文在 notes 站的 langchain4j 系列。这些数字只管检索层,答案层的忠实度和正确性我当时用 LLM-as-Judge 另分了一层。

相关推荐
吴凡wudianmei23 分钟前
从0搭AI Agent(六):什么时候该把任务拆给多个 Agent
langchain·llm·ai编程
壹米饭1 小时前
手搓 MCP 到最简 Agent,我都做了什么?
agent·ai编程·mcp
无懈可击1 小时前
Vue 可视化AI表单设计器 FcDesigner v3.6 版本发布,支持导出 SQL功能!
vue.js·开源·ai编程
ZzT1 小时前
vibe coding 做小游戏·玩法重做篇:只留自走棋、去掉羁绊,用电脑自动对局调难度
ai编程·游戏开发
灵感__idea1 小时前
Claude Code 实战:AI这么强了,为什么还在发明新概念?
aigc·ai编程·claude
Carson带你学Android1 小时前
AI时代,你的 App 会被 Agent 这三种方式调用
android·ai编程
装不满的克莱因瓶2 小时前
【SpringAI Alibaba】ReactAgent记忆管理MessageTrimmingHook与MessageDeletionHook
java·人工智能·ai·ai编程·springai·springaialibaba
西安小哥2 小时前
给 LLM 网关加一层熔断:主模型挂了 42 秒以后我做的事
ai编程
聪明的笨猪猪4 小时前
政务知识库AI架构选型:强合规约束下的架构决策与落地路径
经验分享·架构·ai编程·政务