RAG系统中,文档的更新也是个比较常见的操作。我们也支持文档的更新,支持以下这三种操作: 1、文档的基本信息更新,如名称、描述等。 2、指定分段的内容更新。 3、整个文档的更新
文档基本信息更新
更新范围限制
文档基本信息更新(PUT /api/document)与版本管理的关注点是分离的。版本表记录的是"文件内容"相关的不可变快照,而文档主表中的标题、描述属于"业务元数据",可以随时修改,不触发版本迭代。前端编辑模式下,仅允许提交 docTitle、description 和 lockVersion 三个字段,其余字段(知识库类型、权限、状态)均被禁用:
// document.html - saveDocument()
if
data
}
后端 Controller 直接调用 MyBatis-Plus 的 updateById,利用实体字段按需更新(null 字段不参与 SQL)特性,只更新传入的字段:
// KnowledgeDocumentController.java
@PutMapping
public
}
乐观锁保护
knowledge_document 表的 lock_version 字段配合 MyBatis-Plus @Version 注解实现乐观锁。前端提交时须携带当前的 lockVersion,服务端执行 UPDATE ... WHERE lock_version = #{lockVersion} 并自动将版本号 +1。若并发写入时版本号已变更,更新会返回 false,前端据此提示用户刷新后重试,避免覆盖写丢失问题。
单个分段更新与向量同步
我们支持针对某个具体的分段(KnowledgeSegment)内容做更新,这时候需要在数据库与 Elasticsearch 双写,更新文本内容时必须同步更新 ES 中的向量,否则会出现 DB 文本与向量不一致:检索返回的是旧语义,但展示的是新文本。
更新流程:文本变更感知 + 向量原子替换
KnowledgeSegmentService.updateById(entity, updateVectorStore) 在标准 updateById 基础上增加了向量同步逻辑(KnowledgeSegmentServiceImpl):
@Transactional
public
vectorStoreService
entity
entity
entity
log
}
三个触发向量更新的条件必须同时满足: | 条件 | 说明 | | --- | --- | | textChanged | 文本内容确实发生了变化,避免无意义的向量重建 | | hasEmbedding | 该分段已存在向量( | | !skipEmbedding | 未标记跳过嵌入(表头类、元数据类分段无需向量化) |
metadata 复用的重要性
向量写入 ES 时,每条向量的 metadata 中携带了 docId、version、accessibleBy(权限过滤)等检索过滤字段。若更新请求只传了新文本,不传 metadata,则新向量会丢失权限信息,导致跨用户的数据泄露风险。 因此更新时强制检查:若请求体未携带 metadata,自动从旧分段中复用:
if
entity
}
整个文档的更新
上面的单个分段的更新,其实是有可能存在短暂的数据丢失的,即删除和插入中间,数据是查询不到的,因为是单个分段的话,影响比较小,但是如果是整个文档的话,影响就比较大了。 文档越大,这个中间的gap时间就会越长。这在更新期间会有一个服务不可用的窗口——旧向量已删,新向量未就绪,此时 RAG 检索无法返回结果。
影子更新策略
我们的项目中采用的是"影子更新"策略解决此问题:新版本的数据与旧版本的数据并存,直到新版本完全就绪后才原子切换,旧数据延后清理。 上传新版本的完整流程如下:
① 校验版本号递增 & 内容哈希去重
│
▼
② 上传新版本文件到 MinIO
(旧版本文件和向量完全不动,RAG 检索不受影响)
│
▼
③ 格式转换(PDF 转文本 / Excel 解析等)
│
▼
④ 创建 knowledge_document_version 版本记录
(status = CONVERTED,尚未切片)
│
▼
⑤ 更新 document.currentVersionId = 新版本 ID
(激活指针切换到新版本,此时新版本尚未向量化)
│
▼
⑥ 用户手动触发切片(split),分段绑定新 versionId
│
▼
⑦ 向量化(embedAndStore / activateVersion)
分页扫描新版本的 STORED 分段 → 批量 embed 写入 ES
│
▼
⑧ 所有新分段向量化完成(VECTOR_STORED)后
调用 cleanupOldVersionData,精准清理旧版本向量
先增加新版本数据,在清理旧版本数据
为了实现不停机更新,我们的向量数据库中是新激活新文档版本的数据,即针对新上传的文档做embedding和store存储的。然后再把旧版本的数据从向量数据库中删除。(向量数据库不支持update,只能先分别做插入和删除。并且文档变了,分段也会发生改变) 这么做的好处是文档更新或者做版本切换时,不会导致RAG无法检索相关文档。至少有一个版本是生效的。但是有个缺点,那就是切换过程中,可能会短暂的存在2个版本共存的情况,可能会到导致检索结果重复。 但是因为我们在代码中也做了去重相关操作,所以其实影响不太大。当然,我们也不建议在业务高峰期做这样的操作,建议在晚上做文档更新。
向量精准清理
旧版本清理不是简单地删除所有向量,而是通过 ES 的 Filter 机制精准定位,只删除指定版本的向量,保留其他版本:
// VectorStoreServiceImpl.removeByDocIdAndVersion
Filter
embeddingStore
每个向量在写入 ES 时,metadata 中携带了 docId 和 version(versionId),这正是精准过滤的依据:
// DocumentProcessServiceImpl.split()
metadata
metadata
内容哈希去重,防止无效更新
为避免用户重复上传相同内容的文档,每次上传时对文件内容进行流式 SHA-256 计算,并在版本表中全局查重(跨文档、跨版本):
// 流式计算,避免将整个文件加载到内存
private
digest
}
哈希存储在版本表的 content_hash 字段,并建有索引,查重效率高:
// 全局跨文档跨版本去重
if
}
重复上传时,Controller 层统一返回 HTTP 409 Conflict。