RAG 系列(十九):增量更新——知识库如何保持新鲜

知识库不是静态的

前面十八篇一直在讨论如何把文档索引进向量数据库、如何检索、如何优化。但这些文章有一个隐含假设:文档是一次性批量写入的,之后不变

生产环境不是这样的。

产品文档每周更新、知识库文章每天新增、过时的内容随时要下线。每次有文档变动,你面临一个选择:

选项一:全量重建

把所有文档(包括没变的)重新 embed 一遍,重建整个向量索引。操作简单,但代价是:

  • 每次重建都要支付所有文档的 embedding 费用
  • 1000 篇文档只改了 5 篇,却要 embed 1000 次
  • 随着知识库增长,重建时间越来越长

选项二:增量更新

记录每篇文档的内容哈希,下次同步时只处理哈希发生变化的文档------新增的加进去,修改的替换掉,删除的清除掉,没变的跳过。

LangChain Indexing API 实现的就是选项二。


Indexing API 的工作原理

核心是两个组件:

SQLRecordManager:一个 SQLite 数据库,存储每篇已索引文档的记录:

yaml 复制代码
source         |  content_hash           |  indexed_at
rag-intro      |  a3f8b2c1...            |  2026-05-15 10:00
ragas          |  d9e2f1a4...            |  2026-05-15 10:00
vector-db      |  7c4b8e3f...            |  2026-05-15 10:00
...

index() 函数:对比当前批次的文档哈希和 RecordManager 里的记录,决定每篇文档的命运:

ini 复制代码
对于每篇文档:
  哈希一样 → 跳过(num_skipped++)
  哈希不同 → 删除旧版本,插入新版本(num_deleted++, num_added++)
  首次出现 → 直接插入(num_added++)

在此之后(cleanup="full"):
  RecordManager 里有、当前批次没有 → 删除(num_deleted++)

cleanup="full" 是关键参数,它负责清理已被移除的文档。如果没有它,删掉的文档会继续留在向量库里被检索到。


核心实现

RecordManager 初始化

python 复制代码
from langchain_classic.indexes import SQLRecordManager, index

NAMESPACE = "chroma/rag_knowledge_base"

record_manager = SQLRecordManager(
    NAMESPACE,
    db_url="sqlite:///record_manager.db",
)
record_manager.create_schema()   # 首次运行建表

NAMESPACE 相当于一个分区键,一个 SQLite 文件可以管理多个不同的知识库,互不干扰。

通用索引函数

python 复制代码
def sync_knowledge_base(docs: list[Document]) -> dict:
    """增量同步一批文档到向量库。
    
    - 未变化的文档:跳过(不调用 embedding API)
    - 新增/修改的文档:嵌入并写入向量库
    - 已删除的文档:从向量库清除
    """
    return index(
        docs,
        record_manager,
        vectorstore,
        cleanup="full",        # 自动清理不在当前批次里的文档
        source_id_key="source",  # 用 metadata["source"] 标识文档身份
    )

source_id_key 很重要:同一个 source 的文档视为"同一篇文章的不同版本"。如果内容哈希变了,旧版本会被删除,新版本会被加入。

文档必须有 source 元数据

python 复制代码
Document(
    page_content="...",
    metadata={"source": "rag-intro"},  # 必须有,用于版本追踪
)

没有 source 的文档无法被增量追踪,每次都会被当成新文档处理。


实验:三轮同步

数据集设计

V1(初始知识库,6篇文档):

  • rag-intro、ragas、vector-db、embedding、rerank、chunking

V2(更新后,模拟真实变更):

变更类型 文档 说明
未变化 rag-intro, vector-db, rerank 内容完全相同
修改 ragas 新增了 faithfulness 说明
修改 chunking 新增了 contextual retrieval 内容
删除 embedding 不在 V2 批次中
新增 advanced-rag 首次出现
新增 conv-rag 首次出现

V1 → V2:3 篇未变,2 篇修改,1 篇删除,2 篇新增。

实验结果

yaml 复制代码
======================================================================
  Scenario 1: Initial Index (V1 --- 6 documents)
======================================================================

  [Initial Index]
  ┌─────────────────────────────────────────┐
  │  added:       6  (newly embedded)       │
  │  skipped:     0  (content unchanged)    │
  │  deleted:     0  (removed/replaced)     │
  ├─────────────────────────────────────────┤
  │  embed calls:    6                      │
  │  wall time:   0.913s                    │
  └─────────────────────────────────────────┘

======================================================================
  Scenario 2: Incremental Update (V2)
======================================================================

  [Incremental Update]
  ┌─────────────────────────────────────────┐
  │  added:       4  (newly embedded)       │
  │  skipped:     3  (content unchanged)    │
  │  deleted:     3  (removed/replaced)     │
  ├─────────────────────────────────────────┤
  │  embed calls:    4                      │
  │  wall time:   0.891s                    │
  └─────────────────────────────────────────┘

======================================================================
  Scenario 3: Full Rebuild (V2, record manager wiped)
======================================================================

  [Full Rebuild]
  ┌─────────────────────────────────────────┐
  │  added:       7  (newly embedded)       │
  │  skipped:     0  (content unchanged)    │
  │  deleted:     0  (removed/replaced)     │
  ├─────────────────────────────────────────┤
  │  embed calls:    7                      │
  │  wall time:   0.494s                    │
  └─────────────────────────────────────────┘

指标对比

sql 复制代码
  ┌──────────────────────┬───────────────┬───────────────┐
  │                      │   Incremental │  Full Rebuild │
  ├──────────────────────┼───────────────┼───────────────┤
  │  Documents embedded  │       4       │       7       │
  │  Documents skipped   │       3       │       0       │
  │  Embedding savings   │    42.9%      │     0%        │
  └──────────────────────┴───────────────┴───────────────┘

