文档目录

二次核对引用

通过与源文档比对,找出错误或虚构的引用。一道 Choice(选择题)问题决定引文的上下文是否支撑该声明。

非官方中文翻译 · 内容以 英文原文 ↗ 为准 · 同步于 2026-10-15

大语言模型(LLM)在回答一个问题时会附上引用:对每一条声明,给出一段源文档以及其所依据的引文。其中部分引用是错误的或虚构的:引文可能完全不在源文档里,也可能原原本本躺在源文档中,但其上下文却表达了与该声明相反的意思。

人工逐一核查很慢:先找到文档,再到文档里找到引文,然后读足够多的上下文才能判断它是否支撑该声明。

为了让这一核查自动化,我们先用普通的字符串匹配找出缺失的引文,然后对每一条幸存的引文使用一道 Choice 问题来读取其上下文,并判断它是否支撑该声明。

mermaid
  %%{init: {"flowchart": {"wrappingWidth": 330}}}%%
flowchart LR
    cite["source document + citation"]

    match{"is the quote<br/>in the source?"}
    fab["mark <b>fabricated</b>"]

    subgraph request[" "]
        q["Choice &mdash; how does the<br/>section relate to the claim?<br/>supports &rarr; mark <b>verified</b><br/>contradicts &rarr; mark <b>contradicted</b><br/>says nothing &rarr; mark <b>unsupported</b>"]
    end

    gate{"confidence<br/>&ge; 0.8?"}
    stand["let the verdict stand"]
    review["a human confirms it"]

    cite --> match
    %% the two edges that reach the call come first, so they stay adjacent; the
    %% string match's own verdict is declared last and lands below them
    match -- "found" --> request
    match -- "no quote" --> request
    match -- "not found" --> fab
    request --> gate
    gate --> stand
    gate --> review

下面有 8 条来自大语言模型关于 RFC 7519(JSON Web Token)回答时的引用,将走完整套核查。其中 4 条准确的引用以不低于 0.93 的置信度返回了 verified(已核实)。埋设的 4 条失败全部被抓出:一条 fabricated(虚构的)引文、一条 contradicted(被驳斥的)声明,外加两条 unsupported(无支撑的)引用被送交人工。

check_citation()——也就是你在这里构建的函数——接受一份源文档和一条引用,返回四种结果之一:verified、unsupported、contradicted 或 fabricated。它同时返回一个置信度,把那些需要人工查看的引用标记出来。

Setup

bash
pip install ipython 'cooksafe>=0.2.0,<0.3.0'

然后设置 TYPESAFE_API_KEY。每一次 API 调用都会缓存在 json_cache.json 中,该文件随实战示例一同发布,因此重新运行只会回放已发布的数字,而不会真正调用 API。删除该文件即可让所有调用实时执行。

下面的数字来自 jev-1.12,时间为 2026-08-16。

python
import json
import os
import re
from pathlib import Path
from time import perf_counter

from cooksafe import JsonCache, make_playground_link
from IPython.display import Markdown, display
from typesafe_sdk import Choice, TypeSafeClient

TYPESAFE_MODEL = "jev-1.12"
AUTO_ACCEPT = 0.8  # start high for more human review as you build trust in the model

client = TypeSafeClient(
    api_key=os.environ.get("TYPESAFE_API_KEY", "cache-only"),
    base_url=os.environ.get("TYPESAFE_ENDPOINT"),
    timeout=120.0,
)
json_cache = JsonCache(Path("json_cache.json"))

Load the source and the citations

源文档是 RFC 7519(JSON Web Token),从 rfc-editor.org 取回,并随本实战示例一同提交为 rfc7519.txt。下面的代码会剥离页眉和页脚,然后把正文拆成编号小节。

citations.json 中的 8 条引用是大语言模型针对该 RFC 写出的。其中 4 条是准确的;另 4 条被我们编辑过,让它们触发失败。

python
def load_source() -> str:
    """RFC 7519 verbatim, minus the page headers and footers that interrupt its paragraphs."""
    lines = []
    for line in Path("rfc7519.txt").read_text().splitlines():
        bare = line.lstrip("\f")
        if re.match(r"Jones, et al\.\s.*\[Page \d+\]$", bare):
            continue
        if re.match(r"RFC 7519\s+JSON Web Token \(JWT\)\s+May 2015$", bare):
            continue
        lines.append(bare)
    return re.sub(r"\n{3,}", "\n\n", "\n".join(lines))


