知识库向量化流水线:增量更新的挑战比全量重建更大
基础设施不需要漂亮话。全量重建是一个 batch job,写好就能跑;增量更新是长在系统里的持续服务,每一步都可能掉链子。
一、全量重建为什么"简单"
知识库向量化的全量重建流程是线性的:
- 从数据库中全量导出文档(100 万条,约 20 分钟)。
- 分批调用 Embedding API 计算向量(100 万 × 50ms/batch of 32 = 约 26 分钟)。
- 重建向量索引(HNSW 构建,约 5 分钟)。
- 原子切换新旧索引。
整个过程约 1 小时,可以在业务低峰期执行。出错了可以重试,中间状态可以丢弃。这是典型的批处理任务——可控、可重试、可观测。
增量更新则完全不同。每个文档的增删改都需要实时地反映到向量索引中,要求在保证数据一致性的同时,不影响查询延迟。挑战点至少有三个:
- 写入延迟的稳定性:插入/更新一个文档片段,从入库到可检索的端到端延迟要求在秒级以内。
- 部分失败的处理:一个 batch 中的 32 条向量,Model API 只返回了 28 条,剩下的 4 条怎么办?
- 索引碎片:频繁的增量写入导致 HNSW 图结构劣化,召回率逐步下降。
flowchart TD subgraph 全量重建 A1[全量导出文档] --> A2[批量 Embedding] --> A3[重建索引] --> A4[原子切换] end subgraph 增量更新 - 挑战 B1[文档变更事件] --> B2{变更类型} B2 -->|新增| B3[计算 Embedding] B2 -->|更新| B4[删除旧向量 + 计算新 Embedding] B2 -->|删除| B5[标记删除旧向量] B3 --> B6[写入向量索引] B4 --> B6 B5 --> B7[向量索引碎片累积] B6 --> B8{写入成功?} B8 -->|是| B9[更新元数据状态] B8 -->|否| B10[重试队列] B10 --> B6 B7 --> B11[召回率下降] B11 --> B12[触发全量重建] end style B2 fill:#fff3e0 style B8 fill:#ffcdd2 style B7 fill:#ffcdd2二、增量更新的架构设计
增量向量化流水线需要以下核心组件:
(1)变更事件源
文档的增删改必须产生可靠的变更事件。使用数据库的 CDC(Change Data Capture,如 Debezium + Kafka)是最可靠的方案。每个变更事件包含:
{ "op": "u", // c=create, u=update, d=delete "doc_id": "doc_12345", "chunk_seq": 3, "content_hash": "sha256:abc123...", "timestamp": 1719763200000 }content_hash是关键——它用于去重。如果文档连续被修改了 10 次,但其中 7 次是格式调整(内容不变),通过 content_hash 可以跳过不必要的 Embedding 计算,节省 API 调用成本。
(2)Embedding Worker 池
Worker 从 Kafka 消费变更事件,调用 Embedding API。关键设计:
- 并发控制:通过 worker 数量控制对 Embedding API 的并发调用,避免触发 API 限流。
- 退避重试:API 调用失败时使用指数退避(1s → 2s → 4s → 8s),超过最大重试次数后写入死信队列。
- 幂等写入:基于
(doc_id, chunk_seq, content_hash)的幂等 Key,确保相同内容不会被重复 Embedding。
type EmbeddingWorker struct { apiClient *EmbeddingClient vectorDB VectorDB retryQueue chan *ChangeEvent } func (w *EmbeddingWorker) Process(event *ChangeEvent) error { switch event.Op { case "c", "u": // 检查是否需要重新 Embedding cached, _ := w.vectorDB.LookupHash(event.DocID, event.ChunkSeq) if cached == event.ContentHash { return nil // 内容未变化,跳过 } vec, err := w.apiClient.Embed(event.Content) if err != nil { return fmt.Errorf("embedding failed: %w", err) } return w.vectorDB.Upsert(event.DocID, event.ChunkSeq, vec, event.ContentHash) case "d": return w.vectorDB.Delete(event.DocID, event.ChunkSeq) } return nil }(3)向量索引的增量写入
不同向量数据库对增量写入的支持差异巨大:
| 数据库 | 增量写入性能 | 删除支持 | 碎片处理 |
|---|---|---|---|
| Milvus | 优 | 支持逻辑删除 | 需定期 Compact |
| Qdrant | 优 | 支持物理删除 | WAL 自动优化 |
| Weaviate | 良 | 支持 | HNSW 碎片累积 |
| pgvector | 中(PostgreSQL 写放大) | 支持 | VACUUM 清理 |
在 Milvus 中,频繁增量写入后需要调用compact()操作合并碎片。在生产环境中,通常设置为每日凌晨自动执行一次 compact。
三、索引退化的监控与补偿
增量更新的最大隐患是索引质量的逐步退化。HNSW 图索引在只读场景下表现最优,频繁的插入/删除会导致图的连通性下降,表现为:
- 相同查询的 Top-10 结果逐渐不稳定。
- 召回率每月下降 2%-3%。
- 某些新插入的文档在检索中"不可见"(insert 但未充分连接)。
监控指标至少需要三个维度:
# 1. 索引碎片率 milvus_index_fragmentation_ratio > 0.3 # 2. 增量写入延迟 P99 histogram_quantile(0.99, embedding_pipeline_latency_seconds) > 10 # 3. 召回率基准测试(自动化巡检) rag_recall_at_10 < 0.95当召回率低于阈值时,触发全量重建。这个重建不是一蹴而就的,而是先构建新索引,验证召回率恢复到预期值后,再执行索引切换。整个过程中线上服务不受影响——通过双写(新数据同时写入旧索引和新索引)保证一致性。
四、混合策略:定期全量重建 + 增量更新
在实际的运维中,纯增量更新的方案很难长期保持索引质量。更务实的做法是混合策略:
- 日常增量更新:处理文档的实时变更,保证秒级的数据新鲜度。
- 每周全量重建:在周末低峰期(如周日凌晨 3:00),从数据库全量导出并重建索引,消除一周积累的索引碎片。
- 异常触发重建:当碎片率、召回率等指标触发告警阈值时,自动触发全量重建。
全量重建的窗口期存在一个数据一致性问题:重建过程中,增量更新仍在发生,新产生的变更在重建完成后可能丢失。解决方案是双写:
- 启动全量重建,开始从数据库导出快照。
- 在导出的同时,所有增量变更同时写入旧索引和新索引。
- 新索引构建完成后,回放导出快照之后的增量变更。
- 原子切换到新索引。
这个流程需要 CDC 的offset/timestamp支持——全量导出开始后,增量变更从导出开始的时间点开始回放。
五、总结:增量更新的复杂度往往被低估
知识库向量化流水线在初期通常会选择全量重建方案——简单、可控、不出错。当文档量增长、对实时性有要求时,才会引入增量更新。但很多团队在这里低估了复杂度:
- 增量更新不是"全量重建的简化版",而是一个完全不同的问题。全量重建是批处理,增量更新是流处理。
- 增量更新的关键不是写代码,而是设计失败处理策略。Embedding API 调用失败怎么办?向量索引写入失败怎么办?这些异常路径才是决定系统可靠性的关键。
- 索引碎片和召回率退化是渐进式的问题,短期内不可感知,但 3 个月后检索质量会明显下降。必须建立自动化巡检机制,而不是依赖用户反馈。
一个现实的建议:在团队人数少于 5 人的情况下,优先使用向量数据库自带的增量更新能力(如 Milvus/Qdrant),而不是自建增量管道。自建方案的维护成本远超预期。