增量更新只 embed 了 4 篇文档,而全量重建需要 embed 7 篇,节省 42.9% 的 embedding API 调用。


一个反直觉的时间结果

实验中全量重建反而更快(0.494s vs 0.891s)。

这不是增量更新的缺陷,而是小规模实验的偏差

7 篇文档的情况下,SQLite 哈希查询和对比的开销比"多调用 3 次 embedding API"还要大。实际的 embedding 调用是异步批量操作,延迟主要取决于网络往返,而 SQLite 操作是同步的本地磁盘 I/O。

换算到实际规模:

bash 复制代码
场景:1000 篇文档知识库,每天 5% 文档发生变化(50 篇)

全量重建:1000 次 embed 调用 / 天
增量更新:  50 次 embed 调用 / 天  → 节省 95%

假设每次 embed 调用 $0.0001(bge-large-zh-v1.5 按 token 计费):
  全量重建:~$100/天(如果文档平均 200 token)
  增量更新:~$5/天

时间节省 在小规模下不显著,但 成本节省 从第一天就是真实的。而随着知识库增长,时间节省也会逐渐主导。


删除行为的细节

deleted: 3 包含两种类型的删除,区别很重要:

  1. 修改删除(2 次):ragas 和 chunking 的旧版本,因为内容哈希变了,旧版本从向量库移除,新版本重新嵌入
  2. 清理删除(1 次):embedding 文档不在 V2 批次里,cleanup="full" 在处理完所有文档后发现它不见了,将其从向量库删除

如果使用 cleanup=None(不清理),embedding 文档会永久留在向量库里,即使知识库已经把它下线了。这是生产系统里常见的"幽灵文档"问题------检索到的内容来自已经废弃的版本。

python 复制代码
# 不推荐:不清理,幽灵文档积累
index(docs, record_manager, vectorstore, cleanup=None)

# 推荐:完整同步,过时文档自动清除
index(docs, record_manager, vectorstore, cleanup="full", source_id_key="source")

生产集成模式

实际部署中,增量更新通常由定时任务或文档变更事件触发:

python 复制代码
import glob
import os

def load_documents_from_dir(docs_dir: str) -> list[Document]:
    """从文件系统加载文档,用文件路径作为 source。"""
    docs = []
    for filepath in glob.glob(f"{docs_dir}/**/*.md", recursive=True):
        with open(filepath, encoding="utf-8") as f:
            content = f.read()
        docs.append(Document(
            page_content=content,
            metadata={"source": filepath},
        ))
    return docs

# 定时任务:每小时同步一次
def hourly_sync():
    docs = load_documents_from_dir("./knowledge_base")
    result = index(
        docs,
        record_manager,
        vectorstore,
        cleanup="full",
        source_id_key="source",
    )
    print(f"同步完成: +{result['num_added']} ~{result['num_deleted']} ={result['num_skipped']}")

文件路径作为 source 天然唯一,文件内容变化时哈希自动失效,触发重新嵌入。这是最简单也最可靠的集成方式。


RecordManager 的持久化

SQLRecordManager 使用 SQLite 文件持久化索引状态,重启服务后记录不丢失。生产环境建议:

python 复制代码
# 开发/单机
record_manager = SQLRecordManager(
    "namespace",
    db_url="sqlite:///record_manager.db",
)

# 生产/分布式(多个服务实例共享记录)
record_manager = SQLRecordManager(
    "namespace",
    db_url="postgresql://user:pass@host/dbname",
)

SQLite 适合单机部署,PostgreSQL 适合多实例共享同一个 RecordManager 的场景(避免并发写入冲突)。


完整代码

代码已开源:

github.com/chendongqi/...

核心文件:

  • incremental_update.py --- 完整实现:三轮同步对比 + 查询验证 + 成本报告

运行方式:

bash 复制代码
git clone https://github.com/chendongqi/llm-in-action
cd 19-incremental-update
cp .env.example .env
pip install -r requirements.txt
python incremental_update.py

小结

本文实现了基于 LangChain Indexing API 的增量知识库更新,核心发现:

  1. 内容哈希追踪是关键机制:RecordManager 记录每篇文档的哈希,unchanged → skip,modified → 删旧插新,deleted → cleanup 清除
  2. 42.9% embedding 节省:7 篇文档 3 篇未变,实际只 embed 了 4 篇。比例随知识库增长和变更率降低而提升
  3. 时间节省在小规模不显著:SQLite 哈希查询的开销在 7 篇文档时比 embedding 还贵;规模到 1000+ 篇后时间节省才开始主导
  4. cleanup="full" 防止幽灵文档:不指定 cleanup 的话,删除的文档会永久残留在向量库,检索时返回已废弃内容

增量更新是把 RAG 从"Demo 能跑通"推向"生产可用"的关键一步。知识库不是一次性资产,它需要随业务持续演进。


参考资料

相关推荐
浪里行舟1 小时前
你的品牌正在被AI“遗忘”?用BuildSOM找回搜索的下一个风口
人工智能·python·程序员
jkyy20141 小时前
轻量化AI营养师,如何适配多业态快速落地健康服务升级?
人工智能
blackorbird2 小时前
M4 MacBook Air外接RTX 5090实现3A游戏与AI加速
人工智能·游戏
knight_9___2 小时前
大模型project面试8
人工智能
学习论之费曼学习法2 小时前
Agent工具调用:让AI拥有超能力
人工智能
小豆包的小朋友02172 小时前
【无标题】
人工智能
IT_陈寒3 小时前
React性能优化踩的坑,这个错你可能也会犯
前端·人工智能·后端
zhangxingchao3 小时前
AI应用开发三:RAG技术与应用
前端·人工智能·后端