Appearance
第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%。
生产推荐方案 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 监控——超过阈值告警
事件丢失的防御
任何 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。
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 项目第一次真正意义上的压力测试。
冷启动和周期性全量重建的区别
- 周期性重建(§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 能力、变更通知、权限模型、更新频率都不同——协调这些源让它们作为统一知识库被检索、是比单源索引难一个数量级的工程问题。
多源的典型挑战
- 事件格式不统一: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 的基础:
python
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 从总线消费:
事件格式统一为:
json
{
"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 记完整血缘链:
json
{
"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 和索引之间的一道关口——低质量文档在这里被拦截或标记、不污染下游。
数据质量的五个维度
五个维度每一个都要单独检查:
- 完整性:文档不为空、不是半截(比如下载中断)、必要字段齐全
- 有效性:内容有实质信息(不是占位符、不是"TODO: 待补充"、不是纯 log)
- 时效性:不是过期文档(如 2020 年的价目表不应进 2026 年索引)
- 合规性:没有未脱敏 PII、没有敏感标签、无版权红线
- 原创性:不是已入库文档的重复(跨源复制常见)
自动化检查实现
常见检查可以自动化:
python
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 区——有专门的工作流:
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,工程上和全量重建很不同。这节把 backfill 的典型场景和工程实现讲清楚、让团队不必为每个小范围修复都跑全量。
为什么要 backfill 而不是全量重建
对"只影响 5% 数据"的问题、全量重建是 20× 浪费。Backfill 精准打击。
典型 backfill 场景
场景 1:某数据源修复
Confluence 里某个 space 的文档都没正确标 author 字段——需要重新解析 + 重建这部分 chunk,其他数据源不动。
场景 2:局部 schema 变更
某类文档(合同类)要加 signed_date 字段——只这类文档的 chunk 需要重建 metadata、其他不变。
场景 3:embedding 模型对某类内容效果差
发现代码类 chunk 用通用 embedding 效果差、升级到代码专用 embedding。只需要重 embed 代码 chunk,其他不变。
场景 4:发现某批数据污染
发现 2026 年 3 月 15 日那一周的 ingest 有解析 bug——只 backfill 那一周的文档,其他不影响。
场景 5:补历史数据
新上线 RAG、先 ingest 最近 1 年数据、后续慢慢 backfill 更早的历史数据。
这些场景都是全量重建的子集——为什么要全量?
Backfill 的实现 pipeline
python
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'")关键点:filter 精准、更新原子、过程可监控。
和全量重建的区别
| 维度 | Backfill | 全量重建 |
|---|---|---|
| 范围 | 过滤后的子集 | 全部 chunk |
| 耗时 | 数据量的 5-20% | 100% |
| 成本 | 比例小 | 大 |
| 对在线影响 | 微(逐步更新) | 中(蓝绿切换) |
| 索引切换 | 原地更新 | 蓝绿整索引 |
| 回滚 | 按文档回滚 | 切回老索引 |
Backfill 是原地更新——不用建新索引、逐条替换。优点是不额外占存储、缺点是中间态不一致(有些 chunk 新、有些老)。
在线查询的一致性
Backfill 跑的时候、在线查询可能命中半新半老的结果:
- 查询到某文档的 chunk、可能 partial 是新 embed、partial 是老 embed
- 这种"混合状态"可能产生怪结果
缓解:
- per-source 原子:一个文档的所有 chunk 在一个事务里更新
- 标记过渡态:backfill 中的 chunk 打
pending_backfill标记、查询时可选择 filter 掉 - 短时间窗口:backfill 跑快(比如小时级)、过渡态的时间窗口小
极敏感场景:backfill 期间冻结相关查询(改路由到只读副本、跳过 backfill 文档)。但多数场景不需要这么严格。
Backfill 的并行度控制
全量重建可以开大并行(反正没在线流量)、backfill 不行——要和在线流量共存、不能打爆资源:
- rate limit:backfill 每分钟最多更新 N 个文档
- CPU / 内存 throttle:backfill worker 有资源上限
- 避开高峰:业务白天高峰期暂停 backfill、凌晨加速
- 监控干扰:在线 P99 涨了 > 10% → 自动降 backfill 速率
这让 backfill 可以连续跑几天(对大规模数据)而不影响用户。
Backfill 的进度监控
Backfill 持续运行、必须能回答 "进度到哪":
text
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/minprogress_pct:完成百分比processed_rate:当前速率eta:预计完成时间dlq_count:失败进 DLQ 的数
没有这些指标、backfill 跑几天团队不知道还要多久、不知道有没有在跑、不知道有没有卡死。
增量 vs backfill vs 全量
三种机制各有场景:
| 机制 | 触发 | 范围 | 频率 |
|---|---|---|---|
| 增量 | 新文档 / 改动 | 单个文档 | 实时 / 近实时 |
| Backfill | 局部问题修复 | 过滤子集 | 按需 |
| 全量重建 | 结构性变更 | 所有 chunk | 月 / 季度 |
三者组合使用、覆盖所有更新场景。
Backfill 的失败处理
Backfill 中途失败(worker 崩、网络断)——需要能续跑:
- Checkpoint:已处理的 doc_id 持久化、重启从 checkpoint 继续
- Idempotency:重复处理同一文档等价于一次
- DLQ:个别文档处理失败进 DLQ、不阻塞整体
Backfill 可能跑几天——没续跑能力就是灾难。
Backfill 的工具化
成熟团队会把 backfill 做成自助工具:
bash
# 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 # 停止某个或 UI / API 让业务方自助触发——降低工程介入、让数据治理成为日常。
Backfill 的审计
大批量更新要审计——谁发起、为什么、影响多少:
json
{
"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
}合规场景(金融 / 医疗)这个审计日志是刚需——数据变更必须可追溯。
Backfill 的 cost 控制
虽然比全量便宜、backfill 也花钱:
- Embedding API 调用:按量计费
- GPU 跑 ingest pipeline:小时计费
- 向量库的 upsert 调用
超出预算的 backfill 可能意外地贵——上线前估算成本、设上限:
python
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}大 backfill 前看估算、需要的话拆成多次。
一个真实的 backfill 例子
某团队发现 2025 年 Q3 的文档的 chunking 策略错了、分块太细导致检索质量差。决策:
- 全量重建要跑 24 小时、影响太大
- Backfill 策略:
indexed_at BETWEEN '2025-07-01' AND '2025-09-30' - 范围:约 18 万 chunk、占总索引 4%
- 执行:60 docs/min、白天暂停、凌晨加速
- 完成:3 天、用户无感
成本:$40(全量重建估 $800)——精准打击省 20 倍。
Backfill 的常见陷阱
- 过滤不精准:扫太多文档、浪费
- 没 checkpoint:中途失败全白跑
- 没 rate limit:打爆在线
- 不审计:合规场景出事查不到
- 不估算成本:突然超预算
为什么 backfill 是团队能力的标志
有 backfill 工具 vs 没有:
- 有:局部问题快速修、不伤业务
- 没:每个小问题都要"要么忍、要么全量重建"——陷入两难
Backfill 能力的投入(1-2 人月)带来的是长期敏捷度——团队能快速响应任何数据问题。这是成熟 RAG 和初级 RAG 的分水岭之一。
8.14 源头数据治理:RAG 质量始于 ingest
§8.12 quality gate 讲的是"垃圾到我这里怎么挡"——但更好的做法是源头不产垃圾。企业 RAG 的质量上限不是 retrieval 算法、是文档本身的质量。垃圾文档再好的 chunking / embedding / rerank 也救不了——garbage in, RAG out。这节讲如何从源头治理数据质量、让 RAG 有好的"食材"。
源头质量问题的典型现象
这些问题不是 pipeline 能解决的——要业务方 / 内容所有者从源头治。
责任划分
RAG 团队 vs 文档所有者:
| 责任 | RAG 团队 | 业务 / 内容方 |
|---|---|---|
| 解析、分块、embedding | ✓ | - |
| 检索、rerank、生成 | ✓ | - |
| 文档内容准确性 | - | ✓ |
| 文档时效性 | - | ✓ |
| 文档结构一致性 | - | ✓ |
| 新知识入库 | 流程支持 | 触发 |
RAG 团队不是内容的最终负责人——要推动业务方参与治理。
和业务方的协作
推动业务方治理数据的工程:
- 向业务方展示问题:把 RAG badcase 归因到具体文档、给业务看
- Content owner 制度:每类文档有明确 owner、owner review 质量
- 定期文档 review:每月 / 季度业务方过文档列表、标过期 / 不准
- 流程嵌入:发布新文档时强制走 review 流程、不是发完就完
没这些机制——业务方不觉得文档质量是他们的事、问题反复。
源头治理的工具
给业务方建工具:
- 文档健康 dashboard:展示每类文档的年龄、被使用频率、用户反馈
- 过期提醒:文档年龄超过阈值、提醒 owner review
- 矛盾检测:多份文档说法冲突的自动报警
- 使用报告:每月给 owner 自己文档的"使用分析"、他们能看到价值 / 问题
业务方不懂 RAG 技术——但看数据能懂"我的文档被人用、但说错了"——推动他们行动。
过期文档的处理
企业文档持续积累、过期是必然:
- 明确 retention policy:不同类型文档多久过期(政策 1 年、技术文档 2 年、历史记录 5 年)
- 自动标过期:达到年龄但未更新 → 标 deprecated、检索时降权
- 硬删 or 软删:硬删丢历史、软删占空间——按合规要求
- 历史归档:业务不用、但保留备查
不做过期管理、RAG 永远在"捞旧资料"——答过时 answer。
矛盾文档的治理
多份文档矛盾是常见问题:
- A 文档:企业版价格 20000
- B 文档:企业版价格 25000(更新过但 A 没同步)
RAG 可能选错任一条。源头解法:
- Canonical source:每类信息指定一个权威源、其他引用权威
- 版本控制:所有文档有 version、明确"哪个是最新"
- 主动发现矛盾:RAG badcase 分析时、矛盾文档归集、通知 owner 处理
结构化元数据的强制
Ingest 时强制每份文档带 metadata:
- author、publish_date、last_reviewed_date
- doc_type、version、status
- owner、approval_chain
没 metadata 的文档不入库——倒逼业务方规范化。这比"入库后再补"易施行。
文档结构的规范化
推动文档用标准结构:
- 模板化:每类文档有 template(SOP / 产品规格 / FAQ 等)
- 章节规范:标题 / 小标题清晰、RAG 结构化分块容易
- 代码 / 表格规范:用标准 markdown、不是图片截屏
模板和规范要业务能力支持——写作工具(Confluence / Notion)内置模板、业务方按模板填就行。
知识管理系统的集成
企业有 Confluence / Notion / SharePoint——RAG 可以和这些深度集成:
- 变更通知:文档改了、RAG 自动触发 re-ingest
- 删除同步:文档在源系统删、RAG 立即清
- 权限同步:源系统的权限变更反映到 RAG
- 反馈回流:RAG 的 "引用错"告警给源文档 owner
让 RAG 成为知识管理的反馈回路——而不是单向消费。
源头治理的组织架构
在大企业:
- Chief Knowledge Officer(CKO):治理策略
- Documentation team:模板、规范、工具
- Content owners:每类文档的负责人
- RAG 团队:工具支持、badcase 反馈
小团队这些角色可能一人兼——但责任必须清晰。
数据治理的量化
治理好坏要量化:
doc_freshness:文档平均年龄doc_review_rate:每月被 review 过的文档比例badcase_per_owner:每个 owner 下的 badcase 数content_coverage:业务话题被文档覆盖的比例
定期 review 这些指标——让治理工作可管理。
源头治理的 ROI
投入:
- 工具建设:2-3 人月
- 业务方沟通:持续
- 培训:一次几人周
收益:
- RAG 质量 +10-20 点(有研究支持、高质量 source 对 recall 和 faithfulness 都有帮助)
- 减少 RAG 团队修 "其实是源头问题" 的时间
- 合规性提高
源头投 1 块、RAG 团队省 10 块——但这 1 块需要业务方投。
治理的挑战
- 业务方不重视:觉得是"IT 的事"
- 缺激励:owner 更新文档没考核
- 工具不好用:强制更新但工具体验差
- 组织阻力:大公司跨部门协调难
治理是组织工程、不是技术工程——工具只能帮、不能替代组织意愿。
源头治理和 RAG 的因果关系
很多团队抱怨 "RAG 不好用"——根因是源文档不好。表现:
- 改 chunking 数月、recall 提 1-2 点
- 源文档过期清理一次、recall 提 5-10 点
治理 1 月 > 技术 6 月——投资重心的判断。
小企业 vs 大企业
- 小企业:文档少、owner 明确、治理易
- 中型企业:开始有"漂移"、需要主动治理
- 大型企业:文档万级、跨多团队、需要系统化治理
企业规模决定治理成本——大企业投入大、小企业轻装。
文档量大时的优先级
大企业有百万文档——不能全治理:
- P0:被 RAG 高频召回的(top 5% 使用量的文档)
- P1:badcase 归因到的文档
- P2:业务关键类型(定价 / 政策 / SOP)
- P3:长尾、低优先级
80/20 治理——先治影响大的。
源头治理的长期价值
治理不是一次性——持续文化:
- 每月 review 老文档
- 新文档入库前 review
- Badcase 反馈给 owner
- 质量指标纳入业务方 KPI
这是组织能力——好 RAG 项目往往源于好的知识治理文化、不是相反。
RAG 作为知识治理的契机
有个有趣的观察:上 RAG 反而推动企业改善知识治理——因为 RAG 让文档质量问题"可见化"。没 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 模型的选型和调用工程讲起。