第2378篇:知识库的版本管理——文档更新时如何保持RAG系统一致性
大约 6 分钟
第2378篇:知识库的版本管理——文档更新时如何保持RAG系统一致性
适读人群:维护长期运营的RAG系统的AI工程师 | 阅读时长:约18分钟 | 核心价值:掌握知识库版本管理的工程方案,解决文档更新时的一致性问题和回滚机制
维护RAG知识库有一种特别让人焦虑的情况:产品经理说"帮我把这几份文档更新一下",你删了旧的、加了新的,但不知道有没有漏掉什么,也不知道更新之后系统效果有没有变差。
普通文件系统有Git,有版本历史,改错了能回滚。但RAG知识库向量数据库没有天然的版本控制。一旦操作错了,可能根本不知道删了什么,也没法恢复。
我们踩过一个坑:运营同事手滑,把一批产品FAQ文档的一半给删了(以为是重复文档),结果接下来两周,用户问这些产品的问题,AI的回答质量大幅下降,排查了很久才发现原因。
版本管理的核心需求
/**
* 知识库版本管理需要解决的问题:
*
* 1. 可追溯:每次变更都有记录(谁改的、改了什么、什么时间)
* 2. 可回滚:发现问题后能快速恢复到历史状态
* 3. 一致性:更新时保证知识库处于一致状态(不出现半新半旧)
* 4. 可比较:能对比两个版本之间的差异
* 5. 测试先行:新版本上线前可以在测试环境验证效果
*/知识库版本元数据设计
@Service
public class KnowledgeBaseVersionManager {
/**
* 创建一个新版本(快照)
*
* 不是把所有文档复制一份(太贵),
* 而是记录"在这个时间点,哪些文档在库里、各自的版本是什么"
*/
public KnowledgeBaseVersion createSnapshot(String snapshotName, String createdBy) {
// 获取当前所有文档的ID和版本号
List<DocumentVersionInfo> currentDocVersions = getAllDocumentVersionInfo();
KnowledgeBaseVersion version = KnowledgeBaseVersion.builder()
.id(UUID.randomUUID().toString())
.name(snapshotName)
.createdBy(createdBy)
.createdAt(LocalDateTime.now())
.documentVersions(currentDocVersions)
.totalDocuments(currentDocVersions.size())
.status(VersionStatus.ACTIVE)
.build();
versionRepository.save(version);
log.info("Knowledge base snapshot created: {} with {} documents",
snapshotName, currentDocVersions.size());
return version;
}
/**
* 文档版本信息
* 每个文档有自己的版本,知识库版本是文档版本的快照集合
*/
@Data
@Builder
public static class DocumentVersionInfo {
private String docId;
private String docName;
private String contentHash; // 内容hash,用于快速检测变更
private LocalDateTime lastModified;
private int version;
}
}文档的版本追踪
@Service
public class DocumentVersionTracker {
private final DocumentVersionRepository versionRepo;
private final VectorStore vectorStore;
/**
* 添加或更新文档时,保留版本历史
*/
public DocumentVersion addOrUpdateDocument(DocumentInput input) {
String docId = input.getDocId();
String contentHash = calculateHash(input.getContent());
// 检查是否真的有变化
DocumentVersion latest = versionRepo.findLatestByDocId(docId);
if (latest != null && latest.getContentHash().equals(contentHash)) {
log.info("Document {} has no content change, skipping", docId);
return latest;
}
int newVersionNumber = (latest == null) ? 1 : latest.getVersionNumber() + 1;
DocumentVersion newVersion = DocumentVersion.builder()
.docId(docId)
.versionNumber(newVersionNumber)
.content(input.getContent())
.contentHash(contentHash)
.metadata(input.getMetadata())
.createdBy(input.getOperatorId())
.createdAt(LocalDateTime.now())
.changeType(latest == null ? ChangeType.CREATED : ChangeType.UPDATED)
.changeSummary(input.getChangeSummary())
.isActive(true)
.build();
// 把旧版本标记为非活跃(但保留记录)
if (latest != null) {
latest.setActive(false);
versionRepo.save(latest);
}
versionRepo.save(newVersion);
// 更新向量库:先删除旧的chunk,再添加新的
updateVectorStore(docId, newVersion);
return newVersion;
}
/**
* 删除文档(软删除,保留版本历史)
*/
public void deleteDocument(String docId, String operatorId, String reason) {
DocumentVersion latest = versionRepo.findLatestByDocId(docId);
if (latest == null) {
throw new DocumentNotFoundException(docId);
}
// 创建一个"删除"版本记录
DocumentVersion deleteVersion = DocumentVersion.builder()
.docId(docId)
.versionNumber(latest.getVersionNumber() + 1)
.content(null) // 删除版本没有内容
.contentHash("DELETED")
.createdBy(operatorId)
.createdAt(LocalDateTime.now())
.changeType(ChangeType.DELETED)
.changeSummary(reason)
.isActive(false)
.build();
latest.setActive(false);
versionRepo.save(latest);
versionRepo.save(deleteVersion);
// 从向量库中移除
vectorStore.delete(findChunksByDocId(docId));
log.info("Document {} deleted by {} with reason: {}", docId, operatorId, reason);
}
}回滚机制
@Service
public class KnowledgeBaseRollbackService {
/**
* 回滚到某个快照版本
*
* 这是一个破坏性操作,需要仔细处理
*/
@Transactional
public RollbackResult rollbackToVersion(String targetVersionId, String operatorId) {
KnowledgeBaseVersion targetVersion = versionRepository.findById(targetVersionId)
.orElseThrow(() -> new VersionNotFoundException(targetVersionId));
// 在回滚之前,先对当前状态创建一个快照(以便回滚回来)
KnowledgeBaseVersion currentSnapshot = versionManager.createSnapshot(
"pre-rollback-" + LocalDateTime.now(),
operatorId
);
log.info("Starting rollback to version: {}, pre-rollback snapshot: {}",
targetVersionId, currentSnapshot.getId());
List<String> processedDocs = new ArrayList<>();
List<String> failedDocs = new ArrayList<>();
// 获取目标版本的文档列表
Map<String, DocumentVersionInfo> targetDocMap = targetVersion.getDocumentVersions()
.stream()
.collect(Collectors.toMap(
DocumentVersionInfo::getDocId,
v -> v
));
// 获取当前文档列表
Set<String> currentDocIds = getCurrentDocumentIds();
// 第一步:删除目标版本中不存在的文档(在目标版本之后新增的)
for (String currentDocId : currentDocIds) {
if (!targetDocMap.containsKey(currentDocId)) {
try {
softDeleteFromVectorStore(currentDocId);
processedDocs.add(currentDocId + " (removed)");
} catch (Exception e) {
failedDocs.add(currentDocId);
log.error("Failed to remove document during rollback: {}", currentDocId, e);
}
}
}
// 第二步:恢复目标版本中的文档(到对应的历史版本)
for (Map.Entry<String, DocumentVersionInfo> entry : targetDocMap.entrySet()) {
String docId = entry.getKey();
DocumentVersionInfo targetDocInfo = entry.getValue();
try {
restoreDocumentToVersion(docId, targetDocInfo.getContentHash());
processedDocs.add(docId + " (restored to version " + targetDocInfo.getVersion() + ")");
} catch (Exception e) {
failedDocs.add(docId);
log.error("Failed to restore document during rollback: {}", docId, e);
}
}
RollbackResult result = RollbackResult.builder()
.success(failedDocs.isEmpty())
.targetVersionId(targetVersionId)
.preRollbackSnapshotId(currentSnapshot.getId())
.processedDocuments(processedDocs)
.failedDocuments(failedDocs)
.build();
log.info("Rollback completed: {} processed, {} failed",
processedDocs.size(), failedDocs.size());
return result;
}
/**
* 恢复单个文档到特定内容hash的版本
*/
private void restoreDocumentToVersion(String docId, String targetContentHash) {
// 从版本历史中找到对应hash的版本
DocumentVersion targetDocVersion = versionRepo
.findByDocIdAndContentHash(docId, targetContentHash)
.orElseThrow(() -> new DocumentVersionNotFoundException(docId, targetContentHash));
// 把向量库里的当前版本替换为历史版本
vectorStore.delete(findChunksByDocId(docId));
// 重新切片和索引历史版本的内容
List<Document> chunks = documentSplitter.split(
targetDocVersion.getContent(),
targetDocVersion.getMetadata()
);
vectorStore.add(chunks);
}
}批量变更的原子性保证
@Service
public class AtomicKnowledgeUpdateService {
/**
* 批量更新时保证原子性
*
* 问题:更新10份文档,如果在第7份失败了,
* 知识库会处于"前6份是新版本,后4份是旧版本"的不一致状态
*
* 解决方案:事务性更新
* 1. 先在暂存区准备好所有新内容
* 2. 原子切换:一次性把暂存区内容替换到正式库
*/
@Transactional
public BatchUpdateResult atomicBatchUpdate(List<DocumentInput> updates, String operatorId) {
// 第一步:创建回滚点
KnowledgeBaseVersion rollbackPoint = versionManager.createSnapshot(
"pre-batch-" + LocalDateTime.now(),
operatorId
);
List<String> successDocs = new ArrayList<>();
List<BatchUpdateError> errors = new ArrayList<>();
// 第二步:逐个处理,但先不写向量库
List<PreparedDocument> prepared = new ArrayList<>();
for (DocumentInput update : updates) {
try {
PreparedDocument prep = prepareDocument(update);
prepared.add(prep);
successDocs.add(update.getDocId());
} catch (Exception e) {
errors.add(new BatchUpdateError(update.getDocId(), e.getMessage()));
}
}
// 如果有任何准备失败,中止整个批次
if (!errors.isEmpty()) {
log.warn("Batch update aborted due to {} preparation errors", errors.size());
return BatchUpdateResult.failed(errors, rollbackPoint.getId());
}
// 第三步:所有文档准备成功,执行原子写入
try {
for (PreparedDocument prep : prepared) {
applyPreparedDocument(prep);
}
} catch (Exception e) {
// 写入失败,触发回滚
log.error("Atomic batch update failed, rolling back to {}", rollbackPoint.getId());
rollbackService.rollbackToVersion(rollbackPoint.getId(), "system-auto-rollback");
return BatchUpdateResult.rolledBack(errors, rollbackPoint.getId());
}
return BatchUpdateResult.success(successDocs, rollbackPoint.getId());
}
}版本比较:上线前的差异确认
@Service
public class KnowledgeBaseDiffService {
/**
* 比较两个版本之间的差异
* 在更新生效前,让运营同学确认改了什么
*/
public VersionDiff compare(String fromVersionId, String toVersionId) {
KnowledgeBaseVersion fromVersion = versionRepository.findById(fromVersionId)
.orElseThrow();
KnowledgeBaseVersion toVersion = versionRepository.findById(toVersionId)
.orElseThrow();
Map<String, DocumentVersionInfo> fromDocMap = toMap(fromVersion.getDocumentVersions());
Map<String, DocumentVersionInfo> toDocMap = toMap(toVersion.getDocumentVersions());
List<DocDiff> diffs = new ArrayList<>();
// 新增的文档
for (String docId : toDocMap.keySet()) {
if (!fromDocMap.containsKey(docId)) {
diffs.add(DocDiff.added(toDocMap.get(docId)));
}
}
// 删除的文档
for (String docId : fromDocMap.keySet()) {
if (!toDocMap.containsKey(docId)) {
diffs.add(DocDiff.removed(fromDocMap.get(docId)));
}
}
// 修改的文档
for (String docId : toDocMap.keySet()) {
if (fromDocMap.containsKey(docId)) {
DocumentVersionInfo fromInfo = fromDocMap.get(docId);
DocumentVersionInfo toInfo = toDocMap.get(docId);
if (!fromInfo.getContentHash().equals(toInfo.getContentHash())) {
diffs.add(DocDiff.modified(fromInfo, toInfo));
}
}
}
return VersionDiff.builder()
.fromVersion(fromVersionId)
.toVersion(toVersionId)
.diffs(diffs)
.addedCount((int) diffs.stream().filter(d -> d.getType() == DiffType.ADDED).count())
.removedCount((int) diffs.stream().filter(d -> d.getType() == DiffType.REMOVED).count())
.modifiedCount((int) diffs.stream().filter(d -> d.getType() == DiffType.MODIFIED).count())
.build();
}
}版本管理是RAG系统工程成熟度的一个重要标志。早期可以简化,但要留好接口。一旦系统进入维护阶段,知识频繁更新,没有版本管理的痛苦会被放大很多倍。
