跳到主要内容

数据截至 (上游 commit dc85934f318c)

05 — 增量更新

本章讲什么: 改了 3 个文件,要不要把 10 万条 chunk 全部重算一遍?LEANN 的答案是"看后端"。本章讲清变更是怎么检测出来的、三条更新路径怎么选、以及每条路径的实现与失败回滚。


1. 变更检测:一棵两层的 Merkle 树

1.1 它要解决的小问题

要知道"上次建索引之后哪些文件变了",且不能依赖 mtime(mtime 会因为 checkout、复制而失真)。

1.2 结构

Merkle 树是"用子节点哈希算父节点哈希"的树,改一个叶子,根哈希必变。LEANN 用的是极简版——只有两层:

root
hash = sha256(路径1+哈希1 + 路径2+哈希2 + ...)
┌──────┼──────┐
▼ ▼ ▼
文件1 文件2 文件3
(节点 key = 文件路径,data = 文件内容 sha256)

构造在 build_merkle_tree(sync.py:237-251):先按路径排序,把 路径+哈希 拼成一长串算根哈希,再把每个文件挂成根的子节点——注意子节点的 key 用路径、data 用内容哈希(sync.py:249),这是后面比对能工作的关键。

源码自己标了 TODO:这个两层结构对大型代码库需要改进(sync.py:125)。

1.3 比对

compare_with 先比根哈希,一样就直接返回三个空列表——这是快路径(sync.py:157-158)。不一样才逐文件比:两边都有且 data 不同 → modified;只在新的一边 → added;只在旧的一边 → removed(sync.py:160-175)。

1.4 两段式提交

这个设计很重要:

方法干什么
detect_changes()算出变更,新树存进 _pending_tree,不落盘
commit()索引真的更新成功后,才把新树变成当前树并落盘
check_for_changes()上面两步的便利包装(检测即提交)

定义在 sync.py:253-281。CLI 用的是两段式:先 _detect_build_changes,索引更新成功后才 _commit_synchronizers(cli.py:2058:2069,调用点如 cli.py:2585)。建索引失败,下次还会重新检测到这些变更,不会漏。

快照本身是 pickle,路径默认是 <root>.sync_context.pickle,可以指定(sync.py:283-301)。

1.5 扫描范围

_iter_directory_files 按扩展名白名单过滤,默认白名单 DEFAULT_INDEX_EXTENSIONS 有 40 多个扩展名(sync.py:12-61)。不含隐藏文件时,目录和文件名以 . 开头的都跳过,而且相对路径里任何一段以 . 开头也跳过(sync.py:102-119_path_has_hidden_segment:82-83)。

哈希是整文件读进内存算 sha256(sync.py:87-89)。大仓库上这是主要开销。


2. 三条路径怎么选

2.1 决策图

从上往下,第一个命中的分支胜出。

索引已存在 且 没加 --force ?
│否 ──▶ 全量构建
│是

检测变更;三者皆空 ──▶ 打印 "Index up to date." 直接返回


嵌入模型/模式和 meta 一致 ? ──否──▶ 全量重建
│是

backend == ivf 且 非 compact ? ──是──▶ IVF 删加路径(支持增删改)
│否

只有新增 且 backend ∈ {hnsw, ivf} 且 非 compact ? ──是──▶ 只加路径
│否

全量重建(打印原因)

判定条件在 cli.py:2568-2577(can_ivf_update / can_add_only),分派在 :2576-2650。模型名比较做了归一化——all-MiniLM-L6-v2sentence-transformers/all-MiniLM-L6-v2 视为同一个(cli.py:2559-2566)。

默认 HNSW 是 compact 的,所以默认配置下任何变更都走全量重建。想要 HNSW 增量,建索引时得关掉 compact。

2.2 还有一条免加载的快路径

如果只有删除(没有新增和修改)且后端是 IVF,连文档都不用加载和分块,直接从 passages.jsonl 里查出这些文件对应的 chunk ID 删掉就完事(cli.py:2173-2203,分派在 :2576-2584)。


3. 路径 A:IVF 删加

3.1 chunk ID 怎么定位

先从 passages.jsonl 建一张 文件路径 → [chunk ID] 的表(_load_chunk_ids_by_file,cli.py:2425),并用偏移表里的 key 集合限定"还活着的" ID(cli.py:2229-2235)。

