RAG 命中后的引用格式与拒答规则

RAG 命中后的引用格式与拒答规则

文章目录

  • [RAG 命中后的引用格式与拒答规则](#RAG 命中后的引用格式与拒答规则)
    • [1. 命中了文档,仍可能答错给业务](#1. 命中了文档,仍可能答错给业务)
    • [2. 先说结论](#2. 先说结论)
    • [3. 回答策略文件](#3. 回答策略文件)
    • [4. 合格引用至少包含三项](#4. 合格引用至少包含三项)
    • [5. 准备文档与切分检索](#5. 准备文档与切分检索)
    • [6. 完整脚本:生成回答信封](#6. 完整脚本:生成回答信封)
    • [7. 完整脚本:校验信封](#7. 完整脚本:校验信封)
    • [8. 完整脚本:回放用例](#8. 完整脚本:回放用例)
    • [9. 接到大模型时的最小约束](#9. 接到大模型时的最小约束)
    • [10. 检查清单(全文)](#10. 检查清单(全文))
    • [11. 排障对照](#11. 排障对照)
    • [12. 常见误区](#12. 常见误区)
      • [12.1 把"模型总结"当成引用](#12.1 把“模型总结”当成引用)
      • [12.2 证据不够时用更强模型硬答](#12.2 证据不够时用更强模型硬答)
      • [12.3 拒答只说"我不知道"](#12.3 拒答只说“我不知道”)
      • [12.4 引用只给文档名,不给片段](#12.4 引用只给文档名,不给片段)
      • [12.5 为了通过回放而删掉拒答用例](#12.5 为了通过回放而删掉拒答用例)
    • [13. 术语速查](#13. 术语速查)
    • [14. 小结](#14. 小结)
    • [15. 相关阅读](#15. 相关阅读)

摘要:本地知识库把检索 hit@3 做上去之后,下一层翻车常出现在对外回复:模型说得很顺,却说不出依据哪份文档;或者证据不够,仍然硬答。本文给出可执行的回答信封:命中时强制带 doc_id、chunk_id 与原文片段;未命中或分数过低时输出 REFUSE 并写明原因。沿用支付回调、登录接口、Orin 发布三份示例文档,本机用标准库脚本即可跑通。
说明 :承接 本地知识库落地顺序:先做可验收的 RAG。上一篇解决"先证明能找对文档";本篇解决"找对之后如何引用,找不到时如何拒答"。

承接前文:

建议目录:

bash 复制代码
mkdir -p ~/kb-cite-refuse/{notes,scripts,configs,fixtures/docs,fixtures/cases}
cd ~/kb-cite-refuse
文件 作用
fixtures/docs/*.md 三份示例文档(可与上一篇共用)
configs/answer_policy.example.json 引用与拒答策略
scripts/chunk_docs.py 切分文档
scripts/retrieve.py 关键词检索
scripts/build_answer_envelope.py 生成 ANSWER / REFUSE 信封
scripts/validate_answer_envelope.py 校验信封字段
scripts/replay_answer_cases.py 回放通过与拒答用例
fixtures/cases/answer_cases.jsonl 回放用例
notes/citation_refuse_checklist.md 检查清单

文中配置与脚本均全文给出。演示检索仍用标准库;换成向量检索后,信封字段与拒答原因可以保持不变。


1. 命中了文档,仍可能答错给业务

上一篇已经能把"支付回调超时先查哪几个配置字段"命中到 payment_callback。但上线问答时,还常见两种输出:

  1. 流畅无引用:回答写了"去看超时配置",却不写文档名和原文。
  2. 证据不足仍硬答:会议室审批、报销流程等库外问题,模型靠常识编一段。

对内部知识库来说,这两种都不能验收。业务同学需要的是:能点开哪一页核对;证据不够时,系统应明确说"暂不回答",而不是装作知道。

图1. 缺 doc_id 与原文时,错误会被当成结论继续传播。

本篇硬规则:

能引用就按固定格式引用;不能引用就拒答,并写下原因代码。


2. 先说结论

情况 对外输出
检索命中且分数达标 status=ANSWER,正文必须含文档来源与原文片段
无命中 status=REFUSEreason=NO_HIT
有弱相关但分数低于阈值 status=REFUSEreason=LOW_SCORE
命中但缺 doc_id / chunk_id status=REFUSEreason=MISSING_CITATION
命中但没有原文片段 status=REFUSEreason=EMPTY_SNIPPET

四条落地判断:

  1. 对外单位是回答信封,不是聊天段落。
  2. ANSWER 必须能追溯到 doc_id 与 chunk_id。
  3. REFUSE 必须带原因代码,方便统计和排障。
  4. 阈值写进策略文件,由脚本执行,不靠口头约定。

图2. 要么 ANSWER(带引用),要么 REFUSE(带原因)。


3. 回答策略文件

保存为 configs/answer_policy.example.json

json 复制代码
{
  "min_top_score": 20.0,
  "min_top_k": 1,
  "require_doc_id": true,
  "require_chunk_id": true,
  "require_snippet": true,
  "max_snippet_chars": 280,
  "refuse_reasons": [
    "NO_HIT",
    "LOW_SCORE",
    "MISSING_CITATION",
    "EMPTY_SNIPPET"
  ],
  "cite_template": "根据文档 {doc_id}(片段 {chunk_id}):{claim}"
}

字段说明:

字段 含义
min_top_score 最高检索分低于该值则拒答
cite_template 对外正文模板,强制带 doc_id / chunk_id
refuse_reasons 允许出现的拒答原因代码
require_* 引用字段是否强制

阈值可以按你们的检索器调整。关键词演示里,支付类问题通常远高于 20;库外问题往往低于 20,会被 LOW_SCORE 拦住。


4. 合格引用至少包含三项

图3. doc_id、chunk_id、snippet,三项缺一就难以人工核对。

推荐对外正文结构:

  1. 一句话结论(必须能在原文中找到依据)
  2. 明确写出文档 id 与片段 id
  3. 粘贴关键原文片段(截断到策略允许长度)

不合格示例:

建议检查一下超时配置,必要时看下代码。

合格示例方向:

根据文档 payment_callback(片段 payment_callback#0):......(贴出超时字段与排查顺序原文)


5. 准备文档与切分检索

示例文档与上一篇相同,可直接复制 fixtures/docs/。先切分:

保存为 scripts/chunk_docs.py

python 复制代码
#!/usr/bin/env python3
"""Split local markdown docs into retrieval chunks."""

from __future__ import annotations

import argparse
import json
import re
from pathlib import Path


def split_chunks(text: str, max_chars: int = 280) -> list[str]:
    parts = re.split(r"\n\s*\n", text.strip())
    chunks: list[str] = []
    buf = ""
    for part in parts:
        part = part.strip()
        if not part:
            continue
        if len(buf) + len(part) + 2 <= max_chars:
            buf = f"{buf}\n\n{part}".strip() if buf else part
        else:
            if buf:
                chunks.append(buf)
            if len(part) <= max_chars:
                buf = part
            else:
                for i in range(0, len(part), max_chars):
                    chunks.append(part[i : i + max_chars])
                buf = ""
    if buf:
        chunks.append(buf)
    return chunks


def main() -> None:
    parser = argparse.ArgumentParser(description="Chunk markdown docs for local RAG demo")
    parser.add_argument("--docs-dir", type=Path, required=True)
    parser.add_argument("--out", type=Path, required=True)
    parser.add_argument("--max-chars", type=int, default=280)
    args = parser.parse_args()

    rows = []
    for path in sorted(args.docs_dir.glob("*.md")):
        doc_id = path.stem
        text = path.read_text(encoding="utf-8")
        for idx, chunk in enumerate(split_chunks(text, args.max_chars)):
            rows.append(
                {
                    "chunk_id": f"{doc_id}#{idx}",
                    "doc_id": doc_id,
                    "path": str(path),
                    "text": chunk,
                }
            )

    args.out.parent.mkdir(parents=True, exist_ok=True)
    with args.out.open("w", encoding="utf-8") as f:
        for row in rows:
            f.write(json.dumps(row, ensure_ascii=False) + "\n")
    print(f"CHUNK_OK docs={len({r['doc_id'] for r in rows})} chunks={len(rows)} -> {args.out}")


if __name__ == "__main__":
    main()

保存为 scripts/retrieve.py

python 复制代码
#!/usr/bin/env python3
"""Keyword retrieval over chunked docs (stdlib only)."""

from __future__ import annotations

import argparse
import json
import math
import re
from collections import Counter
from pathlib import Path


WORD_RE = re.compile(r"[A-Za-z0-9_]+|[\u4e00-\u9fff]+", re.UNICODE)


def tokenize(text: str) -> list[str]:
    """English words plus Chinese character bigrams for short local docs."""
    tokens: list[str] = []
    for piece in WORD_RE.findall(text):
        if re.fullmatch(r"[A-Za-z0-9_]+", piece):
            if len(piece) > 1:
                tokens.append(piece.lower())
            continue
        # Chinese segment: keep unigrams and bigrams so queries can overlap docs
        chars = list(piece)
        tokens.extend(chars)
        tokens.extend(chars[i] + chars[i + 1] for i in range(len(chars) - 1))
    return tokens


def load_chunks(path: Path) -> list[dict]:
    rows = []
    for line in path.read_text(encoding="utf-8").splitlines():
        line = line.strip()
        if line:
            rows.append(json.loads(line))
    return rows


def build_index(chunks: list[dict]):
    df: Counter[str] = Counter()
    tfs: list[Counter[str]] = []
    for row in chunks:
        tf = Counter(tokenize(row["text"]))
        tfs.append(tf)
        for term in tf:
            df[term] += 1
    n = max(len(chunks), 1)
    idf = {t: math.log((n + 1) / (df[t] + 1)) + 1.0 for t in df}
    return tfs, idf


def score_query(query: str, chunks: list[dict], tfs, idf) -> list[tuple[float, dict]]:
    q_tf = Counter(tokenize(query))
    scored = []
    for i, row in enumerate(chunks):
        score = 0.0
        for term, q_w in q_tf.items():
            if term not in tfs[i]:
                continue
            score += q_w * tfs[i][term] * idf.get(term, 0.0)
        if score > 0:
            scored.append((score, row))
    scored.sort(key=lambda x: x[0], reverse=True)
    return scored


def main() -> None:
    parser = argparse.ArgumentParser(description="Retrieve chunks for a question")
    parser.add_argument("--chunks", type=Path, required=True)
    parser.add_argument("--query", required=True)
    parser.add_argument("--top-k", type=int, default=3)
    parser.add_argument("--json", action="store_true")
    args = parser.parse_args()

    chunks = load_chunks(args.chunks)
    tfs, idf = build_index(chunks)
    ranked = score_query(args.query, chunks, tfs, idf)[: args.top_k]
    hits = [
        {
            "rank": i + 1,
            "score": round(score, 4),
            "doc_id": row["doc_id"],
            "chunk_id": row["chunk_id"],
            "snippet": row["text"][:160].replace("\n", " "),
        }
        for i, (score, row) in enumerate(ranked)
    ]
    if args.json:
        print(json.dumps({"query": args.query, "hits": hits}, ensure_ascii=False, indent=2))
    else:
        print(f"QUERY: {args.query}")
        if not hits:
            print("NO_HIT")
        for h in hits:
            print(f"#{h['rank']} score={h['score']} doc={h['doc_id']} chunk={h['chunk_id']}")
            print(f"  {h['snippet']}")


if __name__ == "__main__":
    main()
bash 复制代码
python3 scripts/chunk_docs.py \
  --docs-dir fixtures/docs \
  --out fixtures/chunks.jsonl \
  --max-chars 360

6. 完整脚本:生成回答信封

保存为 scripts/build_answer_envelope.py

python 复制代码
#!/usr/bin/env python3
"""Build a cited answer or a refuse envelope from retrieval hits."""

from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
from retrieve import build_index, load_chunks, score_query


def load_policy(path: Path) -> dict:
    return json.loads(path.read_text(encoding="utf-8"))


def decide(query: str, ranked: list[tuple[float, dict]], policy: dict) -> dict:
    if not ranked:
        return {
            "status": "REFUSE",
            "reason": "NO_HIT",
            "query": query,
            "answer": "知识库未检索到相关片段,不能给出可核对的结论。请补充文档或换一种问法。",
            "citations": [],
        }

    top_score, top = ranked[0]
    min_score = float(policy.get("min_top_score", 0.0))
    if top_score < min_score:
        return {
            "status": "REFUSE",
            "reason": "LOW_SCORE",
            "query": query,
            "answer": (
                f"检索最高分 {top_score:.2f} 低于阈值 {min_score},"
                "证据不够,暂不回答。请补充更接近业务原话的文档,或降低阈值前先人工核对。"
            ),
            "citations": [],
            "top_score": round(top_score, 4),
            "top_doc_id": top.get("doc_id"),
        }

    max_chars = int(policy.get("max_snippet_chars", 280))
    citations = []
    for score, row in ranked[: int(policy.get("min_top_k", 1)) + 2]:
        snippet = (row.get("text") or "").strip().replace("\n", " ")
        if not snippet:
            continue
        citations.append(
            {
                "doc_id": row.get("doc_id"),
                "chunk_id": row.get("chunk_id"),
                "score": round(score, 4),
                "snippet": snippet[:max_chars],
            }
        )

    if policy.get("require_doc_id") and any(not c.get("doc_id") for c in citations):
        return {
            "status": "REFUSE",
            "reason": "MISSING_CITATION",
            "query": query,
            "answer": "命中片段缺少 doc_id,不能对外输出。请先修复切分脚本。",
            "citations": [],
        }
    if policy.get("require_chunk_id") and any(not c.get("chunk_id") for c in citations):
        return {
            "status": "REFUSE",
            "reason": "MISSING_CITATION",
            "query": query,
            "answer": "命中片段缺少 chunk_id,不能对外输出。请先修复切分脚本。",
            "citations": [],
        }
    if policy.get("require_snippet") and not citations:
        return {
            "status": "REFUSE",
            "reason": "EMPTY_SNIPPET",
            "query": query,
            "answer": "命中结果没有可用原文片段,不能对外输出。",
            "citations": [],
        }

    top_c = citations[0]
    claim = (
        f"优先依据 {top_c['doc_id']} 中的说明处理。"
        f"关键原文:{top_c['snippet']}"
    )
    tmpl = policy.get("cite_template") or "根据文档 {doc_id}(片段 {chunk_id}):{claim}"
    answer = tmpl.format(
        doc_id=top_c["doc_id"],
        chunk_id=top_c["chunk_id"],
        claim=claim,
    )
    return {
        "status": "ANSWER",
        "reason": "OK",
        "query": query,
        "answer": answer,
        "citations": citations,
        "top_score": round(top_score, 4),
    }


def main() -> None:
    parser = argparse.ArgumentParser(description="Build cited answer or refuse envelope")
    parser.add_argument("--chunks", type=Path, required=True)
    parser.add_argument("--policy", type=Path, required=True)
    parser.add_argument("--query", required=True)
    parser.add_argument("--top-k", type=int, default=3)
    parser.add_argument("--out", type=Path, default=None)
    parser.add_argument("--json", action="store_true")
    args = parser.parse_args()

    policy = load_policy(args.policy)
    chunks = load_chunks(args.chunks)
    tfs, idf = build_index(chunks)
    ranked = score_query(args.query, chunks, tfs, idf)[: args.top_k]
    envelope = decide(args.query, ranked, policy)

    if args.out:
        args.out.parent.mkdir(parents=True, exist_ok=True)
        args.out.write_text(json.dumps(envelope, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
        print(f"wrote {args.out}")

    if args.json:
        print(json.dumps(envelope, ensure_ascii=False, indent=2))
    else:
        print(envelope["status"], envelope.get("reason", ""))
        print(envelope["answer"])
        for c in envelope.get("citations") or []:
            print(f"- {c['doc_id']} / {c['chunk_id']} score={c['score']}")

    raise SystemExit(0 if envelope["status"] == "ANSWER" else 2)


if __name__ == "__main__":
    main()

命中示例:

bash 复制代码
python3 scripts/build_answer_envelope.py \
  --chunks fixtures/chunks.jsonl \
  --policy configs/answer_policy.example.json \
  --query '支付回调超时先查哪几个配置字段' \
  --out fixtures/cases/ok_payment.json \
  --json

预期 statusANSWER,并带 citations。示例输出:

json 复制代码
{
  "status": "ANSWER",
  "reason": "OK",
  "query": "支付回调超时先查哪几个配置字段",
  "answer": "根据文档 payment_callback(片段 payment_callback#0):优先依据 payment_callback 中的说明处理。关键原文:# 支付回调超时排查  适用系统:订单中台 v3。  现象:商户后台显示支付成功,订单状态长时间停留在待确认。监控里 callback_latency_p99 超过 8 秒。  现行超时配置:  - 网关等待上游回调:5 秒 - 订单服务重试次数:2 次 - 重试间隔:3 秒  配置文件路径:configs/payment.yaml,字段名为 callback_timeout_ms。  常见根因:  1. 商户回调地址返回非 200,订单服务持续重试,占满线程池。 2. 签名校验失败:字段顺序与文档不一致,签名串多了 sign 自身。 3. 回调 IP ",
  "citations": [
    {
      "doc_id": "payment_callback",
      "chunk_id": "payment_callback#0",
      "score": 75.511,
      "snippet": "# 支付回调超时排查  适用系统:订单中台 v3。  现象:商户后台显示支付成功,订单状态长时间停留在待确认。监控里 callback_latency_p99 超过 8 秒。  现行超时配置:  - 网关等待上游回调:5 秒 - 订单服务重试次数:2 次 - 重试间隔:3 秒  配置文件路径:configs/payment.yaml,字段名为 callback_timeout_ms。  常见根因:  1. 商户回调地址返回非 200,订单服务持续重试,占满线程池。 2. 签名校验失败:字段顺序与文档不一致,签名串多了 sign 自身。 3. 回调 IP "
    },
    {
      "doc_id": "payment_callback",
      "chunk_id": "payment_callback#1",
      "score": 16.133,
      "snippet": "1. 查网关访问日志,确认回调是否到达。 2. 核对 configs/payment.yaml 的超时与重试。 3. 对照接口文档校验签名字段。 4. 若需要看最近代码变更,再打开仓库 services/order。"
    },
    {
      "doc_id": "deploy_runbook",
      "chunk_id": "deploy_runbook#0",
      "score": 5.3987,
      "snippet": "# Orin 现场发布手册  发布前:  1. 确认板端磁盘剩余大于 8GB。 2. 记录当前模型目录:/opt/models/current。 3. 备份提示词文件:/opt/prompts/system.txt。  发布步骤:  1. 停止推理服务:systemctl stop local-infer。 2. 同步模型包到 /opt/models/staging。 3. 切换软链:ln -sfn /opt/models/staging /opt/models/current。 4. 启动服务:systemctl start local-infer。 "
    }
  ],
  "top_score": 75.511
}

拒答示例(库外问题,常落到 LOW_SCORE 或 NO_HIT):

bash 复制代码
python3 scripts/build_answer_envelope.py \
  --chunks fixtures/chunks.jsonl \
  --policy configs/answer_policy.example.json \
  --query '会议室预订系统的默认审批人是谁' \
  --out fixtures/cases/refuse_meeting.json \
  --json

示例输出:

json 复制代码
{
  "status": "REFUSE",
  "reason": "LOW_SCORE",
  "query": "会议室预订系统的默认审批人是谁",
  "answer": "检索最高分 15.87 低于阈值 20.0,证据不够,暂不回答。请补充更接近业务原话的文档,或降低阈值前先人工核对。",
  "citations": [],
  "top_score": 15.8726,
  "top_doc_id": "payment_callback"
}

注意:退出码在 REFUSE 时为 2,便于流水线把"拒答"与"系统故障"区分开;信封文件仍会写到 --out

图4. NO_HIT、LOW_SCORE、MISSING_CITATION、EMPTY_SNIPPET。

图5. 有手册命中则 ANSWER;问会议室审批人则 REFUSE。


7. 完整脚本:校验信封

保存为 scripts/validate_answer_envelope.py

python 复制代码
#!/usr/bin/env python3
"""Validate answer envelope against citation / refuse policy."""

from __future__ import annotations

import argparse
import json
from pathlib import Path


REQUIRED_ANSWER = ["status", "query", "answer", "citations"]
REQUIRED_CITE = ["doc_id", "chunk_id", "snippet"]


def validate(policy: dict, envelope: dict) -> list[str]:
    errors: list[str] = []
    for key in REQUIRED_ANSWER:
        if key not in envelope:
            errors.append(f"missing field: {key}")

    status = envelope.get("status")
    if status not in ("ANSWER", "REFUSE"):
        errors.append(f"invalid status: {status}")

    if status == "ANSWER":
        cites = envelope.get("citations") or []
        if not cites:
            errors.append("ANSWER requires non-empty citations")
        for i, c in enumerate(cites):
            for key in REQUIRED_CITE:
                if policy.get(f"require_{key}", True) and not c.get(key):
                    errors.append(f"citations[{i}] missing {key}")
        if "根据文档" not in str(envelope.get("answer", "")) and "doc_id" not in str(
            envelope.get("answer", "")
        ):
            # soft check: answer text should mention document source somehow
            if not any(c.get("doc_id", "") in str(envelope.get("answer", "")) for c in cites):
                errors.append("ANSWER text does not mention any doc_id")

    if status == "REFUSE":
        reason = envelope.get("reason")
        allowed = set(policy.get("refuse_reasons") or [])
        if reason not in allowed:
            errors.append(f"refuse reason not allowed: {reason}")
        if envelope.get("citations"):
            errors.append("REFUSE should not carry citations pretending to be evidence")

    return errors


def main() -> None:
    parser = argparse.ArgumentParser(description="Validate answer envelope")
    parser.add_argument("--policy", type=Path, required=True)
    parser.add_argument("--envelope", type=Path, required=True)
    parser.add_argument("--json", action="store_true")
    args = parser.parse_args()

    policy = json.loads(args.policy.read_text(encoding="utf-8"))
    envelope = json.loads(args.envelope.read_text(encoding="utf-8"))
    errors = validate(policy, envelope)
    ok = not errors
    out = {"ok": ok, "errors": errors, "status": envelope.get("status"), "reason": envelope.get("reason")}
    if args.json:
        print(json.dumps(out, ensure_ascii=False, indent=2))
    else:
        print("ENVELOPE_OK" if ok else "ENVELOPE_FAIL")
        for e in errors:
            print(f"FAIL: {e}")
    raise SystemExit(0 if ok else 2)


if __name__ == "__main__":
    main()
bash 复制代码
python3 scripts/validate_answer_envelope.py \
  --policy configs/answer_policy.example.json \
  --envelope fixtures/cases/ok_payment.json

python3 scripts/validate_answer_envelope.py \
  --policy configs/answer_policy.example.json \
  --envelope fixtures/cases/refuse_meeting.json

预期两次都输出 ENVELOPE_OK

校验要点:

  • ANSWER 必须有非空 citations,且含 doc_id / chunk_id / snippet
  • ANSWER 正文应出现文档 id
  • REFUSE 的 reason 必须落在策略允许列表
  • REFUSE 不应再挂一串"看起来像证据"的 citations 误导读者

8. 完整脚本:回放用例

保存为 fixtures/cases/answer_cases.jsonl

jsonl 复制代码
{"case_id":"cite-payment","query":"支付回调超时先查哪几个配置字段","expect_status":"ANSWER"}
{"case_id":"cite-login","query":"登录成功时 HTTP 状态码和 token 字段","expect_status":"ANSWER"}
{"case_id":"refuse-unknown","query":"会议室预订系统的默认审批人是谁","expect_status":"REFUSE"}
{"case_id":"refuse-weak","query":"xyzabc 无关乱码问题","expect_status":"REFUSE"}

保存为 scripts/replay_answer_cases.py

python 复制代码
#!/usr/bin/env python3
"""Replay answer/refuse cases against expected status."""

from __future__ import annotations

import argparse
import json
import subprocess
import sys
from pathlib import Path


def load_jsonl(path: Path) -> list[dict]:
    rows = []
    for line in path.read_text(encoding="utf-8").splitlines():
        line = line.strip()
        if line:
            rows.append(json.loads(line))
    return rows


def main() -> None:
    parser = argparse.ArgumentParser(description="Replay citation/refuse cases")
    parser.add_argument("--cases", type=Path, required=True)
    parser.add_argument("--chunks", type=Path, required=True)
    parser.add_argument("--policy", type=Path, required=True)
    parser.add_argument("--builder", type=Path, required=True)
    parser.add_argument("--workdir", type=Path, default=Path("."))
    args = parser.parse_args()

    failed = []
    for case in load_jsonl(args.cases):
        proc = subprocess.run(
            [
                sys.executable,
                str(args.builder),
                "--chunks",
                str(args.chunks),
                "--policy",
                str(args.policy),
                "--query",
                case["query"],
                "--json",
            ],
            cwd=str(args.workdir),
            capture_output=True,
            text=True,
        )
        try:
            data = json.loads(proc.stdout)
        except json.JSONDecodeError:
            data = {"status": "PARSE_ERROR", "raw": (proc.stdout or "")[:300]}
        expect = case.get("expect_status")
        got = data.get("status")
        mark = "PASS" if got == expect else "FAIL"
        print(f"[{mark}] {case.get('case_id')}: expect={expect} got={got} reason={data.get('reason')}")
        if got != expect:
            failed.append(case.get("case_id"))

    print("REPLAY_OK" if not failed else "REPLAY_FAIL")
    if failed:
        print("failed:", ", ".join(str(x) for x in failed))
    raise SystemExit(0 if not failed else 2)


if __name__ == "__main__":
    main()
bash 复制代码
python3 scripts/replay_answer_cases.py \
  --cases fixtures/cases/answer_cases.jsonl \
  --chunks fixtures/chunks.jsonl \
  --policy configs/answer_policy.example.json \
  --builder scripts/build_answer_envelope.py \
  --workdir .

预期 REPLAY_OK。改阈值或改模板后,先跑回放,避免把拒答样例误放成 ANSWER。


9. 接到大模型时的最小约束

如果后面用大模型润色正文,仍建议:

  1. 先由 build_answer_envelope.py 决定 ANSWER 还是 REFUSE
  2. 只有 ANSWER 才允许模型改写语气
  3. 改写时禁止删除 doc_id、chunk_id,禁止改写 snippet 事实
  4. REFUSE 时不要让模型"补充常识"绕过拒答

可以把信封 JSON 当作唯一输入上下文;模型看不到 citations 以外的自由发挥空间时,幻觉会少很多。


10. 检查清单(全文)

保存为 notes/citation_refuse_checklist.md

markdown 复制代码
# 引用与拒答检查清单

## 对外回复前

- [ ] 有检索命中,且 top_score 不低于策略阈值
- [ ] 回复正文写出了 doc_id
- [ ] 附带 chunk_id 与原文 snippet
- [ ] 没有把"建议你看看代码"当成引用

## 必须拒答的情况

- [ ] 无命中(NO_HIT)
- [ ] 最高分低于阈值(LOW_SCORE)
- [ ] 缺 doc_id / chunk_id(MISSING_CITATION)
- [ ] 没有可用原文片段(EMPTY_SNIPPET)

## 收尾

- [ ] 跑通 build_answer_envelope.py
- [ ] 跑通 validate_answer_envelope.py
- [ ] 回放用例包含至少 2 条 ANSWER 与 2 条 REFUSE

11. 排障对照

现象 先查什么 动作
明明命中却 REFUSE top_score 与 min_top_score 核对分数分布,再决定是否调阈值
ANSWER 但人工打不开依据 citations 是否缺路径/文档名 保证 doc_id 与仓库文件名一致
拒答文案每次都不一样 是否绕过了脚本直接让模型答 强制先出信封,再决定是否润色
库外问题偶发 ANSWER 阈值过低,或问句与某文档词重叠 提高 min_top_score,并补拒答用例
校验 ENVELOPE_FAIL 缺 snippet 或正文未提 doc_id 修模板,不要关掉校验
回放失败 expect_status 与真实策略不一致 先改用例或策略,二选一,不要跳过回放

12. 常见误区

12.1 把"模型总结"当成引用

总结可以放在结论句,但不能替代 snippet。没有原文,人工无法核对。

12.2 证据不够时用更强模型硬答

更强模型也不会凭空拥有你们的会议室审批表。库外问题应 REFUSE 或转人工。

12.3 拒答只说"我不知道"

线上要统计原因:NO_HIT 多还是 LOW_SCORE 多,处理方式不同。前者补文档,后者调阈值或改切分。

12.4 引用只给文档名,不给片段

一份手册很长,只写文件名仍难定位。chunk_id 与 snippet 是给人核对用的。

12.5 为了通过回放而删掉拒答用例

回放的价值就是锁住拒答。删掉失败样例,等于允许以后偷偷改松策略。


13. 术语速查

术语 本文用法
回答信封 包含 status、answer、citations 或拒答原因的 JSON
引用字段 doc_id、chunk_id、snippet
ANSWER 允许对外的带引用回答
REFUSE 明确拒答,并带 reason 代码
min_top_score 最高检索分阈值,低于则拒答

14. 小结

检索命中只是本地知识库的前半段。对外还需要:

  1. 用策略文件规定引用字段与拒答原因
  2. 用脚本生成 ANSWER / REFUSE 信封
  3. 用校验与回放锁住格式,防止聊天层把拒答绕开

上文已给出策略、生成、校验、回放与清单全文。先在本机跑通,再接到你们的向量检索与大模型润色层。


15. 相关阅读

上一篇回答"先把 RAG 做到可验收";本篇回答"验收通过后,如何引用、如何拒答"。两者叠在一起,知识库才不容易在对外回复层翻车。

相关链接:

如果本篇对你有帮助,欢迎点赞、收藏,也欢迎关注后续更新。

相关推荐
DeepAgent22 分钟前
AI Agent 工程实践(37):需求分析——一个 Agent 项目到底应该怎么拆
agent·需求分析
tachibana228 分钟前
AI Agent 的记忆机制
人工智能·ai·大模型·llm·agent
ovO1 小时前
DeepSeek Harness 源码解读(三):七个核心服务怎样拼成一次 Agent 运行
开源·agent·deepseek
小羊432 小时前
从Prompt到Skill:专家经验的标准化封装指南
agent
一个处女座的程序猿2 小时前
Agent之Harness:WorkBuddy的简介、安装和使用方法、案例应用之详细攻略
agent·workbuddy·harness
乌拉布拉乌2 小时前
用 agents-md-writer 优化你的 AGENTS.md
人工智能·agent
GoCoding3 小时前
DeepSeek Harness 插件
agent·deepseek
Csvn3 小时前
第 11 章 路由 Routing
人工智能·aigc·agent
GoCodingInMyWay3 小时前
DeepSeek Harness 插件
agent·deepseek·harness