本页目录

有依据的知识库回答

把检索证据转换成可引用的回答,处理冲突、缺证、删除与权限,并分别评测检索和生成。

L2 · 能交付约 19 分钟阅读含示例、练习与验收

建议先读:向量、混合检索与重排序提示词与上下文工程结构化输出与校验

本页内容

RAG 不只是把文档贴进提示词#

检索增强生成通常包括两条链路:离线处理知识资料,在线根据问题找证据并生成答案。上一章关注有没有找到需要的材料,本章关注模型有没有正确使用这些材料。目标是让用户能判断答案依据、适用范围和不确定性,而不是给任意流畅文字配上几个链接。

前置是文档生命周期、检索指标与结构化校验。达标后,你应能区分“找不到证据”“找到但没答对”“引用存在却不支持结论”,并设计不同的处理策略。RAG 适合基于资料的说明和问答,不自动替代交易执行、身份核验或精确计算。

从问题到证据包#

首先明确用户问的对象、时间和范围。“能退款吗”缺少产品、购买时间和使用状态,直接检索可能召回一堆相关但不适用的制度。可以先澄清,也可以检索后明确列出仍缺条件。对话里的已确认对象应来自结构化状态,不能从模型上一轮猜测中自动继承。

检索返回后先检查权限、当前版本和有效期,再组装证据包。每条证据应有稳定 ID、原文、文档版本、来源位置与适用范围。模型看到的是受控的证据集合;引用链接由服务端依据证据 ID 生成,不能让模型自由编造文档 URL。

证据包不是简单拼接前十段。需要去重、保留标题和必要前后文、控制 token 预算,并避免把旧制度和现行制度混用。时间更晚的文件也不一定适用所有对象;可能只适用于新合同。冲突应该依据明确的生效规则解决,解决不了就展示冲突并说明缺少哪个条件。

回答需要哪些约束#

应用可以要求模型为每个关键结论附上 evidenceIds,并给出 answered、insufficient、conflict 等状态。answered 表示当前证据足够支持所述范围,不是宣称绝对真理。insufficient 需要说明缺少信息并给出下一步;conflict 应列出冲突来源,而不是凭语言风格选一个。

引用存在性可以用代码检查,但引用支持性更困难。“退款政策第十页”确实存在,不代表其中支持“任何订单均可退款”。你需要检查具体结论与具体证据的关系。可以通过人工标注、受控规则和模型辅助评价组合完成;不应把另一个模型的评分当成无误判的裁判。

保留证据原文还能帮助解释错误。用户点击来源时,应定位到本次引用版本或清楚提示版本变化。若资料已经删除或用户失去权限,原文入口应重新鉴权。过去回答中的敏感内容如何清除属于产品与数据治理设计,不能仅依赖检索层删除向量。

完整示例:只允许可核对的摘录回答#

环境:Node.js 22;文件 rag-answer.mjs;无依赖,运行 node rag-answer.mjs。本例使用固定检索候选与确定性摘录器,没有调用模型。验证器只证明“摘录原文且来源可见”,不具备判断自由改写或复杂推理是否正确的能力。

js
const documents = [
  { id: 'refund-v2-p1', documentId: 'refund', version: 2, tenant: 't1',
    groups: ['support'], active: true, title: '退款制度',
    text: 'S-42 未拆封订单可在签收后七天内申请退款。' },
  { id: 'refund-v1-p1', documentId: 'refund', version: 1, tenant: 't1',
    groups: ['support'], active: false, title: '旧退款制度',
    text: 'S-42 可在三十天内申请退款。' },
];
const activeVersions = new Map([['refund', 2]]);

function evidenceFor(tenant, groups) {
  return documents.filter(document => document.tenant === tenant &&
    document.active && document.groups.some(group => groups.includes(group)) &&
    activeVersions.get(document.documentId) === document.version);
}

function extractAnswer(question, evidence) {
  const matched = evidence.filter(item =>
    question.includes('S-42') && question.includes('退款') && item.text.includes('S-42'));
  if (matched.length === 0) {
    return { status: 'insufficient', claims: [], message: '当前可见资料不足以回答。' };
  }
  return {
    status: 'answered',
    claims: matched.map(item => ({ text: item.text, evidenceIds: [item.id] })),
  };
}

