RAG 的文档版本管理:同一文档多版本的索引与检索策略

版本问题的三种真实形态

"文档更新了怎么办"是每个 RAG 系统上线三个月后必然撞上的问题。但把它笼统地当成一个"增量更新"问题来处理,会漏掉一半的语义。企业知识库里的版本问题实际上是三种不同的形态,每种形态对检索语义的要求完全不同,混在一起用"新版本覆盖旧版本"来处理,迟早会出生产事故。

形态一:全量修订——制度文档 v1 到 v2

最典型的是制度、规范、产品说明这类文档:有明确的版本号、发布时间和生效动作。旧版 v1 规定"报销上限 2000 元",新版 v2 改成"3000 元"。覆盖更新的致命问题是丢掉了"变化"本身这个可检索的事实——用户问"报销制度今年有什么变化",答案恰恰需要 v1 和 v2 同时在场才能生成;审计与合规场景要求"2023 年 5 月时报销上限是多少"这种时间点查询可答,覆盖之后这个问题永远答错。这不是边缘场景,制度类知识库的时间点查询占比往往超出预期。

形态二:滚动更新——wiki 与协作文档

wiki 页面、协作文档没有正式版本号,随时被编辑,一年可能被改上百次。这类文档的版本语义不是"v1/v2 的选择问题",而是"快照粒度"问题:检索系统应该感知到什么程度的中间状态?经验做法是把它当作"单一当前版本 + 变更事件流"处理——索引永远只有一个活版本,但每次实质变更(内容 diff 超过阈值)记录一条变更事件,供后续的"最近改了什么"类查询和缓存失效使用。把 wiki 的每次 keystroke 保存都当成新版本去建索引,索引系统会被噪声淹没。

形态三:时间点快照——财报与报告类

季度财报、年度审计报告、会议纪要,每一份都是独立的时间点快照,它们不是同一个文档的版本,而是不同的文档。Q1 财报和 Q2 财报应该有各自的文档身份,互相之间是"系列关系"而不是"版本关系"。很多系统的第一个版本事故就出在这里:把每季财报当成同一文档的新版本做覆盖,然后所有历史季度的数据查询全部失效。判断标准很简单:内容会互相矛盾但都长期有效的,是快照系列;内容会互相取代的,才是版本。

数据模型设计:身份与版本分离

doc_id 与 version_id 的分离

正确的数据模型是两层主键:doc_id 是稳定的概念身份("员工报销制度"这个文档本身),version_id 是一次具体的修订。每个 chunk 同时携带两级标识和版本元数据:

{
  "chunk_id": "c-9f2a",
  "doc_id": "doc-expense-policy",
  "version_id": "v-2024-03",
  "version_seq": 3,
  "valid_from": "2024-03-01",
  "valid_to": null,
  "status": "active",
  "superseded_by": null,
  "title": "员工报销制度",
  "content_hash": "sha1:ab12..."
}

几个字段的设计理由值得展开。valid_from/valid_to 是生效区间,null 的 valid_to 表示"现行有效";superseded_by 指向取代它的版本,让"这个规定后来改没改"类查询有链可循;content_hash 用于 chunk 级去重——新旧版本里未变动的段落 hash 相同,增量索引时直接复用已算好的 embedding,这是版本系统的性能前提(一次全量修订通常只有三成段落真正变化,全部重算向量既浪费又拖慢切换)。这套模型里,"版本"不是一个布尔开关,而是带时间语义的一等实体,后续所有检索策略都建立在它之上。版本的完整生命周期是一条受控的状态链:

stateDiagram-v2
    [*] --> shadow: 旁路建索引(hash 复用向量)
    shadow --> active: 金丝雀评测通过,元数据原子翻转
    shadow --> [*]: 评测不通过,删除旁路数据
    active --> deprecated: 被新版本取代(写 valid_to 与 superseded_by)
    deprecated --> archived: 冷热分层(文本保留、向量下线)

shadow 状态是旁路索引的实现载体;archived 不是删除——文本与元数据永久保留,时间旅行查询的全部能力都以"历史版本永远在场"为前提。

