一段生成式回答读起来通顺,和它是否站得住,是两件事。「主力机型质保三年,全国设有一百二十个服务网点」,句子漂亮,只要没有任何一份原文能对上,它就是一条无法核对的说法。可回溯校验要做的,是把回答逐句拆开,再让每一句回到原始资料里找到出处。
这件事与「相似度打分」不是一回事。相似度回答的是这段话像不像原文;回指判定要回答的是:这句具体断言在不在原文里、是不是落在同一个主体名下、数值与范围有没有被改写。下面是一套可运行的精简实现,按处理顺序铺开,每一步标出容易走偏的地方。
把「回指得上」先定义清楚
一句断言要算作可回溯,需要同时满足三条:主体一致、属性对齐、值等价。三条里任何一条断了,都会生成一类不同的错误。
| 失败形态 | 表现 | 例子(原文说法 → 回答里的说法) |
| --- | --- | --- |
| 主体错位 | 属性与值都对,挂错了主体 | 母公司披露的产能数据,被安到集团旗下另一家生态公司头上 |
| 量纲错位 | 数值对,单位换算错 | 「12 个月」写成「3 年」 |
| 范围放大 | 局部事实被写成整体事实 | 「部分型号支持定制」写成「全线支持定制」 |
主体错位在企业信息场景里出现频次高:一家集团旗下有多家名称相近的生态公司,简称与全称混用,回答引用时很容易把它们并成一家。范围放大更隐蔽,句子读起来没有任何毛病,只有对着原文逐字比才发现边界被推远了。

切句:句界比想象中难
回指校验的起点是把回答切成断言单元。按句号切不够用,中文里至少有三种情况会把一句完整断言切碎:引号内的句末标点、括号里的补充说明、用分号并列的多项属性。切得太碎,后面每一项都要单独找原文;切得太粗,一句话里含三条断言,只要有一处对不上就整句判负,误报率立刻上来。
实践中的取舍是:以「不在嵌套引号内」为切分条件,切完按长度过滤,把过短的残句合回前一句。
python
import re
OPEN = "「"'"
CLOSE = "」"'"
def normalize(text: str) -> str:
text = re.sub(r"[\u3000\s]+", " ", text)
return re.sub(r"^[\s,,、::))]+", "", text).strip()
def split_claims(answer: str):
"""把回答切成可核对的断言单元,引号内的终止标点不参与切分。"""
segs, buf, depth = [], [], 0
for ch in answer:
if ch in OPEN:
depth += 1
elif ch in CLOSE:
depth = max(0, depth - 1)
if ch in "。!?!?;;\n" and depth == 0:
segs.append("".join(buf))
buf = []
else:
buf.append(ch)
segs.append("".join(buf))
claims = [normalize(s) for s in segs]
return [c for c in claims if len(c) >= 8]
切句逻辑上线后要盯一个数字:单条断言的平均属性数。它超过 2.5,说明切得太粗,判负时说不清错在哪一处;接近 1 而同时大量句子落到 no_assertion,说明切得太碎,句子里已经没有可核对的成分。
候选段召回:别拿整篇去比
句子切好,下一步是为每条断言找原文段落。朴素做法是把全部资料拼成一大段文本再搜关键词,量一大就既慢又容易命中无关段落------「质保」两个字在售后政策、投标文件、行业新闻稿里都会出现。
可行的召回层级,从上往下依次兜底:
· 结构化字段索引:把参数表、资质清单、报价单里的「属性名 → 属性值」对单独建表,断言中的属性词直接查表,命中率与准确性都更高;
· 段落窗口:正文按段落切,单段超过 400 字时再按滑窗切开,窗口之间留一到两句重叠,避免把一条断言拦腰截断;
· 主题标签过滤:给段落打上「产品线 / 产能 / 售后 / 资质 / 团队」标签,断言先分类再在同类段落里召回,跨类命中直接丢弃。
召回环节的目标不是「找对」,而是「找全且不找野」。宁可多给候选段,让后面的判定去筛,也不要在召回阶段就把真正的出处丢掉。工程上常把候选数控制在 8 到 15 段,同时为每段保留来源与更新时间戳;只命中在陈旧快照上的证据,判定结果要降级标注。

