feat(harness): RAG 忠实度评测 —— judge 拿检索原文评幻觉

补 Harness 已知洞:此前 LLM-judge 只看 input+output,看不到检索来源,幻觉其实没评。

- Evaluator.Score 增 sources 参数;有来源时走 llmJudgeGrounded:一次调用同时评 quality 质量 +
  faithfulness 忠实度,并列出 unsupported(未被来源支持的说法)→ 进 Flags。
  综合分(有来源)=0.3规则+0.35质量+0.35忠实;无来源时维持原 0.4规则+0.6质量。Result 增 Faithful 字段。
- 透传检索来源:runGraph 返回 (answer, refs, err),executeGraph 同步;Handle→evaluate(input,output,refs);
  refsOf(board)=检索资料+工具产出。compose 路径暂返回 nil refs(不评忠实度)。
- eval 日志增「忠实 X.XX,来源 N」。

测试:单测覆盖 grounded(quality/faithfulness/unsupported 解析 + 加权 + flags)与无来源跳过;
3 处测试 runGraph 三返回值更新。live 实测 RAG 任务忠实 1.00/来源 1,judge 正确判定无编造。
project_analysis Harness 清单勾掉该项。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-25 15:03:17 +08:00
parent 82b1d3e802
commit 446b784fc6
9 changed files with 148 additions and 44 deletions
@@ -8,11 +8,11 @@ import (
func TestRuleScore(t *testing.T) {
cases := []struct {
name string
output string
wantMax float64 // 期望分 ≤ 此值
wantMin float64 // 期望分 ≥ 此值
wantFlag string // 期望命中的标签(空=不校验)
name string
output string
wantMax float64 // 期望分 ≤ 此值
wantMin float64 // 期望分 ≥ 此值
wantFlag string // 期望命中的标签(空=不校验)
}{
{"正常", "杭州是浙江省会,历史悠久,有西湖等名胜,是著名的旅游与电商之城。", 1.0, 1.0, ""},
{"空", " ", 0, 0, "空输出"},
@@ -40,7 +40,7 @@ func TestRuleScore_HeavyRepeat(t *testing.T) {
func TestScore_RuleOnly(t *testing.T) {
e := NewEvaluator(nil, nil) // 无 LLM → 仅规则
r := e.Score(context.Background(), "问题", "一段质量不错的较完整回答内容,长度足够,没有任何问题。")
r := e.Score(context.Background(), "问题", "一段质量不错的较完整回答内容,长度足够,没有任何问题。", nil)
if r.LLM != 0 || r.Overall != r.Rule {
t.Errorf("无 LLM 时 Overall 应等于规则分, got overall=%.2f rule=%.2f llm=%.2f", r.Overall, r.Rule, r.LLM)
}
@@ -54,7 +54,7 @@ func TestScore_WithLLMJudge(t *testing.T) {
return "```json\n{\"score\":4,\"reason\":\"相关且较完整\"}\n```", nil
},
)
r := e.Score(context.Background(), "介绍杭州", "杭州是浙江省会,西湖闻名,历史与现代交融,电商发达。")
r := e.Score(context.Background(), "介绍杭州", "杭州是浙江省会,西湖闻名,历史与现代交融,电商发达。", nil)
if r.LLM <= 0 {
t.Fatalf("应有 LLM 分, got %.2f", r.LLM)
}
@@ -75,12 +75,56 @@ func TestScore_LLMJudgeBadJSONFallsBack(t *testing.T) {
func() bool { return true },
func(ctx context.Context, sys, user string) (string, error) { return "我觉得还行吧", nil },
)
r := e.Score(context.Background(), "q", "一段足够长且正常的回答内容用于评测。")
r := e.Score(context.Background(), "q", "一段足够长且正常的回答内容用于评测。", nil)
if r.LLM != 0 || r.Overall != r.Rule {
t.Errorf("LLM 返回非 JSON 应回退到规则分, got overall=%.2f llm=%.2f", r.Overall, r.LLM)
}
}
func TestScore_GroundedFaithfulness(t *testing.T) {
// 有检索来源 → 走忠实度评测:judge 返回 quality/faithfulness/unsupported。
var gotPrompt string
e := NewEvaluator(
func() bool { return true },
func(ctx context.Context, sys, user string) (string, error) {
gotPrompt = user
return `{"quality":4,"faithfulness":2,"unsupported":["该产品支持离线模式"],"reason":"部分说法无资料支撑"}`, nil
},
)
r := e.Score(context.Background(), "这产品支持什么", "它支持在线与离线模式。",
[]string{"产品支持在线协作。", "产品提供云端存储。"})
if !strings.Contains(gotPrompt, "检索资料") || !strings.Contains(gotPrompt, "产品支持在线协作") {
t.Fatalf("judge 提示词应包含检索资料, got: %q", gotPrompt)
}
if r.LLM < 0.79 || r.LLM > 0.81 { // quality 4/5
t.Errorf("quality 应为 0.8, got %.2f", r.LLM)
}
if r.Faithful < 0.39 || r.Faithful > 0.41 { // faithfulness 2/5
t.Errorf("忠实度应为 0.4, got %.2f", r.Faithful)
}
if !contains(r.Flags, "未被来源支持:该产品支持离线模式") {
t.Errorf("未支持说法应进 Flags, got %v", r.Flags)
}
want := 0.3*r.Rule + 0.35*r.LLM + 0.35*r.Faithful
if r.Overall < want-1e-9 || r.Overall > want+1e-9 {
t.Errorf("综合分应为 0.3规则+0.35质量+0.35忠实=%.3f, got %.3f", want, r.Overall)
}
}
func TestScore_NoSourcesSkipsFaithfulness(t *testing.T) {
e := NewEvaluator(
func() bool { return true },
func(ctx context.Context, sys, user string) (string, error) {
return `{"score":5,"reason":"好"}`, nil
},
)
r := e.Score(context.Background(), "q", "一段足够长且正常的回答内容用于评测。", nil)
if r.Faithful != 0 {
t.Errorf("无来源不应评忠实度, got %.2f", r.Faithful)
}
}
func contains(ss []string, want string) bool {
for _, s := range ss {
if s == want {