Skip to content

增量更新与版本管理:改一篇文档不用重灌全库 ​

1. 本节产出 ​

一个幂等的增量灌库服务:按 sourceId 识别文档、内容哈希未变则跳过、变更则删旧片段重建、支持版本共存与回滚。并且有一个对账任务能发现「库里有、源里没有」的孤儿数据。

2. 前置依赖 ​

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:run
bash
# 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. 生产避坑 ​

  1. sourceId 必须稳定,绝不能用文件名或路径。用文件名做锚点的话,文档改一次名就产生一份新数据 + 一份孤儿数据,几个月后库里全是垃圾。这是增量更新最常见的失败原因。
  2. 删旧与建新必须在同一事务。非事务实现(先 delete 再 add)在中间失败时会造成数据丢失,且表现为「用户搜不到这篇文档」,排查困难。这也是推荐 PGVector 的理由。
  3. 内容哈希必须包含切分参数与模型版本。只算文档内容的话,你改了切分策略或升级了 Embedding 模型后系统认为「没变化」而跳过,导致库里的数据与新策略不一致——这个问题非常隐蔽,可能几个月后才发现。

8. 延伸与锚点 ​

  • 思考题:热门问题会被反复问,每次都要走完整检索 + 生成。能不能缓存?(答案在下一课时)
  • 代码锚点:git checkout ch02-14-incremental
  • 下一课时:02-15 缓存策略
  • 对应课件:L02-14 增量更新