第四阶段:治理与生产化第 14 章

RAG 质量评测与 Bad Case 闭环

建立覆盖检索与答案的本地评测体系,通过固定样本、JSON 报告、质量门禁和 Bad Case 回流,让系统改动具备可比较的质量依据。

15 分钟阅读

第一部分:技术背景 — 为什么 RAG 需要系统化评测

1.1 RAG 评测的挑战

传统软件测试通常是二元的(通过/失败)。但 RAG 系统的输出是自然语言文本,不能简单地用assertEqual(expected, actual)来判断。

问题:"入职流程有哪些步骤"

预期行为:
  ✅ 召回了正确的文档片段(检索质量)
  ✅ 答案包含了流程的完整步骤(完整性)
  ✅ 答案基于提供的资料而非幻觉(忠实性)
  ✅ 来源引用正确(可溯源性)

❌ 这些指标不能用一个简单的 test case 覆盖

1.2 三层保障体系

flowchart TD
    subgraph L1["第一层:入库质量"]
        L1A["文件解析成功率"]
        L1B["低质量 chunk 比例"]
        L1C["FAQ 空值/重复率"]
        L1D["FAQ/正文冲突检测"]
    end

    subgraph L2["第二层:检索评测"]
        L2A["Recall@K 召回率"]
        L2B["MRR 平均倒数排名"]
        L2C["关键词覆盖率"]
        L2D["场景隔离准确率"]
    end

    subgraph L3["第三层:性能基线"]
        L3A["首 token 耗时"]
        L3B["总耗时 P50/P95"]
        L3C["各阶段耗时分布"]
    end

    L1 --> L2 --> L3

    subgraph Gates["回归验收体系"]
        G1["入库质量检查"]
        G2["RAG 回归验收(分组)"]
        G3["追问回归验收"]
        G4["性能回归验收"]
        G5["接口验收"]
    end

    L1 --> G1
    L2 --> G2
    L2 --> G3
    L3 --> G4
    G1 --> G5
    G2 --> G5
    G3 --> G5
    G4 --> G5

    style L1 fill:#EFF6FF,stroke:#3B82F6,stroke-width:2px
    style L2 fill:#ECFDF5,stroke:#059669,stroke-width:2px
    style L3 fill:#FFFBEB,stroke:#D97706,stroke-width:2px
    style Gates fill:#FEF2F2,stroke:#DC2626,stroke-width:2px

第二部分:入库质量门禁如何与这一部分衔接

边界说明:候选版本的文件解析、空内容、重复 FAQ、低质量 chunk 和 OCR 风险,由项目阶段 13在入库发布流程中负责。本部分只保留质量报告的字段契约和门禁关系,帮助理解“资料健康”如何成为 RAG 评测的前置条件;不要把本部分当成另一条入库主流程。

2.1 检查项

python scripts/quality/check_ingestion_quality_gate.py \
    --scenario enterprise_knowledge

生成报告覆盖以下维度:

文件解析检查

  • 哪些文件解析失败(PDF 损坏、编码错误)
  • 哪些文件类型不被支持
  • 哪些文件为空(没有任何有效文本)

Chunk 质量检查

  • 过短 chunk:去除首尾空白后长度<30,表格行不套用该规则
  • 过长 chunk:长度大于max(parent_chunk_size × 2, 2000)- 低字符唯一率:长度>=50unique_ratio < 0.08,表格行不套用该规则
  • 重复 chunk:标准化正文的稳定 hash 完全相同,不是语义相似判断

FAQ 质量检查

  • question 或 answer 为空的记录
  • 完全相同的 FAQ 对(重复录入)
  • source 不在 valid_sources 白名单中的 FAQ

2.2 FAQ/正文冲突检测

# qa_core/quality/conflicts.py

def _related_threshold(keywords: list[str]) -> int:
    if len(keywords) >= 6:
        return 3
    if len(keywords) >= 3:
        return 2
    return 1

def _numbers(text: str) -> set[str]:
    cleaned_text = VERSION_NUMBER_RE.sub("", text or "")
    return {item.replace(" ", "") for item in NUMERIC_FACT_RE.findall(cleaned_text) if item.strip()}

def _polarity(text: str) -> str:
    has_negative = bool(NEGATIVE_RE.search(text or ""))
    has_positive = bool(POSITIVE_RE.search(text or ""))
    if has_negative and not has_positive:
        return "negative"
    if has_positive and not has_negative:
        return "positive"
    return "mixed_or_unknown"

为什么用 jieba.cut_for_search 而不是简单正则

cut_for_search是 jieba 的搜索模式分词,会同时输出原词和更细粒度的子词。例如"管理员密码重置"会被分为["管理员", "管理", "密码", "重置"],这样"用户密码修改"也能匹配到"密码"这个公共关键词。

真实冲突检测分两步,不使用一个虚构的“冲突相似度”:

  1. 先找相关正文:FAQ 问题和答案一起分词,过滤单字与停用词、按顺序去重,最多保留 12 个关键词;同 source 的正文命中数量达到动态门槛后才进入候选,最多取命中最多的 5 条。
  2. 再比对事实口径:同时检查数字集合是否完全无交集,以及肯定/否定倾向是否相反。

关键词关联门槛为:

关键词数 1~2:至少命中 1 个
关键词数 3~5:至少命中 2 个
关键词数 >=6:至少命中 3 个

表格行通常很短,单独采用“命中一个长度至少 3 的具体词,或命中至少两个普通词”的关联条件。版本号如2024.01会在数字事实比较前剔除,避免把文档版本误报为金额、时长或比例冲突。

检测结果只有三类:

issue 判断条件 含义
no_related_document 找不到达到关联门槛的正文 FAQ 可能缺少资料依据
numeric_mismatch FAQ 与正文都有数字事实,但集合无交集 金额、时间、比例或数量口径可能冲突
polarity_mismatch 一方明确肯定,另一方明确否定 支持范围或制度要求可能冲突

这些都是“潜在冲突告警”,不是语义真值判定。报告用于阻止候选版本自动激活并交给人工复核,不应自动改写 FAQ 或正文。

2.2.1 Chunk 噪声公式

字符唯一率定义为:

unique_ratio = 去空白后不同字符数量 / 去空白后字符总数

例如 OCR 失败产生大量重复的||||||||、页眉线或乱码时,分母持续增大而不同字符数量很少,unique_ratio会下降。只有正文长度至少 50 且比例低于 8% 才告警,避免短文本天然字符种类少而误报;表格行同样豁免。重复检测则使用正文strip()后的稳定 hash,只识别完全重复,不宣称识别语义近似重复。

2.3 入库质量检查

python scripts/quality/check_ingestion_quality_gate.py \
    --report reports/ingestion/enterprise_knowledge_phase1_gate_check.json

这里要区分两个概念:

概念 职责 对应代码
入库质量报告 记录本次候选版本有哪些质量事实,例如失败文件、空文件、重复 FAQ、低质量 chunk build_ingestion_quality_report()
入库质量门禁 根据阈值判断候选版本能不能继续激活 evaluate_report_against_gate()