版本粒度:什么改动才构成新版本

wiki 类滚动更新里,不是每次保存都值得产生一个版本。判定的标准是"实质变更":通过 diff 计算正文变化比例,低于阈值(比如百分之五)且不涉及关键区段(制度数字、生效条款、责任字段)的编辑,只更新内容 hash、不切新版本——这类措辞修正不影响检索语义,频繁切版本反而制造版本噪声:版本列表里全是标点修订,时间旅行查询的语义也被稀释。判定要保守:拿不准就当新版本处理,因为"少一个真实版本"的伤害(时间点查询答错)远大于"多一个噪声版本"(多占一点存储)。

另一个粒度选择是 chunk 级版本:只对发生变化的段落记录变更,而不是给整个文档盖新版本戳。chunk 级的好处是语义缓存与引用链接的失效范围最小(只有真正变化的段落需要重新评估),坏处是版本链复杂度上升(同一文档的 chunk 分布在多个版本戳上)。实践里的折中:版本元数据挂在 doc 级,chunk 级只记 hash 与所属版本号——"这个规定哪天改的"由 doc 级版本链回答,增量索引的粒度由 chunk 级 hash 决定,两个问题各用各的粒度,互不纠缠。

版本与权限的叠加:草稿与未生效版本

版本状态机里还有一个容易被漏掉的状态:已发布但未生效(next)。制度文档常在生效日之前发布,检索系统在这段窗口里要同时面对现行版本与未来版本——默认通道仍应只返回现行版本,但"新制度什么时候生效"要有可答性:给 next 状态开一条受控通道,或在文档元数据里注入生效日期供生成引用。更敏感的是草稿版本:制度草稿对多数用户不可见,检索过滤要在 chunk 层带访问控制(与权限感知检索同一套机制),owner 与评审组可以命中草稿、其他人一律过滤。权限与版本叠加时的过滤顺序必须固定:先权限后版本状态——顺序反了会出现"草稿内容泄进现行语义"的检索事故,而且这种事故只在特定人群的视角里发生,极难复现与排查。

为什么必须时间旅行查询

status=active 的过滤解决"默认只看现行版本",但时间点查询需要另一个算子:as_of。用户问"2023 年 5 月的报销上限",检索过滤器变成 valid_from <= as_of AND (valid_to IS NULL OR valid_to > as_of)。实现这个算子的前提是历史版本的 chunk 永远在场(哪怕躺在冷存储),这也是本文反复强调"不要物理覆盖"的根本原因。时间旅行查询在实现上有一个容易忽略的细节:as_of 的取值要来自查询理解层显式抽取的时间表达,而不是当前时间——用户说"去年五月的制度",这个"去年"要按当前日期解析,但解析结果要作为显式参数传递,避免在检索层各自为政。

检索策略:版本感知的融合与消解

默认语义:现行版本优先

绝大多数查询的意图是"现行规定是什么",所以默认检索通道必须带 status=active 过滤。但要注意"过滤"和"降权"的区别:硬过滤(元数据过滤那套机制)适合状态字段,因为废弃版本的存在会系统性污染排序——旧版本往往是更"标准"的文本(被更多文档引用过、措辞更完整),在纯向量相似度上经常赢过新版本,靠降权很难稳定压住。硬过滤简单、可预测、不引入排序抖动。

意图识别层负责发现例外:查询里出现"以前""去年""变化""差异""历史"等信号,或显式时间点表达时,切换到放开版本过滤并附加 as_of 的历史通道。这个分流可以并入查询理解模块的输出契约(intent 里加一个 version_scope: current | as_of | compare 字段),不必单独建一套机制。

对比查询:并排取版本的专用通道

"新旧政策有什么区别"是最难也最有价值的查询类型,正确做法是为它建一条专用通道:按 doc_id 找到相邻版本(superseded_by 链),两个版本各自检索与查询最相关的段落,成对送进生成层并明确标注版本。不要指望通用检索通道自己"恰好"召回两个版本再靠 LLM 分辨——通用通道的排序会把两个版本的同段落视为冗余(内容高度相似),去重逻辑经常只留一个。对比通道的核心设计是把"成对"作为检索目标而不是巧合。

