RAG 回答带有引用,并不代表答案有依据:引用可能与主张无关,也可能只支持其中一部分。本文实现一条可运行的 Node.js 流水线,逐主张识别直接支持、错引、漏引与超出证据的推断,并将结果用于拒答、补充检索和人工复核。
为什么不能只检查有没有引用
很多 RAG 系统把「回答中包含引用编号」当作可信度信号,例如要求模型在句末输出 [S1]。这种检查只能证明模型生成了一个引用标记,无法回答三个更重要的问题:
- 引用片段是否真的讨论了当前主张;
- 片段是否直接支持主张中的数字、范围和因果关系;
- 一个长句中的每个独立主张是否都有依据。
例如,证据只说「Node.js 20 提供全局 fetch API」,回答却写成「因此所有旧版 Node.js 都不需要第三方 HTTP 客户端」。虽然引用编号一致,但「所有旧版」和「不需要第三方客户端」都没有被原片段支持。这不是漏引,而是超出证据的推断。
验证对象因此不应是整段回答,而应是尽可能原子的主张。每条主张至少保留以下信息:主张文本、引用编号、命中的证据片段、支持分数、判定状态和后续动作。引用编号只是连接回答与证据的外键,证据文本才是判断依据。
逐主张验证的判定模型
一条基础流水线可以分成四步:
- 按句号、分号及推断连接词拆分回答;
- 提取
[S1]形式的引用,并解析到实际片段; - 比较主张和证据的关键词、数字及范围限定;
- 输出结构化状态,而不是只给一个总分。
本文使用四种核心状态:
| 状态 | 含义 | 建议动作 |
|---|---|---|
supported | 引用片段直接覆盖主张 | 允许进入最终回答 |
missingCitation | 主张没有引用 | 补充检索,找不到则删除或拒答 |
wrongCitation | 引用存在,但内容与主张不匹配 | 重新检索或更换引用 |
beyondEvidence | 有部分关联,但数字、范围或结论超出片段 | 收缩表述,必要时人工复核 |
这里要刻意区分「错引」和「证据外推」。引用 Node.js 文档去证明向量数据库采用何种距离算法,属于错引;从「Node.js 20 支持某 API」推导出「所有旧版本都支持」,则属于外推。
下面的实现采用可解释的词项重合度和数字一致性规则。它不是完整的自然语言推理模型,但适合作为低成本、可审计的第一道门。生产系统可以再接入重排序模型或 NLI 模型,不过模型判定本身也应记录输入、版本和阈值,不能把另一个模型的输出当作绝对事实。
可运行的 Node.js 验证器
示例只使用 Node.js 内置 API,在 Node.js 20 中可直接运行。将以下内容保存为 verifier.mjs:
const sources = {
S1: {
title: 'Node.js 运行时说明',
text: 'Node.js 20 提供全局 fetch API。'
},
S2: {
title: 'RAG 审计规范',
text: 'RAG 验证应记录主张与证据关系,并保存引用片段和验证状态,供人工复核。'
}
};
const answer = [
'Node.js 20 提供全局 fetch API。[S1]',
'向量数据库采用余弦距离。[S1]',
'因此所有旧版 Node.js 都无需第三方 HTTP 客户端。[S1]',
'RAG 验证应记录主张与证据关系。[S2]',
'该验证器能把幻觉率降低 80%。'
].join('');
const segmenter = new Intl.Segmenter('zh', { granularity: 'word' });
const stopWords = new Set([
'的', '了', '和', '与', '及', '是', '把', '该', '一个', '中'
]);
function splitClaims(text) {
const sentences = text.match(/[^。!?!?]+[。!?!?]?/g) ?? [];
return sentences.flatMap((sentence) => {
const refs = [...sentence.matchAll(/\[([A-Za-z]\w*)\]/g)]
.map((match) => match[1]);
const clean = sentence
.replace(/\[[A-Za-z]\w*\]/g, '')
.replace(/[。!?!?]+$/g, '')
.trim();
return clean
.split(/[;;]/)
.flatMap((part) => part.split(/,(?=因此|所以|这意味着|由此可见)/))
.map((claim) => ({ text: claim.trim(), refs }))
.filter((claim) => claim.text.length > 0);
});
}
function tokenize(text) {
return new Set(
[...segmenter.segment(text)]
.filter((item) => item.isWordLike)
.map((item) => item.segment.toLowerCase())
.filter((token) => !stopWords.has(token))
);
}
function similarity(left, right) {
const a = tokenize(left);
const b = tokenize(right);
const intersection = [...a].filter((token) => b.has(token)).length;
const union = new Set([...a, ...b]).size;
return union === 0 ? 0 : intersection / union;
}
function extractNumbers(text) {
return text.match(/\d+(?:\.\d+)?%?/g) ?? [];
}
function verifyClaim(claim) {
const base = {
claim: claim.text,
citations: claim.refs
};
if (claim.refs.length === 0) {
return {
...base,
status: 'missingCitation',
score: 0,
reason: '主张没有引用任何证据片段'
};
}
const candidates = claim.refs
.map((id) => ({ id, source: sources[id] }))
.filter((item) => item.source);
if (candidates.length !== claim.refs.length) {
return {
...base,
status: 'wrongCitation',
score: 0,
reason: '至少一个引用编号无法解析'
};
}
let best = { id: null, source: null, score: 0 };
for (const candidate of candidates) {
const score = similarity(claim.text, candidate.source.text);
if (score > best.score) {
best = { ...candidate, score };
}
}
const combinedEvidence = candidates
.map((item) => item.source.text)
.join(' ');
const evidenceNumbers = new Set(extractNumbers(combinedEvidence));
const unsupportedNumbers = extractNumbers(claim.text)
.filter((number) => !evidenceNumbers.has(number));
const inferenceCue = /因此|所以|必然|一定|所有|完全|意味着/.test(claim.text);
if (unsupportedNumbers.length > 0) {
return {
...base,
sourceId: best.id,
status: 'beyondEvidence',
score: best.score,
reason: `证据未出现数字:${unsupportedNumbers.join(', ')}`
};
}
if (best.score >= 0.42) {
return {
...base,
sourceId: best.id,
status: 'supported',
score: best.score,
reason: '主张与引用片段具有较高词项覆盖'
};
}
if (inferenceCue) {
return {
...base,
sourceId: best.id,
status: 'beyondEvidence',
score: best.score,
reason: '主张包含范围或推断词,但证据覆盖不足'
};
}
return {
...base,
sourceId: best.id,
status: 'wrongCitation',
score: best.score,
reason: '引用片段与主张缺少直接内容关联'
};
}
function actionFor(status) {
const actions = {
supported: 'accept',
missingCitation: 'retrieve',
wrongCitation: 'retrieve',
beyondEvidence: 'rewrite-or-review'
};
return actions[status];
}
const results = splitClaims(answer).map((claim) => {
const result = verifyClaim(claim);
return {
...result,
score: Number(result.score.toFixed(3)),
action: actionFor(result.status)
};
});
const canPublish = results.every((item) => item.status === 'supported');
console.table(results);
console.log(JSON.stringify({
decision: canPublish ? 'PASS' : 'HOLD',
totalClaims: results.length,
supportedClaims: results.filter(
(item) => item.status === 'supported'
).length,
results
}, null, 2));
运行命令:
node verifier.mjs
示例中的第一、第四条主张会得到直接支持;第二条引用了无关片段;第三条把特定版本扩展到所有旧版本;最后一条既没有引用,也引入了无法验证的 80%。因此整体决策是 HOLD,而不是因为部分句子正确就放行整段回答。
把验证结果接入 RAG 流水线
验证器的价值不只是一份评测报告,更重要的是驱动下一步动作。建议在生成之后、返回用户之前加入验证节点,并保留原始回答,不要让修订结果覆盖审计记录。
对于 missingCitation 和 wrongCitation,可以用主张文本重新生成检索查询。新片段返回后只重验受影响的主张,避免整篇答案反复生成。经过限定次数仍找不到证据时,应删除该主张,或者明确回复「现有资料不足以确认」。
对于 beyondEvidence,优先尝试收缩表达。例如把「所有旧版都支持」改为证据能够覆盖的「Node.js 20 提供该 API」。如果主张涉及价格、法律、医疗、安全配置等高风险内容,即使分数超过阈值,也可以强制进入人工复核。
人工复核界面至少应并排展示主张、引用片段、来源元数据和判定理由。只给审核员一个支持分数,会迫使其重新寻找上下文。来源 URL、文档版本、抓取时间和片段位置也应随证据保存,否则文档更新后很难重现当时的判断。
词项重合规则还有明确边界:它不擅长识别否定、同义改写和复杂条件。例如「允许」与「禁止」可能共享大量词项,却表达相反结论。因此可以增加否定词冲突、单位一致性、日期范围等确定性规则,再将低分或临界样本交给语义模型。阈值也应基于自身语料的标注集调整,不宜照搬示例中的 0.42。
总结
RAG 的可追溯性不能停留在「回答附带了几个链接」。可靠的验证流程需要把回答拆成独立主张,再检查每条主张是否被指定片段直接支持。
要点回顾:
- 引用标记只是索引,不等于证据已经支持结论;
- 逐主张验证能够区分直接支持、漏引、错引和证据外推;
- 数字、版本、范围词与因果词应作为独立风险信号;
- 验证结果应直接触发拒答、补充检索、收缩表述或人工复核;
- 规则验证适合作为透明的基础层,语义模型可以补充能力,但不能替代审计记录。
先让每条主张都能回答「依据是哪一段、具体支持了什么」,再讨论整篇回答是否可信,会比单纯统计引用数量更接近真实的 RAG 质量。
评论