def split_sections(source: str) -> dict[str, str]:
    """Map each numbered section ("4.1.3") to its text, split on the RFC's header lines."""
    boundary = re.compile(r"(?m)^(?:(\d+(?:\.\d+)*)\.  .+|Appendix [A-Z]\..*)$")
    marks = list(boundary.finditer(source))
    sections = {}
    for mark, nxt in zip(marks, marks[1:] + [None]):
        if mark.group(1) is None:  # an appendix header only terminates the section before it
            continue
        sections[mark.group(1)] = source[mark.start() : nxt.start() if nxt else len(source)].strip()
    return sections


SOURCE = load_source()
SECTIONS = split_sections(SOURCE)
CITATIONS = json.loads(Path("citations.json").read_text())

print(f"{len(SOURCE):,} characters, {len(SECTIONS)} numbered sections, {len(CITATIONS)} citations")
print("\nA citation with a quote:")
print(json.dumps(CITATIONS[1], indent=2))
print("\nA claim-only citation:")
print(json.dumps(next(c for c in CITATIONS if c["quote"] is None), indent=2))
text
58,365 characters, 45 numbered sections, 8 citations

A citation with a quote:
{
  "id": "aud_reject",
  "claim": "If a validator does not find itself in a token's audience list, it has to reject the token.",
  "quote": "If the principal processing the claim does not identify itself with a value in the \"aud\" claim when this claim is present, then the JWT MUST be rejected.",
  "section": "4.1.3"
}

A claim-only citation:
{
  "id": "iat_future",
  "claim": "The \"iat\" claim requires validators to reject tokens whose issue time is in the future.",
  "quote": null,
  "section": "4.1.6"
}

Find each quote in the source

不在源文档中的引文就是虚构的——这一点无需借助模型即可判定。先把空白和弯引号归一化,让引文在 RFC 的换行处依然能匹配上,然后再把它作为子串进行查找。一次匹配还能告诉我们引文出自哪一小节,该小节就是下一步模型要读的文本。

一条引用也可以只指定小节而不引用其中的任何内容。这种情况下没有字符串可匹配,直接把引用所指定的小节送进模型即可。

python
def normalize(text: str) -> str:
    """Collapse whitespace and fold curly quotes, so a quote matches across line wraps."""
    table = str.maketrans({"“": '"', "”": '"', "‘": "'", "’": "'"})
    return re.sub(r"\s+", " ", text.translate(table)).strip()


def find_quote(sections: dict[str, str], quote: str) -> str | None:
    """The number of the section that contains the quote verbatim, or None."""
    needle = normalize(quote)
    for number in sorted(sections, key=lambda n: [int(p) for p in n.split(".")]):
        if needle in normalize(sections[number]):
            return number
    return None


def locate(sections: dict[str, str], citation: dict) -> tuple[str, str | None]:
    """Step 1 for one citation: a status, plus the section step 2 will read."""
    if citation["quote"] is None:
        return "section-only", sections[citation["section"]]
    number = find_quote(sections, citation["quote"])
    if number is None:
        return "missing", None
    return "found", sections[number]


for citation in CITATIONS:
    status, section = locate(SECTIONS, citation)
    where = f"section of {len(section):,} chars" if section else "not in the source"
    print(f"{citation['id']:<18}{status:<14}{where}")
text
epoch_seconds     found         section of 3,122 chars
aud_reject        found         section of 761 chars
sig_reporting     missing       not in the source
clock_skew        found         section of 529 chars
exp_required      found         section of 529 chars
pii_encryption    found         section of 1,653 chars
iat_future        section-only  section of 270 chars
duplicate_names   found         section of 918 chars

Verify whether the source supports the claim

走到这一步仍保留着引文的引用,与源文档是逐字匹配的。但仅此还不够:引文本身可能准确,建立在它之上的声明仍然可能是错的。要做出判断,就需要用到该引文的上下文,也就是步骤 1 所找到的小节。

对每一条幸存的引用,一道 Choice 问题覆盖了小节与声明之间的三种关系。概率最高的选项即作为结果,并由 AUTO_ACCEPT(上面代码中取 0.8)决定其后续处置:

  • 置信度不低于 0.8:结果独立成立;
  • 低于 0.8:在任何处置生效之前,需由人工对该结果加以确认。

起步时把阈值定高一些,等在你自己的文档上看过模型表现之后,再把阈值往下调。

python
QUESTIONS = {
    "relation": Choice(
        instructions="How does the section relate to the claim?",
        criteria={
            "supports": "The section states the claim or directly implies that it is true",
            "contradicts": "The section states the opposite of the claim or implies it is false",
            "says_nothing": "The section does not address what the claim asserts, either way",
        },
    ),
}

