AI 工具

用 Exa Contents 核验 AI 回答引文:匹配原文并检查数字

AI 回答里的“引文”有时是改写过的,甚至把数字改了。 这篇做 quotecheck.py:用 Exa Contents 接口取回被引网页的正文,检查引文是否真的出现在页面上,以及主张和引文中的数字是否都能在页面上找到。

BINBHO · 图文教程

AI 回答里的“引文”有时是改写过的,甚至把数字改了。 这篇做 quote_check.py:用 Exa Contents 接口取回被引网页的正文,检查引文是否真的出现在页面上,以及主张和引文中的数字是否都能在页面上找到。

先看清成品

  • 5 条主张:2 条通过,2 条不符(数字 0.78 不存在;引文相似度只有 0.57),1 条网页无法取回(CRAWL_NOT_FOUND)。
  • 发现并修复:把 0.74 改成 0.47 的引文与原文相似度高达 0.98,第一版判为“基本一致”。
  • 发现并修复:引文多一个词时相似度从 0.9 以上掉到 0.81;扩大窗口后为 0.88,归为“近似,需对照原文”。pytest 4 项通过。

合规提醒:Exa 返回的是第三方网页内容,转载或商用前要遵守原网站的版权和使用条款;不要用 Exa 采集个人信息。本文只使用 Exa 官方 API 和官方 Python SDK(exa-py)。API key 在 Exa 控制台(dashboard.exa.ai)创建,放在环境变量 EXA_API_KEY 中。调用费用以 Exa 官方价格页为准(本文不列价格)。

运行说明:box 上没有 Exa API key。我用无效 key 真实调用了 POST https://api.exa.ai/search,得到 HTTP 401 {"error":"Invalid API key. …","tag":"INVALID_API_KEY"};通过官方 SDK(exa-py 2.26.0)调用时,SDK 抛出的是 ValueError(2026-10-21 20:06,UTC+8)。其余响应来自我写的本地替身:两条结果照抄官方快速入门页面的示例响应,另外的结果和网页正文是我编的(example.com)。SDK 通过官方的 base_url 参数指向替身。所有脚本和测试都在 box 上真实运行过。

做完之后你会得到什么?

成果 说明
quote_check.py 核对引文
claims.csv 5 条示例主张
fake_exa.py 与 A 篇相同的替身
demo_local.py 用替身演示
test_quotes.py 4 项测试

需要准备哪些环境?

项目 说明
Python 3.9 以上,pip install exa-py pytest(本文为 exa-py 2.26.0)
Exa API key 在 Exa 控制台创建,export EXA_API_KEY=…

关键事实

事实 来源
/contents 的内容选项(text、highlights)直接写在顶层,与 urls 并列;/search 则包在 contents 里 官方 Search 快速入门中的警告
响应中的 statuses 给出每个网址是否取回成功 exa-py 返回的 ContentStatus(实测)

文件结构

74-exa-C-src/
├── quote_check.py
├── claims.csv
├── fake_exa.py
├── demo_local.py
└── test_quotes.py

第 1 步:示例主张

claim,url,quote
混合检索把长尾查询的 recall@10 从 0.61 提高到 0.74,https://example.com/benchmark,hybrid retrieval improved recall@10 from 0.61 to 0.74
混合检索让 recall@10 提高到 0.78,https://example.com/benchmark,hybrid retrieval improved recall@10 to 0.78
延迟增加了 12 毫秒,https://example.com/benchmark,"Latency rose by 12 ms"
混合检索几乎没有额外延迟,https://example.com/benchmark,hybrid retrieval adds almost no latency
引用了一个打不开的页面,https://example.com/blocked,some quote

第 2 步:脚本

"""核对 AI 回答中的引文:用 Exa /contents 取回被引网页的正文,检查①引文是否真的出现在页面上(允许大小写、空白、标点差异),
②主张里的数字是否出现在页面上。官方提醒:/contents 的内容选项(text、highlights)直接写在顶层,不要包在 contents 里。"""
import csv, difflib, os, re, sys
from exa_py import Exa

