RAG 评估实战:从 0.44 到 0.75 的 Prompt Bug 修复

0 阅读6分钟

一次 RAGAS 评估,揪出了 30% 的查询 Bug

项目上线前,我跑了一次 RAGAS 评估。20 条测试集,6 条返回"您尚未提出具体问题"。

我第一反应是 RAGAS 有问题。加了日志才发现——问题真的拼进 Prompt 了,但 LLM 就是"看不见"。

这篇文章记录完整的排查过程,以及为什么"感觉回答不错"是最危险的自我欺骗。

一、上线前跑了一次评估,30% 的查询是废的

RAG 项目做到一半,功能都通了。手测了几个问题——"LangChain 是什么"、"RAG 和 Rerank 的区别",答得都挺好。当时觉得差不多可以收了。

后来想,还是按 RAGAS 跑一遍评估吧,至少有个量化数据。

20 条测试集跑下来,四指标出来了:

指标
faithfulness0.58
answer_relevancy0.44
context_precision0.84
context_recall0.75

context_precision 0.84 还行,context_recall 0.75 也说得过去。

answer_relevancy 只有 0.44——这个数字太低了

翻了一下逐条结果,发现一个问题:20 条里 6 条的回答,全是同一句话

您尚未提出具体问题。请补充您需要咨询的具体问题……

这不对吧。6 个完全不同的问题,怎么会有同一个答案呢?这已经不是"回答质量差"的情况了,是系统根本没识别出我问的问题

二、加日志:问题明明拼进去了,为什么 LLM 看不见

第一反应:是不是 RAGAS 传参错了?

把评估用的 JSON 翻出来看了一眼,question 字段都在,也没有 null 值啊。那就肯定出现在系统内部的问题。

于是单独写了测试接口,直接源码复制,改了一下接口地址,加了几行日志:

public String ask(String question) {
    log.info("=== 收到问题: [{}]", question);
    String context = buildContext(topDocs);
    log.info("=== Context 长度: {}", context.length());
    String fullPrompt = "参考资料:\n" + context + "\n\n问题:" + question;
    log.info("=== 完整Prompt尾部: ...{}", 
        fullPrompt.substring(Math.max(0, fullPrompt.length() - 200)));
    // ...
}

跑一次出问题的查询:"LangGraph 的核心总结?"

日志输出:

=== 收到问题: [LangGraph 的核心总结?]
=== Context 长度: 5208
=== 完整Prompt尾部: ...+ 数据飞轮

工具治
理

50+ 工具最大瓶颈是上下文膨胀...

上下文

压缩

问题:LangGraph 的核心总结?

问题确实拼进去了,就在最后一行。

那为什么 LLM 还说"您尚未提出具体问题"?

三、根因:5000 字资料淹没了 1 行问题

盯着这段 Prompt 看了半天,终于意识到问题在哪:

参考资料:
<5208 字的资料...>

问题:LangGraph 的核心总结?

问题在于边界不清晰。

从 LLM 的视角看,整个 Prompt 非常像"用户给我一堆资料"。最后那行"问题:xxx"和前面的资料是连续的文本,没有任何分隔标记——LLM 会把它也当成资料的一部分。

再加上 5000 多字的资料占满了上下文,1 行问题的注意力权重被彻底稀释

所以 LLM 真的以为用户只上传了资料没提问,回了句"您尚未提出具体问题"。

这不是模型的锅,是我 Prompt 设计的问题。

我在转换问题的时候,把答案和问题,放到了一个question里面,导致 LLM 没有识别出来我的真正问题

四、修复:给 Prompt 加"边界",让 LLM 分清资料和问题

修复思路很直接——把资料和问题用显式分隔符框起来,并在 system prompt 里说明

改动 1:user message 加分隔符

String userMessage = """
        === 参考资料开始 ===
        %s
        === 参考资料结束 ===

        ### 用户问题开始 ###
        %s
        ### 用户问题结束 ###

        请回答上面【用户问题】中的提问。
        """.formatted(context, question);

三处关键改动:

  1. ===### 是分节标记——LLM 训练数据里很常见,它能识别这是"结构边界"
  2. 结尾再强调一次"请回答上面【用户问题】" ——保险机制,防止 LLM 忽略
  3. 资料和问题之间留空行——视觉上强化边界

