企业知识库如何增量更新?新增、修改、删除文档的处理方案
系列:从零构建企业 RAG 知识库(第 14 篇)
1. 增量更新不是简单 upsert
文档变化会产生一组派生数据:解析块、Chunk、Embedding、关键词索引、图关系和缓存。更新必须保证:
text
新版本完整可用之前,旧版本继续服务;
切换后,新请求只看到一个一致版本;
删除时,所有派生数据最终都被清理。
2. 使用内容哈希识别变化
python
from dataclasses import dataclass
from hashlib import sha256
from typing import Literal
@dataclass(frozen=True)
class DocumentManifest:
document_id: str
tenant_id: str
version: str
content_hash: str
status: Literal["building", "active", "failed", "retired", "deleted"]
def normalized_hash(content: bytes) -> str:
"""二进制哈希识别文件变化;解析配置变化另算策略版本。"""
return sha256(content).hexdigest()
文件哈希相同但解析器、切分或 Embedding 版本改变时,仍需重建派生索引。
3. 计算增量动作
python
@dataclass(frozen=True)
class SourceState:
document_id: str
content_hash: str
@dataclass(frozen=True)
class SyncAction:
action: Literal["create", "rebuild", "delete", "skip"]
document_id: str
def plan_sync(
incoming: list[SourceState],
indexed: list[SourceState],
) -> list[SyncAction]:
incoming_map = {item.document_id: item for item in incoming}
indexed_map = {item.document_id: item for item in indexed}
actions: list[SyncAction] = []
for document_id in sorted(incoming_map.keys() | indexed_map.keys()):
new = incoming_map.get(document_id)
old = indexed_map.get(document_id)
if old is None:
actions.append(SyncAction("create", document_id))
elif new is None:
actions.append(SyncAction("delete", document_id))
elif new.content_hash != old.content_hash:
actions.append(SyncAction("rebuild", document_id))
else:
actions.append(SyncAction("skip", document_id))
return actions
document_id 必须来自稳定业务标识,不能使用随上传变化的临时文件名。
4. 两阶段发布避免半成品
python
from typing import Protocol
class VersionRepository(Protocol):
def create_building(self, manifest: DocumentManifest) -> None: ...
def save_chunks(self, document_id: str, version: str, chunks: list[str]) -> None: ...
def activate(self, document_id: str, version: str) -> None: ...
def mark_failed(self, document_id: str, version: str, reason: str) -> None: ...
class IncrementalIndexer:
def __init__(self, repository: VersionRepository) -> None:
self.repository = repository
def publish(
self,
manifest: DocumentManifest,
chunks: list[str],
) -> None:
if manifest.status != "building":
raise ValueError("新版本必须从 building 开始")
if not chunks:
raise ValueError("空索引不能激活")
try:
self.repository.create_building(manifest)
self.repository.save_chunks(
manifest.document_id,
manifest.version,
chunks,
)
# 所有派生数据成功后再原子切换活动版本
self.repository.activate(manifest.document_id, manifest.version)
except Exception as exc:
self.repository.mark_failed(
manifest.document_id,
manifest.version,
type(exc).__name__,
)
raise
真实系统应让活动版本切换与索引别名/数据库状态一致,并验证并发读写。
5. 删除使用墓碑与异步清理
直接先删原文件可能导致派生向量无法追踪。更稳妥流程:
- 写入 Tombstone(删除墓碑),立即从查询中过滤;
- 清理向量、关键词索引、缓存、解析文本和原文件;
- 记录每个存储的清理状态;
- 验证检索和原文接口均不可访问;
- 按合规策略保留最小审计记录。
6. 幂等任务
python
def indexing_job_key(
document_id: str,
content_hash: str,
parser_version: str,
chunk_version: str,
embedding_model_id: str,
) -> str:
raw = "|".join([
document_id,
content_hash,
parser_version,
chunk_version,
embedding_model_id,
])
return sha256(raw.encode("utf-8")).hexdigest()
同一键只应有一个活动任务,防止消息重投造成重复成本。
7. 可复验测试
python
def test_sync_plan_covers_create_update_delete_and_skip() -> None:
incoming = [
SourceState("new", "h1"),
SourceState("changed", "h2"),
SourceState("same", "h3"),
]
indexed = [
SourceState("changed", "old"),
SourceState("same", "h3"),
SourceState("removed", "h4"),
]
actions = {(item.document_id, item.action) for item in plan_sync(incoming, indexed)}
assert actions == {
("new", "create"),
("changed", "rebuild"),
("same", "skip"),
("removed", "delete"),
}
8. 对抗性审查
- 同一文档并发更新使用乐观锁;
- 旧版在新版完整前保持可用;
- 删除先阻断查询,再清理全部派生数据;
- 缓存键包含知识版本;
- 解析器和 Embedding 变化也触发重建;
- 失败任务可重试但不重复激活;
- 定期做源系统与索引清单对账。
9. 总结
增量更新的核心是不可变版本、幂等任务、两阶段发布和可验证删除。upsert 只解决写入形式,不能独自保证知识的一致性。