知识库不是静态的
前面十八篇一直在讨论如何把文档索引进向量数据库、如何检索、如何优化。但这些文章有一个隐含假设:文档是一次性批量写入的,之后不变。
生产环境不是这样的。
产品文档每周更新、知识库文章每天新增、过时的内容随时要下线。每次有文档变动,你面临一个选择:
选项一:全量重建
把所有文档(包括没变的)重新 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 包含两种类型的删除,区别很重要:
- 修改删除(2 次):ragas 和 chunking 的旧版本,因为内容哈希变了,旧版本从向量库移除,新版本重新嵌入
- 清理删除(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 的场景(避免并发写入冲突)。
完整代码
代码已开源:
核心文件:
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 的增量知识库更新,核心发现:
- 内容哈希追踪是关键机制:RecordManager 记录每篇文档的哈希,unchanged → skip,modified → 删旧插新,deleted → cleanup 清除
- 42.9% embedding 节省:7 篇文档 3 篇未变,实际只 embed 了 4 篇。比例随知识库增长和变更率降低而提升
- 时间节省在小规模不显著:SQLite 哈希查询的开销在 7 篇文档时比 embedding 还贵;规模到 1000+ 篇后时间节省才开始主导
- cleanup="full" 防止幽灵文档:不指定 cleanup 的话,删除的文档会永久残留在向量库,检索时返回已废弃内容
增量更新是把 RAG 从"Demo 能跑通"推向"生产可用"的关键一步。知识库不是一次性资产,它需要随业务持续演进。