引用核查
大模型回答问题时会附上引用:每个结论标明出自原文的哪一条,再摘一句原话作依据。其中有些引用是错的:引文可能在原文里根本找不到,也可能一字不差地出现在原文里,但它所在的那一条恰恰说的是相反的意思。
人工核查一条引用很慢:翻到原文,找到引文,再把上下文读够,才能判断它到底支不支撑结论。
要把这件事自动化,先用普通的字符串匹配找出原文里不存在的引文,再对剩下的每条引用问一个 Choice:读引文所在的条文,判断它和结论是什么关系。
原文 + 一条引用
│
├─ 引文不在原文里 ──────────────→ 判为「捏造」(fabricated),不调用模型
│
└─ 引文在原文里 / 引用只标了条号
│
▼
Choice:这一条和结论是什么关系?
supports → 「已核实」(verified)
contradicts → 「与原文相反」(contradicted)
says_nothing → 「原文未涉及」(unsupported)
│
▼
置信度 ≥ 0.8 ? ── 是 → 判定直接生效
└ 否 → 交给人工确认下面我们用《中华人民共和国个人信息保护法》全文核查 40 条引用,其中 20 条准确、20 条是我们故意改坏的。结果:40 条中判对 38 条(95%);5 条捏造引文全部由字符串匹配拦下;置信度 ≥ 0.8、自动生效的 25 条判定全部正确;仅有的 2 处误判置信度分别为 0.64 和 0.48,都落在阈值以下,被交给了人工。35 次象信调用共 6,583 个输入 token,约 ¥0.00028。
我们要写的函数 check_citation() 接收原文和一条引用,返回四种判定之一:verified、unsupported、contradicted、fabricated,同时返回一个置信度,用来标出哪些需要人看一眼。
环境准备
pip install xiangxin-sdk
export XIANGXIN_API_KEY="sk-xx-..."本文数字来自 2026-09-25 对 xiangxin-latest(响应中的模型版本为 xiangxin-1.0.0)的真实调用,原始输出在 results/。
import json
import re
from pathlib import Path
from xiangxin import Choice, XiangxinClient
MODEL = "xiangxin-latest"
AUTO_ACCEPT = 0.8 # 起步时设高一些,多让人工复核;对模型建立信任后再逐步调低
client = XiangxinClient(timeout=120.0)数据:原文与引用
原文是《中华人民共和国个人信息保护法》(2021 年 8 月 20 日第十三届全国人大常委会第三十次会议通过),取自维基文库,去掉 wiki 标记后存为 data/pipl.txt,共 9,392 字、8 章 74 条。依《中华人民共和国著作权法》第五条,法律及其官方正式译文不适用著作权法保护,可以自由使用。
下面的代码按「第X条」把全文切成 74 个条文,章、节标题只当作分界:
CN_NUM = "零一二三四五六七八九十百"
def split_articles(source: str) -> dict[str, str]:
"""按“第X条”切分法律条文,返回 {"第十五条": 全文}。"""
boundary = re.compile(rf"(?m)^(?:(第[{CN_NUM}]+条) |第[{CN_NUM}]+[章节] )")
marks = list(boundary.finditer(source))
articles = {}
for mark, nxt in zip(marks, marks[1:] + [None]):
if mark.group(1) is None: # 章、节标题只负责结束上一条
continue
articles[mark.group(1)] = source[mark.start() : nxt.start() if nxt else len(source)].strip()
return articles
SOURCE = Path("data/pipl.txt").read_text()
ARTICLES = split_articles(SOURCE)
CITATIONS = json.loads(Path("data/citations.json").read_text())
print(f"{len(SOURCE):,} 字,{len(ARTICLES)} 条,{len(CITATIONS)} 条引用")9,392 字,74 条,40 条引用data/citations.json 中的 40 条引用由本文构造:我们模仿大模型回答合规问题时给出的引用格式,手写了「结论 + 引文 + 条号」。每条都带有我们事先标好的正确判定 expected,被改坏的还写明了 corruption(怎么改的):
| 类别 | 条数 | 构造方式 |
|---|---|---|
| 准确(verified) | 20 | 结论与引文都忠于原文;其中 3 条只标条号、不摘引文 |
| 捏造(fabricated) | 5 | 我们改写或编造了引文,原文中不存在:例如编造「七十二小时内通知」(其实是欧盟 GDPR 的规定)、把「十四周岁」改成「十六周岁」、把「不得公开」改成「可以公开」 |
| 与原文相反(contradicted) | 8 | 引文逐字正确,但我们把结论改反了:例如「撤回同意后,撤回前的处理活动也随之无效」,而第十五条第二款明确说「不影响」;其中 2 条只标条号 |
| 原文未涉及(unsupported) | 7 | 引文逐字正确,但我们给它配了一个该条并未规定的结论:例如「人脸识别数据必须在采集后 30 日内删除」配上第六十二条里的「人脸识别、人工智能等新技术、新应用」;其中 2 条只标条号 |
一条带引文的引用和一条只标条号的引用:
{
"id": "withdraw_retroactive",
"claim": "个人撤回同意后,撤回前基于同意进行的处理活动也随之无效。",
"quote": "基于个人同意处理个人信息的,个人有权撤回其同意。",
"section": "第十五条",
"expected": "contradicted",
"corruption": "同条第二款规定撤回“不影响撤回前……处理活动的效力”"
}
{
"id": "deidentified_not_pi",
"claim": "去标识化处理后的信息不再属于个人信息。",
"quote": null,
"section": "第七十三条",
"expected": "unsupported",
"corruption": "第七十三条只给出去标识化的定义;被排除在个人信息之外的是“匿名化”信息(第四条)"
}第一步:在原文里找引文
引文不在原文里,就是捏造的,这一点不需要模型。先去掉空白、统一全角/半角标点,让跨行的引文也能匹配上,然后当子串查找。匹配成功还顺便告诉我们引文出自哪一条,这一条的全文就是下一步模型要读的上下文。
引用也可能只标了条号、没摘原话。这时没有东西可匹配,直接取它标注的那一条交给模型。
def normalize(text: str) -> str:
"""去掉空白,统一全角/半角分号与引号。"""
table = str.maketrans({";": ";", "“": '"', "”": '"', "‘": "'", "’": "'"})
return re.sub(r"\s+", "", text.translate(table))
def find_quote(articles: dict[str, str], quote: str) -> str | None:
"""包含该引文的条号;找不到返回 None。"""
needle = normalize(quote)
for number, text in articles.items():
if needle in normalize(text):
return number
return None
def locate(articles: dict[str, str], citation: dict) -> tuple[str, str | None]:
"""返回定位状态,以及第二步要读的条文。"""
if citation["quote"] is None:
return "section-only", articles[citation["section"]]
number = find_quote(articles, citation["quote"])
if number is None:
return "missing", None
return "found", articles[number]40 条引用中,5 条捏造的引文全部返回 missing,其余 28 条带引文的都在原文中找到,且找到的条号与引用标注的一致;7 条只标了条号。
第二步:条文是否支撑结论
能走到这一步的引文都与原文逐字一致,但这还不够:引文本身没错,建立在它之上的结论照样可能是错的。要判断这一点,得读引文所在的整条。
对每条引用问一个 Choice,三个选项覆盖条文与结论的三种关系。概率最高的选项就是判定,置信度与 AUTO_ACCEPT 比较决定它的去向:
- 置信度 ≥ 0.8:判定直接生效;
- 置信度 < 0.8:先由人工确认,再采取行动。
QUESTIONS = {
"relation": Choice(
instructions="`section` 中的法律条文与 `claim` 是什么关系?",
criteria={
"supports": "条文明确规定了该结论,或直接意味着该结论成立",
"contradicts": "条文的规定与该结论相反,或直接意味着该结论不成立",
"says_nothing": "条文没有涉及该结论所断言的内容,既不支持也不否定",
},
),
}
RELATION_TO_VERDICT = {
"supports": "verified",
"contradicts": "contradicted",
"says_nothing": "unsupported",
}
def ask(claim: str, section: str) -> dict:
resp = client.system_one(
state={"claim": claim, "section": section}, questions=QUESTIONS, model=MODEL
)
a = resp.answers["relation"]
return {
"choice": a.choice,
"probabilities": a.probabilities,
"confidence": a.confidence,
"input_tokens": resp.usage.input_tokens,
}
def check_citation(articles: dict[str, str], citation: dict) -> dict:
status, section = locate(articles, citation)
answer = ask(citation["claim"], section) if section is not None else None
if status == "missing":
# 没有调用模型,也就没有模型置信度
verdict = {"verdict": "fabricated", "confidence": None, "auto": True}
else:
verdict = {
"verdict": RELATION_TO_VERDICT[answer["choice"]],
"confidence": answer["confidence"],
"auto": answer["confidence"] >= AUTO_ACCEPT,
}
return {"id": citation["id"], "status": status, "answer": answer, **verdict}核查全部引用
40 条引用走同一个流程(最后一列是我们标注的正确答案,模型看不到):
for citation in CITATIONS:
r = check_citation(ARTICLES, citation)
... # 打印,完整代码见文末链接citation quote relation conf verdict action expected
anonymized_excluded found supports 0.92 verified auto verified
collect_minimum found supports 0.88 verified auto verified
contract_basis found supports 0.88 verified auto verified
withdraw_channel found supports 0.85 verified auto verified
no_forced_consent found supports 0.76 verified review verified
shortest_retention found supports 0.82 verified auto verified
joint_liability found supports 0.90 verified auto verified
no_subdelegation found supports 0.90 verified auto verified
recsys_opt_out found supports 0.91 verified auto verified
camera_purpose found supports 0.77 verified review verified
minor_sensitive found supports 0.92 verified auto verified
sensitive_separate_consent found supports 0.83 verified auto verified
gov_localization found supports 0.76 verified review verified
right_to_copy found supports 0.90 verified auto verified
deceased_relatives found supports 0.85 verified auto verified
pia_three_years found supports 0.92 verified auto verified
max_fine found supports 0.90 verified auto verified
household_exempt section-only supports 0.80 verified auto verified
effective_date section-only supports 0.92 verified auto verified
presumed_fault section-only supports 0.90 verified auto verified
breach_72h missing - - fabricated auto fabricated
minimum_necessary missing - - fabricated auto fabricated
dpo_annual_report missing - - fabricated auto fabricated
minor_16 missing - - fabricated auto fabricated
publish_freely missing - - fabricated auto fabricated
withdraw_retroactive found contradicts 0.82 contradicted auto contradicted
consent_once found contradicts 0.72 contradicted review contradicted
minor_self_consent found contradicts 0.83 contradicted auto contradicted
pia_after_the_fact found contradicts 0.77 contradicted review contradicted
breach_no_notice found contradicts 0.73 contradicted review contradicted
ciio_offshore found contradicts 0.77 contradicted review contradicted
household_covered section-only contradicts 0.83 contradicted auto contradicted
fine_five_million section-only supports 0.64 verified review contradicted
crossborder_training found says_nothing 0.53 unsupported review unsupported
dpo_reports_board found says_nothing 0.53 unsupported review unsupported
face_30_days found says_nothing 0.59 unsupported review unsupported
free_paper_copy found says_nothing 0.59 unsupported review unsupported
pil_filing found says_nothing 0.66 unsupported review unsupported
deidentified_not_pi section-only supports 0.48 verified review unsupported
annual_app_ranking section-only says_nothing 0.49 unsupported review unsupported混淆矩阵(行是我们标注的正确答案,列是判定):
| 正确答案 \ 判定 | verified | contradicted | unsupported | fabricated |
|---|---|---|---|---|
| verified(20) | 20 | 0 | 0 | 0 |
| contradicted(8) | 1 | 7 | 0 | 0 |
| unsupported(7) | 1 | 0 | 6 | 0 |
| fabricated(5) | 0 | 0 | 0 | 5 |
- 20 条准确引用全部判为
verified,其中 17 条置信度 ≥ 0.8 自动通过,3 条(0.76、0.77、0.76)转人工。 - 5 条捏造引文没到模型那里:字符串匹配直接判为
fabricated,包括只改了一个数字的minor_16(「十四周岁」→「十六周岁」)。 - 8 条结论相反的引用判对 7 条。
withdraw_retroactive就是字符串匹配不够用的典型:它的引文逐字出自第十五条第一款,而同一条第二款说的正好相反,模型以 0.82 的置信度判为contradicted。 - 7 条「原文未涉及」判对 6 条,但置信度都不高(0.49–0.66),全部转人工。
- 两处误判都被阈值兜住了。
fine_five_million(「一般违法最高罚五百万元」,原文是「一百万元以下」)被判为verified,置信度 0.64,概率为 supports 0.76 / contradicts 0.11 / says_nothing 0.13。第六十六条很长,同时出现了一百万元、五千万元等多个金额,这个 9B 模型没能把「五百万」与「一百万」对上。deidentified_not_pi(「去标识化后不再属于个人信息」)被判为verified,置信度 0.48:第七十三条紧挨着定义了「去标识化」和「匿名化」,模型把两者混在了一起。
阈值怎么取
只看调用了模型的 35 条(捏造的 5 条不涉及置信度):
| AUTO_ACCEPT | 自动生效 | 其中判对 | 转人工 | 转人工中的误判 |
|---|---|---|---|---|
| 0.0(不复核) | 35 | 33 | 0 | 0 |
| 0.5 | 33 | 32 | 2 | 1 |
| 0.6 | 29 | 28 | 6 | 1 |
| 0.7 | 27 | 27 | 8 | 2 |
| 0.8 | 20 | 20 | 15 | 2 |
| 0.9 | 10 | 10 | 25 | 2 |
在这批数据上,0.7 已经能把两处误判都送到人工那里,同时比 0.8 少复核 7 条。但 40 条样本太少,不足以据此把阈值定死:起步时用 0.8,积累一段时间的人工复核记录后,再看你自己文档上的曲线往下调。
用量:35 次调用共 6,583 个输入 token,平均每次 188 个;按 ¥0.042 / 百万输入 token(输出免费)计,全部 35 次约 ¥0.00028。单次调用的模型耗时中位数为 860 ms。
讨论
为什么有效。 两步各管一件事:字符串匹配零成本、零误报地处理「原文里根本没有」这一类;模型只需要回答一个三选一的阅读理解题,而且读的是整条条文,所以能发现「引文没错、结论错了」。对法律这种「一条一事」的文本,按条切分恰好给了模型足够也不过量的上下文,平均每次只有 188 个输入 token。
「原文未涉及」最难。 支持和反对都有明确的文字依据,「没说」却要模型确认条文里不存在某个意思。这一类的置信度普遍在 0.5–0.66 之间,意味着在实际使用中它们大多会转人工。这是合理的行为:如果一条引用不能被原文证实,本来就应该有人看一眼。
局限。
- 字符串匹配是精确匹配(只做了空白和标点归一)。大模型引用时常常会截断或轻微改写原话,这样的引文会被判为
fabricated。生产环境如果要容忍这类「不严谨但没错」的引用,需要换成模糊匹配,并把匹配度一起交给人工。 - 只把引文所在的那一条交给模型。如果结论需要跨条推理(例如第十三条第二项加上第十三条末款,或者第四条与第七十三条对照),把相关条文一起放进
section,或者把整章放进去。 - 本文的 40 条引用是我们手写的,改坏的方式也是我们选的;真实大模型的错误分布会不一样。把
data/pipl.txt和data/citations.json换成你自己的文档和引用,再看一遍混淆矩阵。换成别的文档时,split_articles()需要按新文档的结构重写。 - 象信一号 1.0 是 9B 模型,对长条文中多个数字的比对并不可靠(见上面的
fine_five_million,以及象信一号 1.0 的能力边界)。凡是涉及金额、期限、年龄的结论,可以在代码里先做一次数字抽取和比对,再把结果交给模型或人工。
什么时候不该这么用。 如果你能控制生成端,更好的做法是让回答模型只从检索到的条文里逐字摘引,从源头减少捏造;本文的检查适合作为生成之后的最后一道闸门,而不是替代严格的生成约束。
完整代码
https://github.com/xiangxinai/xiangxin-cookbooks/tree/main/citation_check
包含 main.py、data/pipl.txt(原文)、data/citations.json(40 条引用与标注)和 results/(本次运行的逐条结果与汇总)。