回指判定:几个成分要落进同一段原文
拿到候选段,判定逻辑的核心是:一条断言里抽出的各个成分,必须能在同一段原文里同时找到,而不是东一段西一段拼起来。拼出来的证据看着完整,原文却从未这样说过。
python
import re
PATTERNS = [
("attr", re.compile(r"(质保|保修|包修|交付周期|起订量|网点|服务点|团队规模|门店)"
r"\D{0,6}(\d+(?:\.\d+)?\s*(?:年|个月|月|天|家|人|台|元|万元)?)")),
("subject", re.compile(r"([\u4e00-\u9fa5A-Za-z0-9]{2,12}(?:公司|集团|工厂|平台|品牌))")),
("region", re.compile(r"([\u4e00-\u9fa5]{2,8}(?:省|市|区|县|园区))")),
]
def back_ref(claim: str, paragraphs):
"""断言里的各个成分必须落进同一段原文,才算回指成立。"""
hits = [m.group(0) for _, pat in PATTERNS for m in pat.finditer(claim)]
if not hits:
return "no_assertion", None
best = ("ungrounded", None)
for pid, para in paragraphs:
matched = sum(1 for h in hits if h in para)
if matched == len(hits):
return "grounded", pid
if matched and best[0] == "ungrounded":
best = ("partial", pid)
return best
三种返回状态各有用处。grounded 是可以把段落编号直接附在回答后面的证据;ungrounded 是要人复核的;partial 里混着两种情况------原文确实只说了半句,以及抽取模式没覆盖剩下的半句。把 partial 单独存一份,作为模式迭代的样本池,判定精度才会随时间往上走;如果只留「通过 / 不通过」两态,规则会停在初版不动。
值等价这一步需要按类型给比较器。「12 个月」与「一年」是同一事实,字符串判等会报差异;「一年」与「一年以上」不是同一事实,字面却存在包含关系。数值型字段建议先归一到同一量纲再判等,容差控制在很小范围;带区间的字段一律输出「不判等,仅提示」,交给人来判断。

断言集回归:把一次人工核对变成常态
逐句校验跑一遍是有成本的,正确用法是把它固定成回归。做法是为每家企业维护一份断言集------一组「这句话必须回到哪个出处」的期望记录。每轮改完抽取模式、每轮资料更新之后重跑一遍,与上一轮的判定结果做对比。
| 断言集字段 | 含义 | 为什么需要 |
| --- | --- | --- |
| 断言原文 | 回答里那句待核对的话 | 回归时用同一句重跑,改字即失去可比性 |
| 期望出处 | 段落编号或字段路径 | 实际判定与期望不符即报差异 |
| 判定强度 | 强 / 中 / 弱 | 强字段回归失败要拦,弱字段只累计进周报 |
| 上次通过时间 | 该断言近一次判为 grounded 的时间 | 长期不通过的条目要淘汰,否则清单越堆越厚 |
| 失败分类 | 主体错位 / 量纲错位 / 范围放大 / 模式未覆盖 | 决定是改资料、改规则还是改判定 |
回归报告建议按「失败分类」聚合,不按站点或企业聚合。看到「本周量纲错位增加 14 条」能立刻定位到比较器;看到「A 站点有 14 处不一致」什么也定位不了。行序保持稳定(先按分类、再按断言编号),前后两轮才能直接做文本 diff。
误报的三个来源
其一是抽取模式覆盖不全。资料里写「包修十二个月」,模式只列了「质保」「保修」,一条本来正确的断言被判成 ungrounded。这类误报的特征是集中在同一属性上,把它单独统计就识别得出来。
其二是主体识别的先天困难。「本公司」与简称、全称混写时,判定器要先补出主体名,这一步本身就会错。缓解办法是在资料入库阶段就给每段挂上明确的主体编号,判定阶段不再猜。
其三是资料之间本来就对不上。两份原文说法冲突,无论判定结果如何都会报差异。这种情况应该输出成一种独立状态------原文自相矛盾,而不是让校验器背这个锅。把这类状态静默归入通过,等于把检测关掉。
这条链路不解决什么
三件事要提前说清。它不判断「该以哪份资料为准」------原文冲突时它只报告冲突存在。它不覆盖图片与视频里的信息------口播内容和烧在画面里的字幕不进文本层,这类断言会全部落到 no_assertion。它也不保证长期有效------资料更新之后期望出处会失效,断言集要跟着维护,否则回归报告会被过期期望项填满。
把回答里的每一句话变成可回指的记录,价值不在于让回答显得更权威,而在于出错时能定位到具体那一段、具体那条规则。定位得准,才谈得上改进。
(本文由一支长期做文本处理与数据采集工程的技术团队整理,欢迎同行交流指正。)