14-企业知识库增量更新-新增修改删除文档

企业知识库如何增量更新?新增、修改、删除文档的处理方案

系列:从零构建企业 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. 删除使用墓碑与异步清理

直接先删原文件可能导致派生向量无法追踪。更稳妥流程:

  1. 写入 Tombstone(删除墓碑),立即从查询中过滤;
  2. 清理向量、关键词索引、缓存、解析文本和原文件;
  3. 记录每个存储的清理状态;
  4. 验证检索和原文接口均不可访问;
  5. 按合规策略保留最小审计记录。

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 只解决写入形式,不能独自保证知识的一致性。

相关推荐
reasonsummer几秒前
【办公类-146-07】20260906《总园大班6个班级四大教育》(优化版:标题excle+复制AI文字+Python占位符录入)
人工智能·python·c#
陈童学哦4 分钟前
FDE岗位年薪百万?拆解中小企业AI落地四步框架
人工智能
richard_first6 分钟前
50.5% 美国人认为 AI 恋爱可能算出轨:AI 正在从“工具“变成“关系主体“
人工智能·microsoft
华清远见成都中心7 分钟前
神经网络中的损失函数是什么
人工智能·深度学习·神经网络
小赵AI手记7 分钟前
技术拆解(二十)具身智能:SO-ARM101抓取小狗的胡萝卜,我看见了AI如何降低跨行门槛
人工智能·机器人
AngusKit9 分钟前
AngusTester 是什么:AI 原生软件测试
自动化测试·人工智能·测试工具·性能测试·web测试·api测试·llm测试
知了一笑11 分钟前
AI知识库,是捷径吗?
人工智能·ai·知识库
招风的黑耳11 分钟前
【数据大屏】智慧城市节能减排类可视化大屏原型
人工智能·智慧城市