增量同步只保证文档进了库,不保证命中切片在「现在」仍然有效,更不保证同一实体的多份说法一致。过期制度、未生效修订、FAQ 与正式手册互相打架,都会在相似度很高时把错误上下文送进提示词。本文用 Node.js 给切片补上生效窗口、过期降权、来源优先级和冲突并列,并把每次取舍写成可回放记录。

检索命中只说明相似,不说明仍有效

向量检索给出的是语义距离,不是事实状态。一条切片进入 Top-K,只说明它和查询在嵌入空间里靠近;它可能已经废止,可能尚未生效,也可能和另一条同样高分的切片互相否定。把命中结果直接拼进提示词,等于默认「相似即为当前为真」。这个默认在制度、价格、接口变更这类强时效语料上经常不成立。

增量同步解决的是另一件事:源文档的增删改有没有进入索引。它通常不携带「这条规则从哪天开始、哪天作废」,也不会在检索阶段比较来源权威性。旧版员工手册、新版制度、intranet FAQ 可以同时在库里,查询「年假几天」时三条都可能被召回。同步任务成功,只说明三份文档都在;不说明哪一份对今天的问题负责。

因此需要一层与向量库解耦的裁决,放在检索之后、组装提示词之前。它只处理已经召回的候选:先看生效窗口,再看来源优先级,发现冲突就并列呈现并留下记录。这层不替代检索,也不宣称能消除模型编造;它只避免过期段落和矛盾段落以「现行事实」的口吻进入上下文。

切片元数据:生效窗口与来源

切片除了正文和检索分,至少要带四类字段。entityId 标识同一业务对象,例如「年假制度」而不是某次抓取的文件名。source 与 sourcePriority 表达来源,数字越大越权威,正式手册应高于 FAQ 摘录。effectiveFrom / effectiveTo 表达生效窗口,effectiveTo 为 null 表示尚未废止。时间一律用带时区的 ISO 8601,比较时转成毫秒,不要用字符串排序,以免 +08:00 和 Z 混用时排错。

权重系数只是可调起点,不是测出来的最优值。retrievedScore 仍占主要份额,时效和权威做修正,避免完全丢掉检索信号。下面的脚本不依赖任何向量数据库,假设分数已由上游给出;接到任意 Top-K 之后即可运行。

// rag-validity.mjs
import { writeFileSync } from 'node:fs';

const asOf = new Date('2026-03-22T10:00:00+08:00');
const query = '入职满一年有几天年假?';

const chunks = [
  {
    id: 'leave-2024',
    entityId: 'leave-policy',
    source: 'hr-handbook-2024',
    sourcePriority: 40,
    effectiveFrom: '2024-01-01T00:00:00+08:00',
    effectiveTo: '2025-12-31T23:59:59+08:00',
    retrievedScore: 0.86,
    content: '年假:入职满一年员工每年享有 10 天年假。',
  },
  {
    id: 'leave-2026',
    entityId: 'leave-policy',
    source: 'hr-handbook-2026',
    sourcePriority: 90,
    effectiveFrom: '2026-01-01T00:00:00+08:00',
    effectiveTo: null,
    retrievedScore: 0.84,
    content: '年假:入职满一年员工每年享有 12 天年假。',
  },
  {
    id: 'leave-faq',
    entityId: 'leave-policy',
    source: 'intranet-faq',
    sourcePriority: 20,
    effectiveFrom: '2026-01-15T00:00:00+08:00',
    effectiveTo: null,
    retrievedScore: 0.81,
    content: '年假:入职满一年员工每年享有 10 天年假。',
  },
  {
    id: 'remote-2026',
    entityId: 'remote-work',
    source: 'hr-handbook-2026',
    sourcePriority: 90,
    effectiveFrom: '2026-01-01T00:00:00+08:00',
    effectiveTo: null,
    retrievedScore: 0.47,
    content: '远程办公需事先申请,每周最多两天。',
  },
];

function toTime(value) {
  return value == null ? null : new Date(value).getTime();
}

function validityState(chunk, at) {
  const from = toTime(chunk.effectiveFrom);
  const to = toTime(chunk.effectiveTo);
  const t = at.getTime();
  if (Number.isNaN(t)) throw new Error('invalid asOf');
  if (from != null && Number.isNaN(from)) {
    throw new Error(`invalid effectiveFrom: ${chunk.id}`);
  }
  if (to != null && Number.isNaN(to)) {
    throw new Error(`invalid effectiveTo: ${chunk.id}`);
  }
  if (from != null && t < from) return 'not_yet';
  if (to != null && t > to) return 'expired';
  return 'active';
}