def norm(t): return re.sub(r"\s+", " ", re.sub(r"[“”\"'‘’.,;:!?()]", " ", t.lower())).strip()

def best_match(quote, text):
    """在正文中找与引文最相近的片段,返回相似度(0–1)。"""
    q, t = norm(quote), norm(text)
    if q in t: return 1.0
    words, n = t.split(), len(q.split()); best = 0.0
    # 窗口长度取 n-2…n+2 个词:引文多一个或少一个词(例如 about)时,等长窗口会错位,相似度从 0.9 以上掉到 0.81
    for size in range(max(1, n - 2), n + 3):
        for i in range(max(1, len(words) - size + 1)):
            best = max(best, difflib.SequenceMatcher(None, q, " ".join(words[i:i + size])).ratio())
    return round(best, 2)

def numbers(s): return set(re.findall(r"\d+(?:\.\d+)?", s))

def fetch(urls, exa):
    r = exa.get_contents(sorted(set(urls)), text=True)
    pages = {x.url: x.text for x in r.results}
    errors = {s.id: (s.error or {}) for s in (getattr(r, "statuses", None) or []) if s.status != "success"}
    return pages, errors

def check(rows, pages, errors):
    out = []
    for r in rows:
        u = r["url"]
        if u not in pages:
            e = errors.get(u); tag = getattr(e, "tag", None) or (e.get("tag") if isinstance(e, dict) else None)
            out.append((r, "无法取回", f"Exa 没有返回正文({tag or '未知原因'}),请手动打开")); continue
        text = pages[u]; score = best_match(r["quote"], text)
        # 引文中的数字也要逐个核对:把 0.74 改成 0.47 的引文与原文相似度仍有 0.98(调试时实测),只看相似度会判为“基本一致”
        missing = sorted((numbers(r["claim"]) | numbers(r["quote"])) - numbers(text), key=float)
        if score == 1.0 and not missing: out.append((r, "通过", "引文原样出现在页面上"))
        elif score >= 0.85 and not missing: out.append((r, "近似", f"相似度 {score}:措辞与原文不同,请对照原文"))
        else:
            why = ([f"引文相似度只有 {score}"] if score < 0.85 else []) + ([f"页面上没有数字 {', '.join(missing)}"] if missing else [])
            out.append((r, "不符", ";".join(why)))
    return out

def main(argv=None):
    argv = argv or sys.argv[1:]
    rows = list(csv.DictReader(open(argv[0], encoding="utf-8")))
    exa = Exa(api_key=os.environ.get("EXA_API_KEY"), base_url=os.environ.get("EXA_BASE_URL", "https://api.exa.ai"), user_agent="binbho-tutorial/1.0")
    pages, errors = fetch([r["url"] for r in rows], exa); bad = 0
    for r, verdict, why in check(rows, pages, errors):
        bad += verdict != "通过"; print(f"[{verdict}] {r['claim']}\n    {why}")
    print(f"{bad} 条需要处理"); return 1 if bad else 0

if __name__ == "__main__":
    sys.exit(main())

第 3 步:测试

import csv, pytest, fake_exa as F, quote_check as Q

ROWS = list(csv.DictReader(open("claims.csv", encoding="utf-8")))
PAGE = F.PAGES["https://example.com/benchmark"]

def verdicts(rows, pages, errors={}): return [v for _, v, _ in Q.check(rows, pages, errors)]

def test_offline_verdicts():
    pages = {"https://example.com/benchmark": PAGE}
    assert verdicts(ROWS, pages, {"https://example.com/blocked": {"tag": "CRAWL_NOT_FOUND"}}) == ["通过", "不符", "通过", "不符", "无法取回"]

def test_number_swapped_inside_quote():
    # 调试时发现:引文中 0.74 被改成 0.47,相似度 0.98,第一版判为“基本一致”
    row = {"claim": "混合检索效果更好", "url": "u", "quote": "hybrid retrieval improved recall@10 from 0.61 to 0.47"}
    r = Q.check([row], {"u": PAGE}, {})[0]
    assert Q.best_match(row["quote"], PAGE) == 0.98 and r[1] == "不符" and "0.47" in r[2]

