RAG 工程与检索系统设计
第8章 增量索引:更新、删除、重建与一致性
"Correctness is the property that the system tells the truth. Consistency is the property that it always tells the same truth." — Leslie Lamport 的改写
本章要点
- 知识库是活的——每天都在新增、更新、删除、归档。离线索引必须支持增量更新
- 三类变更事件:新增(最简单)、更新(最复杂)、删除(最容易漏)
- 一致性三层约束:chunk_id 稳定 / 向量与正文同源 / metadata 与内容对齐
- 全量重建是避免一致性退化的终极保险——按月或按季度跑一次,成本可控
- 变更检测靠内容 hash + event log,不靠 mtime 或外部通知
8.1 为什么增量比批处理难
第 4 章讨论过离线索引的三种触发方式(批、流、增量)。多数项目最终会落到增量——全量重建太慢、纯流式对乱序和重试不友好、增量是两者的工程折中。
但增量索引的实现复杂度远高于批处理。批处理有一个珍贵的特性:每次跑完都是新世界——不用管之前的索引长啥样、不用担心残留。增量索引必须和既有状态协作:
- 这份文档是新文档还是更新了旧文档?
- 旧文档的旧 chunk 在哪里、要不要删?
- 文档改了一小段,是整份重新分块还是只改变动的 chunk?
- 向量库的状态、元数据 DB 的状态、业务 DB 的状态,三者能保持一致吗?
- 失败了一半,下一次重试要怎么续?
这些问题在全量批处理里都不存在。但生产 RAG 没有"每天重建百万 chunk"的奢侈——时间、算力、成本都不允许。必须接受增量的复杂度换效率。
8.2 三类变更事件的分别处理
新增:最简单
新文档进入系统的处理路径:
- 解析 → IR(第 5 章)
- 分块 → chunks(第 6 章)
- Embedding → vectors
- 写入向量库 + metadata DB
- 更新 doc_version 表:
doc_id, content_hash, status=indexed, indexed_at
幂等性:重复的"新增"请求(相同 content_hash)应被识别为 no-op,不是重复索引。第 4 章讨论的 content_hash 派生 chunk_id 让这一步天然幂等——重复写同 ID 是 upsert。
更新:最复杂
更新文档的核心难题:旧 chunk 怎么处理。三种方案:
方案 A:全删全插。删除旧 doc_id 下所有 chunk、重建整份文档的 chunk。最简单、最一致。代价:
- 即使只改了一段,整份文档要重跑 embedding——浪费算力
- 向量库删除 + 新增比 upsert 昂贵
方案 B:diff-based 增量。新旧 chunks 做 diff,只 embedding 变动的 chunk、删除消失的、新增出现的。最省算力。代价:
- diff 算法复杂——chunk 边界变了怎么算相似?内容小改怎么识别?
- 实现复杂度是方案 A 的 5-10 倍
方案 C:chunk 级内容 hash。给每个 chunk 按 (doc_id, chunk_index, content_hash) 建稳定 ID。更新时重分块、重计算每个 chunk 的 content_hash。hash 相同的 chunk 原样保留、hash 变的 chunk 重 embedding。
方案 C 是方案 A 的工程优化——全删全插逻辑不变,但 embedding 复用已有的 cache。实测下来:一份文档改了 20% 内容,方案 C 能把 embedding 调用减少到原来的 20%、整体成本降 80%。
flowchart TB
NEW[新文档内容] --> PARSE[解析 + 分块]
PARSE --> CHUNK_LIST[新 chunk 列表]
CHUNK_LIST --> LOOKUP{查 chunk_id}
LOOKUP -->|已存在| SKIP[跳过 embedding<br/>复用 cache]
LOOKUP -->|新 chunk| EMBED[embedding]
SKIP & EMBED --> UPSERT[upsert 向量库]
UPSERT --> CLEANUP[清理旧 doc 下<br/>未出现在新列表的 chunk]
CLEANUP --> COMMIT[更新 doc_version]
生产推荐方案 C——复杂度接近方案 A、成本接近方案 B。
删除:最容易漏
删除是三类里最容易出问题的。"漏删"导致检索结果里混着已删除文档的 chunk——用户看到"幽灵"知识。
硬删除 vs 软删除:
- 硬删除:从向量库和 metadata DB 物理删除。不可恢复
- 软删除:标记
deleted=true,查询 filter 过滤掉。可恢复
生产推荐软删除 + 周期性清理:
- 即时生效:查询 filter 必须加
deleted = false - 保留窗口:软删除后 30-90 天物理清理。期间误删可恢复
- 审计:删除操作记 audit log(谁、何时、为什么)
级联删除的范围
删一份文档时要删的不止 chunk:
- 向量库中该 doc_id 的所有 chunk
- metadata DB 中该 doc 的 row
- 缓存层的相关条目(query cache、rerank cache)
- 反馈闭环里关联的日志(可能保留用于审计、但要标 "referenced doc deleted")
任何一处漏掉都会在后续某处露馅。生产建议:删除走事件总线——发一个 doc_deleted 事件,各下游(向量库、缓存、日志服务)订阅各自处理。事件处理幂等 + 记录 offset。
8.3 变更检测:不靠 mtime
怎么知道某份文档变了?几种方案优劣:
- mtime(修改时间):最常见、最不靠谱——touch 一下文件就变,复制、解压都可能改 mtime
- size + mtime:略好但同样不可靠
- 完整 content hash:绝对可靠、但每次都要读全文算 hash
- 事件通知(inotify / webhook / event bus):最及时,但容易丢事件,需要对账机制
生产推荐 content hash + event bus 兜底:
- 主链路:文档源系统(Git / CMS / S3)推送
doc_changed事件给 RAG 的索引队列 - 兜底:每天全量扫描一遍所有文档、按 content hash 对比索引记录、发现不一致的重新入队
- 对账:event bus 的 offset lag 监控——超过阈值告警
flowchart LR
SRC[(文档源)] -->|webhook / CDC| BUS[Event Bus]
BUS --> Q[索引队列]
Q --> W[Worker]
W --> VDB[(向量库)]
SCAN[每日全扫] -.对账.-> BUS
SCAN -.发现缺失.-> Q
事件丢失的防御
任何 event bus 都不是 100% 可靠:网络分区、broker 重启、消费者 bug 都可能导致事件丢失或重复。防御:
- 幂等消费:同一事件重复处理不会坏
- dead letter queue:处理失败的事件进 DLQ,运维定期看
- 对账:每日一次全扫对比、自动补齐
有的团队觉得全扫"浪费"——但每日一次对几十万文档计算 content hash 耗时不到 10 分钟(纯 I/O + 小 hash),这个成本完全值得换来的"永不漏"的保证。
8.4 一致性的三层约束
RAG 索引系统的一致性有三层。任何一层破坏都是 bug。
chunk_id 稳定
同样的内容(同样的 doc_id + chunk_index + content_hash)必须映射到同一个 chunk_id。违反的后果:文档更新一次、所有 chunk_id 都变了、向量库里既有旧 id 又有新 id、清理时漏删旧 id、最终变成幽灵 chunk。
向量与正文同源
chunk 的 embedding 向量必须由同一版本的 chunk 文本产生。违反的后果:向量记录的是上周的版本、正文是这周的版本、检索命中了"看起来很相关"但内容其实改过的 chunk。
这层最容易被忽略——"正文存了一份、向量存了一份、两者不对应"的故障常发生在:
- 索引 pipeline 的向量写入和正文写入分两步,第一步成功第二步失败,之间没有事务
- 更新文档时漏了重新 embedding,只改了正文
正确姿势:向量和正文的写入在同一个原子操作里完成(向量库的 upsert API 支持 vector + payload 一起写)。或者用 outbox pattern——先写 outbox、再有一个 worker 读 outbox 同步到向量库。
metadata 与内容对齐
chunk 的 metadata 必须反映当前内容的真实属性。违反的后果:
publish_date指向文档首版、但内容已经被改了十次access_level指向旧的访问级、而文档已升级到更敏感tags指向旧分类、内容已经换主题
这些错位不会立刻发现——直到有一天某个用户通过 metadata filter 拿到了本不该看到的 chunk。
一致性检查的日常作业
每天跑一次 consistency check:
- 向量库的 chunk 数 vs metadata DB 的 chunk 数 应相等
- 每个 chunk 的 content_hash 必须在 metadata DB 里有对应 row
- 每份 doc 的 chunk_count 必须等于 metadata DB 里该 doc 的 chunk 数
- 软删除超 90 天的 chunk 应已硬清理
不一致记 alert、人工复盘。
8.5 失败恢复:从哪里续跑
增量 pipeline 必然有失败——OOM、OOM、网络抖动、第三方 API 限流。失败恢复的关键是知道从哪续。
checkpoint 机制
pipeline 的每个阶段记 checkpoint:
- 解析:已解析的 doc_id 列表
- 分块:已分块的 doc_id 列表
- Embedding:已 embedding 的 chunk_id 列表
- 写入:已 upsert 的 chunk_id 列表
失败时从最后一个 checkpoint 之后继续。不重跑已完成的部分。
idempotency key
每次 pipeline run 有一个 run_id。同一个 (run_id, doc_id) 多次处理等价于一次——避免重试导致重复写入。
DLQ 人工介入
某份文档反复失败(解析 bug、编码异常、权限问题)进 DLQ。每周人工看一次。DLQ 里的文档不会阻塞整体 pipeline。
flowchart LR
Q[输入队列] --> W[Worker]
W -->|成功| DONE[完成]
W -->|短暂失败| RETRY[重试 3 次]
RETRY -->|成功| DONE
RETRY -->|仍失败| DLQ[DLQ]
DLQ --> HUMAN[人工 review]
HUMAN -->|修复| Q
HUMAN -->|确认放弃| ARCHIVE[归档]
8.6 周期性全量重建:终极保险
增量再精细也会累积一致性退化。索引几个月后不一定和源完全对齐——可能有漏删、有残留的老版本、有 metadata 漂移。
解药:周期性全量重建。按月或按季度完整跑一次全量索引、原子切换到新版本、老版本保留 48 小时后删除。
全量重建的意义
- 修复所有累积的一致性漂移
- 利用新版 Embedding 模型(过去几个月可能升级过)
- 清理软删除数据、收缩索引体积
- 验证离线 pipeline 的端到端能跑通
成本评估
100 万 chunk 的全量重建:
- Embedding:单 GPU 约 30-40 分钟(bge-m3 @ 800 chunk/s)
- 向量库构建:HNSW 约 15-20 分钟
- 总资源成本:约 1 张 A100 × 1.5 小时 ≈ $6(AWS 按量)
月度一次完全可接受。季度一次更保险。季度成本相对 RAG 整体 infra 成本是个零头。
切换策略
全量重建完成后的切换:
- 新版本验证:新索引上跑一次 gold set、对比旧版的 recall/MRR/延迟
- 灰度切换:10% 流量 → 50% → 100%,每个阶段观察 24 小时
- 快速回滚:保留旧版本 pointer,发现问题立刻切回
- 留档:成功切换后老版本保留 48 小时,之后删除
8.7 Schema 变更的平滑迁移
增量索引的最大挑战不是日常新增/删除,而是 schema 变更——metadata 字段增减、chunk 格式变化、embedding 模型升级。
三阶段迁移模板
- 双写(2-4 周):新 schema 字段和旧 schema 并存,新写入填两份、旧数据保留原样
- backfill(1-2 周):后台 job 把老数据按规则填充新字段
- 切换(1 周观察 + 下线旧字段):新查询路径用新字段、老字段停止读取、最终 drop 列
每阶段都有灰度 + 对账 + 回滚预案。和方案 C 更新策略一样,schema 迁移也靠增量完成——不停服、不 breaking。
Embedding 模型升级:最难的迁移
Embedding 模型升级(bge-m3-v1 → v2)必须把所有向量重算。这不能增量做——不同模型的向量空间不兼容,混用会让检索质量崩溃。
正确做法:
- 新模型版本启动全量重建(§8.6)
- 新旧索引各服务一部分流量做 A/B
- 新索引表现稳定后 100% 切换
- 旧索引保留 2 周作为回滚兜底
这是为什么第 4 章强调 embedding_model_version 必须写进索引 metadata——混用事故的防线。
8.8 写入吞吐和延迟
生产增量索引的吞吐瓶颈通常在:
| 阶段 | 典型吞吐 | 瓶颈 |
|---|---|---|
| 解析 | 50-200 doc/s | 外部 OCR / 大文件 I/O |
| 分块 | 5000-20000 chunk/s | 纯 CPU |
| Embedding | 500-800 chunk/s | GPU 计算 |
| 向量库写入 | 1000-5000 chunk/s | 向量库索引构建 |
| Metadata DB 写入 | 10000+ row/s | 普通 SQL 写入 |
瓶颈几乎永远是 Embedding。扩容策略:
- 水平:多 GPU worker 并行、共享消息队列
- 降级:小更新用 small 模型(bge-small)快速入库、次日全量重建时再用 large 模型统一
- 批处理:积累 batch=64/128 再调用 embedding,GPU 利用率高一倍
延迟 SLA
离线索引的新鲜度 SLA 通常这样约定:
- 紧急更新(< 1 小时):关键文档改动(价格、政策)必须 1 小时内生效
- 日常更新(< 24 小时):一般文档改动次日生效
- 批量归档(< 7 天):历史数据整理可以延迟
队列按优先级分:紧急队列永远优先处理、日常队列承担 90% 流量、批量队列低优先级填补空闲。
反压和限流
生产增量 pipeline 必须有反压机制——上游爆发性写入(一次性 push 10 万份文档)不能把下游打爆:
- 队列长度监控:队列超过阈值时上游写入被限流或排队
- GPU 利用率监控:embedding worker 持续 100% 时告警,加 worker 或降级到小模型
- 下游限速:向量库 upsert QPS 有上限(Qdrant 单实例建议 < 5000 write QPS),超过时队列自动 backoff
限流策略按租户分——不能因为一个大客户批量上传把其他客户的更新拖慢。每租户独立队列 + 全局配额是成熟方案。
索引热点和冷数据
生产 RAG 的索引里数据分布极不均匀:
- 约 10-20% 的 "热 chunk" 承担 80% 的检索命中
- 约 50% 的 chunk 可能从未被召回过
- 长尾冷数据占用索引空间、拖慢 ANN 搜索
应对策略:
- 冷热分离:热 chunk 放 HNSW in-memory、冷 chunk 放磁盘 IVF-PQ。查询先查热索引、miss 才查冷
- 归档下沉:一年没命中的 chunk 移到冷存储、metadata 保留但向量下沉。需要时可以 on-demand 重激活
- 淘汰策略:对明显过时的 chunk(超 deprecation 期的政策文档)主动清理
这些策略会让索引成本降 30-50%、P50 延迟改善明显。第 11 章会展开向量数据库的相关配置。
8.9 冷启动:首次全量索引的工程
前面 8 节讨论的增量机制都假设"系统已经运转着、老索引在"。但项目刚上线时——从零向百万级文档建第一份索引——面对的是另一类问题:没有老索引可 dual-write、没有基准 recall 可对比、没有参数经验可复用。首次全量索引往往是整个 RAG 项目第一次真正意义上的压力测试。
冷启动和周期性全量重建的区别
flowchart LR
classDef cold fill:#fed,stroke:#c60
classDef warm fill:#def,stroke:#06c
CS[冷启动<br/>首次全量]:::cold
RB[周期重建<br/>定期全量]:::warm
CS --> A1[无老索引对比]
CS --> A2[参数未标定]
CS --> A3[失败模式未探明]
CS --> A4[优先级不清]
RB --> B1[有老索引做 shadow]
RB --> B2[参数已调优]
RB --> B3[失败模式有 runbook]
RB --> B4[和老索引优先级一致]
- 周期性重建(§8.6)是在既有基础上 refresh、有对比基准、参数已调
- 冷启动是从零构建、每一步都要摸索——出错找不到"昨天还好的版本"做对比
这导致冷启动不能一把梭——多数团队直接跑"把 200 万文档全灌进去"的脚本、跑到一半内存爆、队列积压、embedding 账单失控、哪一步错都说不清。
三阶段路径
冷启动的稳健做法是渐进扩大:
阶段 1:试验切片(1% 样本、1-2 天)
- 从全量文档里随机抽 1%(或精心挑选的代表集)
- 跑完整 pipeline:parse → chunk → embed → upsert
- 目标是摸清每个阶段的真实吞吐、失败率、成本
- 构造一个小 gold set 验证 retrieval 基本工作
这一步不是为了有可用索引、是为了校准参数和发现未知问题。1% 样本的失败率和 100% 的比例一致——试验切片里 5% 文档解析失败,全量会是相同比例、提前知道。
阶段 2:分批全量(按优先级分批、1-2 周)
- 按业务优先级分批:P0 核心文档(产品手册、FAQ)→ P1 常用(技术博客)→ P2 长尾
- 每批独立可发布——P0 跑完就能上线服务核心场景、不用等 P2
- 每批前把阶段 1 的经验代入:调过参数、修过 bug、预算可控
这种分批让首次可用时间从"全部灌完"提前到"P0 灌完"——业务侧提前几天到几周开始收反馈。
阶段 3:校验与开放(1-3 天)
- 全量完成后跑一次 consistency check(§8.4)
- 在 gold set 上验证 recall@10、和试验切片的数字对比
- 灰度开放给用户——不是一次 100%
- 保留 1-2 周的"冷启动观察期"、监控 badcase
优先级分批的依据
怎么定 P0 / P1 / P2?三条线索:
- 业务热度:哪些文档最常被员工 / 用户翻阅——从业务 analytics 抽
- 时效敏感:哪些文档回答生效期强(价格、政策、产品规格)
- 高风险场景:合规 / 医疗 / 法律相关必须先进、不允许漏
典型 P0 规模:总文档的 5-15%。这个体量能在几小时到一天内灌完、当日出 demo。
进度追踪与 watermark
冷启动跑几天的 pipeline 必须能回答"现在进度到哪、还有多久、哪些 doc 已完成":
- watermark:pipeline 每个阶段记录 "已处理到 doc_id = X、时间 T"
- queue lag:待处理队列深度 + 处理速率 → ETA
- cost counter:实时累加 embedding 调用成本、和预算对比
- 失败桶:失败文档按错误类型分组、随时看"解析失败 120、权限不足 35"
没这些仪表盘的冷启动等于闭眼跑——跑到一半团队没人能答"我们现在烧了多少钱、再 3 天能不能跑完"。
失败的判断:停下来 vs 继续
冷启动里遇到大规模失败(某批次 > 20% 文档失败)要果断决策:
- 停下来修:失败原因系统性(某类 PDF 解析全挂、权限配置错)——继续跑浪费算力
- 继续跑:失败是随机性的小比例、可以进 DLQ 事后补
错误判断的代价是几千美元 embedding 账单。早期阶段(试验切片)发现一类系统性失败、能避免这种损失。
常见坑
- 低估成本:想当然 "每 chunk $0.0001、总共 500 万 chunk、不就 $500 吗"——忽略了 embedding batch 重试、失败重跑、多模型混用。实际账单经常 2-3×
- 没做试验切片:直接上 100% 、遇到问题时已经跑了一半
- 并发失控:同时起 200 个 worker、每个 worker 1000 QPS、总 QPS 超过 embedding 服务 rate limit、大部分请求 429。要从小并发开始爬
- P0 定义太宽:声称 "核心文档占 30%"——实际冷启动期对外说 "P0 完成"、其实还没到真正核心。P0 要严格、宁少不多
- 切换时机错:P0 完成后直接切 100% 流量——前期用户期望值拉高、后来 P1 还没跟上时体验反而掉。灰度切换要稳
冷启动之后是稳态
冷启动完成的标志不是"索引建好了"、是:
- gold set recall 和试验切片一致
- 增量 pipeline 稳定跑了 2 周
- 第一次小规模周期性重建(§8.6)成功执行
- runbook 和监控都齐备
到此为止项目才算从"冷启动"过渡到"稳态运营"。这个过程通常 1-2 月、不要压缩。
8.10 实战:一次糟糕的删除事故复盘
某企业知识库上线三个月后收到安全投诉:
- 员工发现 RAG 能召回一份上月被 HR 标记为"作废"的政策文档(已被"删除")
- 调查:该政策确实从 CMS 删了,但 RAG 向量库里 chunk 还在
- 根因:CMS 的"删除"走的是软删除、发
doc_updated事件、payload 标记deleted=true。但 RAG 侧索引 pipeline 只监听doc_created和doc_updated,把deleted=true的doc_updated事件当成普通更新处理——重跑了一遍 embedding + 写入向量库,且没加deletedfilter
修复:
- 紧急:向量库每份 chunk 加
deletedmetadata 字段,查询 filter 强制deleted=false - 短期:RAG 索引 pipeline 识别
deleted=true的 doc_updated,走删除流程而非更新流程 - 中期:CMS → RAG 事件改用独立的
doc_deleted事件类型,不用 payload 标记区分 - 长期:CI 加"删除后索引应不可见"自动化测试
复盘写在内部 wiki,标题就叫 "deleted=true 字段的隐形 bug"。这类事故的教训是事件类型要显式——别在相同事件类型里用 payload 字段暗示不同语义。
8.11 多数据源的协调与数据血缘
前 10 节讨论的增量索引默认单一数据源——要么都是 Git 文档、要么都是 Confluence。真实企业 RAG 的数据源几乎永远多源:Confluence + Google Drive + SharePoint + Notion + Git + 飞书 / 钉钉 + Jira / Linear + 业务 DB。每个源的 API 能力、变更通知、权限模型、更新频率都不同——协调这些源让它们作为统一知识库被检索、是比单源索引难一个数量级的工程问题。
多源的典型挑战
flowchart TB
classDef src fill:#fed,stroke:#c60
classDef prob fill:#fdd,stroke:#c00
S1[Confluence]:::src
S2[Google Drive]:::src
S3[Git Wiki]:::src
S4[Notion]:::src
S5[Jira]:::src
S6[业务 DB]:::src
S1 & S2 & S3 & S4 & S5 & S6 --> H{协调}
H --> P1[事件格式不统一]:::prob
H --> P2[权限模型不统一]:::prob
H --> P3[更新频率差异大]:::prob
H --> P4[ID 不冲突难]:::prob
H --> P5[跨源内容重复]:::prob
- 事件格式不统一:Confluence webhook 是
{pageId, ...}、Drive 是{fileId, ...}、Jira 是{issueKey, ...}——索引服务要统一消费 - 权限模型不统一:Confluence 用空间 + 页面 ACL、Drive 用文件共享设置、Git 用 repo 权限——RAG 要把它们映射到统一 ACL
- 更新频率差异:业务 DB 每秒更新、wiki 每天更新、SharePoint 每月——索引 pipeline 要对频率差异鲁棒
- ID 冲突:不同源可能用同样的 ID 序列——chunk_id 里必须带 source 前缀
- 跨源内容重复:同一份制度在 Confluence 和 Drive 都有——检索时需要去重
统一 Source 抽象
把每个数据源抽象成统一接口是多源 pipeline 的基础:
class Source(ABC):
@abstractmethod
async def list_docs(self, since: datetime) -> list[DocRef]:
"""列出自 since 以来变更的文档"""
@abstractmethod
async def fetch(self, doc_ref: DocRef) -> Doc:
"""拉取文档完整内容"""
@abstractmethod
async def subscribe(self, callback) -> None:
"""订阅实时变更事件"""
@abstractmethod
def resolve_permissions(self, doc: Doc) -> list[str]:
"""把源的权限映射到统一 ACL"""
每个 source 实现这个接口——Confluence 实现、Drive 实现、Git 实现各有不同内部逻辑、但对上层索引 pipeline 行为一致。新加一个数据源就是实现这个 interface——不用改 pipeline。
事件总线作为协调核心
多源事件汇聚到统一事件总线、索引 worker 从总线消费:
flowchart LR
S1[Confluence] --> B[Event Bus]
S2[Drive] --> B
S3[Git] --> B
B --> W1[Indexer 1]
B --> W2[Indexer 2]
B --> W3[Indexer 3]
W1 & W2 & W3 --> Q[(向量库)]
事件格式统一为:
{
"event_type": "doc_upserted | doc_deleted",
"source": "confluence",
"source_id": "page-12345",
"unified_id": "confluence:space-A:page-12345",
"timestamp": "2026-04-25T10:00:00Z",
"checksum": "sha256:...",
"permissions": ["role:eng", "tenant:acme"]
}
unified_id 是跨源的稳定标识、chunk_id 派生自它——避免不同源 ID 冲突。permissions 是 source 权限经过 resolve_permissions 映射后的统一 ACL。
数据血缘:chunk 到 source 的可追溯
多源环境下、一个生产 chunk 的诞生经过多步:source → doc → parsed_blocks → chunks。任一环节有问题都可能 bug——必须能反向追溯:
每个 chunk 记完整血缘链:
{
"chunk_id": "confluence:space-A:page-12345:chunk-7",
"lineage": {
"source": "confluence",
"source_url": "https://confluence.../page/12345",
"source_doc_version": "v47",
"fetched_at": "2026-04-25T10:05:00Z",
"parser_version": "unstructured-0.15.2",
"chunker_version": "structured-v3",
"chunk_strategy": "heading-based",
"chunk_index": 7,
"embedding_model": "bge-m3",
"embedding_version": "2024.12"
}
}
这条血缘的价值:
- 事故归因:某 chunk 答错、血缘告诉你是哪个源、哪一版文档、哪个 parser、哪个 chunker——定位根因
- 版本回滚:发现 parser v0.15.2 有 bug、按血缘找出所有用它产生的 chunk、批量重处理
- Audit 合规:法务问"这条答案的原始来源是什么"、血缘直接出证(呼应 ch17 法务级溯源)
没血缘的多源 pipeline 是调试地狱——"这个 chunk 哪来的"靠猜。
跨源冲突的处理
同一份知识在多源存在——最常见:
- 产品规格在 Confluence 有一版、Drive 里有一份 PDF、Git 里 README 也提——三版可能不同步
- 会议纪要在 Notion 写了、转到 Confluence 归档、Drive 里还有录音转写——同一内容三表达
处理策略:
- 授权源(authority source):人为指定每类内容的 "唯一权威源"——规格以 Confluence 为准、代码以 Git 为准、会议以 Notion 为准。其他源即使召回到也优先级降低
- 版本仲裁:同一 unified_id 在多源出现、按
fetched_at最新为主、其他标记 deprecated - 召回层去重:通过 content_hash 相似度去重(ch16 §16.3)、检索时只返回一份
哪种策略都不完美——多源知识治理的终极方案是推动组织层面的"单一来源"纪律。工程手段只能缓解、不能根治。
新增数据源的 onboarding
多源 RAG 最常见运维任务:新加一个数据源(新收购的公司、新上线的工具)。标准 onboarding 流程:
- 可行性评估:source 的 API / webhook / 权限模型是否支持
- Source 实现:继承
Source接口、写具体逻辑 - 权限映射:和统一 ACL 的映射规则
- 试运行:10% 采样、跑一周、观察指标
- 全量灌入:按 ch8 §8.9 冷启动三阶段
- 监控告警:加该源独有的监控(API 限流、webhook 丢失等)
- 文档化:写进 runbook、onboarding 手册
一次完整 onboarding 典型 2-4 周。不要为了快省略流程——后续发现问题修复成本远大。
多源场景的独特监控
除了单源的常规指标、多源要加:
source_availability:每个 source 的 webhook / API 可用性cross_source_drift:不同源里相同内容的一致性(按 unified_id 对比)source_throughput:每源的 ingest 速率、异常波动告警permission_mapping_errors:权限映射失败次数(source ACL 对不到统一 ACL)
多源系统的故障模式比单源复杂得多、没有针对性监控就是盲区。
8.12 索引前的数据质量 gate:过滤与修复
前面章节讨论的 ingest pipeline 默认 输入文档质量可接受——解析后直接分块、embed、入库。生产里这个假设几乎总是错的:文档库里混杂着空页、扫描模糊的 PDF、过时草稿、测试数据、甚至随机 log 文件。把这些"垃圾文档"喂进索引、后果比完全不索引还坏——污染检索结果、降低用户信任。数据质量 gate 是 ingest 和索引之间的一道关口——低质量文档在这里被拦截或标记、不污染下游。
数据质量的五个维度
flowchart TB
classDef ok fill:#dfd,stroke:#080
classDef bad fill:#fdd,stroke:#c00
D[入库候选文档]
D --> Q[质量 gate]
Q --> Q1[完整性<br/>文档 is complete]:::ok
Q --> Q2[有效性<br/>内容有信息量]:::ok
Q --> Q3[时效性<br/>未过期]:::ok
Q --> Q4[合规性<br/>无敏感 / 合规问题]:::ok
Q --> Q5[原创性<br/>非重复]:::ok
Q1 & Q2 & Q3 & Q4 & Q5 --> PASS[通过 入索引]
Q --> REJECT[拒绝 进 quarantine]:::bad
五个维度每一个都要单独检查:
- 完整性:文档不为空、不是半截(比如下载中断)、必要字段齐全
- 有效性:内容有实质信息(不是占位符、不是"TODO: 待补充"、不是纯 log)
- 时效性:不是过期文档(如 2020 年的价目表不应进 2026 年索引)
- 合规性:没有未脱敏 PII、没有敏感标签、无版权红线
- 原创性:不是已入库文档的重复(跨源复制常见)
自动化检查实现
常见检查可以自动化:
def quality_check(doc):
issues = []
# 完整性
if len(doc.text) < 100:
issues.append("too_short")
if doc.title is None or not doc.title.strip():
issues.append("missing_title")
# 有效性
if is_placeholder(doc.text): # 如全是 "TODO" 或 "lorem ipsum"
issues.append("placeholder")
if entropy(doc.text) < MIN_ENTROPY: # 信息熵过低
issues.append("low_entropy")
# 时效性
if doc.publish_date < EXPIRY_THRESHOLD:
issues.append("expired")
# 合规
if pii_scan(doc.text):
issues.append("contains_pii")
# 原创性(和已有 chunk 做 near-duplicate 检测)
if find_duplicates(doc.text, threshold=0.9):
issues.append("duplicate")
return issues
每个检查都简单、组合起来覆盖绝大多数低质量文档。
低质量文档的处理
识别到低质量不是简单"丢弃"——有几种处理:
- Hard reject:完全不入库。适合完全无效的(空文档、纯 log)
- Soft reject + quarantine:进隔离区、等人工 review。适合模棱两可的
- 标记 + 入库:入库但打 low_quality 标签、检索时降权或过滤。适合"有价值但质量一般"的
- 修复后入库:自动修复简单问题(trim、去重复段)、修复后通过 gate
判断:宁可多拦截、不要漏——污染索引的代价高于漏入库。
Quarantine 的工作流
被拦截的文档进 quarantine 区——有专门的工作流:
flowchart LR
Q[Quarantine queue] --> R{每周 review}
R -->|修复| FIX[人工编辑后重新提交]
R -->|确认坏| DEL[删除]
R -->|误判| ADJ[调整 gate 规则<br/>放行类似文档]
FIX --> Q2[重新 ingest]
Quarantine 不是"丢了就忘"——是一个持续闭环:
- 每周 review 队列(积累不超过 1 周、否则变僵尸)
- Review 结果反馈到 gate 规则(误判多→放宽、漏判多→收紧)
- 统计哪些源文档经常被拦——推动数据治理源头优化
质量 metric 随时间变化
质量 gate 输出的指标是数据源健康度的温度计:
pass_rate:该源文档通过 gate 的比例、稳定 > 95% 好issue_distribution:被拦的原因分布(过期 / 低熵 / 重复 / PII)source_quality_ranking:按源排序 pass_rate、低质源要治理
如果某个源的 pass_rate 突降(从 98% 到 70%)——源那边出问题了(可能重构了文档、可能引入 bot 生成内容)——推动业务方解决、而不是调低 gate。
和第 5 章解析的区别
解析(ch5)把格式转成结构化 text——处理的是语法层。质量 gate 处理的是语义层。两者互补:
- 解析:能不能转出文本?格式是否能识别?
- 质量:文本是否有价值?是否该入库?
解析 OK 但质量低的文档会被 gate 拦截——不会进索引。gate 前置到解析之后、入索引之前。
领域特定的 gate 规则
不同业务的 gate 规则差异大:
法律 RAG:
- 合同版本必须明确("草稿""生效")
- 条款编号完整、无遗漏
- 签字页必须存在
医疗 RAG:
- 诊断编码(ICD)规范
- 药品名使用规范名
- PII 必须脱敏
代码仓 RAG:
- 文件 compile 通过(至少语法合法)
- 不是 binary 或依赖混杂文件
- 去掉自动生成的 boilerplate
通用 gate + 领域 gate 叠加——不能一套规则走天下。
一个实际事故:质量 gate 漏判
某客服 RAG 上线后、客户投诉 "答案里引用了内部未发布的产品名"。调查发现:
- 某开发者把内部未发布产品的 SPEC 文档放进了 Confluence 用于内部讨论
- 未打"内部草稿"标签、按默认可见性入 RAG 索引
- 客服场景没过滤、导致用户看到该产品名
根因:质量 gate 没检查"文档成熟度 / 发布状态"。修复:
- 紧急:所有未打 "published" 标签的文档清出索引
- 短期:gate 加"文档必须有 status = published"的硬检查
- 长期:推动 Confluence 的发布工作流规范化
这个事故反映 gate 不只是技术——要和业务流程对齐。
质量 gate 的反模式
- 不做 gate:垃圾文档直接入索引、检索结果污染
- Gate 规则太严:大量正常文档被拦、quarantine 队列膨胀
- Gate 规则不迭代:一套规则跑多年、新问题进不去
- 只做自动化、没有人工 review:误判积累、规则漂移
- Gate 日志不存:事后想查 "为什么这个文档被拒" 查不到
Gate 的投入
完整质量 gate 的工程投入:
- 初版规则:1-2 人周
- 规则迭代:每月 0.5 人天
- Review 队列:每周 1-2 人小时
- 监控面板:复用通用看板
小投入、但事故预防价值极大——有 gate 的项目比没有的系统性事故率低一个数量级。
8.13 索引的 backfill:局部重建的工程
§8.6 讨论的是周期性全量重建,但现实里更常见的是只需要重建一部分——某个数据源、某个时间段、某类文档。这种局部重建叫 backfill,工程上和全量重建是两回事。
flowchart LR
classDef full fill:#fed,stroke:#c60
classDef back fill:#dfd,stroke:#080
F[全量重建]:::full
F --> F1[5-50 小时]
F --> F2[成本高]
F --> F3[切流大动作]
B[Backfill 局部]:::back
B --> B1[10 分钟-几小时]
B --> B2[成本低]
B --> B3[在线无感]
一个只影响 5% 数据的问题,用全量重建去修就是二十倍的浪费。
五类场景与实现
典型的 backfill 场景有五种,共同点是它们都只是全量重建的一个子集:某个数据源要修(Confluence 里某个 space 的文档都没正确标 author 字段);局部 schema 变更(合同类文档要加 signed_date,只有这类 chunk 需要重建 metadata);某类内容换 embedding(代码类 chunk 用通用模型效果差,升级到代码专用模型);某批数据被污染(发现 2026 年 3 月 15 日那一周的 ingest 有解析 bug);以及补历史(先 ingest 最近一年,之后慢慢往前补)。
实现上是一条短 pipeline,关键在三点——过滤精准、更新原子、过程可监控:
def backfill(filter_expr):
# 1. 定位目标 chunk
target_chunks = metadata_db.query(filter_expr)
print(f"Backfill {len(target_chunks)} chunks")
# 2. 获取原始文档
for chunk in target_chunks:
source = fetch_from_source(chunk.source_id)
# 3. 重跑 pipeline
new_chunks = parse_and_chunk(source)
new_embeddings = embed(new_chunks)
# 4. 原子更新
with transaction():
# 删除老 chunk
vector_db.delete(where=f"source_id={chunk.source_id}")
# 插入新 chunk
vector_db.upsert(new_chunks)
metadata_db.update(...)
# 使用
backfill(filter_expr="doc_type='contract' AND signed_date IS NULL")
backfill(filter_expr="source='confluence' AND space='eng'")
backfill(filter_expr="indexed_at BETWEEN '2026-03-15' AND '2026-03-22'")
和全量重建的差别集中在这张表上:
| 维度 | Backfill | 全量重建 |
|---|---|---|
| 范围 | 过滤后的子集 | 全部 chunk |
| 耗时 | 数据量的 5-20% | 100% |
| 成本 | 比例小 | 大 |
| 对在线影响 | 微(逐步更新) | 中(蓝绿切换) |
| 索引切换 | 原地更新 | 蓝绿整索引 |
| 回滚 | 按文档回滚 | 切回老索引 |
最本质的区别是 backfill 原地更新:不建新索引、逐条替换。好处是不额外占存储,代价是存在中间态——有些 chunk 已经是新的、有些还是旧的。
放到更大的图景里,增量、backfill、全量是三种互补机制:增量应对新文档和改动,实时或近实时、粒度是单个文档;backfill 应对局部问题修复,按需触发、粒度是过滤子集;全量重建应对结构性变更,月度或季度一次、覆盖所有 chunk。
和在线流量共存
backfill 跑的时候在线查询照常,于是要处理三件事。
中间态的一致性——同一份文档的 chunk 可能一半新一半老,混合状态会产生怪结果。三条缓解手段:以文档为单位保证原子(一个文档的所有 chunk 在一个事务里更新)、给处理中的 chunk 打 pending_backfill 标记供查询侧选择性过滤、以及尽量把 backfill 跑快让窗口足够短。极敏感场景可以在 backfill 期间把相关查询路由到只读副本或直接跳过受影响文档,但多数场景不必这么严格。
并行度——全量重建可以开满并行(反正没有在线流量),backfill 不行——它要和线上共存。做法是给每分钟处理的文档数设 rate limit、给 worker 设 CPU 和内存上限、白天业务高峰暂停而凌晨加速,并且监控在线 P99:涨超 10% 就自动降速。这样 backfill 才能连续跑几天而用户无感。
失败续跑——跑几天的任务中途一定会遇到 worker 崩溃或网络中断,所以要持久化已处理的 doc_id 做 checkpoint、保证重复处理同一文档等价于处理一次、并把个别失败的文档丢进 DLQ 而不是阻塞整体。
再加上进度可见,团队才知道它到底在不在跑:
Backfill: contract docs with missing signed_date
Started: 2026-04-25 14:00
Target: 12,543 docs
Processed: 3,210 (25.6%)
Failed: 23 (in DLQ)
ETA: 2026-04-25 18:30
Rate: 15 docs/min
工具化、审计与成本
成熟团队会把 backfill 做成自助工具,降低每次都要工程介入的成本:
# CLI 工具
rag-backfill --filter "doc_type='contract'" --dry-run
rag-backfill --filter "doc_type='contract'" --rate 100/min
rag-backfill --status # 查看正在跑的 backfill
rag-backfill --stop backfill-id # 停止某个
批量更新数据必须留审计记录,金融、医疗这类场景更是刚需:
{
"backfill_id": "bf-abc123",
"initiated_by": "user@example.com",
"reason": "fix missing signed_date field",
"filter": "doc_type='contract' AND signed_date IS NULL",
"started_at": "2026-04-25T14:00",
"completed_at": "2026-04-25T18:32",
"target_count": 12543,
"success_count": 12520,
"failed_count": 23,
"rollback_supported": true
}
成本要在开跑前估,别等账单出来才知道:
def estimate_backfill_cost(filter_expr):
target_count = metadata_db.count(filter_expr)
embed_cost = target_count * 0.00005 # $0.05 per 1000
compute_cost = target_count * 0.0001
return {"embed": embed_cost, "compute": compute_cost, "total": embed_cost + compute_cost}
一个真实例子能把上面这些串起来:某团队发现 2025 年 Q3 的文档 chunking 策略有误、分得太细导致检索质量下降。全量重建要跑 24 小时、影响太大,于是按 indexed_at BETWEEN '2025-07-01' AND '2025-09-30' 做 backfill,范围约 18 万 chunk、占总索引 4%,以 60 docs/min 的速率白天暂停、凌晨加速,三天跑完、用户无感。成本 $40,而全量重建估算是 $800。
常见陷阱正好对应上面每一环没做好的样子:过滤不精准(扫了太多文档白花钱)、没有 checkpoint(中途失败全部白跑)、没有 rate limit(把在线打爆)、不留审计(合规出事查不到)、不估成本(突然超预算)。
这套能力的投入大约 1–2 人月,换来的是遇到任何局部数据问题都能快速修掉,而不是每次都在「忍着」和「跑全量」之间二选一。
8.14 源头数据治理:RAG 质量始于 ingest
§8.12 的 quality gate 讲的是「垃圾到我这里怎么挡」,但更好的做法是源头不产垃圾。企业 RAG 的质量上限不是 retrieval 算法,而是文档本身的质量——垃圾文档再好的 chunking、embedding、rerank 也救不了。
flowchart TB
classDef problem fill:#fdd,stroke:#c00
P[源头质量问题]
P --> P1[信息过时<br/>2020 的政策还在]:::problem
P --> P2[内容矛盾<br/>多份文档说法不一]:::problem
P --> P3[结构混乱<br/>一份文档讨论 5 个主题]:::problem
P --> P4[重要信息缺失<br/>SOP 没写关键步骤]:::problem
P --> P5[信息重复<br/>同一事实 10 篇提]:::problem
这五类问题都不是 pipeline 能解决的,得由内容所有者从源头治。
责任划分与推动业务方参与
先把边界划清楚:
| 责任 | RAG 团队 | 业务 / 内容方 |
|---|---|---|
| 解析、分块、embedding | ✓ | - |
| 检索、rerank、生成 | ✓ | - |
| 文档内容准确性 | - | ✓ |
| 文档时效性 | - | ✓ |
| 文档结构一致性 | - | ✓ |
| 新知识入库 | 流程支持 | 触发 |
RAG 团队不是内容的最终负责人,但要负责推动业务方参与治理。四个机制是有效的:把 RAG 的 badcase 归因到具体文档再拿给业务方看;每类文档设明确的 content owner 并由 owner 负责质量;每月或每季度组织业务方过一遍文档列表、标记过期与不准;把 review 嵌进发布流程,而不是发完就完。
推动要靠工具,因为业务方不懂 RAG 技术但看得懂数据。文档健康 dashboard 展示每类文档的年龄、被检索的频率和用户反馈;文档年龄超阈值自动提醒 owner;多份文档说法冲突时自动报警;每月给每个 owner 一份自己文档的使用分析——当他看到「我的文档被大量使用、但里面说错了」,行动力和空谈完全不同。
三类源头问题的处理
过期是必然发生的。要定明确的 retention policy(政策类 1 年、技术文档 2 年、历史记录 5 年),到龄未更新的自动标 deprecated 并在检索时降权;硬删丢历史、软删占空间,按合规要求选;业务上不用但需备查的走归档。不做过期管理,RAG 就永远在捞旧资料、答过时的答案。
矛盾同样常见——A 文档写企业版 20000,B 文档更新过写 25000 而 A 没同步,RAG 选中任何一条都可能是错的。源头解法是给每类信息指定 canonical source、其余文档引用权威源;所有文档带 version 以明确哪个最新;并在 badcase 分析时把矛盾文档归集起来通知 owner 处理。
结构混乱要靠强制约束。ingest 时要求每份文档带齐 metadata——author、publish_date、last_reviewed_date、doc_type、version、status、owner、approval_chain——没有 metadata 的文档不入库,倒逼业务方规范化,这比入库后再补容易施行得多。同时推动文档模板化:每类文档(SOP、产品规格、FAQ)有 template,标题层级清晰便于结构化分块,代码和表格用标准 markdown 而不是截图。模板要落在业务方真正在用的工具里(Confluence / Notion 内置模板),让他们照着填就行。
企业本来就有 Confluence / Notion / SharePoint,RAG 应当和它们深度集成,形成回路而不是单向消费:源文档改了自动触发 re-ingest、源系统删除后 RAG 立即清、权限变更同步过来、RAG 侧的「引用错」告警回流给文档 owner。
量化与优先级
治理效果要能量化才好管理,四个指标够用:doc_freshness(文档平均年龄)、doc_review_rate(每月被 review 的文档比例)、badcase_per_owner(每个 owner 名下的 badcase 数)、content_coverage(业务话题被文档覆盖的比例)。
大企业有百万级文档,不可能全治,按 80/20 排优先级:P0 是被 RAG 高频召回的文档(使用量前 5%),P1 是 badcase 归因到的,P2 是定价、政策、SOP 这类业务关键类型,P3 是长尾。治理成本随规模上升——小企业文档少、owner 明确,治理容易;中型企业开始出现「漂移」,需要主动治理;大型企业跨多团队,必须系统化。
投入产出大致是:工具建设 2–3 人月,业务方沟通是持续的,培训一次几人周。收益是 RAG 质量提升 10–20 点(高质量 source 对 recall 和 faithfulness 都有帮助)、RAG 团队不用再花时间修「其实是源头问题」的 bug、以及合规性提高。源头投 1 块,RAG 团队省 10 块——难点在于这 1 块要业务方出。
治理是组织工程,不是技术工程
大企业里这件事涉及多个角色:CKO 一级定治理策略,documentation team 负责模板、规范和工具,content owners 对各自文档负责,RAG 团队提供工具支持并反馈 badcase。小团队一人兼几职没问题,但责任必须清晰。
真正的阻力也都在组织侧:业务方觉得这是「IT 的事」;owner 更新文档没有考核激励;工具体验差却又强制更新;大公司跨部门协调本身就难。工具只能帮忙,替代不了组织意愿——所以治理指标最终要进业务方的 KPI,配上每月 review 老文档、新文档入库前 review、badcase 反馈到 owner 的固定动作。
值得强调的是投资重心的判断。很多团队抱怨「RAG 不好用」,根因是源文档不好:改 chunking 折腾几个月,recall 提 1–2 点;把过期文档清理一次,recall 提 5–10 点。治理 1 个月常常胜过技术 6 个月。
还有一个反向的收获:上 RAG 反而推动了企业改善知识治理,因为 RAG 让文档质量问题第一次变得可见——过去问题隐藏在无人翻阅的文档里,现在被放大到每一次问答。这是 RAG 项目的隐形价值。对 RAG 团队来说,早期就要认识到数据质量是关键瓶颈而不只做技术,中期建治理工具推动业务方参与,长期把数据质量纳入团队 KPI——别把自己定位成纯技术团队,好的 RAG 团队一半是内容工程。
8.15 跨专栏关联:事件驱动架构的通用模式
增量索引是典型的事件驱动数据管道——和 Kafka 生态、CDC(Change Data Capture)、现代 MLOps 的数据流是同一套范式。
《Tokio 源码深度解析》第 10 章讨论的 I/O driver 在单机层面和本章事件 bus 在分布式层面解决相似问题——按事件唤醒消费者、避免轮询浪费。第 8-10 章 Tokio 的原理对理解 Kafka 消费者的水位、重平衡、backpressure 有帮助。
CDC 工具(Debezium、Maxwell、MySQL binlog consumer)直接可用——把业务 DB 的变更事件转成 doc_changed 事件送进索引队列。很多企业 RAG 这么接业务 CMS。
8.16 本章小结
- 增量索引比批处理复杂得多——必须处理既有状态和三类变更事件
- 更新的最佳方案是 chunk 级 content_hash 复用——逻辑简单 + 成本低
- 删除易漏——软删除 + 事件总线 + 级联清理三重保险
- 一致性三层约束:chunk_id 稳定 / 向量与正文同源 / metadata 与内容对齐
- 变更检测靠 content hash + event bus,每日全扫兜底对账
- 全量重建是终极保险——月度或季度一次,月成本零头
- Schema 变更和 Embedding 升级分别走三阶段迁移和全量重建
下一章进入第三部分"表示与索引"——从 Embedding 模型的选型和调用工程讲起。