AI 工程基础体系 · 第 94/100 篇。内容覆盖机器学习、深度学习与生成式 AI;模型、数据、评测、权限和成本会作为同一生产系统处理。

RAG 增量索引:变更捕获、版本、删除、重嵌入和一致性

RAG(Retrieval-Augmented Generation,检索增强生成)把“生成”拆成两步:先从外部知识中检索相关内容,再把检索结果作为上下文交给生成模型。原始 RAG 论文将外部知识表示为可检索的非参数记忆,生成模型则使用检索到的文档完成回答。生产系统通常还要处理文档更新、删除、权限、嵌入模型升级和失败恢复,因此“把文档向量化后写入向量库”只是索引生命周期中的一个步骤。

本文讨论的“RAG 增量索引”,是指:

当知识源发生变化时,只重新处理受影响的文档、分块和向量,并使检索结果逐步或按发布边界切换到新的知识状态,而不是每次都全量重建索引。

这里的“索引”不只指向量数据库中的向量,还包括文档元数据、分块、嵌入模型信息、权限、删除标记、处理状态和可见性版本。


一、先建立对象模型:RAG 索引到底保存什么

一个生产 RAG 索引至少涉及以下对象:

  • 文档(document):知识源中的逻辑对象,例如一篇文章、一个网页、一份 PDF 或一条工单。
  • 文档版本(document version):文档在某个时刻的内容快照。
  • 分块(chunk):对某个文档版本切分后的检索单元。
  • 嵌入(embedding):由嵌入模型将文本映射成向量的结果。
  • 向量记录(vector record):向量及其过滤元数据,例如文档 ID、版本、租户和权限标签。
  • 索引代(index generation):一组可以共同对外提供检索的索引数据。
  • 事件(change event):知识源变化的可消费记录,例如创建、更新、删除或权限变更。

一个向量记录不能只保存向量本身。至少应能回答以下问题:

  1. 它属于哪个文档?
  2. 属于哪个文档版本?
  3. 使用了哪一个分块算法和嵌入模型?
  4. 当前是否仍然有效?
  5. 哪个租户或用户可以访问?
  6. 它是否已经对查询可见?
  7. 如果删除或回滚,如何找到并撤销它?

一个简化的数据关系如下:

Document
  └── DocumentVersion
        └── Chunk
              └── Embedding / VectorRecord

其中:

document_id       = 逻辑身份,不随内容更新改变
version_id        = 某一次内容快照的身份
chunk_id          = 某个版本中的分块身份
embedding_id      = 某种模型、分块文本和版本组合的向量身份

1. 文档身份不能用内容哈希替代

内容哈希适合判断内容是否变化,但不一定适合作为业务身份。例如,用户把文章标题改回原值时,内容哈希可能重复;两个不同租户可能拥有内容完全相同的文档;权限不同的副本也可能拥有相同文本。

因此通常同时保存:

document_id       逻辑身份
source_uri        来源地址
source_revision   来源系统提供的版本号、更新时间或提交序号
content_hash      规范化内容的哈希
acl_hash          权限集合的哈希

document_id 用于定位对象,content_hash 用于判断内容是否真的改变,acl_hash 用于判断仅权限是否改变。

2. 分块身份必须考虑版本

假设旧版本被切成:

doc-7:v1:chunk-0
doc-7:v1:chunk-1

更新后第二段文本发生了变化,重新切块得到:

doc-7:v2:chunk-0
doc-7:v2:chunk-1
doc-7:v2:chunk-2

不能只通过数组下标判断“旧 chunk-1 对应新 chunk-1”。分块边界可能因一个段落插入而整体移动。更安全的做法是让新版本生成新的分块身份,并通过 document_id + version_id 找到旧版本的全部向量,在新版本发布后整体撤销旧版本。

如果系统需要减少重复嵌入,可以通过规范化文本哈希复用相同分块的向量,但复用是优化,不应改变版本和可见性语义。


二、什么是变更捕获

**变更捕获(Change Data Capture,CDC)**是把知识源的变化转换为索引系统可以处理的事件。例如:

{
  "event_id": "evt-1008",
  "document_id": "doc-7",
  "operation": "update",
  "source_revision": 42,
  "content_uri": "s3://kb/doc-7.json",
  "occurred_at": "2025-03-08T10:00:00Z"
}

事件并不等于文档内容。事件通常只说明“哪个对象发生了什么变化”,索引 worker 再根据 content_uridocument_id读取权威内容。

这一区分很重要:

  • 事件流负责传递变化;
  • 源系统负责提供权威状态;
  • 索引系统负责构造可检索投影。

1. 常见变更捕获方式

数据库事务日志

如果知识源存储在关系数据库中,可以读取数据库日志或使用 CDC 工具捕获插入、更新和删除。

优点是能观察数据库事务中的变化;风险是日志格式、事务提交语义和保留时间依赖具体数据库与 CDC 工具。不能把“已经读取到日志”误认为“向量已经完成更新”。

Outbox 模式

应用更新文档时,在同一个数据库事务中写入文档和事件:

BEGIN;

UPDATE documents
SET source_revision = 42,
    content = '新的内容',
    updated_at = CURRENT_TIMESTAMP
WHERE document_id = 'doc-7';

INSERT INTO index_outbox (
    event_id,
    document_id,
    operation,
    source_revision,
    payload,
    created_at
) VALUES (
    'evt-1008',
    'doc-7',
    'upsert',
    42,
    '{"content_uri":"s3://kb/doc-7.json"}',
    CURRENT_TIMESTAMP
);

COMMIT;

这保证了“文档更新成功但事件没有写入”不会因为两个独立请求而发生。Outbox 消费通常仍然是至少一次投递,因此消费者必须幂等。

定期轮询

索引任务周期性查询:

SELECT document_id, source_revision, updated_at
FROM documents
WHERE updated_at > :watermark
ORDER BY updated_at, document_id;

只使用时间戳有风险:多个写入可能拥有相同时间戳,时钟可能回拨,查询期间还可能插入新数据。更可靠的是使用源系统的单调递增提交序号;如果只能使用时间戳,应使用重叠窗口并通过 (document_id, source_revision) 去重。

Webhook 或消息通知

源系统发送“文档已变更”的通知。Webhook 轻量,但可能丢失、重复或乱序,不能只依赖通知中的内容完成一致性。通常还需要在处理时回源读取当前版本,并定期执行对账扫描。

2. CDC 的三种性质

必须区分:

  • 至少一次(at-least-once):事件可能重复,但不应永久丢失。
  • 至多一次(at-most-once):事件最多处理一次,但可能丢失。
  • 恰好一次(exactly-once):端到端通常很难实现,单个消息系统的“恰好一次”也不能自动覆盖数据库、嵌入服务和向量库。

生产索引更常选择“至少一次 + 幂等处理”。幂等意味着同一个事件执行一次或多次,最终可见状态相同。

幂等键可以是:

event_id

但仅使用 event_id 不够。如果两个不同事件都表示同一个文档的同一版本,仍可能重复处理。因此还应按文档保存已接受的最大源版本:

仅当 incoming.source_revision > current.source_revision 时才推进版本

具体是否可以直接比较整数,取决于源系统是否保证该版本号对每个文档单调递增。


三、版本:防止旧任务覆盖新内容

增量索引最常见的竞态是:

  1. 文档从版本 41 更新到 42;
  2. 版本 41 的嵌入任务运行很慢;
  3. 版本 42 的任务先完成并发布;
  4. 版本 41 的任务随后完成,错误地覆盖版本 42。

因此,版本必须参与任务接受和发布判断,而不能只依赖任务完成时间。

1. 三个不同层次的版本

生产系统常常需要同时记录三种版本:

源版本

由源系统定义,例如数据库提交序号、Git commit、对象存储 ETag 或业务递增 revision。

它回答:

这是源文档的第几个版本?

投影版本

索引系统已经接受并构造到哪个源版本。

它回答:

当前索引处理到了哪个版本?

可见版本或索引代

对查询公开的版本边界。

它回答:

查询应该看到哪个完整的索引状态?

这三者不一定同时推进。例如:

源版本:42
投影版本:42
可见版本:仍为 41

这表示新版本已经计算完成,但尚未通过发布检查。

2. 版本接受规则

对同一 document_id,可以定义如下规则:

如果 incoming_revision < indexed_revision:
    丢弃为过期事件
如果 incoming_revision = indexed_revision:
    按幂等逻辑检查并返回
如果 incoming_revision > indexed_revision:
    创建新版本并进入处理流程

但这里有一个前提:source_revision 必须能够可靠排序。如果源系统只提供更新时间,而更新时间不是严格单调的,就不能把它当作强版本号。

3. 版本更新的完整过程

以文档 doc-7 从 41 更新到 42 为例:

1. 读取源版本 42
2. 规范化正文并计算 content_hash
3. 创建 document_versions(id=v42, revision=42)
4. 分块生成 chunk(v42, 0..n)
5. 为每个 chunk 生成 embedding
6. 写入“不可见”的向量记录
7. 校验分块数量、向量维度、权限元数据和写入结果
8. 原子地把 v42 标记为 active
9. 再异步删除或回收 v41 的向量

“先发布新版本,再删除旧版本”通常比“先删除旧版本,再写入新版本”更安全。后者在任意一步失败时会出现检索空窗。

但是,旧版本和新版本同时存在时,查询必须有版本过滤,否则会返回重复内容。常见做法是让检索层只查询当前 active version,或在查询结果去重时按 document_id 保留当前版本。


四、删除不是更新的反面,而是一种需要持久记录的状态

删除有三类,处理方式不同:

  1. 内容删除:文档不再存在。
  2. 版本删除:旧版本不可再被检索。
  3. 权限删除:文档仍存在,但某些用户不应再看到。

1. 为什么物理删除不能作为唯一事实

如果只调用向量数据库的删除接口,随后发生以下任一情况,系统可能无法恢复:

  • 删除请求超时,但实际已成功;
  • 删除只删除了部分分块;
  • worker 重试时不知道旧版本有哪些向量;
  • 向量数据库删除接口是异步的;
  • 删除后重新导入时没有审计记录。

因此,应在自己的持久化存储中记录删除状态:

document.deleted_at
document_versions.status = deleted
vector_records.visibility = hidden

物理删除可以作为后续清理动作,而不是唯一的业务事实。

2. Tombstone:删除墓碑

**墓碑(tombstone)**是代表“该对象已被删除”的持久记录。例如:

{
  "document_id": "doc-7",
  "source_revision": 43,
  "operation": "delete",
  "deleted_at": "2025-03-08T11:00:00Z"
}

墓碑的作用是阻止旧事件复活数据。假设版本 43 删除了文档,但网络中仍有延迟的版本 42 更新事件。如果索引系统只看“向量是否存在”,就可能重新写入版本 42。墓碑让系统知道:

任何 revision <= 43 的 upsert 都必须拒绝

墓碑不能无限期保留而不考虑存储成本。只有在能够证明所有旧事件都已过期、所有消费者水位都超过删除版本后,才可以清理墓碑。

3. 权限删除必须进入索引一致性模型

检索结果必须满足:

返回的每个 chunk 都属于当前用户有权访问的文档

仅在生成阶段检查权限已经太晚,因为未授权文本可能在检索日志、重排器或提示词中泄露。

权限可以:

  • 作为向量数据库的过滤条件;
  • 先检索候选,再在受控服务中授权过滤;
  • 使用租户或用户分区隔离。

如果权限变化频繁,不能把 ACL 只写入长期不变的向量记录而不更新。权限更新事件即使不改变正文,也需要触发索引元数据更新或查询时权限校验。


五、重嵌入:内容没变,向量也可能需要变

**重嵌入(re-embedding)**是使用新的嵌入模型或新的嵌入参数,对已有文本重新计算向量。

发生重嵌入的原因包括:

  • 嵌入模型升级;
  • 需要支持新的语言或领域;
  • 分块策略改变;
  • 向量维度改变;
  • 向量模型服务迁移;
  • 发现旧模型对特定查询召回较差。

即使文本完全没有变化,新旧模型的向量空间通常也不兼容。不能把模型 A 生成的向量和模型 B 生成的查询向量直接进行有意义的相似度计算,除非模型和服务明确保证兼容。

1. 嵌入缓存的正确键

嵌入缓存不能只用文本哈希。一个较完整的键是:

embedding_key =
  hash(
    normalized_text,
    chunker_version,
    embedding_model_id,
    embedding_model_revision,
    preprocessing_version
  )

需要记录:

model_id
model_revision
dimension
distance_metric
chunker_version
normalization_version

例如,余弦相似度、点积和欧氏距离的排序含义不同;即使向量维度相同,也不能因此认为它们可混用。

2. 两种重嵌入迁移方式

原地替换

直接删除旧向量、写入新向量。

优点是实现简单,临时存储少。缺点是失败时可能产生检索空窗,且新旧数据难以对比。

双索引迁移

同时保留旧代和新代:

generation-41: model-A
generation-42: model-B

先在新代中完成全量或增量重嵌入,再切换一个发布指针:

active_generation = generation-42

切换后再异步清理 generation-41。

双索引的核心价值不是“永远零停机”,而是把“构建”和“发布”分离。构建过程中失败不会影响当前可见代;发布只改变一个小的、可原子更新的控制记录。

3. 重嵌入期间的双写

如果迁移时间较长,源文档仍会更新。不能只扫描迁移开始时的快照,否则新版本会漏掉。

一种可靠流程是:

T0:记录迁移起点 watermark
T1:扫描所有文档构建新索引
T2:持续消费 T0 之后的变化事件
T3:直到新索引追平源系统水位
T4:执行一致性校验
T5:切换 active_generation

关键是 T2 必须覆盖构建期间产生的更新。否则文档在 T1 已经被嵌入过,但 T1 之后又更新,切换后仍可能显示旧内容。


六、增量索引的一致性究竟是什么

“索引一致”不是单一性质。应先说明系统提供哪一种保证。

设源系统在时刻 tt 的状态为 StS_t,索引是源状态经过索引函数得到的投影:

It=P(St)I_t = P(S_t)

其中 PP 包含规范化、分块、嵌入、元数据和可见性规则。

由于 CDC、嵌入和向量写入都需要时间,实际索引可能是:

I=P(StΔ)I = P(S_{t-\Delta})

这里的 Δ\Delta 是索引陈旧时间,而不是数据错误。若系统允许最终一致性,必须能观测并约束 Δ\Delta

1. 基本不变量

一个合理的索引系统至少应满足:

不返回已删除文档

如果文档在源版本 rdr_d 被删除,那么对所有查询时刻 tt,只要查询看到的水位不低于 rdr_d,就不能返回它:

rqueryrddocResultsr_{\text{query}} \ge r_d \Rightarrow \text{doc} \notin \text{Results}

不返回旧版本覆盖新版本

对同一文档,若查询水位为 rr,返回的版本应满足:

version(document)rversion(document) \le r

并且在“已追平”语义下应返回该水位内最新的有效版本,而不是任意旧分块。

权限安全优先于召回率

对用户 uu,结果集合必须满足:

Results(u)Authorized(u)Results(u) \subseteq Authorized(u)

检索少了相关文档属于召回损失;返回未授权文档属于安全错误,两者严重性不同。

向量元数据和向量内容必须匹配

一个向量不能标记为 model-B,但实际由 model-A 生成;不能把文档版本 42 的 ACL 挂到版本 41 的文本上。这类错误不会总是导致请求失败,却会造成隐蔽的数据污染。

2. 常见一致性级别

最终一致性

更新提交后,经过有限但不固定的时间,索引最终反映新状态。

适合大多数知识库,但必须提供:

index_lag
last_applied_revision
last_published_generation

否则“最终”无法验证。

读己之写

用户更新文档后,自己的下一次查询能看到新内容。可通过返回 required_revision,查询时要求检索水位至少达到该版本:

PUT /documents/doc-7
=> { "accepted_revision": 42 }

GET /search?min_revision=42

如果索引尚未追平,可以等待、返回重试提示,或明确告知结果仍可能陈旧。

快照一致性

一次查询中的所有候选来自同一个索引代或水位。不能让前十个结果来自 generation-41,后十个结果来自 generation-42,除非系统明确允许这种行为。

快照一致性对于重嵌入、分块策略变化和评测尤其重要,因为混合代会使排序比较失去稳定性。


七、推荐的组件和状态流转

一个可审计的增量索引系统可以包含:

flowchart LR
    A[权威知识源] --> B[变更捕获 CDC / Outbox]
    B --> C[事件队列]
    C --> D[版本协调器]
    D --> E[内容读取与规范化]
    E --> F[分块器]
    F --> G[嵌入服务]
    G --> H[候选索引 generation]
    H --> I[校验器]
    I --> J[发布指针]
    J --> K[检索服务]
    K --> L[权限过滤与重排]
    L --> M[生成模型]

    D --> N[状态数据库]
    H --> N
    I --> N
    J --> N

一次更新的状态可以表示为:

RECEIVED
  -> ACCEPTED
  -> FETCHED
  -> CHUNKED
  -> EMBEDDED
  -> WRITTEN
  -> VERIFIED
  -> PUBLISHED

异常状态包括:

RETRYABLE_ERROR
PERMANENT_ERROR
STALE
CANCELLED

状态不应只保存在内存队列中。至少需要把以下信息持久化:

document_id
source_revision
target_generation
job_id
attempt_count
status
last_error
updated_at

这样 worker 崩溃后才能重试或恢复,而不是重新猜测任务是否已经完成。

1. 组件之间的责任边界

  • CDC 层:保证事件可重放、记录事件 ID 和源版本。
  • 协调器:决定事件是否过期,防止乱序覆盖。
  • 内容读取器:读取权威内容,而不是盲信旧事件中的正文。
  • 分块器:使用显式版本的分块规则。
  • 嵌入服务:返回模型身份、维度和请求状态。
  • 索引写入器:使用幂等 ID,并验证写入结果。
  • 发布器:只发布通过校验的完整代或文档版本。
  • 检索服务:固定查询代,执行权限过滤,并记录索引水位。
  • 状态数据库:保存业务事实、任务状态和审计记录。

八、并发处理的关键:协调文档级顺序

假设队列同时收到:

doc-7 revision=41
doc-7 revision=42
doc-7 revision=43 delete

它们可能以任意顺序到达。一个安全的 worker 伪代码如下:

def process_event(event):
    with state_db.transaction() as tx:
        state = tx.get_document_state_for_update(event.document_id)

        if event.source_revision <= state.accepted_revision:
            tx.record_event(event.event_id, status="stale")
            return "stale"

        tx.update_accepted_revision(
            document_id=event.document_id,
            source_revision=event.source_revision,
        )
        tx.create_index_job(
            document_id=event.document_id,
            source_revision=event.source_revision,
            operation=event.operation,
            idempotency_key=(
                event.document_id,
                event.source_revision,
                target_generation,
            ),
        )

    # 锁只保护版本决策,耗时的嵌入工作在事务外执行
    run_job(event.document_id, event.source_revision, event.operation)

这里有两个重要点:

  1. 不能在数据库事务中长时间等待嵌入服务,否则会占用锁并放大故障。
  2. 释放锁后,较新的版本可能已经提交,因此 run_job 在发布前还要再次检查当前版本。

发布前检查可以是:

def publish_if_current(job):
    current = state_db.get_document_state(job.document_id)

    if current.accepted_revision != job.source_revision:
        mark_stale(job)
        return False

    if not all_chunks_written(job):
        retry(job)
        return False

    if not verify_acl_and_dimensions(job):
        fail_permanently(job)
        return False

    mark_version_active(job)
    return True

这解决的是“旧任务不能发布”的问题,但不一定能解决“新任务读取到旧内容”的问题。内容读取也要带版本条件,例如请求源系统:

读取 document_id=doc-7,要求 revision=42

如果源系统已经是 43,就应重新排队 43,而不是把当前 43 的正文错误地标记为 42。


九、一个完整算例:更新、删除和失败恢复

初始状态:

doc-7 revision=10
  chunk-0: "安装客户端需要管理员权限"
  chunk-1: "Linux 使用 apt 安装"

active_generation = g1

索引中实际保存:

(doc-7, rev=10, chunk=0, model=A, visible=true)
(doc-7, rev=10, chunk=1, model=A, visible=true)

第一步:文档更新到 revision=11

新正文增加了 Windows 安装说明,重新分块后得到:

rev=11
  chunk-0: "安装客户端需要管理员权限"
  chunk-1: "Windows 使用安装程序"
  chunk-2: "Linux 使用 apt 安装"

索引流程:

1. 接收 upsert(rev=11)
2. 发现 11 > 当前 accepted_revision=10
3. 创建 rev=11 的分块记录
4. 生成三个向量
5. 写入 g1,但标记为 pending
6. 校验三个向量的维度和元数据
7. 将 rev=11 标记 active
8. 将 rev=10 标记 hidden
9. 异步清理 rev=10 向量

在第 5 步到第 7 步之间,查询仍然使用 revision=10。这是“构建不可见、验证后发布”的效果。

第二步:旧任务晚到

假设 revision=10 的某个嵌入任务此时才完成。它在发布前读取到:

current accepted_revision = 11
job revision = 10

由于 10 != 11,任务应被标记为 stale,不能把旧向量重新标为可见。

第三步:revision=12 删除文档

系统收到:

delete(doc-7, revision=12)

处理时应:

1. accepted_revision 更新为 12
2. 写入 tombstone
3. 禁止 rev <= 12 的 upsert 发布
4. 将 doc-7 所有可见版本标记 hidden
5. 异步物理删除向量

如果延迟到达的 upsert(revision=11) 被消费,它会因为:

11 <= tombstone_revision=12

而被拒绝。即使物理删除尚未完成,查询过滤 deleted=true 也不应再返回文档。

反例:先删旧向量再写新向量

若实现顺序是:

删除 revision=10
生成 revision=11
写入 revision=11

而嵌入服务在中间超时,结果将是:

旧版本不存在
新版本也不存在

查询出现空窗。更严重的是,如果删除接口成功但客户端超时,重试逻辑不清楚,还可能把删除状态误判为失败。


十、向量数据库中的删除和发布边界

不同向量数据库对以下操作的保证可能不同:

  • 删除是同步完成还是异步生效;
  • 元数据更新是否原子;
  • 批量 upsert 是否全有或全无;
  • 查询是否能固定一个快照;
  • 索引构建完成和数据可查询之间是否有延迟;
  • 删除后多久不再被召回。

因此,不能仅凭某个客户端 SDK 返回成功,就断言“用户下一次查询一定看不到旧向量”。这属于具体产品和版本的行为,需要查对应 API 文档并通过测试确认。

一种与底层实现解耦的发布方式是使用外部 manifest:

{
  "active_generation": "g42",
  "embedding_model": "model-B",
  "chunker_version": "chunker-3",
  "source_watermark": 981223,
  "published_at": "2025-03-08T12:00:00Z"
}

检索请求先读取 manifest,再只查询 g42。如果底层向量库支持按 generation 过滤,就通过过滤实现;如果不支持,可以使用独立 collection、namespace 或索引实例。

这仍然不能自动解决权限问题。manifest 只定义内容代,授权过滤仍必须在检索路径执行。


十一、重嵌入和增量更新同时发生时如何保持正确

考虑以下时间线:

T0:开始从 model-A 迁移到 model-B
T1:扫描到 doc-7 revision=20
T2:doc-7 更新到 revision=21
T3:model-B 生成 revision=20 的向量
T4:迁移任务结束并切换

如果 T2 的事件没有补偿到新索引,切换后会出现:

新模型索引中只有 revision=20
源系统已经是 revision=21

所以迁移必须有“扫描 + 追平 + 校验 + 切换”四个阶段,而不是“扫描完成就切换”。

可以用以下水位判断:

source_watermark = 源系统已确认的最大提交序号
target_watermark = 新索引已成功发布的最大可处理序号

只有当:

target_watermark >= source_watermark

并且没有处于运行中的更高版本任务时,才可以考虑切换。若源系统持续写入,通常需要短暂冻结、双写或定义一个明确的切换时刻;否则“追平”可能永远无法达到。


十二、代码和数据库设计示例

下面是一个简化的 PostgreSQL 表结构。它表达状态和约束,向量列可以由具体向量数据库维护。

CREATE TABLE documents (
    document_id TEXT PRIMARY KEY,
    tenant_id TEXT NOT NULL,
    accepted_revision BIGINT NOT NULL DEFAULT 0,
    deleted BOOLEAN NOT NULL DEFAULT FALSE,
    deleted_revision BIGINT,
    acl_hash TEXT NOT NULL,
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE document_versions (
    document_id TEXT NOT NULL,
    source_revision BIGINT NOT NULL,
    content_hash TEXT NOT NULL,
    chunker_version TEXT NOT NULL,
    status TEXT NOT NULL CHECK (
        status IN ('pending', 'active', 'hidden', 'deleted', 'stale')
    ),
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    PRIMARY KEY (document_id, source_revision)
);

CREATE TABLE index_jobs (
    job_id BIGSERIAL PRIMARY KEY,
    document_id TEXT NOT NULL,
    source_revision BIGINT NOT NULL,
    operation TEXT NOT NULL CHECK (
        operation IN ('upsert', 'delete', 'acl_update')
    ),
    target_generation TEXT NOT NULL,
    status TEXT NOT NULL,
    attempts INTEGER NOT NULL DEFAULT 0,
    last_error TEXT,
    UNIQUE (document_id, source_revision, operation, target_generation)
);

CREATE TABLE index_manifest (
    singleton BOOLEAN PRIMARY KEY DEFAULT TRUE,
    active_generation TEXT NOT NULL,
    source_watermark BIGINT NOT NULL,
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

这段设计中的几个约束分别解决不同问题:

  • documents.accepted_revision 防止旧事件覆盖新事件;
  • document_versions 让同一文档的不同版本可审计;
  • index_jobs 的唯一约束提供任务级幂等;
  • index_manifest 把“已经写入”与“对查询可见”分开。

实际生产中还应增加租户约束、事件表、ACL 快照、向量记录映射和状态迁移审计。


十三、失败路径和恢复方法

1. 事件重复

表现:同一文档出现重复分块或重复向量。

原因:消费者按消息到达次数写入,没有使用幂等 ID。

诊断

SELECT document_id, source_revision, COUNT(*)
FROM index_jobs
GROUP BY document_id, source_revision
HAVING COUNT(*) > 1;

恢复:按 (document_id, source_revision, generation, chunk_no) 去重,重新执行发布校验。不要简单地按最新写入时间保留记录,因为最新记录不一定来自最新源版本。

2. 乱序事件导致旧内容复活

表现:文档明明已更新或删除,查询仍返回旧段落。

原因:没有比较源版本,或删除没有墓碑。

诊断

查询结果中的 source_revision
与 documents.accepted_revision
以及 tombstone revision 对比

恢复:重新应用当前权威版本;为删除写入墓碑;将低于墓碑版本的任务标记为 stale。

3. 部分分块写入

表现:一个文档只有部分段落可检索,或回答缺少上下文。

原因:批量写入不是原子操作,worker 在中间崩溃。

恢复:使用 pending 状态,只有预期分块数、向量维度、模型身份和权限字段全部校验通过后才 active。重试时使用确定性的向量 ID覆盖已有结果。

4. 嵌入服务成功但响应丢失

表现:客户端认为请求失败并重试,向量服务中出现重复计算或重复写入。

原因:请求超时不等于服务端没有执行。

恢复:为嵌入请求和向量写入设置幂等键;重试前查询目标记录,或直接使用相同 ID upsert。不要用“随机 ID + 重试”作为默认策略。

5. 向量已写入但状态库提交失败

表现:底层向量库有数据,但状态数据库认为任务失败。

恢复:以状态库为业务事实,重试同一 job。因为向量 ID稳定,重试应覆盖而不是新增。定期执行孤儿向量扫描,删除没有对应有效版本的记录。

6. 新模型维度不匹配

表现:写入接口拒绝,或查询时报维度错误。

原因:旧 collection 的维度由创建时固定,不能直接写入不同维度的向量。

恢复:创建独立的新代索引,完成迁移后通过 manifest 切换;不要强行截断、补零或混合不同维度向量,除非这是经过验证的模型设计。


十四、如何验证增量索引真的正确

不能只测试“worker 返回成功”。验证应覆盖内容、版本、删除、权限和检索行为。

1. 内容级验证

对每个已发布版本检查:

实际 chunk 数 == 预期 chunk 数
每个 chunk 都有唯一 ID
每个向量维度 == 目标维度
每个向量的 model_id 与 generation 一致
content_hash 与源快照一致

2. 事件级验证

保存并比较:

源系统最大 revision
事件队列最大已确认 revision
索引已应用 revision
active generation 的发布 watermark

对于可排序的事件流,可以检查是否存在:

source_revision <= applied_watermark
但没有对应 active 版本

3. 行为级验证

构造四组测试:

  1. 更新后查询新关键词,必须能召回新版本;
  2. 删除后查询旧关键词,不能召回已删除文档;
  3. 乱序投递旧、新、删除事件,最终状态必须等于源系统;
  4. 无权限用户查询高相似度内容,结果中不得出现该文档。

4. 故障注入

在以下位置随机终止 worker:

读取正文后
分块后
嵌入返回后
写入一半后
状态提交前
发布后、旧版本清理前

恢复后检查两个性质:

最终可见结果正确
重试没有产生重复或越权数据

如果系统只在无故障路径下正确,它还不能称为一致的增量索引系统。


十五、增量索引对检索质量和评测的影响

增量索引不仅改变存储状态,也会改变 RAG 的检索质量。

1. 分块策略改变会改变评测基线

同一文档从 500 字符分块改为按标题分块后:

  • chunk 数量变化;
  • 每个 chunk 的语义边界变化;
  • top-k 的候选空间变化;
  • 召回率和上下文长度变化。

因此评测结果必须记录:

index_generation
chunker_version
embedding_model_revision
retrieval_top_k
reranker_version

否则一次指标变化无法判断来自模型、分块还是数据更新。

2. 不能把“新向量召回率下降”简单归因于模型

下降可能来自:

  • 新索引漏了部分文档;
  • 删除过滤错误;
  • ACL 元数据缺失;
  • 查询使用了旧模型;
  • 新旧索引混合;
  • 文档版本没有追平;
  • 重排器收到的字段格式变化。

诊断时应先验证索引完整性,再比较嵌入模型质量。

3. 生成模型看到的上下文也要版本化

即使检索正确,生成模型、提示词和上下文拼接策略变化,也会影响答案。生产日志至少应记录:

query
user/tenant authorization context
retrieved document_id and source_revision
chunk_id
similarity score
index_generation
embedding model
reranker and generator versions

这样才能区分“索引没有召回”和“召回了但生成模型没有正确使用”。


十六、成本取舍:哪些工作适合增量,哪些仍需全量

增量索引降低了嵌入和写入成本,但会增加状态管理、对账和清理成本。

设:

N = 全部 chunk 数量
ΔN = 一个时间窗口内发生变化的 chunk 数量
C_e = 单个 chunk 嵌入成本
C_w = 单个向量写入成本
C_a = 增量系统额外维护成本

全量重建的近似工作量为:

N(Ce+Cw)N(C_e + C_w)

增量处理的近似工作量为:

ΔN(Ce+Cw)+Ca\Delta N(C_e + C_w) + C_a

ΔNN\Delta N \ll N 时,增量通常更经济;但发生以下变化时,ΔN\Delta N 可能接近 NN

  • 分块规则全面变化;
  • 嵌入模型更换;
  • 权限模型变化;
  • 文档规范化逻辑变化;
  • 向量数据库迁移。

这时把全量重建称为“失败”并不准确。全量重建更容易获得一致的快照,增量则更适合日常小规模内容变化。生产系统常常同时拥有:

日常 CDC 增量更新
定期对账扫描
模型或分块变化时的全量新代构建

十七、常见误解

误解一:更新一个文档只需 upsert 新向量

如果旧版本的分块没有撤销,查询可能同时返回新旧内容。正确做法是让旧版本不可见,或者查询时严格过滤到当前 active version。

误解二:向量库返回删除成功,就说明删除完成

删除可能是异步的,具体可见性语义取决于产品实现。业务状态、查询过滤和后台物理清理应分开设计。

误解三:事件不重复就不需要幂等

即使消息不重复,网络超时也可能造成客户端重试;跨系统调用很难仅靠消息层保证端到端恰好一次。幂等仍然需要。

误解四:更新时间可以天然作为版本

时间戳可能相同、回拨或在不同节点上不一致。若要排序,优先使用源系统的提交序号或明确的 revision。

误解五:重嵌入只替换向量列

重嵌入还涉及模型身份、维度、距离度量、查询编码器、索引代和评测基线。只替换数值而不记录这些元数据,会导致后续无法解释结果。

误解六:权限过滤可以放在生成前

未授权内容如果已经进入候选集,就可能出现在日志、重排输入或提示词中。权限控制必须尽量靠近检索数据源,并对最终结果再次校验。


十八、最低可行的生产语义

一个可接受的增量 RAG 索引,不必一开始就提供全局强一致,但至少应明确以下契约:

1. 每个文档有可排序的源版本。
2. 事件至少一次投递,消费者幂等。
3. 旧版本不能覆盖新版本。
4. 删除有持久墓碑,旧事件不能复活数据。
5. 新版本验证完成后才可见。
6. 查询使用明确的 generation 或 revision 水位。
7. 权限过滤在检索路径中执行。
8. 嵌入模型、分块器和向量维度可追溯。
9. 任务失败可重试,孤儿向量可发现和清理。
10. 通过水位、延迟、错误和对账结果验证系统状态。

RAG 的检索效果建立在知识索引之上,而增量索引决定这份知识是否仍然代表源系统。变更捕获解决“变化如何进入系统”,版本解决“哪个变化可以覆盖哪个状态”,删除和墓碑解决“旧知识如何失效”,重嵌入解决“向量空间如何迁移”,一致性模型则明确“查询在什么时候能看到什么”。只有把这些部分作为同一个数据、模型、权限、评测和成本系统设计,RAG 才不仅能在首次导入时工作,也能在长期变化中保持可解释和可恢复。


系列导航与关联阅读

官方资料

本文依据研究论文、标准组织与主流框架官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。