增量同步只保证文档进了库,不保证命中切片在「现在」仍然有效,更不保证同一实体的多份说法一致。过期制度、未生效修订、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。这套后处理与具体向量库无关,也不依赖未经验证的评测数字。可以先接到制度、价格、接口变更这类强时效语料上,用误把过期句写进答案、以及漏掉现行冲突这两类错误来调阈值和权重。
评论