真实代码默认采用严格门禁,下面这些问题默认都要求为 0:

条件 阈值
文件解析失败 max_failed_files = 0
未支持文件或不在 source 白名单的文件 max_unsupported_files = 0
空文件 max_empty_files = 0
低质量 chunk max_low_quality_issues = 0
重复 chunk max_duplicate_chunks = 0
FAQ question 为空 max_empty_faq_questions = 0
FAQ answer 为空 max_empty_faq_answers = 0
FAQ 问题重复 max_duplicate_faq_questions = 0
FAQ source 非法 max_invalid_faq_sources = 0
FAQ/正文潜在冲突 max_faq_document_conflicts = 0

验收不通过时,不激活新版本。这样可以确保线上知识库始终是经过质量验证的。真实企业项目可以在资料治理早期临时放宽某个阈值,但必须通过命令行显式传入,例如--max-duplicate-chunks 3;不要在代码里悄悄吞掉质量问题。

这部分和第 13 章的关系是:第 13 章负责说明“为什么门禁失败不能切换 active 指针”,第 16 章负责说明“门禁根据哪些质量事实做判断”。


第三部分:检索评测

3.1 评测数据集格式

// eval_sets/multi_scenario_smoke.json
[
    {
        "case_id": "enterprise_onboarding_doc",
        "scenario_id": "enterprise_knowledge",
        "source_filter": "hr",
        "query": "入职流程有哪些步骤",
        "expected_effective_source": "hr",
        "expected_hit_type": "rag",
        "expected_source_contains": ["onboarding.md", "入职流程"],
        "expected_keywords": ["入职", "流程", "步骤", "材料", "合同"]
    },
    {
        "case_id": "enterprise_vpn_faq",
        "scenario_id": "enterprise_knowledge",
        "source_filter": "it",
        "query": "忘记密码怎么办",
        "expected_effective_source": "it",
        "expected_hit_type": "faq_direct",
        "expected_source_contains": ["忘记密码", "密码重置"],
        "expected_keywords": ["密码", "重置", "邮箱", "手机"]
    }
]

3.2 评测指标

# 以下 RAGEvaluationMetrics 为简化伪代码,实际评测逻辑分布在
# scripts/evaluate_core_chain.py 和 scripts/eval_common.py 中,不存在该独立类

def evaluate_case(item, result):
    # expected_source_contains 用于判断预期资料是否被召回。
    # 它可以填写文件名、FAQ 标准问题、source 名或正文关键片段。
    expected_sources = item.get("expected_source_contains", [])
    rank = find_expected_source_rank(
        result["debug_sources"] or result["sources"],
        expected_sources,
        prefer_table=result.get("prefer_table", False),
    )

    return {
        # 当前实现的 Recall@K 是“预期来源是否在服务返回的候选列表中”。
        # K 由检索服务实际返回的候选数量决定,评测脚本不再固定写死为 5。
        "source_recall_hit": rank is not None if expected_sources else None,

        # 第一个匹配预期来源的排名越靠前,MRR 越高。
        "mrr": 1.0 / rank if rank else 0.0,

        # keyword_coverage 检查最终答案,而不是召回文档。
        "keyword_coverage": keyword_coverage(
            result["answer"],
            item.get("expected_keywords", []),
        ),

        # 命中路径:FAQ 直出、RAG、source/scenario 边界等。
        "hit_type_matched": (
            item.get("expected_hit_type") == result.get("hit_type")
            if item.get("expected_hit_type")
            else None
        ),

        # source 和 Prompt Profile 都只在样本提供对应 expected_* 标注时参与计算。
        "source_inference_matched": (
            item.get("expected_effective_source")
            == result.get("effective_source_filter")
            if item.get("expected_effective_source")
            else None
        ),
        "prompt_profile_matched": (
            item.get("expected_prompt_profile")
            == result.get("prompt_profile")
            if item.get("expected_prompt_profile")
            else None
        ),
    }

def summarize(rows):
    source_rows = [row for row in rows if row["source_recall_hit"] is not None]
    keyword_rows = [row for row in rows if "keyword_coverage" in row]

    return {
        # 预期来源被召回的样本比例。
        "recall_at_k": sum(row["source_recall_hit"] for row in source_rows)
        / max(len(source_rows), 1),

        # 预期来源首次出现位置的倒数平均值。
        "mrr": sum(row["mrr"] for row in source_rows)
        / max(len(source_rows), 1),

        # 最终答案覆盖人工标注关键事实的平均比例。
        "avg_keyword_coverage": sum(
            row["keyword_coverage"] for row in keyword_rows
        ) / max(len(keyword_rows), 1),
    }

3.3 分组验收

关键设计:回归验收不只是看全局平均值,而是按场景、source、hit_type 分组检查

# scripts/quality/check_evaluation_gate.py

def check_evaluation_gate(report):
    """按维度分组检查 RAG 回归验收。"""
    failures = []
    metrics = report["metrics"]

    # 全局验收。当前默认阈值来自
    # EvaluationGateThresholds:Recall@K >= 0.8,MRR >= 0.6,
    # 关键词覆盖率 >= 0.7,错误率 <= 0。
    if metrics["recall_at_k"] < 0.8:
        failures.append(f"全局 Recall@K {metrics['recall_at_k']} < 0.8")
    if metrics["mrr"] < 0.6:
        failures.append(f"全局 MRR {metrics['mrr']} < 0.6")
    if metrics["avg_keyword_coverage"] < 0.7:
        failures.append(
            f"全局关键词覆盖率 {metrics['avg_keyword_coverage']} < 0.7"
        )

    # 按场景分组验收,防止某个场景退化被全局均值掩盖。
    # 真实门禁从 group_metrics["scenario_metrics"] 读取分组结果。
    for scenario_id, scenario_metrics in report["group_metrics"][
        "scenario_metrics"
    ].items():
        if scenario_metrics["recall_at_k"] < 0.8:
            failures.append(
                f"场景 {scenario_id} Recall@K "
                f"{scenario_metrics['recall_at_k']} < 0.8"
            )

    # 按 source 分组验收,防止某一类资料退化被其他 source 的好结果掩盖。
    for source, source_metrics in report["group_metrics"][
        "source_metrics"
    ].items():
        if source_metrics["recall_at_k"] < 0.8:
            failures.append(f"分类 {source} 召回率不达标")

    return len(failures) == 0, failures

这里要特别区分三个概念:

指标 当前代码实际检查什么
recall_at_k 预期来源是否出现在检索服务返回的候选列表中
mrr 第一个匹配预期来源的排名倒数
keyword_coverage 最终答案是否覆盖人工标注的关键事实

因此,Recall@K主要回答“资料找没找到”,keyword_coverage主要回答“答案说没说全”。前者通过不了,优先查入库、过滤、查询改写和检索排序;后者偏低但召回已命中,则重点查上下文构建、Prompt 或生成结果。