function validityWeight(state) {
  if (state === 'active') return 1;
  if (state === 'expired') return 0.15;
  return 0.35;
}

function annotate(chunk, at) {
  const state = validityState(chunk, at);
  const recency = validityWeight(state);
  const authority = Math.min(Math.max(chunk.sourcePriority, 0), 100) / 100;
  const finalScore = chunk.retrievedScore * (0.55 + 0.25 * recency + 0.2 * authority);
  return { ...chunk, state, finalScore };
}

function sameClaim(a, b) {
  return a.content.trim() === b.content.trim();
}

function resolveConflicts(items) {
  const groups = new Map();
  for (const item of items) {
    const list = groups.get(item.entityId) ?? [];
    list.push(item);
    groups.set(item.entityId, list);
  }

  const conflicts = [];
  for (const [entityId, group] of groups) {
    const unique = [];
    for (const item of group) {
      if (!unique.some((row) => sameClaim(row, item))) unique.push(item);
    }
    if (unique.length <= 1) continue;
    unique.sort(
      (a, b) => b.sourcePriority - a.sourcePriority || b.finalScore - a.finalScore,
    );
    conflicts.push({
      entityId,
      claims: unique.map((row) => ({
        id: row.id,
        source: row.source,
        sourcePriority: row.sourcePriority,
        state: row.state,
        content: row.content,
      })),
    });
  }
  return conflicts;
}

function decide(queryText, candidateChunks, at) {
  const annotated = candidateChunks.map((chunk) => annotate(chunk, at));
  const accepted = [];
  const rejected = [];

  for (const item of annotated) {
    if (item.retrievedScore < 0.5) {
      rejected.push({ id: item.id, reason: 'low_similarity', state: item.state });
      continue;
    }
    if (item.state === 'expired') {
      rejected.push({
        id: item.id,
        entityId: item.entityId,
        source: item.source,
        reason: 'expired',
        state: item.state,
        content: item.content,
      });
      continue;
    }
    if (item.state === 'not_yet') {
      rejected.push({
        id: item.id,
        entityId: item.entityId,
        source: item.source,
        reason: 'not_yet_effective',
        state: item.state,
        content: item.content,
      });
      continue;
    }
    accepted.push(item);
  }

  accepted.sort((a, b) => b.finalScore - a.finalScore);

  return {
    query: queryText,
    asOf: at.toISOString(),
    accepted: accepted.map((item) => ({
      id: item.id,
      entityId: item.entityId,
      source: item.source,
      sourcePriority: item.sourcePriority,
      state: item.state,
      retrievedScore: item.retrievedScore,
      finalScore: Number(item.finalScore.toFixed(4)),
      content: item.content,
    })),
    rejected,
    conflicts: resolveConflicts(accepted),
  };
}

function formatForPrompt(decision) {
  const lines = [
    `查询时间:${decision.asOf}`,
    `问题:${decision.query}`,
    '',
    '现行证据:',
  ];
  const conflictIds = new Set(
    decision.conflicts.flatMap((row) => row.claims.map((claim) => claim.id)),
  );

  if (decision.accepted.length === 0) {
    lines.push('- (无仍在生效窗口内的命中)');
  }
  for (const item of decision.accepted) {
    const tag = conflictIds.has(item.id) ? '现行·存在冲突' : '现行';
    lines.push(`- 【${tag}】来源 ${item.source}|${item.content}`);
  }

  if (decision.conflicts.length > 0) {
    lines.push('', '冲突说明:同一实体存在多条不一致表述,请并列引用,不要自行合并。');
    for (const conflict of decision.conflicts) {
      lines.push(`- 实体 ${conflict.entityId}:`);
      for (const claim of conflict.claims) {
        lines.push(
          `  - ${claim.source}(优先级 ${claim.sourcePriority}):${claim.content}`,
        );
      }
    }
  }

  const expired = decision.rejected.filter((row) => row.reason === 'expired');
  if (expired.length > 0) {
    lines.push('', '已排除的过期命中(不得当作现行制度):');
    for (const row of expired) {
      lines.push(`- ${row.source}|${row.content}`);
    }
  }

  return lines.join('\n');
}

const decision = decide(query, chunks, asOf);

if (!decision.accepted.some((row) => row.id === 'leave-2026')) {
  throw new Error('expected current handbook chunk');
}
if (decision.accepted.some((row) => row.id === 'leave-2024')) {
  throw new Error('expired chunk must not enter current evidence');
}
if (!decision.rejected.some((row) => row.id === 'leave-2024' && row.reason === 'expired')) {
  throw new Error('expired chunk must be logged');
}
if (!decision.conflicts.some((row) => row.entityId === 'leave-policy')) {
  throw new Error('faq and handbook 2026 should conflict');
}