组间消解:同一文档只留一个版本

对常规"现行"查询,还有一个兜底消解:如果历史通道意外漏进旧版本 chunk(过滤器配置失误、意图误判),在重排后按 doc_id 分组,组内只保留排序最高的 active 版本条目。这个消解要做在融合层的最后,且只对 version_scope=current 的查询生效——它是一道防线,不是策略本身。

def resolve_versions(hits, scope="current"):
    """版本组间消解:current 意图下,同 doc_id 只保留 active 版本"""
    if scope != "current":
        return hits
    best = {}
    for h in hits:
        if h["status"] != "active":
            continue
        key = h["doc_id"]
        if key not in best or h["score"] > best[key]["score"]:
            best[key] = h
    return sorted(best.values(), key=lambda h: -h["score"])

工程实践:切换、灰度与下线

版本切换的原子性与灰度

新版本上线走旁路索引模式:v2 的 chunk 先以 status=shadow 建好索引(向量复用 hash 命中的部分),对新版本跑一轮金丝雀评测(用探针集对比 v1 索引与 v2 索引在"现行语义"查询上的表现),通过后执行原子切换——把 v1 批量置为 deprecated(写 valid_to 和 superseded_by)、v2 置为 active。切换是一个事务性的批量元数据操作,不涉及向量重算,秒级完成,失败即整体回滚(把状态字段写回去)。为什么必须旁路而不能原地覆盖:因为"索引中同时存在两套版本"是实现原子切换的最简方式,原地覆盖意味着切换期间系统处于半新半旧的不一致状态,而元数据翻转是天然原子的小操作。

历史版本的下线与冷热分层

历史版本不能删,但也不必同等对待。落地满一定周期(比如 18 个月)且无近期命中的 deprecated 版本,做冷热分层:chunk 文本与元数据保留(时间旅行查询依赖它们),向量从内存索引移出(转入磁盘或干脆不存,命中历史通道时现算 embedding——历史查询是低频流量,现算的延迟完全可接受)。这一步能把活跃索引体积压掉三到五成,而时间旅行能力毫发无损。审计要求更高的场景(法务、财务)则在分层之上加一层 WORM 式归档,但这属于合规层的追加要求,不影响检索架构本身。

变更事件流:让下游感知版本切换

版本切换不能只改索引,还要广播变更事件(doc_id、新旧版本号、变更的 chunk 列表),下游的语义缓存、答案缓存、引用链接校验都消费这个事件流做定向失效。没有事件流的版本系统,经常在"索引已经更新、缓存的旧答案还在服务"的窗口里被用户抓个正着——这比索引慢一天更伤信任,因为用户看到的是明确过时的答案而不是缺失的答案。

小结

版本管理的核心洞察是:覆盖更新丢掉的不是旧数据,而是"变化"这个一等的可检索事实。架构上抓住三个要点。第一,数据模型把文档身份(doc_id)与版本(version_id)分离,chunk 带生效区间与状态机(shadow → active → deprecated → archived),content_hash 让增量索引只重算真正变化的段落。第二,检索策略按查询意图分语义:默认通道硬过滤现行版本,时间点查询走 as_of 通道,对比查询建专用成对通道,最后用组间消解兜底。第三,版本切换走旁路索引加元数据原子翻转,配金丝雀评测与变更事件流,历史版本用冷热分层控制成本。这三种形态——全量修订、滚动更新、时间点快照——先分清楚再设计,能避免大多数"上线半年后发现历史查询全错"的返工。版本系统做对了,RAG 系统才从"知识快照"升级为"知识的时间机器",而后者才是企业知识库的真实需求。

← 返回资讯列表

读者留言

COMMENTS 暂无
仅本站原创文章开放留言 · 请勿留下手机号、邮箱等个人信息

还没有留言,来说第一句?