当前scenario_isolation_accuracy是一个简化的场景集合覆盖指标:代码比较评测样本声明的场景集合与报告行中的场景集合。它可以发现某些场景没有进入评测结果,但不能单独证明每条返回文档都没有跨场景泄露;严格的文档级隔离仍需要结合 DataScope 和专项边界样本验证。


3.4 RAGAS 补充评测

本项目的主评测不是 RAGAS,而是面向企业 RAG 主链路的工程回归门禁。原因是企业项目不能只判断答案文本是否“看起来合理”,还必须验证:

  • 是否召回到预期来源:Recall@K
  • 预期来源排名是否靠前:MRR
  • 答案是否覆盖关键事实:keyword_coverage
  • FAQ 直出、RAG 生成、边界提示等路径是否正确:hit_type_accuracy
  • source 自动推断是否正确:source_inference_accuracy
  • Prompt Profile 路由是否正确:prompt_profile_accuracy
  • 多场景隔离是否正确:scenario_isolation_accuracy
  • 最终答案综合置信度是否偏低:avg_answer_confidence_score(观察项,默认不阻断;内部由证据置信度和生成后核验保守合并)
  • 是否出现错误和明显超时

为什么不直接把 RAGAS 作为主评测

RAGAS 是很好的 RAG 语义质量评估工具,但它默认关注的是“问题、答案、上下文、参考答案”之间的语义关系。KnowForge 的主评测目标更偏企业工程回归,很多关键指标不是 RAGAS 默认能直接判断的。

企业级验收问题 RAGAS 默认是否能直接判断 本项目主评测如何判断
是否召回到预期业务来源 部分能,需要额外改造样本和上下文标注 expected_source_contains+Recall@K+MRR
FAQ 是否应该高置信直出 不能直接判断 expected_hit_type=faq_direct+faq_direct_accuracy
问题是否应该进入 RAG 生成 不能直接判断 hit_type_accuracy
是否识别到 source 选错边界 不能直接判断 source_boundary/source_inference_accuracy
是否命中正确 Prompt Profile 不能直接判断 expected_prompt_profile+prompt_profile_accuracy
是否遵守多场景隔离 不能直接判断 scenario_isolation_accuracy
是否只查当前 activekb_version 不能直接判断 工程评测报告记录kb_version和检索诊断
是否遵守 DataScope 权限过滤 不能直接判断 评测样本传入tenant_id/dataset_id/visibility/user_role
最终答案置信度是否异常偏低 不能直接判断 answer_confidence.score/answer_confidence.evidence_confidence/answer_confidence.generation_verification作为观察项
是否出现接口错误或依赖异常 不能作为主指标 errors/error_rate
响应耗时是否可接受 不能作为主指标 avg_elapsed_ms/ 性能基线门禁

因此,如果把 RAGAS 直接作为主评测,会出现三个问题:

  1. 会漏掉企业级链路指标:FAQ 直出、source 边界、Prompt Profile、多场景隔离、DataScope、active 版本等都不是 RAGAS 的默认评价对象。
  2. 会把工程问题误看成语义问题:例如召回 source 错了、版本过滤错了、权限过滤错了,RAGAS 可能只看到“答案和上下文是否一致”,但无法指出是哪条工程链路退化。
  3. 不适合做唯一 CI 门禁:RAGAS 依赖 LLM-as-judge,成本更高、速度更慢、结果有一定波动;本项目需要一个稳定、可解释、可失败的工程回归门禁。

所以本项目的定位是:

主评测:自研工程回归门禁
  负责判断主链路有没有退化:
  Recall@K / MRR / hit_type / source 推断 / Prompt Profile / 场景隔离 / DataScope / 错误率 / 耗时 / answer_confidence 观察

补充评测:RAGAS
  负责分析答案语义质量:
  faithfulness / answer_relevancy / context_relevance / groundedness

一句话总结:RAGAS 适合回答“答案语义质量怎么样”,本项目自研门禁负责回答“企业 RAG 主链路是否稳定正确”。两者互补,不是替代关系。

RAGAS 适合补充回答语义质量,例如:

  • faithfulness:答案是否忠实于召回上下文
  • answer_relevancy:答案是否回应了用户问题
  • context_relevance:召回上下文是否和问题相关
  • response_groundedness:答案是否能被上下文支撑

所以本项目采用两层评测:

层级 工具 作用 是否主门禁
工程回归门禁 evaluate_core_chain.py+check_evaluation_gate.py 验证召回、路由、场景隔离、Prompt、错误率、耗时和答案置信度观察项
追问专项门禁 evaluate_followup_chain.py+check_followup_gate.py 验证多轮追问、改写和历史上下文
RAGAS 补充分析 evaluate_ragas_quality.py 评估忠实度、答案相关性等语义质量

典型执行顺序:

python scripts/evaluate_core_chain.py --dataset eval_sets/multi_scenario_smoke.json --limit 20 --output reports/evaluation/core_chain_latest.json
python scripts/quality/check_evaluation_gate.py --report reports/evaluation/core_chain_latest.json
python scripts/quality/evaluate_ragas_quality.py --report reports/evaluation/core_chain_latest.json --limit 10 --output reports/evaluation/core_chain_latest_ragas.json

evaluate_ragas_quality.py会读取工程评测报告中的完整答案和检索上下文,生成独立的 RAGAS 报告。它不会替代check_evaluation_gate.py,因为 RAGAS 无法直接判断本项目最关键的企业级指标,例如 activekb_version是否正确、DataScope 是否隔离、source boundary 是否识别、FAQ 是否高置信直出。

avg_answer_confidence_score默认是观察项,不是硬门禁。它读取的是最终answer_confidence.score,该分数由两段公共逻辑得出:生成前evidence_confidence先判断检索证据是否扎实,生成后generation_verification再检查 LLM 答案的行内引用、引用编号合法性和上下文词面支撑度,最终取更保守的一侧。需要把它变成阻断条件时,显式传入:

python scripts/quality/check_evaluation_gate.py --report reports/evaluation/core_chain_latest.json --min-avg-answer-confidence-score 0.65

这样设计的原因是:最终答案置信度能帮助定位“为什么这次回答不稳”,但当前实现还没有用大量人工标注做概率校准。默认把它放进报告和 Trace,而不是直接替代 Recall@K、MRR、关键词覆盖和人工复核。


第四部分:评测指标手算示例

上面的代码展示了指标的计算公式。为了能真正理解 MRR,需要用具体检索排序样例解释它的含义。以下用本项目的真实评测数据说明。

4.1 Recall@K 手算示例

Recall@K衡量的是:在检索服务返回的 Top-K 候选中,预期资料是否被召回。

本项目当前不是统计“关键词在文档中出现了多少”,而是使用评测样本的expected_source_contains,在来源的正文、文件名、FAQ 标准问题和 source 元数据中做规范化匹配。

