撤回发生在重建途中,旧索引还能切回来吗
知识库的文档 A 已撤回,旧索引清理任务也已经执行。几分钟后,全量重建完成,把一个更早的快照切成在线索引。A 的旧片段又出现在候选中;与此同时,由 A 与 B 合成的摘要没有任何一条记录被显式删除。
这是本文构造的故障情境,不是真实泄露记录。它与"图片删除后,后台生成任务又上传一次"有共同的一致性背景,但检索系统多了两项必须处理的事实:一次重建包含多个时刻的知识版本,一条摘要可能依赖多个来源。删掉一个对象键无法回答这两件事。
本例从序号 10 的快照开始,在 11 撤回 A、12 收缩 B 的权限、13 更新 C,随后才允许重建交付。相同的四个候选对快照全部可读,对当前权威状态却全部失效。索引代次更晚、构建结束更晚,都不代表其中的知识更有效;在线读取应以当前权威版本和完整来源血缘判断,重建还需追齐快照之后的变更。
快照正确,读到的仍可能是过去

PostgreSQL 17 的事务隔离文档说明,Repeatable Read 的连续查询看到同一事务快照,而不会自动看到其他事务后来提交的变化。这是稳定读取的有用保证,却不能让长时间构建作业自动获得后来发生的撤回。本文借此说明"稳定快照"与"当前有效"分别回答不同问题;下面的程序并未运行 PostgreSQL。
构造输入的所有人名、ID 和状态如下,序号是同一条本地日志的顺序号,不是跨机器时间戳:
| 时刻 | 权威状态发生的变化 | 旧快照仍保留什么 |
|---|---|---|
| 10 | A、B、C 均为内容版本1、策略版本1,alice和bob可读 | 三份有效来源及双源摘要AB |
| 11 | A 撤回,内容/策略推进到2 | A-v1 以及依赖它的摘要 |
| 12 | B 内容仍是1,策略改为2,仅alice可读 | B旧策略1与摘要中的旧依赖 |
| 13 | C 内容更新到2,策略不变 | C-v1 |
索引中的"存在"只表示构建输入包含这个候选。它没有资格证明撤回没有发生,更不能把候选自带的 active=true 当成当前权威状态。
这里把文档版本与策略版本分开。B 的正文一字没变,bob 仍然失去读取权;C 的权限没变,旧正文仍可能回答错。若只比较内容哈希,B 的变化会漏掉;若只检查ACL,C-v1会被错当成当前知识。
索引的新代次不能替来源重新发号
重建任务可以拥有自己的 index_generation,但不能因为它今天完成,就把其中的文档都当成今天的新内容。至少有三种身份需要分别记录:索引构建代次、来源内容版本、来源策略版本。前者回答"这次查询走了哪一套索引",后两者回答"命中的知识及其可读范围是否仍有效"。
这三个值混在同一个 version 字段里,排障时容易得到相反结论。例如新索引代次是20,比在线代次19大,但它读取的A仍为内容1;权威状态已经撤回到内容2。用20大于19作为可用条件,只是在比较两个构建作业,并没有比较A的知识状态。本文代码没有生成索引代次,故不能从示例的13推导任何索引产品的代次规则。
实际候选宜保留能追到来源的稳定标识,不从文件名或片段文本临时猜测。同一原文被重新切块后,chunk ID可能不同,撤回仍应能按来源定位它们。若仅在旧chunk列表删记录,而重建生成了新chunk身份,就会出现"旧列表已经空,但来源仍被重新召回"。这是源身份映射要解决的问题,本例用A、B、C固定ID简化了它,没有运行真实解析和切块。
在模型看见正文之前检查什么
候选携带自己的来源ID、内容版本和策略版本。本例所有来源元数据由可信测试输入建立,不能让模型随意删改依赖边。权威表保存每份来源的当前版本、策略、可读主体和撤回状态。
检查顺序是:来源是否存在,是否撤回,内容版本是否一致,策略版本是否一致,当前主体是否可读。缺失权威记录或血缘也拒绝。这样会牺牲可用候选数量,但不会把"查不到当前授权"解释成"沿用以前许可"。本文选的是保守策略,不是某个向量库的通用默认行为。
策略版本不同,即使alice仍被允许,本例也先让旧B候选失效;重新构建当前策略元数据后,alice可以读,bob不可以。这个保守失效有成本:一次权限变化可能触发很多派生物重验。真实系统若能在可信服务中证明仅ACL更新、正文与引用边界未变,可以采用更细的重新授权流程;不能为了减少重建就直接信任旧策略快照。
检查应发生在候选正文进入重排、上下文和模型之前。若先把全部文本发给外部重排,再删掉无权项,数据已经走出门禁。本例只操作元数据,不生成Embedding、不取正文,也不模拟模型;它演示的是候选资格判断,不验证任何检索引擎的安全性。
将内容过期与主体无权分开处置
看到B旧候选被拒,不应立即删除B的正文。当前alice仍可读,系统需要的是更新可信策略元数据并重新授权;bob的拒绝则是新的访问范围要求。两种结果共享同一正文,处理方式却不同。示例用 current_B 明确给出这个对照,避免把"所有旧候选都拒绝"误读成"所有来源都必须删除"。
反过来,C-v1即使对alice和bob都有权限,也可能包含已经被纠正的事实。更新权限不能修正内容,必须取当前来源版本重建。业务若允许历史版本查询,应建立有明确用途和授权的入口,不能让普通当前知识检索在找不到新片段时自动回退。这个最小例只允许当前版本,未实现历史查询。
还有一项信任前提:principal是测试代码给出的alice或bob,frozenset中的名单也由作者预置。字符串相等不是身份认证。本地输出只证明在这些可信参数下规则如何分支,不能证明真实登录用户、租户隔离或权限服务可靠。真实接入需要从已验证的请求上下文取得主体,并在权威查询失败时保留拒绝原因,避免把故障解释成资源不存在或沿用旧授权。
双来源摘要要一起失效到哪里