writeFileSync('rag-decision.json', JSON.stringify(decision, null, 2), 'utf8');
console.log(formatForPrompt(decision));

保存后执行 node rag-validity.mjs。脚本会打印提示词证据,并在当前目录写下 rag-decision.json。固定 asOf 是有意的:回放必须能指定查询时刻,而不是隐式使用机器当前时间。

过期降权,且不要把过期制度写进现行证据

硬过滤与降权分工

对「现在怎么办」类问题,过期切片不应再以现行口吻进入提示词。上面脚本里,过期和未生效都会从 accepted 里拿掉,只保留窗口内且检索分过线的候选。降权仍然有用:综合分会写入日志,后续若你改成「历史对照模式」,可以用同一套分数决定旧切片的排列,而不必另写一套排序。

两类处理不要混成一句「过期就乘个系数还留在上下文里」。系数只能改变排序,不能改变模型是否把它读成当前规则。只要过期正文和现行正文同时出现且未加标签,模型仍可能选用分数更高的旧句——示例里 2024 手册的检索分甚至高于 2026 手册。必须先按窗口切开,再在现行集合里排序。

尚未生效的修订同样不能提前当成已实施。not_yet 与 expired 在业务上方向相反,但对「当前为真」这个问题,结论相同:不能进现行证据。低相似结果直接拒绝;0.5 只是示例切断点,要按你的检索器分数分布校准,不要把这里的数字当成通用阈值。

过期命中仍应出现在记录和提示词的排除区,并写明「不得当作现行制度」。这是给排障用的,不是给答案用的。若产品确实要回答「去年怎么规定」,应另开历史模式,把 asOf 拨到当时,而不是在现行问答里混入旧句。

冲突并列:高优先级排序,但不静默覆盖

同一 entityId 下,规范化后的正文不一致,就视为冲突。示例里 2026 手册说 12 天,FAQ 仍说 10 天,两条都在生效窗口内,检索分也都过线。这不是同步失败,是多来源在同一时刻同时为真地存在着两种说法。

静默只留最高优先级,问答会看起来干净,但也删掉了「库里其实有相反说法」这一信息。出了错你很难判断是检索错了,还是下游把冲突吞掉了。并列策略是:现行冲突全部进入提示词,先按 sourcePriority、再按综合分排序,并明确要求不要自行合并。手册会排在 FAQ 前面,但 FAQ 仍在,便于回答「正式制度与内部问答不一致」。

是否同一说法,这里只用去首尾空白后的精确匹配。生产里可以先做连续空白折叠、全角数字归一;这些变换必须是纯函数,才能回放。不要在没有把握时再调一次模型来判冲突——那会把不可复现的判断藏进另一次生成,和「可回放」直接相反。

过期的 10 天与现行的 12 天也是矛盾,但过期句已经退出现行集合,只出现在排除区。冲突清单只覆盖仍可能被当作当前事实的条目,避免把「历史对照」和「现行打架」混成同一类警告。

把决策写入可回放记录

每次裁决输出三件事:给模型的证据文本、结构化的 accepted / rejected / conflicts、落盘 JSON。asOf 必须是查询时刻。回放时用同一批切片和同一个 asOf,应得到同一份记录。这样才能分清某次错误回答是检索召回了不相关内容、窗口字段填错、还是冲突被模型擅自合并。

不要把这层裁决只写成提示词里的自然语言规则。规则写在提示词里不可测试;写成纯函数才能对固定输入做断言。脚本末尾几条检查就是最小回放:现行手册必须留下,过期手册不得进入 accepted,FAQ 与新手册必须形成冲突。接入线上时,把 rag-decision.json 和最终提示词一并归档,比只存模型输出更有用。

字段要在切片入库时写入,而不是检索时猜测。生效窗口来自制度的发布日期和废止日期,来源优先级来自你维护的来源表。这两类数据缺失时,宁可让切片进不了现行证据,也不要补一个「看起来合理」的默认有效期。缺元数据本身就应记入 rejected,原因写成 missing_validity,便于回头补数据。

总结

检索命中只是候选,不是「当前为真」。增量同步管的是库里有没有文档,不管切片是否仍在生效窗口,也不管多来源是否打架。给切片补上时间窗和来源优先级,过期与未生效退出现行证据,窗口内的冲突并列而不是覆盖,再把取舍写成可回放 JSON。这套后处理与具体向量库无关,也不依赖未经验证的评测数字。可以先接到制度、价格、接口变更这类强时效语料上,用误把过期句写进答案、以及漏掉现行冲突这两类错误来调阈值和权重。