以测试样本为例:
  查询:"企业 VPN 连接失败要收集哪些信息?"
  expected_source_contains:["it_support.md", "VPN 连接排查"]

召回结果(服务返回的候选列表):
  [1] "企业网络常见问题..."                 → 未命中
  [2] "it_support.md / VPN 连接排查"        → 命中

第一个匹配预期来源的排名是 2:
  source_recall_hit = True
  mrr = 1 / 2 = 0.5
def expected_source_rank(expected_source_contains, returned_sources):
    """返回第一个匹配预期来源的排名;找不到时返回 None。"""
    return find_expected_source_rank(
        returned_sources,
        expected_source_contains,
    )

rank = expected_source_rank(
    ["it_support.md", "VPN 连接排查"],
    returned_sources,
)
source_recall_hit = rank is not None

当前评测脚本没有在evaluate_core_chain.py中固定写死K=5。这里的 Top-K 由检索服务实际返回的候选数量和运行时检索配置决定。开发时可以用 Top-5 作为示例,但不要把“5”讲成评测代码的固定规则。

在数据集层面,当前recall_at_k的汇总方式是:

Recall@K = 预期来源被召回的样本数 / 提供 expected_source_contains 的样本数

4.2 MRR 手算示例

MRR(Mean Reciprocal Rank)衡量的是:第一个匹配预期来源的结果排在召回列表的第几位。

MRR = (1/排名₁ + 1/排名₂ + ... + 1/排名n) / n

假设有 3 个测试查询:

查询 1:"入职流程有哪些步骤"
  召回结果:[doc_A(0.92), doc_B(0.85), doc_C(0.78), ...]
  第一个相关文档是 doc_A,排名第 1 位
  → Reciprocal Rank = 1/1 = 1.0

查询 2:"VPN 连不上怎么办"
  召回结果:[doc_X(0.78), doc_Y(0.75), doc_Z(0.71), ...]
  前两个都不相关(虽然分数高,但内容不匹配)
  第一个相关文档是 doc_Z,排名第 3 位
  → Reciprocal Rank = 1/3 ≈ 0.333

查询 3:"员工报销需要准备哪些材料"
  召回结果:[doc_M(0.95), doc_N(0.82), ...]
  第一个相关文档是 doc_M,排名第 1 位
  → Reciprocal Rank = 1/1 = 1.0

MRR = (1.0 + 0.333 + 1.0) / 3 ≈ 0.778
def reciprocal_rank(expected_source_contains, returned_sources):
    """当前评测口径:按预期来源片段匹配并计算单条样本的倒数排名。"""
    rank = find_expected_source_rank(
        returned_sources,
        expected_source_contains,
    )
    return round(1.0 / rank, 4) if rank else 0.0

# 找不到预期来源时,单条样本的 reciprocal rank 为 0。
print(reciprocal_rank(
    ["it_support.md", "VPN 连接排查"],
    returned_sources,
))

MRR 的直观理解

MRR = 1.0  → 每个查询的第一个结果是相关的           → 完美
MRR = 0.9  → 第一个相关结果平均排在第 1.1 位          → 本项目水平
MRR = 0.5  → 第一个相关结果平均排在第 2 位            → 合格
MRR = 0.2  → 第一个相关结果平均排在第 5 位            → 需要改进
MRR = 0.05 → 几乎找不到相关结果                        → 严重问题

4.3 关键词覆盖率手算示例

关键词覆盖率衡量的是:最终生成答案中有多少条人工标注的关键事实被覆盖。

它和Recall@K的职责不同:

  • Recall@K看预期资料有没有被召回;
  • keyword_coverage看最终答案有没有把关键事实说出来。
查询:"跨境贸易中 HS 编码归类争议怎么处理"
期望关键词:["HS编码", "归类", "海关", "争议", "行政复议", "预裁定"]

最终答案:
  "HS 编码归类争议可通过以下途径解决:1. 向海关申请预裁定
   2. 如对归类决定有异议可申请行政复议 3. 必要时走行政诉讼流程"

逐个检查关键词是否出现在文本中:
  "HS编码"     → 出现了 "HS 编码"   → ✅ 覆盖
  "归类"       → 出现了 "归类"       → ✅ 覆盖
  "海关"       → 出现了 "海关"       → ✅ 覆盖
  "争议"       → 出现了 "争议"       → ✅ 覆盖
  "行政复议"   → 出现了 "行政复议"   → ✅ 覆盖
  "预裁定"     → 出现了 "预裁定"     → ✅ 覆盖

关键词覆盖率 = 6/6 = 1.0
def keyword_coverage(answer, expected_keywords):
    """计算最终答案覆盖了多少条预期关键事实。"""
    keywords = [item for item in expected_keywords if str(item).strip()]
    if not keywords:
        return 0.0

    normalized_answer = normalize_text(answer)
    hits = sum(
        1
        for item in keywords
        if normalize_text(item) in normalized_answer
    )
    return round(hits / len(keywords), 4)

4.4 一个完整评测样本长什么样

{
    "case_id": "engineering_quality_hidden_acceptance",
    "scenario_id": "engineering_project_qa",
    "source_filter": "quality",
    "query": "隐蔽工程验收需要哪些资料?",
    "expected_hit_type": "rag",
    "expected_effective_source": "quality",
    "expected_prompt_profile": "knowledge_answer",
    "expected_source_contains": [
        "hidden_acceptance.md",
        "隐蔽工程验收"
    ],
    "expected_keywords": [
        "隐蔽工程",
        "验收",
        "质量验收报告",
        "隐蔽工程验收记录",
        "材料检测报告",
        "功能性试验报告"
    ],
    "grading_notes": "答案需要覆盖验收记录、材料检测和功能性试验资料。"
}

一个好的评测样本需要: 1.expected_source_contains:指定用于 Recall@K 和 MRR 判断的文件名、FAQ 问题或正文片段 2.expected_keywords:指定最终答案必须覆盖的具体事实,不是模糊描述 3.expected_hit_type:验证 FAQ 直出、文档 RAG 或边界路径是否正确 4.expected_effective_source:验证 source 自动推断是否正确 5.expected_prompt_profile:验证问题是否路由到正确的 Prompt 模板 6.grading_notes:解释人工期望,便于复核和 Bad Case 沉淀

expected_sourcemin_expected_sourcesrelevant_doc_id可以作为业务扩展字段保留,但当前evaluate_core_chain.py的核心指标不直接读取它们;不要用这些字段替代expected_source_containsexpected_effective_source等当前评测字段。


第五部分:Bad Case 沉淀

本部分结构

本部分主线是“线上问题如何沉淀为eval_sets/回归样本,并通过 Gate 阻断同类问题复发”。5.1 / 5.1.1 / 5.1.2 是上游背景,先讲无人值守质量周期与发现问题后的诊断顺序;5.2 才正式进入 Bad Case 沉淀本身,5.2-5.9 走完“发现 → 筛出 → 复核 → 合并 → 重跑 → Gate”完整闭环。阈值校准与 Bad Case 关系较弱,已独立放到 5.10 作为附录。

