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 |
引文对不对,让原文说了算。