function verifyExtracts(answer, evidence) {
  const allowed = new Map(evidence.map(item => [item.id, item]));
  if (!['answered', 'insufficient'].includes(answer.status) ||
      !Array.isArray(answer.claims)) throw new Error('回答结构错误');
  if (answer.status === 'answered' && answer.claims.length === 0) {
    throw new Error('声称可回答却没有结论');
  }
  for (const claim of answer.claims) {
    if (typeof claim.text !== 'string' || !claim.text.trim() ||
        !Array.isArray(claim.evidenceIds) || claim.evidenceIds.length === 0) {
      throw new Error('缺少有效结论或引用');
    }
    for (const id of claim.evidenceIds) {
      const source = allowed.get(id);
      if (!source || !source.text.includes(claim.text)) {
        throw new Error('引用不可见或不是所引原文的摘录');
      }
    }
  }
  return answer;
}

const evidence = evidenceFor('t1', ['support']);
console.log(JSON.stringify(verifyExtracts(
  extractAnswer('S-42 的退款条件是什么?', evidence), evidence)));
console.log(JSON.stringify(extractAnswer('S-42 能退款吗?', evidenceFor('t2', ['support']))));
try {
  verifyExtracts({ status: 'answered', claims: [
    { text: 'S-42 无条件退款。', evidenceIds: ['refund-v2-p1'] },
  ] }, evidence);
} catch (error) {
  console.log('拦截:' + error.message);
}

预期第一条返回带七天与未拆封条件的结论,引用 refund-v2-p1;第二条返回 insufficient;最后打印拦截提示,拒绝有真实引用但不受其支持的“无条件退款”。旧版三十天条款不会进入证据包。

第一段把文档版本显式建模,避免靠模型识别新旧。evidenceFor 在生成前实施权限与版本限制。extractAnswer 故意只做原文摘录,使验证规则足够透明。verifyExtracts 检查每条引用可见且包含摘录文本,说明格式检查和证据检查应分开;若改为自由生成,就必须引入更完整的质量评价,不能沿用这个包含判断假装解决了语义蕴含。

检索与生成要分别评测#

评测检索时,不调用生成模型,只看必要证据是否进入最终证据包。记录召回率、排序位置、越权命中数和过时证据数。若本来就没找回“未拆封”条件,生成阶段再优秀也无法可靠回答。把这种失败算成“提示词不好”会误导优化。

评测生成时,可以给定人工确认完整的证据包,检查结论正确性、条件完整性、引用支持性、拒答或澄清是否合适。再用真实检索结果做端到端评测,观察两部分组合后的损失。三种测试各回答一个问题:找得对吗、用得对吗、连起来可靠吗。OpenAI 评测实践

准备问题时需包含无答案样本。如果所有题都能答,模型学会“任何情况都输出答案”也可能拿高分。还应加入资料冲突、旧政策、无权资料、相似产品编号和需要两个条件共同成立的题目。数据应保留来源与人工依据,防止评测标准本身变化却未被记录。

不要只用关键词命中判断答案正确。“可在七天内申请,但必须未拆封”与“七天内无条件退款”共享很多关键词,业务含义却不同。可以使用结论拆解或人工评分表,逐项看时间、对象、条件、例外与引用。模型辅助评估应先用人工样本校准一致性。

缓存和前端也参与证据一致性#

答案缓存至少要考虑问题、租户、权限范围、文档版本、提示版本与模型配置。只按问题文本缓存,会把管理员的答案复用给普通用户,也可能在资料更新后继续返回旧政策。权限撤销和文档删除时,需要让相应缓存失效。

生成期间资料也可能变化。高要求场景可以在输出前再次校验版本与权限;若发生变化,重新检索或提示资料更新。这个选择取决于业务一致性需求,但不能完全不定义。来源页面也必须单独鉴权,不因为用户拿到引用 ID 就放行。