5.1 无人值守质量周期与人工门禁

当前项目将评测自动化和模型训练治理分开:评测、策略校准、性能门禁和 Bad Case 草稿可以无人值守运行;Bad Case 真值确认、正式回归集合并、意图模型训练与激活必须保留人工审批。

定时任务统一调用:

python scripts/quality/run_v1_quality_cycle.py --docker --include-performance

该周期会生成主链路评测报告、评测门禁、意图策略报告、FAQ/意图阈值候选报告、性能门禁和两类 Bad Case 草稿。阈值扫描只生成候选策略,不修改config/rules.toml或生产模型。任一步失败,周期报告的okfalse,调度器可以据此告警或阻止发布;锁文件会阻止同一环境重叠执行。

这里的“自动训练”不是无人监管的在线自训练,而是:自动收集、自动评测、人工确认、受控发布

如果把第 05、06 章的闭环也合在一起看,完整质量链路其实是下面这条顺序:

意图策略评测
  -> 先判断 route / intent / source / policy 是否正确
  -> 再决定改规则、改模型、改网关还是改 source

阈值候选校准
  -> 先判断 FAQ 直出和模型接管阈值有没有样本支撑
  -> 再决定是否采纳候选值

主链路评测
  -> 再判断召回、MRR、keyword coverage、hit type、延迟和最终答案是否达标
  -> 再决定改 top_k、context 数量、FAQ 直出阈值或 Prompt

Bad Case 回流
  -> 把失败样本补进 eval_sets/
  -> 下一轮重新评测

更实用的总表是:

评测结果来源 首先回答的问题 主要动作
evaluate_intent_policy.py 入口意图、source、rewrite 和网关仲裁对不对 改规则、补意图样本、调model_min_score、修 source 规则
demo_intent_model.py --eval-only BERT 意图模型本身是否稳定 补训练集、重训、调模型采用门槛
calibrate_thresholds.py FAQ 直出阈值和模型采纳阈值有没有证据 采纳或拒绝候选阈值
evaluate_core_chain.py 召回、MRR、关键词覆盖、场景隔离和最终答案是否达标 改检索策略、top_k、context、Prompt、资料
check_evaluation_gate.py 当前评测是否允许进入发布门禁 阻断或放行
extract_bad_cases_from_report.py/export_feedback_bad_cases.py 失败样本能不能变成下一轮回归集 生成 Bad Case 草稿并复核

注意闭环顺序不要倒:先定入口意图和 source,再定检索策略,再看最终答案。如果意图识别本身已经错了,直接调DOC_TOP_KFINAL_CONTEXT_TOP_N只会把错误检索做得更稳定。

5.1.1 诊断流程图

flowchart TD
    A["发现评测失败或需要调参"] --> B{"先看意图策略评测是否失败?"}
    B -->|是| C["修规则、BERT 样本、网关或 source 规则"]
    B -->|否| D{"阈值候选是否样本不足或门禁失败?"}
    D -->|是| E["保持当前阈值,先补样本或修 Bad Case"]
    D -->|否| F{"主链路评测是否出现误直出或漏召回?"}
    F -->|误直出| G["上调 FAQ 直出阈值或风险类 direct threshold"]
    F -->|漏召回| H["扩大 DOC_TOP_K 或 FINAL_CONTEXT_TOP_N,必要时补资料"]
    F -->|延迟高| I["收缩 top_k 或优化 rerank / cache / context"]
    C --> J["重新跑回归门禁"]
    E --> J
    G --> J
    H --> J
    I --> J

这张图对应的不是“所有问题一把梭”,而是排查顺序:先分清入口判断错了没有,再看阈值候选有没有证据,最后才动检索参数和上下文预算。

5.1.2 完整案例:FAQ 误直出怎么排查

near_expense_tax_risk这类样本为例,问题是“报销材料齐全是否代表不存在税务风险”。它看起来像 FAQ,但业务上不该被当成安全直答。

  1. 先看evaluate_intent_policy.py。如果这条样本在意图层已经被分成KNOWLEDGE_QUERY或保守路线,说明入口判断基本没错。
  2. 再看calibrate_thresholds.py。如果当前 FAQ 直出候选的false_direct_rate偏高,说明阈值太松,不该让相似 FAQ 过早直出。
  3. 再看evaluate_core_chain.py。如果主链路里这条样本仍然变成faq_direct,而不是进入 RAG,那么问题就不是检索召回,而是 FAQ 直出保护线不够严。
  4. 处理动作不是先改DOC_TOP_K,而是先提高FAQ_DIRECT_SCORE_THRESHOLD或风险类 direct threshold,再重新跑评测和门禁。

同样地,如果样本是“新员工入职第一天要完成什么”,却被拖进了 RAG,多数情况下应该先反向检查是不是阈值过高,而不是先把文档召回池无限放大。

5.2 先区分通过案例与 Bad Case

与项目阶段 16的边界

本部分是 Bad Case 质量闭环的主位置:重点讲“问题如何被复核、沉淀为eval_sets/回归样本、进入 Evaluation,并最终影响发布 Gate”。项目阶段 16只讲线上 Trace 如何发现问题、定位问题和把问题交接到这里,不再重复完整评测闭环。

Bad Case 不是一句“答案不对”,而是一条能复现、能标注、能再次评测的问题样本。开发时要先建立“什么是合格答案”的标准,再展示什么是不合格。

用户提问:

VPN 客户端版本、账号锁定、公网 IP 这些排查项分别应该怎么处理?

先看截图中的通过案例

截图中的实际回答已经按三个子问题分别组织内容:

用户问到的点 回答表现
VPN 客户端版本 给出客户端版本核查、官方渠道确认和升级处理步骤
账号锁定 给出账号锁定判断、统一身份平台操作和重新登录步骤
公网 IP 单独说明公网 IP 获取与提交,便于 IT 继续排查

同时,这个回答具备来源引用、可执行步骤和人工介入条件。因此它应该被视为通过案例, 不属于 Bad Case。评测时可以把它作为完整回答样本,用于说明关键事实覆盖和引用可追溯性。

再构造一条不完整答案作为 Bad Case

下面这段是为了开发演示演示而故意简化的答案,不是截图中的实际回答

可以先重启 VPN 客户端,确认网络正常;如果仍然无法连接,请提交 IT 工单。

这段回答看起来并非完全错误,但它没有分别回答三个排查项:

用户问到的点 期望回答 当前回答是否覆盖
VPN 客户端版本 确认是否为 IT 发布的最新版,旧版本需重新安装
账号锁定 检查账号是否过期、锁定或权限被回收
公网 IP 判断当前公网 IP 是否在允许范围或是否被安全策略拦截

所以,这条故意简化的答案才应该被沉淀为 Bad Case。它的价值不是“记录一次失败”,而是保证后续改 Prompt、改检索策略、重建知识库后,这个问题必须被重新验证。