RELATION_TO_VERDICT = {
    "supports": "verified",
    "contradicts": "contradicted",
    "says_nothing": "unsupported",
}


@json_cache
def ask(claim: str, section: str) -> dict:
    started = perf_counter()
    response = client.system_one(
        state={"claim": claim, "section": section},
        questions=QUESTIONS,
        model=TYPESAFE_MODEL,
    )
    answer = response.answers["relation"]
    return {
        "choice": answer.choice,
        "probabilities": answer.probabilities,
        "confidence": answer.confidence,
        "seconds": round(perf_counter() - started, 2),
        "input_tokens": response.usage.input_tokens or 0,
        "output_tokens": response.usage.output_tokens or 0,
    }


def verdict(status: str, answer: dict | None) -> dict:
    """Fold step 1 and step 2 into one of the four labels, plus an auto-or-review flag."""
    if status == "missing":
        # confidence None: no model was called, so there is no model confidence to report
        return {"verdict": "fabricated", "confidence": None, "auto": True}
    return {
        "verdict": RELATION_TO_VERDICT[answer["choice"]],
        "confidence": answer["confidence"],
        "auto": answer["confidence"] >= AUTO_ACCEPT,
    }


def check_citation(sections: dict[str, str], citation: dict) -> dict:
    status, section = locate(sections, citation)
    answer = ask(citation["claim"], section) if section is not None else None
    return {"id": citation["id"], "status": status, "answer": answer, **verdict(status, answer)}

逐条核查引用

让八个引用都跑同一道检查:

python
print(f"{'citation':<18}{'quote':<14}{'relation':<14}{'conf':>6}  {'verdict':<13}{'action':>7}")
for citation in CITATIONS:
    result = check_citation(SECTIONS, citation)
    answer = result["answer"]
    relation = answer["choice"] if answer else "-"
    conf = f"{answer['confidence']:.2f}" if answer else "-"
    action = "auto" if result["auto"] else "review"
    print(
        f"{result['id']:<18}{result['status']:<14}{relation:<14}{conf:>6}"
        f"  {result['verdict']:<13}{action:>7}"
    )
text
citation          quote         relation        conf  verdict       action
epoch_seconds     found         supports        0.93  verified        auto
aud_reject        found         supports        0.95  verified        auto
sig_reporting     missing       -                  -  fabricated      auto
clock_skew        found         supports        0.99  verified        auto
exp_required      found         contradicts     0.99  contradicted    auto
pii_encryption    found         says_nothing    0.27  unsupported   review
iat_future        section-only  says_nothing    0.56  unsupported   review
duplicate_names   found         supports        0.99  verified        auto

四个引用返回 verified,一个 fabricated,一个 contradicted,两个 unsupported。

  • epoch_seconds、aud_reject、clock_skew 和 duplicate_names 是四条正确引用。它们都返回 verified,置信度 0.93 以上,远超 AUTO_ACCEPT。
  • sig_reporting 根本没走到模型。它的引文在 RFC 里找不到,所以仅靠字符串匹配就把它标为 fabricated。
  • exp_required 一字不差地引用了 4.1.4 节,而同一节写着 "Use of this claim is OPTIONAL",所以返回 contradicted,置信度 0.99。
  • pii_encryption 和 iat_future 返回 unsupported,分别为 0.27 和 0.56,都低于阈值,因此都送到了人工复核。pii_encryption 恰恰说明了仅靠字符串匹配是不够的:它的引文在原文里一字不差,而引文所在的章节对该声明只字未提。

要把这套流程搬到自己的数据上,只需替换 rfc7519.txt 和 citations.json。load_source() 和 split_sections() 是按 RFC 的排版写的,结构不同的文档需要自己另外写解析逻辑。

归一化之后的字符串匹配是精确匹配:引文被截断或稍作改写都会被判为 fabricated。生产系统如果允许容错性较高的引述方式,就需要换成模糊匹配。

在 playground 中打开

下面这个链接里放了一条引用的声明(claim)和对应章节,外加问题。在浏览器里打开就能对同一条数据跑同一调用。

python
example = next(c for c in CITATIONS if c["id"] == "exp_required")
_, example_section = locate(SECTIONS, example)
playground_link = make_playground_link(
    {"claim": example["claim"], "section": example_section}, QUESTIONS, models=[TYPESAFE_MODEL]
)
display(Markdown(f"🔗 [Open one citation's claim + section in the TypeSafe playground]({playground_link})"))

在 TypeSafe playground 中打开这条引用的声明与章节 →

本文档由 Mintlify 构建和托管,Mintlify 是一个面向开发者的文档平台。