前端应区分正在检索、正在生成、已完成和失败。引用不是装饰性脚注:应展示文档名、版本或日期、摘录和定位入口。资料不足时可提供具体澄清项;工具故障则显示本次查询失败,不能把技术失败伪装成“没有政策”。

先定义“这个问题需要哪些事实”#

知识库回答经常失败在需求没有被拆开。用户问“拆封后发现损坏还能退款吗”,至少包含产品、状态、损坏原因、申请时限和审核结果几个判断。检索到“七天内可申请”只解决了时间条件,不能推出拆封商品也符合资格。生成提示再强调准确,也无法把缺失的条件凭空补回来。

一个实用方法是先把问题转成信息需求,而不是直接生成答案。对于简单问法,可以用确定性规则提取产品编号和日期;对于复杂问法,可以让模型提出需要查询的子问题,再由应用验证范围。拆解结果仍是候选计划,不是事实。例如模型把“损坏”改写成“质量问题”时,必须保留这个语义差别,不能在后续步骤里当作用户已经确认。

多轮问答尤其需要保存已确认条件。用户第一轮说“我买的是 S-42”,后面只问“拆封了呢”,系统应把产品编号从结构化会话状态带入,而不是依赖最近几条文本恰好包含它。若用户随后改成 S-43,就要更新状态并使基于旧产品的检索与缓存失效。上下文继承应像表单状态一样有来源和修改规则。

问题还可能缺少决定性条件。此时应比较两种成本:继续检索是否能获取缺失事实,还是必须向用户澄清。公司制度可以回答申请规则,无法知道用户手里的商品是否由人为造成损坏。让模型多查几轮不能解决资料本身没有的信息;清楚提出一个必要问题,通常比输出长篇泛泛说明更有效。

证据不是“相关文本”的另一种叫法#

相关性表示一段材料与问题主题接近,支持性表示它能证明某个具体结论。产品介绍可能与退款问题语义相近,却不包含任何退款规则;旧制度可能直接包含答案,但已经不适用。选择证据时要同时考虑主题、对象、时间、权限和权威来源,不能只按一个相似度分数排序后全部交给模型。

证据包应保留足以恢复原意的边界。若一段写“以下条件同时满足”,下一段才列条件,单独取其中一条就可能改变含义。表格也要带列名,脚注中的例外不能因切块被丢掉。对规则性文档,标题路径和相邻条件往往比增加更多不相关片段更有价值。

冲突处理需要业务规则。可以比较文档生效日期、适用合同版本、发布部门和覆盖关系,但“时间更新”不总是等于“优先”。新文档可能只适用于某个区域。若系统没有明确的优先规则,应输出冲突状态并展示两条证据的适用范围,让用户补充信息或转人工,而不是按置信度编出一个唯一答案。

模型生成前可以对证据做压缩,但压缩也是潜在损失环节。保留金额、时间、否定词、条件词、版本与引用位置,并对压缩结果做抽样核对。把“仅在未拆封时可申请”压成“可申请”,字数确实减少了,业务含义却改变了。压缩预算应围绕事实完整性评测,不以压缩率越高越好为目标。

示例二:权限变化为什么必须影响缓存#

保存为 rag-cache.mjs,Node.js 22.22+,无需依赖,运行 node rag-cache.mjs。本例构建一个极小的知识问答服务:身份从服务端用户表获得,生成器只拼接可见原文;缓存使用权限和资料修订号。它没有调用模型或数据库,修订号更新是本进程同步操作。

rag-cache.mjs
import assert from 'node:assert/strict';

const users = new Map([['u1', { tenant: 't1', groups: ['support'], revision: 1 }]]);
const docs = new Map([['d1', {
  tenant: 't1', groups: ['support'], active: true, revision: 1,
  text: 'S-42 未拆封订单可在签收后七天内申请退款。',
}]]);
let corpusRevision = 1;
const cache = new Map();
const readable = (user, doc) => doc.active && doc.tenant === user.tenant &&
  doc.groups.some(group => user.groups.includes(group));

