RAG 回答带有引用,并不代表答案有依据:引用可能与主张无关,也可能只支持其中一部分。本文实现一条可运行的 Node.js 流水线,逐主张识别直接支持、错引、漏引与超出证据的推断,并将结果用于拒答、补充检索和人工复核。

为什么不能只检查有没有引用

很多 RAG 系统把「回答中包含引用编号」当作可信度信号,例如要求模型在句末输出 [S1]。这种检查只能证明模型生成了一个引用标记,无法回答三个更重要的问题:

  1. 引用片段是否真的讨论了当前主张;
  2. 片段是否直接支持主张中的数字、范围和因果关系;
  3. 一个长句中的每个独立主张是否都有依据。

例如,证据只说「Node.js 20 提供全局 fetch API」,回答却写成「因此所有旧版 Node.js 都不需要第三方 HTTP 客户端」。虽然引用编号一致,但「所有旧版」和「不需要第三方客户端」都没有被原片段支持。这不是漏引,而是超出证据的推断。

验证对象因此不应是整段回答,而应是尽可能原子的主张。每条主张至少保留以下信息:主张文本、引用编号、命中的证据片段、支持分数、判定状态和后续动作。引用编号只是连接回答与证据的外键,证据文本才是判断依据。

逐主张验证的判定模型

一条基础流水线可以分成四步:

  1. 按句号、分号及推断连接词拆分回答;
  2. 提取 [S1] 形式的引用,并解析到实际片段;
  3. 比较主张和证据的关键词、数字及范围限定;
  4. 输出结构化状态,而不是只给一个总分。

本文使用四种核心状态:

状态含义建议动作
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 流水线

验证器的价值不只是一份评测报告,更重要的是驱动下一步动作。建议在生成之后、返回用户之前加入验证节点,并保留原始回答,不要让修订结果覆盖审计记录。

对于 missingCitationwrongCitation,可以用主张文本重新生成检索查询。新片段返回后只重验受影响的主张,避免整篇答案反复生成。经过限定次数仍找不到证据时,应删除该主张,或者明确回复「现有资料不足以确认」。

对于 beyondEvidence,优先尝试收缩表达。例如把「所有旧版都支持」改为证据能够覆盖的「Node.js 20 提供该 API」。如果主张涉及价格、法律、医疗、安全配置等高风险内容,即使分数超过阈值,也可以强制进入人工复核。

人工复核界面至少应并排展示主张、引用片段、来源元数据和判定理由。只给审核员一个支持分数,会迫使其重新寻找上下文。来源 URL、文档版本、抓取时间和片段位置也应随证据保存,否则文档更新后很难重现当时的判断。

词项重合规则还有明确边界:它不擅长识别否定、同义改写和复杂条件。例如「允许」与「禁止」可能共享大量词项,却表达相反结论。因此可以增加否定词冲突、单位一致性、日期范围等确定性规则,再将低分或临界样本交给语义模型。阈值也应基于自身语料的标注集调整,不宜照搬示例中的 0.42

总结

RAG 的可追溯性不能停留在「回答附带了几个链接」。可靠的验证流程需要把回答拆成独立主张,再检查每条主张是否被指定片段直接支持。

要点回顾:

  • 引用标记只是索引,不等于证据已经支持结论;
  • 逐主张验证能够区分直接支持、漏引、错引和证据外推;
  • 数字、版本、范围词与因果词应作为独立风险信号;
  • 验证结果应直接触发拒答、补充检索、收缩表述或人工复核;
  • 规则验证适合作为透明的基础层,语义模型可以补充能力,但不能替代审计记录。

先让每条主张都能回答「依据是哪一段、具体支持了什么」,再讨论整篇回答是否可信,会比单纯统计引用数量更接近真实的 RAG 质量。