改动 2:system prompt 明确规则

private final String SYSTEM_PROMPT = """
        你是专业的 Java 技术助手,负责基于知识库回答用户问题。
        
        规则:
        1. 参考资料通过 === 分隔符包裹,只使用其中的信息回答
        2. 用户问题通过 ### 分隔符标注,位于参考资料之后
        3. 只使用参考资料中的信息回答,不要编造
        4. 如果参考资料中没有相关信息,明确告知用户"知识库中未找到相关信息"
        5. 只输出纯文本格式
        6. 在回答末尾标注信息来源
        """;

system prompt 里明确告诉 LLM"参考资料在 === 内,问题在 ### 内" ——这句话让它主动去找边界。

五、效果:answer_relevancy 从 0.44 到 0.75

改完重跑 RAGAS:

指标修复前修复后变化
faithfulness0.580.82+0.24
answer_relevancy0.440.75+70%
context_precision0.840.72-0.12
context_recall0.750.79+0.04

answer_relevancy 从 0.44 涨到 0.75,提升 70%。

faithfulness 也涨了 0.24——说明之前 LLM 因为"看不见问题"而在瞎答,现在基于问题回答,幻觉也少了。

修复后跑一次"AutoGen 是什么" ,日志:

=== 收到问题: [AutoGen 是什么?]
=== Context 长度: 4872
=== 完整Prompt尾部: ...### 用户问题开始 ###

AutoGen 是什么?

### 用户问题结束 ###

请回答上面【用户问题】中的提问。

回答正常,归纳了 AutoGen 的核心哲学、三大特性、分层架构,末尾还标注了来源。

六、反思:不做量化评估,你永远不知道 30% 在裸奔

回头看这次 Bug,最值得说的不是"我怎么修的",而是为什么我之前没发现

手测的时候,我测了 5 个问题,都答得挺好。

  • "LangChain 是什么" ✅
  • "RAG 和 Rerank 的区别" ✅
  • "MCP 是什么" ✅
  • "SSE 和 WebSocket 的区别" ✅
  • "什么是纳什均衡" ✅

5 个问题全部通过——所以我以为项目没问题。

但 RAGAS 用 20 条问题系统性地跑,就暴露了 30% 的失败率。

这就是量化评估的价值。

手测的本质是抽样——你测的恰好是"正常路径",就以为全对。但真实流量里,问题类型千奇百怪,一定有触发 Bug 的样本。

RAGAS 的 20 条测试集,覆盖了事实型、综合型、术语解释、拒答型、口语化提问——只有系统性覆盖,才能暴露问题

所以做 RAG,别靠"感觉回答不错"来判断质量

跑一遍 RAGAS,看四指标,比读一百遍回答有用得多。


附:改动涉及的完整代码

// WebSseEmitterService.java

private final String SYSTEM_PROMPT = """
        你是专业的 Java 技术助手,负责基于知识库回答用户问题。
        
        规则:
        1. 参考资料通过 === 分隔符包裹,只使用其中的信息回答
        2. 用户问题通过 ### 分隔符标注,位于参考资料之后
        3. 只使用参考资料中的信息回答,不要编造
        4. 如果参考资料中没有相关信息,明确告知用户"知识库中未找到相关信息"
        5. 只输出纯文本格式
        6. 在回答末尾标注信息来源
        """;

public EvalResult evalOne(String question, String groundTruth){
    log.info("===收到问题:[{}]", question);
    // 复用现有链路
    RetrieveResult retrieveResult = retrieveAndRerank(question);
    List<String> contexts = retrieveResult.getTopDocs().stream().map(Document::getText).toList();
    // 拼接处改造
    String userMessage = """
    === 参考资料开始 ===
    %s
    === 参考资料结束 ===

    ### 用户问题开始 ###
    %s
    ### 用户问题结束 ###

    请回答上面【用户问题】中的提问。
    """.formatted(buildContext(retrieveResult.getTopDocs()), question);
    String answer = chatClient.prompt().system(SYSTEM_PROMPT).user(userMessage).call().content();
    return new EvalResult(question, groundTruth, answer, contexts);
}

项目已开源: github.com/shuguang-li…

相关文章: