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):知识源变化的可消费记录,例如创建、更新、删除或权限变更。
一个向量记录不能只保存向量本身。至少应能回答以下问题:
- 它属于哪个文档?
- 属于哪个文档版本?
- 使用了哪一个分块算法和嵌入模型?
- 当前是否仍然有效?
- 哪个租户或用户可以访问?
- 它是否已经对查询可见?
- 如果删除或回滚,如何找到并撤销它?
一个简化的数据关系如下:
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_uri 或 document_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 时才推进版本
具体是否可以直接比较整数,取决于源系统是否保证该版本号对每个文档单调递增。
三、版本:防止旧任务覆盖新内容
增量索引最常见的竞态是:
- 文档从版本 41 更新到 42;
- 版本 41 的嵌入任务运行很慢;
- 版本 42 的任务先完成并发布;
- 版本 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. 为什么物理删除不能作为唯一事实
如果只调用向量数据库的删除接口,随后发生以下任一情况,系统可能无法恢复:
- 删除请求超时,但实际已成功;
- 删除只删除了部分分块;
- 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 之后又更新,切换后仍可能显示旧内容。
六、增量索引的一致性究竟是什么
“索引一致”不是单一性质。应先说明系统提供哪一种保证。
设源系统在时刻 的状态为 ,索引是源状态经过索引函数得到的投影:
其中 包含规范化、分块、嵌入、元数据和可见性规则。
由于 CDC、嵌入和向量写入都需要时间,实际索引可能是:
这里的 是索引陈旧时间,而不是数据错误。若系统允许最终一致性,必须能观测并约束 。
1. 基本不变量
一个合理的索引系统至少应满足:
不返回已删除文档
如果文档在源版本 被删除,那么对所有查询时刻 ,只要查询看到的水位不低于 ,就不能返回它:
不返回旧版本覆盖新版本
对同一文档,若查询水位为 ,返回的版本应满足:
并且在“已追平”语义下应返回该水位内最新的有效版本,而不是任意旧分块。
权限安全优先于召回率
对用户 ,结果集合必须满足:
检索少了相关文档属于召回损失;返回未授权文档属于安全错误,两者严重性不同。
向量元数据和向量内容必须匹配
一个向量不能标记为 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)
这里有两个重要点:
- 不能在数据库事务中长时间等待嵌入服务,否则会占用锁并放大故障。
- 释放锁后,较新的版本可能已经提交,因此
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. 行为级验证
构造四组测试:
- 更新后查询新关键词,必须能召回新版本;
- 删除后查询旧关键词,不能召回已删除文档;
- 乱序投递旧、新、删除事件,最终状态必须等于源系统;
- 无权限用户查询高相似度内容,结果中不得出现该文档。
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 = 增量系统额外维护成本
全量重建的近似工作量为:
增量处理的近似工作量为:
当 时,增量通常更经济;但发生以下变化时, 可能接近 :
- 分块规则全面变化;
- 嵌入模型更换;
- 权限模型变化;
- 文档规范化逻辑变化;
- 向量数据库迁移。
这时把全量重建称为“失败”并不准确。全量重建更容易获得一致的快照,增量则更适合日常小规模内容变化。生产系统常常同时拥有:
日常 CDC 增量更新
定期对账扫描
模型或分块变化时的全量新代构建
十七、常见误解
误解一:更新一个文档只需 upsert 新向量
如果旧版本的分块没有撤销,查询可能同时返回新旧内容。正确做法是让旧版本不可见,或者查询时严格过滤到当前 active version。
误解二:向量库返回删除成功,就说明删除完成
删除可能是异步的,具体可见性语义取决于产品实现。业务状态、查询过滤和后台物理清理应分开设计。
误解三:事件不重复就不需要幂等
即使消息不重复,网络超时也可能造成客户端重试;跨系统调用很难仅靠消息层保证端到端恰好一次。幂等仍然需要。
误解四:更新时间可以天然作为版本
时间戳可能相同、回拨或在不同节点上不一致。若要排序,优先使用源系统的提交序号或明确的 revision。
误解五:重嵌入只替换向量列
重嵌入还涉及模型身份、维度、距离度量、查询编码器、索引代和评测基线。只替换数值而不记录这些元数据,会导致后续无法解释结果。
误解六:权限过滤可以放在生成前
未授权内容如果已经进入候选集,就可能出现在日志、重排输入或提示词中。权限控制必须尽量靠近检索数据源,并对最终结果再次校验。
十八、最低可行的生产语义
一个可接受的增量 RAG 索引,不必一开始就提供全局强一致,但至少应明确以下契约:
1. 每个文档有可排序的源版本。
2. 事件至少一次投递,消费者幂等。
3. 旧版本不能覆盖新版本。
4. 删除有持久墓碑,旧事件不能复活数据。
5. 新版本验证完成后才可见。
6. 查询使用明确的 generation 或 revision 水位。
7. 权限过滤在检索路径中执行。
8. 嵌入模型、分块器和向量维度可追溯。
9. 任务失败可重试,孤儿向量可发现和清理。
10. 通过水位、延迟、错误和对账结果验证系统状态。
RAG 的检索效果建立在知识索引之上,而增量索引决定这份知识是否仍然代表源系统。变更捕获解决“变化如何进入系统”,版本解决“哪个变化可以覆盖哪个状态”,删除和墓碑解决“旧知识如何失效”,重嵌入解决“向量空间如何迁移”,一致性模型则明确“查询在什么时候能看到什么”。只有把这些部分作为同一个数据、模型、权限、评测和成本系统设计,RAG 才不仅能在首次导入时工作,也能在长期变化中保持可解释和可恢复。
系列导航与关联阅读
- 系列入口:AI 工程完整学习路线:从机器学习与 Transformer 到 RAG、Agent 和生产治理
- 上一篇:RAG 引用与溯源:证据定位、忠实度、冲突和可验证回答
- 下一篇:Graph RAG:实体关系、图构建、社区摘要、检索与适用边界
官方资料
本文依据研究论文、标准组织与主流框架官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。

评论
0 条讨论