async function answer(actorId, question, beforePublish = async () => {}) {
  const user = users.get(actorId);
  if (!user) throw new Error('UNAUTHENTICATED');
  const stamp = { user: user.revision, corpus: corpusRevision };
  const key = JSON.stringify([actorId, user.tenant, stamp, question]);
  const cached = cache.get(key);
  if (cached) return { ...cached, cacheHit: true };
  const evidence = [...docs].filter(([_id, doc]) => readable(user, doc) &&
    question.includes('S-42') && doc.text.includes('S-42'))
    .map(([id, doc]) => ({ id, revision: doc.revision, text: doc.text }));
  const result = evidence.length
    ? { status: 'answered', text: evidence.map(x => x.text).join('\n'), evidence }
    : { status: 'insufficient', text: '当前可见资料不足。', evidence: [] };
  await beforePublish(); // 模拟生成期间权限或资料被其他请求修改。
  const current = users.get(actorId);
  if (!current || current.revision !== stamp.user || corpusRevision !== stamp.corpus ||
      evidence.some(item => !docs.has(item.id) || !readable(current, docs.get(item.id)) ||
        docs.get(item.id).revision !== item.revision)) {
    throw new Error('EVIDENCE_CHANGED');
  }
  cache.set(key, result);
  return { ...result, cacheHit: false };
}

const question = 'S-42 如何申请退款?';
assert.equal((await answer('u1', question)).status, 'answered');
assert.equal((await answer('u1', question)).cacheHit, true);
users.set('u1', { tenant: 't1', groups: [], revision: 2 });
assert.equal((await answer('u1', question)).status, 'insufficient');

users.set('u1', { tenant: 't1', groups: ['support'], revision: 3 });
await assert.rejects(() => answer('u1', question, async () => {
  docs.get('d1').active = false;
  docs.get('d1').revision++;
  corpusRevision++;
}), /EVIDENCE_CHANGED/);
assert.equal((await answer('u1', question)).status, 'insufficient');
console.log('通过:缓存命中、撤权失效、生成期间删除拦截、删除后拒答');

关键不是使用 Map,而是缓存键里包含影响答案可见性的状态版本。只用 question 会让不同用户共享结果;加入 actorId 但不加入权限修订号,用户被撤权后仍可能取到过去有权时生成的答案。资料修订号使更新与删除影响后续缓存查找,即使旧缓存项尚未物理清理,也不会继续被选择。

beforePublish 模拟的是生成期间的竞态。检索时有权不等于输出时仍有权;对敏感知识,发布前应再次确认权限和证据版本。这里采用“变化就中止”的保守策略,真实产品也可以自动重新检索一次,但必须设限,避免频繁更新时无限重启。把变化原因返回为明确状态,比把它伪装成“模型超时”更容易解释。

这个修订号方案仍有实现前提:任何权限或资料变更都必须更新对应版本,缓存读取与版本读取应有一致性约定。多服务实例各自维护内存 revision 并不可靠,需要共同数据源或可靠失效事件。高频知识库可以采用文档依赖集合、租户版本或权限策略版本降低失效范围,选择哪种要比较复杂度、缓存命中率和撤权生效时限。

引用如何从内部证据变成用户可验证的链接#

内部 evidenceId 应指向本次实际进入上下文的证据,不是让模型自由构造的文件路径。生成后先检查每个引用 ID 属于允许集合,再由服务端映射文档标题、版本、页码和访问链接。URL 生成属于应用职责;模型即使输出看似合理的内部地址,也不能直接作为可信入口。

一句话可能包含多个结论,需要多个引用。比如“七天内申请,拆封后仅质量问题适用”,时间和例外可能来自不同条款。把全部引用统一放在段落末尾,用户很难知道哪条材料支持哪部分。可以让结构化输出把结论拆成 claims,每个 claim 带 evidenceIds,再由 UI 合并展示并保留定位关系。

引用验证至少有存在性、可见性、支持性和完整性四个角度。存在性可以查集合;可见性查权限;支持性判断证据能否支撑结论;完整性检查有没有关键结论缺少依据。前两项适合确定性代码,后两项常需要人工标注或经校准的辅助评估。不能因为两个确定性检查通过,就把剩下两项当作已经完成。

