Skip to content

第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 三类变更事件的分别处理

新增:最简单

新文档进入系统的处理路径:

  1. 解析 → IR(第 5 章)
  2. 分块 → chunks(第 6 章)
  3. Embedding → vectors
  4. 写入向量库 + metadata DB
  5. 更新 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 模型升级。

三阶段迁移模板

  1. 双写(2-4 周):新 schema 字段和旧 schema 并存,新写入填两份、旧数据保留原样
  2. backfill(1-2 周):后台 job 把老数据按规则填充新字段
  3. 切换(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
Embedding500-800 chunk/sGPU 计算
向量库写入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_createddoc_updated,把 deleted=truedoc_updated 事件当成普通更新处理——重跑了一遍 embedding + 写入向量库,且没加 deleted filter

修复:

  1. 紧急:向量库每份 chunk 加 deleted metadata 字段,查询 filter 强制 deleted=false
  2. 短期:RAG 索引 pipeline 识别 deleted=true 的 doc_updated,走删除流程而非更新流程
  3. 中期:CMS → RAG 事件改用独立的 doc_deleted 事件类型,不用 payload 标记区分
  4. 长期: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 流程:

  1. 可行性评估:source 的 API / webhook / 权限模型是否支持
  2. Source 实现:继承 Source 接口、写具体逻辑
  3. 权限映射:和统一 ACL 的映射规则
  4. 试运行:10% 采样、跑一周、观察指标
  5. 全量灌入:按 ch8 §8.9 冷启动三阶段
  6. 监控告警:加该源独有的监控(API 限流、webhook 丢失等)
  7. 文档化:写进 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 没检查"文档成熟度 / 发布状态"。修复:

  1. 紧急:所有未打 "published" 标签的文档清出索引
  2. 短期:gate 加"文档必须有 status = published"的硬检查
  3. 长期:推动 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/min
  • progress_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 本章小结

  1. 增量索引比批处理复杂得多——必须处理既有状态三类变更事件
  2. 更新的最佳方案是 chunk 级 content_hash 复用——逻辑简单 + 成本低
  3. 删除易漏——软删除 + 事件总线 + 级联清理三重保险
  4. 一致性三层约束:chunk_id 稳定 / 向量与正文同源 / metadata 与内容对齐
  5. 变更检测靠 content hash + event bus,每日全扫兜底对账
  6. 全量重建是终极保险——月度或季度一次,月成本零头
  7. Schema 变更Embedding 升级分别走三阶段迁移和全量重建

下一章进入第三部分"表示与索引"——从 Embedding 模型的选型和调用工程讲起。

基于 VitePress 构建