摘要AB的依赖集合是 A-v1/策略1 与 B-v1/策略1。只要A被撤回,这份摘要就不能因为"B还在"而继续使用。摘要可能融合了A的规则与B的例外,删除A的来源标签不会自动删除A对文字的影响。
W3C PROV-DM用派生关系连接生成实体与影响它的来源实体。它提供描述血缘的概念,未替RAG规定撤回策略;"任一依赖失效即先阻断旧摘要,再判断能否重算"是本文的应用设计。
本例没有调用模型重写摘要。输出中的 recompute_required 只表示需要重算或人工复核,绝不是"已经生成一份不含A的新摘要"。若要仅用B重新生成,必须产生新的派生对象与新的来源集合,并重新核对正文;不能原样保留AB文字,只把血缘改成B。
实际摘要若再被压缩成下一层摘要,还需保留可追溯的传递依赖。下面仅列两份直接来源,没有实现递归血缘图或自动识别来源影响。没有准确血缘的历史摘要需要隔离或重建,不能靠来源标题相似度补猜。
重算摘要需要新正文,也需要新的证明关系
假设A给出"可退货",B补充"定制商品除外",摘要将它们合成一条完整政策。A被撤回后,即使B仍可读,旧摘要中的可退货主张也不能靠B独自证明。这个文字例是作者用于解释来源影响的构造,不是代码运行过的模型回答;它说明为何失效判断要追到生成时真正使用的来源,而不是只看摘要当前还能链接到谁。
重新计算时可以先阻断旧摘要,查询当前仍允许使用的来源,再生成新的候选。新对象应记录它实际使用的来源版本、策略版本和生成条件;若剩余材料不足以支持原问题,新的结果可以是证据不足。重算的目的不是保证旧答案继续存在,而是让新的正文有新的依据。程序中的 recompute_required 没有执行这些步骤,所以它只是明确停止复用的决定。
摘要的来源集合越宽,任何一次来源变化可能影响的派生物越多。把完全无关文档合成一个大摘要,会扩大撤回后的重验范围;按业务问题保持适当粒度可能减少这种影响,但需要与上下文成本和信息完整性一起权衡。本文没有比较摘要粒度或生成质量,不给出统一大小。读者可以先检查自己的派生对象能否反向定位来源,而不急着增加新的摘要层。
单文件复现快照、追赶与候选拒绝
保存为 rag_rebuild_demo.py,用 Python 3.10 或更高版本执行 python3 rag_rebuild_demo.py。只用标准库,所有数据留在内存。rebuild把有序事件重放到快照副本,要求从11连续到目标13;它不创建真实索引、不切换在线别名,也没有数据库事务。
python
"""Fixed metadata candidates, snapshot and event log; no vector DB or model calls."""
from copy import deepcopy
from dataclasses import dataclass
@dataclass(frozen=True)
class Source:
version: int
policy: int
readers: frozenset
withdrawn: bool = False
@dataclass(frozen=True)
class Candidate:
id: str
# Complete, trusted lineage for this fixture: (source ID, content version, policy version).
dependencies: tuple
def check(candidate, authority, principal):
if not candidate.dependencies:
return 'missing_lineage'
for source_id, version, policy in candidate.dependencies:
row = authority.get(source_id)
if row is None:
return f'{source_id}:missing_authority'
if row.withdrawn:
return f'{source_id}:withdrawn'
if row.version != version:
return f'{source_id}:content_version_changed'
if row.policy != policy:
return f'{source_id}:policy_version_changed'
if principal not in row.readers:
return f'{source_id}:principal_denied'
return 'allow'
def rebuild(snapshot, start, events, target):
# shortcut: ordered complete local event log; real snapshot/log/switch atomicity is untested.
view = deepcopy(snapshot)
expected = start + 1
for sequence, source_id, row in events:
if sequence != expected:
raise ValueError('event_gap')
view[source_id] = row
expected += 1
if expected - 1 != target:
raise ValueError('watermark_not_reached')
return view
def main():
both = frozenset({'alice', 'bob'})
alice = frozenset({'alice'})
snapshot = {key: Source(1, 1, both) for key in ['A', 'B', 'C']}
events = [
(11, 'A', Source(2, 2, both, withdrawn=True)),
(12, 'B', Source(1, 2, alice)),
(13, 'C', Source(2, 1, both)),
]
authority = rebuild(snapshot, 10, events, 13)
candidates = [Candidate('chunk-A-v1', (('A', 1, 1),)),
Candidate('chunk-B-p1', (('B', 1, 1),)),
Candidate('chunk-C-v1', (('C', 1, 1),)),
Candidate('summary-AB', (('A', 1, 1), ('B', 1, 1)))]
assert all(check(c, snapshot, 'alice') == 'allow' for c in candidates)
print('snapshot_seq=10; revoke_seq=11; rebuild_switch_seq=13')
expected = ['A:withdrawn', 'B:policy_version_changed',
'C:content_version_changed', 'A:withdrawn']
actual = [check(c, authority, 'alice') for c in candidates]
assert actual == expected
for candidate, decision in zip(candidates, actual):
print(f'old_candidate={candidate.id}; current_authority={decision}')
# Removing a lineage edge is not regeneration: the old summary still used A.
current_b = Candidate('chunk-B-p2', (('B', 1, 2),))
assert check(current_b, authority, 'alice') == 'allow'
assert check(current_b, authority, 'bob') == 'B:principal_denied'
print('current_B: alice=allow; bob=principal_denied')
remaining = [c.id for c in candidates if check(c, authority, 'alice') == 'allow']
assert remaining == []
print(f'after_replay_old_candidates={remaining}; old_summary=recompute_required')
for label, event_subset, end, error in [
('missing_middle_event', [events[0], events[2]], 13, 'event_gap'),
('behind_target', events[:2], 13, 'watermark_not_reached'),
]:
try:
rebuild(snapshot, 10, event_subset, end)
except ValueError as exc:
assert str(exc) == error
print(f'{label}: switch_rejected={exc}')
else:
raise AssertionError(f'{label}: switch should be rejected')
assert check(Candidate('unknown', (('X', 1, 1),)), authority, 'alice') == 'X:missing_authority'
assert check(Candidate('no-lineage', ()), authority, 'alice') == 'missing_lineage'
print('unknown_authority_or_lineage=deny; physical_deletion=unverified')
print('PASS fixed snapshot/3 events/4 old candidates; no vector DB, model, or concurrent switch')
if __name__ == '__main__':
main()
本机实际执行输出如下,退出码、命令、Python版本与文件摘要见附件 examples/rag-rebuild-runtime-receipt.json。这份记录继承母稿阶段同一次实际运行;代码未变,本次没有重跑或制造新执行:
text
snapshot_seq=10; revoke_seq=11; rebuild_switch_seq=13
old_candidate=chunk-A-v1; current_authority=A:withdrawn
old_candidate=chunk-B-p1; current_authority=B:policy_version_changed
old_candidate=chunk-C-v1; current_authority=C:content_version_changed
old_candidate=summary-AB; current_authority=A:withdrawn
current_B: alice=allow; bob=principal_denied
after_replay_old_candidates=[]; old_summary=recompute_required
missing_middle_event: switch_rejected=event_gap
behind_target: switch_rejected=watermark_not_reached
unknown_authority_or_lineage=deny; physical_deletion=unverified
PASS fixed snapshot/3 events/4 old candidates; no vector DB, model, or concurrent switch
输出里有两种不同的拒绝

