数据截至 (上游 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-v2 和 sentence-transformers/all-MiniLM-L6-v2 视为同一个(cli.py:2559-2566)。
默认 HNSW 是 compact 的,所以默认配置下任何变更都走全量重建。想要 HNSW 增量,建索引时得关掉 compact。