用户打开引用时还应重新鉴权。历史回答可能保存的是旧版条文,但原文入口不能绕过当前权限。产品可以显示“本回答依据当时的第二版资料;当前版本已更新”,并提供重新生成;如果资料因安全原因被删除,应按组织策略处理历史快照与聊天内容。单纯删向量不能自动删除已经发送到浏览器或导出文件里的文本。

三层评测各自回答一个问题#

第一层检索评测把生成器拿掉,只看必要证据有没有进入最终证据包。这里应使用预算裁剪后的集合,而不只看检索引擎最初返回的一百条结果。若关键条款最初召回、后来被上下文裁剪删掉,端到端仍然无法正确作答;单测初始召回会掩盖这一损失。

第二层生成评测给定人工确认完整的证据,检查模型是否遗漏条件、改变否定词、添加无依据结论或引用错误。它隔离生成阶段,使你能比较提示与型号而不受检索波动干扰。第三层端到端评测使用真实检索结果,衡量两者组合后的效果。三套结果应共用问题定义与事实标注,但不能混成一个含糊总分。

无答案问题需要单独评价。如果十道题里两道本来没有证据,系统对它们清楚说明不足应该得分;强行编出流畅回答应该扣分。也要防止模型过度拒答:所有问题都说“资料不足”可能完全安全,却没有实际价值。因此同时看有据任务的完成率、无据任务的适当拒答率与错误自信率。

评分标准应把“语言好听”与“事实正确”分开。对退款例子,可以标注事实单位:产品为 S-42、未拆封、签收后七天、允许申请而非保证通过。生成答案必须保留这些关系,关键词数量不是合适替代。人工标注有分歧时,先修订题意或评分规则,不要用平均分隐藏产品语义不清的问题。

练习:写一个能区分漏检与误答的评测器#

下面的参考实现采用人工标注的事实 ID,不尝试自动理解任意自然语言。它用于学会组织评测数据与指标。将事实 ID 与真实文本对应的工作仍需人工完成,未来可以加入模型辅助评分,但应先用人工样本校准。保存为 rag-eval.mjs,Node.js 22.22+,无依赖,执行 node rag-eval.mjs

完整参考实现:分离召回、完整性与引用支持
rag-eval.mjs
import assert from 'node:assert/strict';

const evidenceFacts = {
  e1: new Set(['within-seven-days']),
  e2: new Set(['unopened-required']),
};
const requiredFacts = new Set(['within-seven-days', 'unopened-required']);

function retrievalRecall(retrieved, goldEvidence) {
  const unique = new Set(retrieved);
  return goldEvidence.size === 0 ? null :
    [...goldEvidence].filter(id => unique.has(id)).length / goldEvidence.size;
}

function gradeClaims(claims, availableEvidence, required) {
  const visible = new Set(availableEvidence);
  const supportedFacts = new Set();
  let unsupported = 0;
  for (const claim of claims) {
    // 事实标注与引用均来自本题固定合同,而非运行模型自行打分。
    const supported = Array.isArray(claim.evidenceIds) && claim.evidenceIds.length > 0 &&
      claim.evidenceIds.every(id => visible.has(id)) &&
      claim.evidenceIds.some(id => evidenceFacts[id]?.has(claim.fact));
    if (supported) supportedFacts.add(claim.fact);
    else unsupported++;
  }
  const covered = [...required].filter(fact => supportedFacts.has(fact)).length;
  return {
    completeness: required.size ? covered / required.size : null,
    unsupportedClaims: unsupported,
  };
}

const goodClaims = [
  { fact: 'within-seven-days', evidenceIds: ['e1'] },
  { fact: 'unopened-required', evidenceIds: ['e2'] },
];
const wrongClaims = [
  { fact: 'within-seven-days', evidenceIds: ['e1'] },
  { fact: 'unconditional-refund', evidenceIds: ['e2'] },
];
const gold = new Set(['e1', 'e2']);
assert.equal(retrievalRecall(['e1'], gold), 0.5);
assert.deepEqual(gradeClaims(goodClaims, ['e1', 'e2'], requiredFacts),
  { completeness: 1, unsupportedClaims: 0 });