def test_minor_wording():
    row = {"claim": "延迟增加 12 毫秒", "url": "u", "quote": "latency rose by about 12 ms"}
    assert verdicts([row], {"u": PAGE}) == ["近似"]          # 相似度 0.88;阈值 0.85 是按本例选的

def test_via_sdk(monkeypatch):
    st = F.State(); srv, url = F.make(st)
    try:
        from exa_py import Exa
        pages, errors = Q.fetch([r["url"] for r in ROWS], Exa(api_key="good", base_url=url))
        assert st.bodies[0] == ("/contents", {"urls": ["https://example.com/benchmark", "https://example.com/blocked"], "text": True})
        assert list(pages) == ["https://example.com/benchmark"] and "https://example.com/blocked" in errors
    finally: srv.shutdown()

第 4 步:运行

$ python demo_local.py claims.csv
[通过] 混合检索把长尾查询的 recall@10 从 0.61 提高到 0.74
    引文原样出现在页面上
[不符] 混合检索让 recall@10 提高到 0.78
    页面上没有数字 0.78
[通过] 延迟增加了 12 毫秒
    引文原样出现在页面上
[不符] 混合检索几乎没有额外延迟
    引文相似度只有 0.59
[无法取回] 引用了一个打不开的页面
    Exa 没有返回正文(CRAWL_NOT_FOUND),请手动打开
3 条需要处理
请求体: ('/contents', {'urls': ['https://example.com/benchmark', 'https://example.com/blocked'], 'text': True})
$ python -m pytest -q
4 passed

怎么检查结果?

  • 请求体是 {"urls": [...], "text": true},没有 contents 包装(测试检查)。
  • “通过”表示引文原样出现在页面上,且数字都能找到。
  • “近似”需要人工对照;“不符”和“无法取回”必须处理。
  • 脚本只检查文字是否存在,不判断页面是否支持你的结论。

调试示例

1. 改了数字的引文被判为“基本一致”。 第一版只看引文与页面的相似度(≥0.9 为基本一致)。我测试了一条把 0.74 改成 0.47 的引文:

0.98 hybrid retrieval improved recall@10 from 0.61 to 0.47
[('…', '基本一致', '相似度 0.98:措辞有细微差别')]

字符相似度对改一个数字几乎不敏感,而这恰恰是最危险的错误。修复:引文和主张中的每个数字都必须出现在页面上,否则一律“不符”。

2. 多一个词,相似度大幅下降。 “latency rose by about 12 ms” 比原文多了 about,第一版只比较与引文等长的窗口,相似度只有 0.81。改为比较 n−2 到 n+2 个词的窗口后为 0.88,仍低于 0.9。我没有为了让测试通过而继续调参数,而是把 0.85~1.0 定为“近似”,必须人工对照。阈值 0.85 是按本例选的,换一批数据可能需要调整。

常见问题

现象 原因 处理
全部“无法取回” 网址需要登录,或被网站拒绝 手动打开;不要绕过网站限制
中文引文相似度偏低 分词按空格,中文没有空格 中文引文以“原样出现”为准,其余人工看
数字误报 页面把 0.74 写成 74% 人工确认,或在 numbers() 中补充换算

模板规则

  • 引文中的数字必须逐个核对。
  • “近似”不等于“通过”。
  • 网页取不回时手动打开。
  • 不要绕过网站的访问限制。

项目资源

用途 地址
Exa Search 快速入门(官方) https://exa.ai/docs/search/quickstart
Exa Python SDK(官方 GitHub) https://github.com/exa-labs/exa-py
Exa SDK 快速入门(官方) https://exa.ai/docs/sdks/quickstart
Exa Contents 快速入门(官方) https://exa.ai/docs/contents/quickstart

引文对不对,让原文说了算。