Appearance
增量更新与版本管理:改一篇文档不用重灌全库
1. 本节产出
一个幂等的增量灌库服务:按 sourceId 识别文档、内容哈希未变则跳过、变更则删旧片段重建、支持版本共存与回滚。并且有一个对账任务能发现「库里有、源里没有」的孤儿数据。
2. 前置依赖
- 02-07 Document 元数据设计:片段带
sourceId - 02-12 多租户隔离
- 02-05 Embedding 批处理
3. 为什么增量更新是「能不能上线」的分水岭
没有增量更新时,文档更新只有两个选择:
| 做法 | 后果 |
|---|---|
| 全量重灌 | 100 万片段 × 重算 Embedding = 几千元 + 数小时 + 期间服务降级 |
| 不更新 | 知识库越来越旧,用户搜到过期制度 |
两个都不可接受,所以增量更新不是优化项,是必备能力。
一个真实代价估算:某项目 60 万片段,全量重灌一次约 4 小时、成本约 2500 元。制度文档每月更新 2~3 次,一年就是 12 次重灌 = 3 万元 + 数十小时。而增量更新后,单篇文档更新只需几秒、几分钱。
4. 核心原理
4.1 sourceId 是唯一锚点
sourceId = 文档在业务系统中的稳定标识
✅ "handbook-hr-2026"(业务主键)
❌ "员工手册.pdf"(文件名会变)
❌ "/data/docs/a.pdf"(路径会变)判断标准:文档改名、换目录、换存储后,sourceId 必须不变。否则改名就等于新增一篇文档,旧数据成为孤儿。
4.2 内容哈希:跳过未变更文档
计算 SHA-256(文档内容 + 切分参数 + Embedding 模型版本)
│
├─ 与库中记录的哈希相同 → 跳过(0 成本)
└─ 不同 → 删旧片段 + 重建哈希必须包含切分参数和模型版本。只算文档内容的话,你改了切分策略,系统认为「文档没变」而跳过——实际需要重建。
4.3 更新为什么是「删旧建新」而不是「原地改」
| 方案 | 问题 |
|---|---|
| 原地改片段 | 片段数量可能变化(删了一段文字就少一个片段),改不动 |
| 删旧建新 | 简单可靠,但要保证原子性 |
原子性是关键:删除和插入必须在同一事务里。删了旧的、新的插入失败 → 这篇文档在库里消失了 → 用户搜不到但文档确实存在,极难排查。
PGVector 在这方面有明显优势:它支持事务,删除和插入可以原子完成。这也是 02-06 推荐它的理由之一。
4.4 版本共存与回滚
场景:新版本灌进去后发现效果更差,要回滚
做法一(推荐):版本共存
两个版本的片段都在库里,检索时用 version 过滤
version = 'current' 的元数据由「发布动作」切换
→ 回滚 = 切一次元数据,秒级
做法二:备份回滚
更新前导出旧片段,出问题再导入
→ 慢,但实现简单做法一更好,因为它把「数据变更」和「生效」解耦:灌库不影响线上,发布才切换。这在 02-21 管理后台里会落地成「发布」按钮。
5. 代码走查
5.1 文档登记表
sql
CREATE TABLE document_source (
source_id VARCHAR(200) PRIMARY KEY,
tenant_id VARCHAR(64) NOT NULL,
doc_type VARCHAR(32),
current_version VARCHAR(32), -- 当前生效版本
content_hash VARCHAR(64), -- 内容 + 参数哈希
chunk_count INT,
status VARCHAR(16), -- INGESTING / READY / FAILED
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_source_tenant ON document_source (tenant_id, status);这张表是增量更新的大脑。没有它,你无法知道「哪些文档变了」、「哪些失败了」。
5.2 幂等灌库服务
java
// ch02-rag/src/main/java/com/aitech/rag/ingest/DocumentIngestService.java
@Service
public class DocumentIngestService {
private final VectorStore store;
private final SourceRegistry registry;
@Transactional // 关键:删旧与建新在同一事务
public IngestResult ingest(IngestRequest req) {
String hash = computeHash(req);
// 1. 未变更则跳过
Optional<SourceRecord> old = registry.find(req.sourceId());
if (old.isPresent() && hash.equals(old.get().contentHash())) {
log.info("文档未变更,跳过:{}", req.sourceId());
return IngestResult.skipped();
}
// 2. 解析 + 切分 + 元数据注入
ParsedDocument parsed = parserRegistry.parse(req.file());
List<Chunk> chunks = splitter.split(parsed);
enricher.enrich(chunks, req);
// 3. 删除旧片段(按 sourceId + 非当前版本)
store.delete("sourceId == '%s' && version != '%s'"
.formatted(req.sourceId(), req.version()));
// 4. 写入新片段
store.accept(toDocuments(chunks));
// 5. 更新登记表
registry.upsert(req.sourceId(), req.tenantId(), req.version(),
hash, chunks.size());
return IngestResult.ok(chunks.size());
}
private String computeHash(IngestRequest req) {
String raw = req.contentHash() // 文档内容
+ splitterParams() // 切分参数
+ embeddingModelVersion(); // 向量模型版本
return DigestUtils.sha256Hex(raw);
}
}5.3 发布:切换生效版本
java
// ch02-rag/src/main/java/com/aitech/rag/ingest/PublishService.java
@Service
public class PublishService {
/** 发布 = 切 current_version + 刷新片段上的过滤标记,秒级完成 */
@Transactional
public void publish(String sourceId, String version) {
registry.setCurrentVersion(sourceId, version);
// 把「当前版本」标记同步到片段元数据,供检索过滤
jdbc.update("""
UPDATE vector_store
SET metadata = jsonb_set(metadata, '{isCurrent}',
CASE WHEN metadata->>'version' = ? THEN 'true' ELSE 'false' END)
WHERE metadata->>'sourceId' = ?
""", version, sourceId);
log.info("published sourceId={} version={}", sourceId, version);
}
}发布和灌库分离是本节最重要的设计:灌库可以随时做(不影响线上),发布是一个独立动作(秒级、可回滚)。
5.4 对账任务
java
// ch02-rag/src/main/java/com/aitech/rag/ingest/ReconcileJob.java
@Component
public class ReconcileJob {
/** 每天一次:找出孤儿数据与失败任务 */
@Scheduled(cron = "0 0 3 * * *")
public void reconcile() {
// 1. 孤儿:库里有片段,但登记表里已无该 sourceId
List<String> orphans = jdbc.queryForList("""
SELECT DISTINCT metadata->>'sourceId'
FROM vector_store v
WHERE NOT EXISTS (
SELECT 1 FROM document_source d
WHERE d.source_id = v.metadata->>'sourceId')
""", String.class);
// 2. 失败未重试的灌库任务
List<SourceRecord> failed = registry.findByStatus("FAILED");
log.warn("对账:孤儿 {} 个,失败任务 {} 个", orphans.size(), failed.size());
alertIfNeeded(orphans, failed);
}
}对账任务是长期稳定运行的保障。增量系统最怕的就是「静默不一致」——灌了一半失败、旧数据没删干净、孤儿数据堆积。
6. 跑起来
bash
git checkout ch02-14-incremental
docker compose up -d
mvn spring-boot:runbash
# 1. 首次灌入
curl -X POST http://localhost:8080/api/ingest \
-F "file=@handbook.pdf" -F "sourceId=handbook" -F "version=v1"
# 期望:返回 chunkCount,耗时数秒
# 2. 再次灌入同一文档(未修改)
curl -X POST http://localhost:8080/api/ingest \
-F "file=@handbook.pdf" -F "sourceId=handbook" -F "version=v1"
# 期望:skipped=true,0 次 Embedding 调用
# 3. 修改文档后灌入新版本
curl -X POST http://localhost:8080/api/ingest \
-F "file=@handbook-v2.pdf" -F "sourceId=handbook" -F "version=v2"
# 期望:新增 v2 片段,v1 保留
# 4. 发布 v2
curl -X POST http://localhost:8080/api/publish \
-d '{"sourceId":"handbook","version":"v2"}'
# 5. 回滚到 v1
curl -X POST http://localhost:8080/api/publish \
-d '{"sourceId":"handbook","version":"v1"}'| 检查项 | 通过标准 |
|---|---|
| 幂等 | 重复灌同一文档被跳过,无 Embedding 调用 |
| 增量 | 单篇文档更新耗时 < 10s,只重算该篇片段 |
| 原子性 | 灌库失败后,旧数据仍在(不出现空窗) |
| 版本共存 | 发布切换秒级生效,可回滚 |
| 对账 | 手动造一条孤儿数据,对账任务能发现 |
7. 生产避坑
sourceId必须稳定,绝不能用文件名或路径。用文件名做锚点的话,文档改一次名就产生一份新数据 + 一份孤儿数据,几个月后库里全是垃圾。这是增量更新最常见的失败原因。- 删旧与建新必须在同一事务。非事务实现(先 delete 再 add)在中间失败时会造成数据丢失,且表现为「用户搜不到这篇文档」,排查困难。这也是推荐 PGVector 的理由。
- 内容哈希必须包含切分参数与模型版本。只算文档内容的话,你改了切分策略或升级了 Embedding 模型后系统认为「没变化」而跳过,导致库里的数据与新策略不一致——这个问题非常隐蔽,可能几个月后才发现。
8. 延伸与锚点
- 思考题:热门问题会被反复问,每次都要走完整检索 + 生成。能不能缓存?(答案在下一课时)
- 代码锚点:
git checkout ch02-14-incremental - 下一课时:02-15 缓存策略
- 对应课件:L02-14 增量更新