路径匹配是个麻烦事——passages 里的路径可能是相对的也可能是绝对的,所以 _path_lookup_keys 会生成多种变体逐个试(cli.py:2205-2212)。

3.2 新 chunk 的 ID 策略

两个函数,用在不同场合:

函数ID 怎么来用在哪
_assign_chunk_idssha256("归一化路径:序号")[:16],稳定只加路径(cli.py:2079-2091)
_assign_unique_chunk_idsuuid4().hex[:16],每次都新IVF 删加路径(cli.py:2093-2099)

后者的理由写在 docstring 里:路径格式可能对不上,导致有些旧 ID 没被删掉,这时若新 ID 和它们撞了就会出问题——所以干脆用 UUID 保证不撞(cli.py:2095)。

3.3 落到底层

LeannBuilder.update_index(index_path, remove_passage_ids=[...])(api.py:836)在 IVF 分支上:

  1. leann_backend_ivf.remove_ids 删向量;实际删掉数少于请求数会打警告(api.py:871-882)。
  2. 从偏移表里摘掉这些 ID。
  3. 压实 passages.jsonl:按偏移顺序把还活着的行读出来,重写成新文件,再 replace 覆盖,同时重建偏移表(_compact_passages,api.py:812-834)。这一步是防止删除后文件里留下墓碑行、越滚越大。
  4. 算新块的向量,ivf_add_vectors 追加,再把新行 append 到 JSONL 并更新偏移表(api.py:960-1002)。
  5. 失败则把 JSONL 截回原长度、偏移表恢复备份(api.py:1003-1010)。

4. 路径 B:HNSW 追加

这条路径只处理新增,但实现上的讲究最多。

4.1 前置检查

  • compact 索引直接抛错要求重建(api.py:902-907)。
  • 后端名和索引 meta 里记的必须一致(api.py:860-864)。
  • passage ID 已存在则抛错(api.py:925-926)。
  • 维度对不上抛错(api.py:947-952)。

4.2 给 FAISS 补一个假 storage

剪枝过的索引里没有 storage 对象,但 IndexHNSW::add 需要它。所以代码现造一个空的 IndexFlatIP / IndexFlatL2 挂上去,并且把它的 ntotal 强行设成图当前的 ntotal:

不这么做,FAISS 会以为调用方提供了预设的 level,触发 n0 + n == levels.size() 断言失败。

这段解释就写在源码注释里(api.py:1021-1041)。老版本 FAISS 不允许写 ntotal 时会 AttributeError,代码里 catch 掉走默认行为。

4.3 写入顺序:先原文,后图

这是整段代码里最关键的一条约束:

① append 新 passage 到 jsonl + 更新偏移表(先落盘!)


② 起临时嵌入服务(端口来自 LEANN_UPDATE_ZMQ_PORT,默认 5557)


③ index.add(...) ← recompute 模式下,C++ 会立刻 ZMQ 反查新 ID 的原文


④ write_index 落盘


⑤ 停临时服务,再跑一次 prune_hnsw_embeddings_inplace

源码注释直说了原因:"先追加 passages/offsets,这样 ZMQ 服务在 recompute 时才能解析到新分配的 ID"(api.py:1056-1058)。任一步失败就把 JSONL 截回、偏移表回滚(api.py:1127-1135)。

4.4 recompute 模式下逐条 add

if needs_recompute:
for i in range(embeddings.shape[0]):
index.add(1, faiss.swig_ptr(embeddings[i:i+1])) # 一条一条
else:
index.add(embeddings.shape[0], faiss.swig_ptr(embeddings))

这是真实源码的形状(api.py:1116-1121)。为什么要逐条,代码里没有解释 (inferred:多半和上一条的 storage ntotal 记账有关,批量 add 会打乱偏移)。

4.5 追加完要重新剪枝

index.add 会把新向量写进那个临时 storage,write_index 会连它一起落盘。所以最后必须再剪一次,把 storage 段重新变成 null(api.py:1150-1151)。

4.6 这条路径会覆盖 passage ID

追加时强制把 ID 设成 str(index.ntotal + offset)(api.py:1050-1054)。CLI 上游辛辛苦苦算的 sha256 稳定 ID,在 HNSW 路径上被丢掉了(IVF 路径则保留,见 api.py:962-968)。这意味着 passage_id_scheme="content-hash" 对 HNSW 追加不生效


