数据截至 (上游 commit dc85934f318c)
01 — 省 97% 到底省在哪
本章讲什么: 一份 LEANN 索引在磁盘上到底是哪几个文件、
build_index从头到尾干了什么,以及最后那一步"重写索引文件"具体改了哪些字节。读完你能解释清楚"97% 省在哪"。
1. 先看结果:一份索引长什么样
用 leann build my-docs 建完,.leann/indexes/my-docs/ 下会有这些文件(名字来自 api.py:554-555、:594、:604、:651 和 hnsw_backend.py:90):
| 文件 | 内容 | 谁写的 |
|---|---|---|
documents.leann.passages.jsonl | 每行一个 JSON:{id, text, metadata} | api.py:557-577 |
documents.leann.passages.idx | pickle 的 {passage_id: 字节偏移} 字典 | api.py:578-579 |
documents.index | 图本身(HNSW 图 / IVF 倒排 / DiskANN 磁盘图) | hnsw_backend.py:90-91 |
documents.ids.txt | 一行一个 passage ID,行号 = 后端的整数标签 | api.py:591-598 |
documents.leann.meta.json | 后端名、模型、维度、各文件相对路径、标志位 | api.py:604-642 |
documents.leann.bm25.sqlite | 可选:SQLite FTS5 全文索引 | api.py:644-655 |
注意这里没有"向量文件"。 这就是全部答案的开头。
1.1 为什么要偏移表
因为要按 ID 随机取一行原文,又不想把整个 JSONL 读进内存。PassageManager.get_passage 的做法是:在分片的偏移字典里查到字节偏移,f.seek(offset) 再 readline()(api.py:221-233)。
代码里有一句刻意的设计注释:不合并成一张全局大表,而是保留每个分片各自的表、查询时逐片试(api.py:143-146)。对 6000 万级语料,合并表的内存开销是不可接受的。
2. 建索引主线
2.1 流程图
从上到下是时间顺序;最后两步是 LEANN 独有的。
文档 ──▶ 切块(chunk)
│
▼
写 passages.jsonl,同时记每行字节偏移 ──▶ passages.idx
│
▼
一次性算完所有 embedding(不走常驻服务)
│
▼
后端建图(HNSW: faiss.IndexHNSWFlat)──▶ documents.index
│
▼
★ 重写索引文件:邻接表压 CSR + 丢弃向量存储段
│
▼
写 meta.json(记后端 / 模型 / 维度 / is_pruned …)
2.2 逐步对照源码
入口是 LeannBuilder.build_index(api.py:520)。
第一步:剔空块。 文本为空或全空白的块直接丢掉,并打印跳过数量——这是为了保证 passage 数和 embedding 数严格对齐(api.py:524-539)。
第二步:定维度。 若调用方没给 dimensions,就拿字符串 "dummy" 跑一次嵌入,取长度(api.py:540-549)。
第三步:落原文 + 偏移。 边写边 f.tell() 记偏移,写完 pickle 出偏移字典(api.py:557-579)。
第四步:算全部向量。 关键参数是 use_server=False——建索引阶段不用常驻服务,直接本地批量算(api.py:581-588,分发逻辑在 api.py:73-90)。
第五步:写 ID 映射。 后端返回的是整数标签,所以要把"第 i 个向量对应哪个 passage ID"写成 ids.txt,一行一个(api.py:591-598)。
第六步:交给后端建图。 builder_instance.build(embeddings, string_ids, index_path, ...)(api.py:602-603)。
第七步:写 meta。 除了后端名 / 模型 / 维度,HNSW 还会额外记两个标志位 is_compact 和 is_pruned(api.py:629-634)——搜索侧靠它们决定怎么加载。
2.3 HNSW 后端这一侧
HNSWBuilder.build(hnsw_backend.py:66)干三件事:
- 建
faiss.IndexHNSWFlat(dim, M, metric),默认M=32、efConstruction=200(hnsw_backend.py:52-56、:83-84)。 faiss.write_index落盘成<prefix>.index(hnsw_backend.py:89-91)。- 按配置走剪枝:
is_compact=True走 CSR 转换(顺带剪枝),否则若is_recompute=True只剪枝不转 CSR(hnsw_backend.py:102-105)。
两个开关的默认值都是 True(hnsw_backend.py:52-53)。构造函数还会自动修正一个不合法组合:is_recompute=False 时必须 is_compact=False,否则强制改掉并告警(hnsw_backend.py:58-64,api.py:408-417 也做了同样的前置归一化)。
3. 核心机制:重写索引文件
3.1 它要解决的小问题
FAISS 写出来的 IndexHNSWFlat 文件里,图和向量是捆在一起的:先是 HNSW 结构,后面跟一段 storage(那份 IndexFlat,装着全部原始向量)。要做到"不存向量",就得把后面那段拿掉——但又不能破坏 FAISS 的读取逻辑。
3.2 思路
FAISS 的序列化格式里,storage 段前面有一个 4 字节的 fourcc(四字符魔数),标明后面是什么类型的索引。LEANN 的做法是:把这个 fourcc 改写成 b"null",后面什么都不写。
# 示意,非源码:重写的核心就这两行的意思
NULL_INDEX_FOURCC = int.from_bytes(b"null", "little") # convert_to_csr.py:27
f_out.write(struct.pack("<I", NULL_INDEX_FOURCC)) # 写 null,不写向量
于是文件里只剩图。真实实现里,write_compact_format 严格按 C++ 侧的读取顺序写字段,storage fourcc 写完之后才写邻接数据(convert_to_csr.py:184-239);判定何时写 null 的分支在 convert_to_csr.py:904-907 与 :634-647。
3.3 顺带做的第二件事:邻接表压 CSR
CSR(Compressed Sparse Row,压缩稀疏行) 是稀疏矩阵的经典存法:不存每行的定长槽位,而是存一段连续的"真实元素"数组,再加一个"每行从哪开始"的指针数组。
FAISS 的 HNSW 邻接表是定长的:每个节点每层都预留固定槽数,不满就填 -1。转换后变成三段:
| 数组 | 含义 |
|---|---|
compact_neighbors_data | 所有真实邻居 ID 首尾相连 |
compact_level_ptr | 每个(节点, 层)在上面数组里的起点 |
compact_node_offsets | 每个节点在 level_ptr 里的起点 |
# 示意,非源码:去掉 -1 占位槽的核心一步
level_slice = neighbors[begin:end] # 这一层的定长槽
valid = level_slice[level_slice >= 0] # 只留真实邻居
compact_data.extend(valid)
真实实现在 convert_to_csr.py:778-832,过滤那一句是 valid_neighbors_mask = level_neighbors_slice >= 0(:818)。转换完还跑两组一致性校验:总有效邻居数要对得上,最后一个指针要等于数据长度(convert_to_csr.py:838-876)。
3.4 两个入口的分工
| 函数 | 干什么 | 位置 |
|---|---|---|
convert_hnsw_graph_to_csr | 完整转换:原始格式 → CSR,可选剪枝 | convert_to_csr.py:527 |
prune_hnsw_embeddings | 只剪枝:保持原布局,storage 改 null | convert_to_csr.py:408 |
prune_hnsw_embeddings_inplace | 上者的原地版(写 tmp 再 os.replace) | convert_to_csr.py:987 |
prune_hnsw_embeddings 同时认得已是 CSR 的输入,所以增量追加后再剪一次也安全(convert_to_csr.py:450-476,增量路径调用点在 api.py:1150-1151)。
3.5 一个容易忽略的健壮性细节
读二进制向量时先检查声明的元素个数和总字节数是否离谱(超过 100 亿个元素 / 50 GB 就直接抛 MemoryError),避免文件损坏时试图分配天文数字的内存(convert_to_csr.py:50-63)。
还有一处兼容性探测:非 compact 格式在 offsets 前可能多出一个 0x00 字节,读的时候先探一字节,不是就 seek 回去(convert_to_csr.py:699-721)。
4. 关键细节与坑
4.1 passage ID 有两套方案
| 方案 | ID 形式 | 特点 |
|---|---|---|
sequential(默认) | str(已加入块数) | 快,但位置相关:插入 / 重排会变 |
content-hash | sha256(text)[:16] | 内容稳定,跨文件移动 / 重排不变 |
定义在 api.py:39-40,生成逻辑在 _generate_passage_id(api.py:500-511),校验在 api.py:396-405。老索引没有这个字段时,读侧一律按 sequential 处理(api.py:1217-1219 的注释与默认值)。
注意 add_text 里 metadata["id"] 优先级更高——调用方显式给了 ID 就用调用方的(api.py:516)。CLI 的增量路径正是靠这个塞入自己算的稳定 ID(见 05)。
4.2 归一化模型自动切 cosine
构造 LeannBuilder 时会用两层判断(精确匹配 + 模式匹配)识别 OpenAI / Voyage / Cohere 一类输出已 L2 归一化的模型,命中后若调用方没显式指定度量,就自动设成 cosine 并发 UserWarning;若显式指定了别的度量,则发一条"可能次优"的警告(api.py:429-495)。
4.3 meta.json 里同时写了两套相对路径
path / index_path 是历史字段,path_relative / index_path_relative 是为远程构建的可移植性新增的冗余字段(api.py:613-623)。 读侧 PassageManager 按"绝对路径 → meta 同目录 → CWD → 约定的同名兄弟文件"的顺序逐个试,取第一个存在的(api.py:162-210)。索引目录整体搬走也能打开。
4.4 还有两个绕过分块的建索引入口
| 方法 | 用途 | 位置 |
|---|---|---|
build_index_from_arrays | 已有内存里的 (ids, embeddings) | api.py:657 |
build_index_from_embeddings | 从 pickle 文件读 (ids, embeddings) 元组 | api.py:787 |
没提供文本时会自动造 "Document {id}" 占位原文(api.py:690-697)。注意这条路径不建 BM25 索引,meta 里会多一个 built_from_precomputed_embeddings: true(api.py:769)。
5. 本章代码地图
| 主题 | 文件 | 符号 |
|---|---|---|
| 建索引主流程 | packages/leann-core/src/leann/api.py:520 | LeannBuilder.build_index |
| passage ID 生成 | packages/leann-core/src/leann/api.py:500 | _generate_passage_id |
| 原 文随机读 | packages/leann-core/src/leann/api.py:221 | PassageManager.get_passage |
| 路径回退解析 | packages/leann-core/src/leann/api.py:162 | _resolve_candidates |
| BM25 构建 | packages/leann-core/src/leann/api.py:644 | _build_bm25_fts5 |
| HNSW 建图 | packages/leann-backend-hnsw/leann_backend_hnsw/hnsw_backend.py:66 | HNSWBuilder.build |
| CSR 转换 | packages/leann-backend-hnsw/leann_backend_hnsw/convert_to_csr.py:527 | convert_hnsw_graph_to_csr |
| 只剪枝 | packages/leann-backend-hnsw/leann_backend_hnsw/convert_to_csr.py:408 | prune_hnsw_embeddings |
| 原地剪枝 | packages/leann-backend-hnsw/leann_backend_hnsw/convert_to_csr.py:987 | prune_hnsw_embeddings_inplace |
| 按 C++ 顺序写文件 | packages/leann-backend-hnsw/leann_backend_hnsw/convert_to_csr.py:184 | write_compact_format |
| null fourcc 常量 | packages/leann-backend-hnsw/leann_backend_hnsw/convert_to_csr.py:27 | NULL_INDEX_FOURCC |