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. 缺 doc_id 与原文时,错误会被当成结论继续传播。
本篇硬规则:
能引用就按固定格式引用;不能引用就拒答,并写下原因代码。
2. 先说结论
| 情况 | 对外输出 |
|---|---|
| 检索命中且分数达标 | status=ANSWER,正文必须含文档来源与原文片段 |
| 无命中 | status=REFUSE,reason=NO_HIT |
| 有弱相关但分数低于阈值 | status=REFUSE,reason=LOW_SCORE |
| 命中但缺 doc_id / chunk_id | status=REFUSE,reason=MISSING_CITATION |
| 命中但没有原文片段 | status=REFUSE,reason=EMPTY_SNIPPET |
四条落地判断:
- 对外单位是回答信封,不是聊天段落。
- ANSWER 必须能追溯到 doc_id 与 chunk_id。
- REFUSE 必须带原因代码,方便统计和排障。
- 阈值写进策略文件,由脚本执行,不靠口头约定。

图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,三项缺一就难以人工核对。
推荐对外正文结构:
- 一句话结论(必须能在原文中找到依据)
- 明确写出文档 id 与片段 id
- 粘贴关键原文片段(截断到策略允许长度)
不合格示例:
建议检查一下超时配置,必要时看下代码。
合格示例方向:
根据文档 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
预期 status 为 ANSWER,并带 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. 接到大模型时的最小约束
如果后面用大模型润色正文,仍建议:
- 先由
build_answer_envelope.py决定 ANSWER 还是 REFUSE - 只有 ANSWER 才允许模型改写语气
- 改写时禁止删除 doc_id、chunk_id,禁止改写 snippet 事实
- 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. 小结
检索命中只是本地知识库的前半段。对外还需要:
- 用策略文件规定引用字段与拒答原因
- 用脚本生成 ANSWER / REFUSE 信封
- 用校验与回放锁住格式,防止聊天层把拒答绕开
上文已给出策略、生成、校验、回放与清单全文。先在本机跑通,再接到你们的向量检索与大模型润色层。
15. 相关阅读
上一篇回答"先把 RAG 做到可验收";本篇回答"验收通过后,如何引用、如何拒答"。两者叠在一起,知识库才不容易在对外回复层翻车。
相关链接:
如果本篇对你有帮助,欢迎点赞、收藏,也欢迎关注后续更新。