5. 路径 C:全量重建的原子发布

5.1 问题

重建要花很久。期间如果直接往原目录写,老索引就用不了了;写到一半崩了,索引直接废掉。

5.2 做法

检测到已有索引产物 ──▶ 建影子目录 .<name>.rebuild-<uuid>


全部写进影子目录(索引、passages、快照、sync 配置)


发布:os.replace(正式目录 → .backup-<uuid>)
│ os.replace(影子目录 → 正式目录)

成功:删备份 │ 失败:把备份换回来,并清掉半成品

判定"是否已有产物"看三个文件是否齐全(_existing_index_artifacts,cli.py:83-88);影子目录命名在 cli.py:2671-2673;发布与回滚在 _publish_rebuilt_index(cli.py:91-110);构建异常时清理影子目录在 cli.py:2708-2712

注意快照也是在影子目录里建的(cli.py:2693-2704),所以发布后 sync 状态和索引状态天然一致。


6. 关键细节与坑

① 只有删除时,update_index 也要更新 meta。 没有新块时会走一条短路径:只更新 total_passages 就返回(api.py:894-899:929-935)。

② 文件被清空 ≠ 无事发生。 CLI 里即使 all_texts 为空也可能继续走 IVF 更新,因为还需要删掉旧 chunk(cli.py:2613-2616 的注释)。

③ 增量路径必须复用原模型,但"复用"靠比对把关,不是从 meta 读回来。 增量 builder 的 embedding_model / embedding_mode 全部取自本次命令行参数,它唯一从 meta 读回来的字段是 passage_id_scheme——已有索引的方案优先,和 --id-scheme 冲突时打印一条 Note 并沿用旧方案(_existing_index_id_scheme,cli.py:2112-2126;_make_incremental_builder,cli.py:2128-2149)。维度也不从 meta 传,而是靠 update_index 里那次对不上就抛错的校验兜底(api.py:947-952,见 §4.1)。真正防止"拿新模型往旧索引里塞向量"的是决策阶段那次比对:meta 里的模型 / 模式和 args 对不上,same_embedding 为假,三条增量路径全部关闭、退回全量重建(cli.py:2559-2566)。MCP 的 build 工具则是真的读 meta,把 --embedding-model / --embedding-mode 补进命令行再调 CLI(mcp.py:229-247),这样 agent 不显式写模型参数也不会踩到 CLI 默认模型、误触发重建。

④ 全量重建时要重新加载全部文档。 增量分支里只加载了变化的文件,退回重建时必须重新全量 load(cli.py:2650-2654 的注释)。

⑤ 重建原因会打印出来。 _log_rebuild_reason(cli.py:2287)把"为什么不能增量"讲清楚,不让用户对着漫长的重建一脸懵。

⑥ 还有个 leann watch 常驻监听变更并自动更新,复用同一套检测逻辑(_watch_check_changes,cli.py:2717;子命令注册在 cli.py:456)。


7. 本章代码地图

主题文件符号
变更检测器packages/leann-core/src/leann/sync.py:178FileSynchronizer
两段式检测packages/leann-core/src/leann/sync.py:253detect_changes / commit
Merkle 树packages/leann-core/src/leann/sync.py:132MerkleTree / compare_with
扩展名白名单packages/leann-core/src/leann/sync.py:12DEFAULT_INDEX_EXTENSIONS
CLI 建索引编排packages/leann-core/src/leann/cli.py:2458LeannCLI.build_index
增量 builder 构造packages/leann-core/src/leann/cli.py:2128_make_incremental_builder
只加路径packages/leann-core/src/leann/cli.py:2151_incremental_add_only
IVF 只删路径packages/leann-core/src/leann/cli.py:2173_incremental_ivf_remove_only
IVF 删加路径packages/leann-core/src/leann/cli.py:2214_incremental_ivf_update
稳定 chunk IDpackages/leann-core/src/leann/cli.py:2079_assign_chunk_ids
唯一 chunk IDpackages/leann-core/src/leann/cli.py:2093_assign_unique_chunk_ids
原子发布packages/leann-core/src/leann/cli.py:91_publish_rebuilt_index
重建原因日志packages/leann-core/src/leann/cli.py:2287_log_rebuild_reason
底层更新packages/leann-core/src/leann/api.py:836LeannBuilder.update_index
passages 压实packages/leann-core/src/leann/api.py:812_compact_passages