5.3 这条构造 Bad Case 在诊断信息里怎么看

下面的 Trace 是为了演示 Bad Case 定位而构造的失败样例,不是上面截图的真实 Trace, 也不代表截图中的回答存在这些问题。实际开发中,应把它和上面的通过案例并排对照。

在本地评测报告的retrieval/debug_retrieval字段,或者 LangSmith Trace 中,会看到类似信息:

{
  "scenario_id": "enterprise_knowledge",
  "kb_version": "kb_enterprise_knowledge_20260620_082630_4c1df17a",
  "intent": "KNOWLEDGE_QUERY",
  "question_category": "troubleshooting",
  "prompt_profile": "troubleshooting_steps",
  "hit_type": "rag",
  "sources_count": 2,
  "top_source_score": 0.63,
  "answer_confidence": {
    "score": 0.58,
    "level": "medium",
    "reasons": ["rag_with_context", "low_intent_decision_score", "low_inline_citation_coverage"],
    "evidence_confidence": {"score": 0.74, "level": "medium", "label": "中"},
    "generation_verification": {
      "status": "partial",
      "score": 0.58,
      "reasons": ["low_inline_citation_coverage"],
      "signals": {
        "citation_coverage": 0.33,
        "context_overlap": 0.61,
        "valid_citation_numbers": [1, 2],
        "invalid_citation_numbers": []
      }
    }
  },
  "slowest_stage": "llm_generation",
  "stage_timings_ms": {
    "doc_retrieval": 840,
    "rerank": 390,
    "llm_generation": 5200
  }
}

这条 Trace 给出的判断不是简单“模型答错了”,而是:

  • hit_type=rag:它确实进入了 RAG 生成,不是 FAQ 直出问题。
  • prompt_profile=troubleshooting_steps:Prompt 档位基本正确。
  • sources_count=2:召回到了资料,但证据可能不完整。
  • top_source_score=0.63:最高来源相关性一般,需要检查是否缺少更细的排障资料。
  • answer_confidence.level=medium:最终答案置信度没有达到高,需要结合evidence_confidencegeneration_verification判断是检索证据不足、上下文覆盖不足、意图决策不稳,还是 LLM 生成后引用/支撑核验偏弱。
  • 回答漏掉三个子问题:更像“上下文覆盖不足或生成未按子问题展开”。

因此它应进入质量闭环,而不是只作为一次线上投诉处理。

5.4 本地如何处理 Bad Case

本项目默认不要求使用 LangSmith。Bad Case 闭环先按本地文件完成:

flowchart LR
    Eval["evaluate_core_chain.py<br/>生成评测报告"] --> Report["reports/evaluation/*.json<br/>每条样本的诊断结果"]
    Feedback["qa_feedback<br/>用户点踩反馈"] --> ExportFeedback["export_feedback_bad_cases.py<br/>导出反馈复核草稿"]
    Report --> Extract["extract_bad_cases_from_report.py<br/>筛出失败样本"]
    Extract --> Review["人工复核<br/>补齐 expected_* 和 grading_notes"]
    ExportFeedback --> Review
    Review --> Promote["promote_bad_cases_to_regression.py<br/>合并进正式回归集"]
    Promote --> Dataset["eval_sets/xxx_regression.json<br/>正式回归样本"]
    Dataset --> ReEval["重新运行评测"]
    ReEval --> Gate["check_evaluation_gate.py<br/>质量门禁"]
    Gate --> Fix["修复资料 / 检索 / Prompt"]
    Fix --> ReEval

最小可执行命令如下:

python scripts/evaluate_core_chain.py --dataset eval_sets/multi_scenario_smoke.json --limit 20 --output reports/evaluation/core_chain_latest.json
python scripts/extract_bad_cases_from_report.py --report reports/evaluation/core_chain_latest.json --output eval_sets/local_bad_cases.json
python scripts/export_feedback_bad_cases.py --scenario enterprise_knowledge --output eval_sets/local_feedback_bad_cases.json
python scripts/promote_bad_cases_to_regression.py --source eval_sets/local_bad_cases.json --target eval_sets/enterprise_it_troubleshooting_cases.json
python scripts/evaluate_core_chain.py --dataset eval_sets/enterprise_it_troubleshooting_cases.json --output reports/evaluation/enterprise_it_troubleshooting_cases_latest.json
python scripts/quality/check_evaluation_gate.py --report reports/evaluation/enterprise_it_troubleshooting_cases_latest.json

如果要把这条闭环直接挂到发布入口,可以运行python scripts/verify_v1_release.py --include-evaluation --include-docker。这条命令会一次性产出评测报告、门禁摘要和 Bad Case 候选,作为项目发布前的统一验收动作。

这些命令对应五个动作:

动作 本项目文件 作用
发现问题 reports/evaluation/core_chain_latest.json 记录每条样本的实际命中路径、召回、关键词覆盖率和错误
筛出问题 scripts/extract_bad_cases_from_report.py 根据错误、召回失败、字段不匹配和关键词覆盖不足筛选 Bad Case
导出反馈 scripts/export_feedback_bad_cases.py 把用户点踩反馈导出为待复核草稿,保留原问题、备注、实际答案和来源快照
复核问题 eval_sets/local_bad_cases.json 把失败问题变成可人工编辑、可复跑的中间样本
合并回归 scripts/promote_bad_cases_to_regression.py 把复核后的 Bad Case 合并进正式eval_sets/*.json回归集
阻断退化 scripts/quality/check_evaluation_gate.py 指标未达标时返回非 0 退出码,阻止发布或激活

5.5 自动识别

Bad Case 的入口不是人工逐条翻聊天记录,而是先从评测报告里筛出“疑似异常样本”。脚本会检查下面这些字段:

字段 进入 Bad Case 的条件 重点排查
error/debug_error 有异常信息 代码、依赖、模型服务、检索服务
source_recall_hit false 入库、query variants、source_filter、Milvus 过滤、阈值
hit_type_matched false FAQ 直出、RAG、边界拦截的路由判断
source_inference_matched false source 自动推断规则和场景词表
prompt_profile_matched false 问题类别识别和 Prompt Profile 路由
keyword_coverage 低于--min-keyword-coverage 上下文覆盖、Prompt 约束或资料内容

例如,只想把关键词覆盖率低于 0.8 的样本也纳入 Bad Case,可以这样执行:

python scripts/extract_bad_cases_from_report.py --report reports/evaluation/core_chain_latest.json --output eval_sets/local_bad_cases.json --min-keyword-coverage 0.8

用户点踩反馈的处理方式类似,但它不是评测真值,不能直接进入正式回归集。export_feedback_bad_cases.py只导出复核草稿:

python scripts/export_feedback_bad_cases.py --scenario enterprise_knowledge --rating not_useful --output eval_sets/local_feedback_bad_cases.json

导出的样本会保留:

字段 作用
query 用户当时提出的问题
bad_case_reasons 点踩类型和用户备注
observed_answer_preview 当时系统给出的答案片段
observed_sources 当时召回到的来源快照
observed_effective_source 当时实际召回到的 source

人工复核后,需要补齐expected_hit_typeexpected_effective_sourceexpected_prompt_profileexpected_source_containsexpected_keywords等字段,再用promote_bad_cases_to_regression.py合并到正式回归集。

5.6 人工复核怎么填

脚本生成的eval_sets/local_bad_cases.json不是最终答案,而是复核草稿。人工复核要把“哪里不对”补成可评测字段。以 VPN 示例为例,样本可以这样写:

{
  "case_id": "bad_enterprise_it_vpn_sub_questions_001",
  "query": "VPN 客户端版本、账号锁定、公网 IP 这些排查项分别应该怎么处理?",
  "scenario_id": "enterprise_knowledge",
  "source_filter": "it",
  "expected_hit_type": "rag",
  "expected_effective_source": "it",
  "expected_prompt_profile": "troubleshooting_steps",
  "expected_source_contains": [
    "it_support.md",
    "VPN 连接排查"
  ],
  "expected_keywords": [
    "客户端版本",
    "账号锁定",
    "公网 IP",
    "IT 工单",
    "截图"
  ],
  "grading_notes": "答案必须分别说明客户端版本、账号锁定、公网 IP 三个排查项,不能只给泛泛重启建议。"
}

这些字段会直接影响后续评测:

字段 后续如何使用
expected_hit_type 判断是否走了正确路径,避免应该 RAG 的问题被误判为信息不足
expected_effective_source 判断最终生效 source 是否正确,避免跨 source 串库
expected_source_contains 判断预期资料是否被召回,用于 Recall@K 和 MRR
expected_prompt_profile 判断是否使用排障步骤模板
expected_keywords 判断答案是否覆盖关键事实
grading_notes 说明人工期望,便于后续复核和扩展 LLM-as-judge

5.7 提升为评测样本

复核完成后,先用promote_bad_cases_to_regression.py把这些样本合并到正式回归集,例如eval_sets/enterprise_it_troubleshooting_cases.jsonlocal_bad_cases.json只是暂存草稿,不是最终长期回归集。

建议按问题类型拆分文件,避免所有 Bad Case 混成一个大池子:

文件 放什么样本 示例
eval_sets/local_bad_cases.json 临时复核出的失败样本 最近一次评测失败项
eval_sets/enterprise_it_troubleshooting_cases.json IT 排障类正式回归集 VPN、账号锁定、工单、权限回收
eval_sets/finance_reimbursement_cases.json 财务报销类正式回归集 发票、预算、审批、付款材料
eval_sets/multi_turn_followup_cases.json 多轮追问正式回归集 “那审批呢”“材料呢”“谁负责”

进入eval_sets/后,这条样本就不再只是一次线上记录,而是以后每次版本变更都要验证的质量资产。

5.8 LangSmith 作为可选扩展

5.8.1 先回答三个刚接触 RAG 的开发者常问的问题

Q1:LangSmith 是什么?

LangSmith 是 LangChain 公司提供的商业 SaaS 平台(也有自部署版),专门用于“观测 + 评测 LLM 应用”。它把每次 LLM 请求的输入、输出、检索上下文、子步骤耗时都记录成可浏览的Trace,并提供页面化的样本标注、Dataset 管理、批量评测对比能力。可以把它理解成“LLM 应用的 APM + 回归测试平台”——前者负责看到每一次请求发生了什么,后者负责批量跑样本并对比版本。

Q2:为什么本项目要同时讲本地脚本和 LangSmith?

两者解决不同问题,不是替代关系:

  • 本地脚本(evaluate_core_chain.py+check_evaluation_gate.py)负责工程约束:可复现、可在 CI 中以退出码阻断发布、不依赖外部服务。这是本项目的主门禁
  • LangSmith 负责团队协作:页面化筛选、人工标注分发、长期趋势看板。这是可选扩展

项目文档保留 LangSmith 章节不是因为它必需,而是因为企业团队一旦规模上去,光靠 Git 里的 JSON 文件做人工标注分发会不够顺畅,这时候 LangSmith 的页面化能力才有价值。

Q3:不用 LangSmith 会不会缺什么?

不会。本项目所有质量判断(Recall@K / MRR / keyword_coverage / hit_type / source 推断 / Prompt Profile / 场景隔离 / 答案置信度)都能通过本地脚本得出明确结论,质量闭环不依赖 LangSmith。没接触过 LangSmith 的同完成全可以跳过 5.8.3 之后的内容,等企业协作出现信号再回来读。

5.8.2 三个核心概念对刚接触 RAG 的开发者的解释

LangSmith 概念 直观理解 对应本项目本地
Trace / Run 一次完整请求的执行轨迹,像“调试器录的回放”:你能看到意图判断、检索、重排、Prompt、LLM 输出每一步的输入输出和耗时 reports/evaluation/*.json里的诊断字段
Feedback / Annotation 在 Trace 上做人工标记:点赞 / 点踩、补正答案、加评分,可被结构化复用 人工复核时填的expected_*字段
Dataset 长期保存的评测样本集合,可在不同实验间复用 eval_sets/*.json回归集
Evaluation / Experiment 把 Dataset 跑一遍,产出指标报告,可与历史实验对比 evaluate_core_chain.py+check_evaluation_gate.py

一句话理解:Trace 是“看到一次请求发生什么”,Dataset 是“保存一批要测的样本”,Experiment 是“把样本跑一遍得出指标”。本地脚本能完成全部三件事,LangSmith 让这些事在网页上做、可团队协作、可长期对比。

5.8.3 字段映射

如果企业环境已经使用 LangSmith,可以把同一套字段映射到平台对象中:

本地闭环 LangSmith 中的可选对象 作用
reports/evaluation/*.json Trace / Run 查看单次请求的输入、输出、metadata 和耗时
人工复核expected_* Feedback / Annotation 把“感觉不满意”结构化为可评分字段
eval_sets/*.json Dataset 保存长期回归样本
evaluate_core_chain.py Evaluation / Experiment 批量运行评测并对比版本
check_evaluation_gate.py 本地 Gate 给出是否允许发布的确定性结论

5.8.4 什么时候才考虑接入 LangSmith?

当出现下面任一信号时再考虑,不必提前引入:

  • 多人协作需要页面化标注分发,光靠 Git 里的 JSON 文件不够顺畅;
  • 需要长期趋势看板(线上回答质量随时间的变化),本地reports/evaluation/历史对比不够直观;
  • 团队需要把线上 Trace 直接连到评测样本,不想手动 export;
  • 跨团队需要给 PM / 业务方展示质量趋势。

5.8.5 总结

LangSmith 不是本项目质量闭环的前置条件。它的价值在于团队协作、页面化筛选、人工标注分发和长期趋势对比;本地脚本仍然负责提供可复现、可提交、可在 CI 中阻断退化的工程约束。没接触过 LangSmith 的读者,记住一句话即可:本地脚本能完成全部质量判断,LangSmith 只是把同样的事搬到网页上并支持团队协作和长期趋势

参考页面:

5.9 这条 Bad Case 如何影响 Gate

把 VPN 示例加入eval_sets/local_bad_cases.json后,下一次运行 Evaluation 时,这条样本会变成一条明确的验收用例。失败结果可以长成这样:

{
  "case_id": "enterprise_it_vpn_sub_questions_001",
  "query": "VPN 客户端版本、账号锁定、公网 IP 这些排查项分别应该怎么处理?",
  "expected_hit_type": "rag",
  "expected_effective_source": "it",
  "expected_prompt_profile": "troubleshooting_steps",
  "expected_source_contains": [
    "it_support.md",
    "VPN 连接排查"
  ],
  "expected_keywords": [
    "客户端版本",
    "账号锁定",
    "公网 IP",
    "IT 工单",
    "截图"
  ],
  "actual_hit_type": "rag",
  "actual_source_hit": true,
  "actual_prompt_profile": "troubleshooting_steps",
  "keyword_coverage": 0.4,
  "passed": false,
  "failures": [
    "missing_keywords: 客户端版本, 账号锁定, 公网 IP"
  ]
}

这份结果说明:检索路径、业务分类和 Prompt 档位都没错,但答案没有覆盖关键排查项。此时 Gate 不应该放行,因为同一个线上问题仍然会复发。

检查点 失败含义 应该修哪里
actual_hit_type != expected_hit_type 命中路径错了 查意图分类、直出规则、上下文不足判断
actual_source_hit = false 没召回到正确业务资料 查 source 推断、Milvus 过滤、资料入库
actual_prompt_profile != expected_prompt_profile Prompt 档位错了 查问题类别识别和 Profile 选择
keyword_coverage低于阈值 答案漏掉关键事实 查上下文覆盖、Prompt 约束或资料内容

修复后,这条样本的结果应该变成:

{
  "case_id": "enterprise_it_vpn_sub_questions_001",
  "actual_hit_type": "rag",
  "actual_source_hit": true,
  "actual_prompt_profile": "troubleshooting_steps",
  "keyword_coverage": 1.0,
  "passed": true
}

把这条链路说完整后,Bad Case 沉淀就不再是抽象概念,而是一个可执行的质量机制:

线上问题或评测失败
  -> 本地评测报告定位
  -> 人工复核 expected_* 字段
  -> eval_sets 固化样本
  -> Evaluation 重跑验证
  -> Gate 决定是否允许发布

需要抓住三句话:

  1. Bad Case 不是聊天记录,而是可复现、可标注、可验收的质量资产。
  2. expected_*字段把“感觉不满意”变成结构化期望。
  3. Gate 让同类问题不会在下一次版本变更中悄悄复发。

5.10 附录:阈值候选值如何从经验值产生

本附录从无人值守质量周期中的阈值校准内容拆出。它讲的是阈值校准上游闭环,与 Bad Case 沉淀本身关系较弱,因此独立成节,避免冲淡主线。

运行下面的脚本可以对 FAQ 直出阈值和 BERT 模型采纳阈值做离线扫描:

python scripts/intent/calibrate_thresholds.py `
  --faq-dataset eval_sets/threshold_calibration_cases.json `
  --intent-dataset eval_sets/intent_policy_cases.jsonl `
  --output reports/threshold_calibration/threshold_candidate_latest.json `
  --fail-on-insufficient

FAQ 阈值以误直出率、精确率、召回率和风险加权损失选择候选值;意图模型阈值以关键样本错误数和分类准确率选择候选值。样本不足、检索失败或外部依赖失败时,报告为失败且候选值不可应用。脚本只生成applied=false的版本化报告,不会自动修改config/rules.toml,也不会自动切换线上模型。

因此调整一个参数的完整闭环是:

真实链路信号 + 人工真值
  -> 阈值扫描
  -> 风险约束筛选
  -> 候选策略报告
  -> 人工审批
  -> 配置/模型版本变更
  -> 回归门禁 + 性能门禁
  -> 灰度观察与回滚

第六部分:评测执行与跨章节验收接口

本部分给出一次完整验收的调用顺序,重点是说明各类报告如何衔接。入库质量门禁的实现归项目阶段 13,接口合同和自动化测试归项目阶段 15,线上性能观测与容量判断归项目阶段 16;这一部分只负责 RAG Evaluation 和 Bad Case 的质量证据。

6.1 全部回归验收

# 检查顺序
1. 项目守护检查 (check_project_guardrails.py)
2. 编译检查 (Python 语法)
3. 单元测试 (python -m pytest tests -q)
4. 入库质量检查 (check_ingestion_quality_gate.py)
5. RAG 回归验收 (check_evaluation_gate.py)
6. 追问回归验收 (check_followup_gate.py)
7. 性能回归验收 (check_performance_gate.py)
8. API 合同验收 (api_e2e_smoke.py)

6.2 接口验收

python scripts/api_e2e_smoke.py --base-url http://127.0.0.1:8000
python scripts/acceptance_smoke.py --base-url http://127.0.0.1:8000

验证管理接口、问答页面和 WebSocket 流式事件是否可用。

6.3 评测趋势

状态页保留回归报告入口;历次评测对比优先看本地reports/evaluation/历史报告。企业环境需要多人协作和趋势看板时,可以把同一批样本同步到 LangSmith Experiments。

                Recall@K    MRR    关键词覆盖  Source 推断  场景隔离
2026-05-01 v1:   1.000     0.900    0.933      1.000       1.000
2026-05-07 v2:   1.000     0.920    0.945      1.000       1.000
2026-05-14 v3:   0.980 ⚠   0.910    0.940      0.980 ⚠     1.000
                                  ↑ 需要排查 v3 的退化原因

第七部分:核心评测结果

本项目已完成最终验收,核心指标如下:

指标 说明
errors 0 零错误
recall_at_k 1.0 提供预期来源标注的样本全部召回到预期来源
mrr 0.9 正确答案平均排在第 1.1 位
avg_keyword_coverage 0.933 最终答案平均覆盖 93.3% 的关键事实
hit_type_accuracy 1.0 命中类型判断全部正确
source_inference_accuracy 1.0 业务分类推断全部正确
prompt_profile_accuracy 1.0 Prompt 模板选择全部正确
faq_direct_accuracy 1.0 FAQ 直出全部准确
scenario_isolation_accuracy 1.0 无跨场景数据泄露
avg_total_ms 3444 平均总耗时 3.4 秒
p95_total_ms 12810 P95 耗时 12.8 秒
avg_first_token_ms 2479 平均首 token 耗时 2.5 秒

讨论

留下你的想法

评论功能尚未配置。启用 Giscus 后,这里会显示基于 GitHub Discussions 的评论区。

图片预览

100%