命中不等于支持:一个离线证据检索器只保证引文位置

0 阅读4分钟

用户问「这句话在不在资料里」和「这份资料能不能支持这个说法」,是两个问题。前者能用一次字符串切片判定,后者不能。我做的 Offline Evidence Finder 只做前者:在浏览器里对一份可编辑的中英文本做词法检索,返回来源 id、原文引文,以及引文在 source.text 里的 UTF-16 起止位置。它不生成答案,也不判断一段资料是否支持某个事实主张。

入口是 Space 和独立应用,固定提交 d309763。下面讲的行为都来自这个提交。

一条不可答查询,还是返回了命中

样例里有条不可答查询 今天火星咖啡价格。四条不可答查询里只有它返回了命中(1/4),命中的是不相关段落 router-zh 里的一个「天」字。

「今天火星咖啡价格」查询命中 router-zh 段落的「天」,界面分数 1.597193

固定开发样例,不是新评测。不可答查询命中不相关段落的单个汉字「天」,分数 1.597193。

程序返回这个命中是按设计工作的。它做的是词法重叠,词法重叠本身不承诺可答性。

这条命中的分数在两版语料里不一样:1.4588… 和 1.5972…。两版语料的段落数都是 14,df(天) 都是 1,命中引文相同所以 dl 也没变。变的只有平均段落长度 avgdl,从 116 到 134.64…。idf = ln(1+(N-df+0.5)/(df+0.5)) = ln 10 恒定,tf=1,所以分数差全部来自长度归一化项里的 avgdl。这是「分数依赖当前语料、不能当置信度比较」的一个能复算的实例。

引文位置是能复核的

引用包导出全部当前源文,以及每条命中的 citation,含 source_id、start、end、偏移单位和 quote。每条 citation 能被独立复核的一件事是:

source.text.slice(start, end) === quote

选 UTF-16 是因为 JS 字符串原生按 UTF-16 单元索引,charCodeAt 和 slice 落在同一个坐标系。代价是单位不是用户看到的字形:切段只在代理对上回退,组合记号(e 加 U+0301)和 ZWJ 序列不在回退条件里,引文可能从 grapheme 中间切开。一个 502 单元的串完全可以在 500 以内取 499 处的 grapheme 边界,同时满足上限和边界两条约束,所以守住 grapheme 边界在技术上可行,只是当前实现没做。

读者可以拿下载的引用包跑下面这段做外部复核:

import { readFileSync } from 'node:fs';
const b = JSON.parse(readFileSync(process.argv[2], 'utf8'));
const byId = new Map(b.sources.map(s => [s.id, s.text]));
const seg = new Intl.Segmenter();
let ok = 0, split = 0;
for (const c of b.citations) {
  const t = byId.get(c.source_id);
  if (t.slice(c.start, c.end) !== c.quote) throw new Error('span mismatch: ' + c.id);
  ok++;
  const bounds = new Set([...seg.segment(t)].map(s => s.index));
  bounds.add(t.length);
  if (!bounds.has(c.start) || !bounds.has(c.end)) split++;
}
console.log({ citations: b.citations.length, sliceOk: ok, graphemeSplit: split });

这段能查出「位置对但切开了字形」,查不出「这段话是否支持结论」。

分词与打分

分词是 NFC 加小写,拉丁/数字/下划线成词,汉字连续段发单字加相邻二元组,查询词去重。没有 stemming、停用词、embedding、翻译。标题显示但不入索引,这是当前实现的范围,我没测过把标题纳入索引会怎样。

打分是固定参数 BM25,k1=1.2、b=0.75。N 和 df 数段落,零 token 段落计入 N,dl 是发出的 token 数,正分才返回,UI 默认最多 5 条(search() 函数本身接受 1–20)。同分先按 source id 的 Unicode code point 逐位比,再按 start 升序,这样同分顺序在不同 locale 的浏览器可复现。IDF 表达式和默认参数对着 Lucene 的 BM25Similarity 文档核过,不声称实现等价。

样例能证明什么

18 条手写开发查询分成 14 条已知来源和 4 条不可答。抽取 bug 发生在另一个层:样例卡片段落抽取(make_sample.py),不是 search.mjs 的 CR/LF 切段。第一版因为正文前导换行把英文标题抽进语料,首次测量 12/14 R@1、13/14 R@3,修正后 14/14。查询和排序参数没改,但修正语料是在看到首次结果之后生成的,所以不是 held-out。第一版语料和完整分数留在 sample-history-v0/。

样例是 7 份本地候选卡片的 14 段节选,不代表这 7 个项目已公开。这个工具能证到字符级的是引文位置,语义支持判断不在它的输出里。


作者:Renchao Wu | 原文:widechaos.cn/blog/offlin…