assert.deepEqual(gradeClaims(wrongClaims, ['e1', 'e2'], requiredFacts),
  { completeness: 0.5, unsupportedClaims: 1 });
assert.deepEqual(gradeClaims(goodClaims, ['e1'], requiredFacts),
  { completeness: 0.5, unsupportedClaims: 1 });

console.log(JSON.stringify({
  retrievalOnly: { recall: retrievalRecall(['e1'], gold) },
  generationWithGold: gradeClaims(goodClaims, ['e1', 'e2'], requiredFacts),
  endToEndWithMissingEvidence: gradeClaims(goodClaims, ['e1'], requiredFacts),
}, null, 2));

结果中,给定完整证据时生成可以达到完整性一;真实检索只返回 e1 时,召回率为零点五,引用 e2 的结论也不再合法。这说明端到端失败不一定意味着需要换生成模型,可能只需修复切块、过滤或候选裁剪。反过来,完整证据下仍生成“无条件退款”,就应修复生成约束或模型选择,而不是继续增加 topK。

练习的下一步是加入没有答案的题目,明确 expectedStatus,并分别统计适当拒答与错误回答。不要把 goldEvidence 为空时的 recall 强行记为一;本例返回 null,表示该指标不适用,需要使用拒答指标。平均指标时应排除不适用项并报告样本量,否则不同数据集的分数无法公平比较。

一个真实排错顺序:答案为什么遗漏了例外条款#

从用户问题和期望事实开始,先检查原文中是否真的存在例外。如果原文没有,问题应进入澄清或资料补充;如果原文有,再看解析后是否保留了脚注与表格列名。不要先动模型提示,因为上游已经丢失的信息无法在下游恢复。

解析正确后,检查块边界与检索结果。例外可能单独成为一个没有产品名的块,导致查询产品时只召回主条款。可以把标题路径补入索引文本、建立父子块关系,或召回后扩展相邻必要上下文。但扩展不能跨越权限与版本边界,也不能无条件拼接整篇文档,让噪声再次淹没关键内容。

如果证据包完整,继续检查生成输出。模型可能将“可申请”写成“可退款”,或者把“同时满足”误读成“任一满足”。这时用固定证据重放,比较结构化 claims、提示版本与输出预算,才能找到稳定修复。若只是输出被截断,增大可见输出预算或减少无关铺垫可能足够,不需要更换检索系统。

最后把这个失败案例加入回归集,并记录资料版本、用户权限、候选 ID 与最终证据包。只保存最终问答文本会失去排错所需的中间状态。完成修复后同时跑检索、给定证据生成和端到端检查,确认改善发生在预期层,也没有让无答案与越权样本退步。

验收与三个自测#

验收包括有据回答、无据拒答、条件不丢失、引用可验证、旧版本不混入、权限及删除影响缓存,并提供检索、给定证据生成、端到端三套结果。真实自由生成的事实性与用户体验尚未在离线例子中验证。

  1. 问:有引用就等于有依据吗?答:不等于,还需检查引用是否支持对应结论。
  2. 问:为什么要给定正确证据单测生成?答:这样可以把检索失败与生成误用分开定位。
  3. 问:删除向量后是否所有旧答案都会消失?答:不会,缓存、会话与导出内容有独立生命周期。

本章示例验证记录#

已离线运行摘录回答、权限缓存与分层评测三个程序,覆盖不可见引用、撤权后缓存失效、生成期间删除、漏检与无依据断言。事实标识由人工 fixture 标注,未使用真实生成模型或自动语义评审器。

官方资料#

参考 OpenAI RetrievalFile search评测最佳实践。本章采用自建证据包说明机制,不宣称托管检索默认具备所有业务治理规则。

原有课程整理于 2026-09-10;Node / Electron 扩充于 2026-09-11。示例环境与验证范围以正文为准。
原创中文学习手册,阅读结构参考 Vue 文档;非 Vue 官方教材。
下载本章 Markdown

支持中文和英文全文搜索 · ↑ ↓ 选择 · Enter 打开 · Esc 关闭