四个旧候选在快照里都允许,源码先对这个基线作断言。对当前权威状态,A因撤回被拒,B因旧策略被拒,C因旧内容被拒,摘要AB因依赖A被拒。它们都没有进入允许集合,但失败原因不同,修复也不同:恢复A不是默认动作,B需按当前策略重验,C需用新正文重建,摘要需重新核对来源影响。
current_B这一行是重要的正对照:提供B当前策略版本2的候选后,alice允许,bob拒绝。规则没有把整个知识库永久封死,也没有因为B正文未变就沿用bob的旧权限。
后两条拒绝针对构建过程。遗漏12而直接收到13,会出现日志缺口;只处理到12却宣布追到13,也不成立。二者都抛出异常,不能报告"已追齐目标"。这验证了本例固定事件序列的水位检查,不证明真实消息系统能提供全局连续序号。
after_replay_old_candidates=[]指把这些旧候选交给重放所得当前状态后,资格过滤结果为空。它不表示向量条目已物理删除,不表示召回质量改善,也不表示系统已经生成替代内容。检索可以暂时无答案,不能用旧候选数量补足可用证据。
连续序号是本例的输入约定

11、12、13是作者为一条完整本地日志分配的连续序号。event_gap检查的是这个特定约定:预期12,却收到了13。它不是PostgreSQL LSN,也不是Kafka offset,更没有声称真实平台每份资源或所有分区都共享这样的全局递增数字。
接入真实系统时,先确认自己拿到的位点覆盖什么范围。多个分区可能分别前进,一份文档的事件顺序也不等于整个语料库的完成水位。若快照来自一份数据库、变更却来自另一条未配对的日志,即使数字看起来连续,也可能漏过快照尚未包含的那次撤回。本例的快照与事件由同一个main函数构造,配对关系是输入假设,不是程序替真实系统建立的保证。
因此,本例拒绝缺口后,真实处理应回到对应事件源补齐或重建,不是简单修改expected让校验通过。对于上游本就不提供连续整数的协议,要按其真实位点和覆盖关系判定是否追齐,不能照搬加一算法。读者可借用"水位未证明则不宣布可切换"的判断,保留自己的实际日志语义。
追到目标水位之后,仍有切换窗口
真实构建会经历:获得一致快照及对应日志位置,构建派生索引,追赶后续事件,核对完成水位,然后切换服务入口。快照和起始位置必须配对;随意从另一条时间线挑一个序号,会漏掉尚未包含在快照里的事件。
本文函数把13当作已知目标,并检查本地列表是否连续。它没有运行"校验13之后、在线切换之前又发生撤回14"的调度,因此不能拿 watermark_not_reached 消失作为原子切换证明。实际系统需要定义切换屏障或继续追赶的协议;在线读取仍要查当前权威状态。构建水位与逐次读取检查承担互补责任,不能用一个代替另一个。
逐次检查本身也有边界:若先查权限、隔很久才向外发送正文,期间仍可能撤回。应用需明确最后使用点和同步规则;跨进程、跨缓存或外部模型的发送无法被这段字典代码变成原子操作。已经发出的上下文也不能通过稍后的本地拒绝抹掉。这里证明的是固定检查点上的规则输出,未承诺瞬时收回所有在途使用。
撤回标记和事件至少需要覆盖仍可能恢复的快照与重建输入窗口;过早清掉标记,旧快照恢复后就缺少拒绝依据。在线不可读与备份按期限处置是两个结果,期限须按真实系统的策略确定。本文没有操作备份、擦除数据或给出保留天数建议。
一次候选拒绝,应该能回答具体原因
联调时可为每条候选保留候选ID、索引代次、来源版本集合、当前权威状态版本、主体范围及判定结果。记录重点是能复算这次拒绝,不需要把被撤回正文复制进新的诊断日志。若错误只剩一个"无结果",工程师很难分清真的没有相关知识、资料过期、权限改变,还是权威查询失败。
本文函数遇到第一个失效依赖就返回,因此摘要AB输出A撤回后,没有继续列出B旧策略也失效。这不影响拒绝决定,却不构成所有失效原因的完整扫描。诊断工具若要展示完整影响范围,应在受控环境另做枚举;在线入口可以保持尽早拒绝,避免为得到漂亮报告增加正文暴露。不要把一条拒绝原因当成整份血缘图只有这一处问题。
最后检查这条判定有没有被旁路:缓存直接返回的正文、预先生成的回答、摘要与重排输入都可能绕开主召回列表。本文未实现这些入口,只将其列为接入同一可用性判断时需要核对的真实路径。按实际系统逐条补证据,比用一次"索引删除成功"给所有使用入口签收更能定位问题。
给一次重建留下可复算记录
读者可以直接保留快照起点、变更事件、目标水位、候选的来源版本集合与拒绝原因,用这个单文件先测自己的规则。接入真实检索引擎后,再验证候选获取、正文取回、重排和最终上下文都经过同一当前状态检查,并补齐切换时出现新事件的联调证据。
先用上述完整输入检查旧候选是否会越过当前状态门禁,再把快照与真实日志位点、切换屏障对应起来。外部概念来源核验于2026年10月15日;事件、主体与规则是作者设计,线上语料、真实ACL、索引服务、摘要重算、物理删除与生产效果均未验证。