跳到主要内容

数据截至 (上游 commit dc85934f318c)

01 — 省 97% 到底省在哪

本章讲什么: 一份 LEANN 索引在磁盘上到底是哪几个文件、build_index 从头到尾干了什么,以及最后那一步"重写索引文件"具体改了哪些字节。读完你能解释清楚"97% 省在哪"。


1. 先看结果:一份索引长什么样

leann build my-docs 建完,.leann/indexes/my-docs/ 下会有这些文件(名字来自 api.py:554-555:594:604:651hnsw_backend.py:90):

文件内容谁写的
documents.leann.passages.jsonl每行一个 JSON:{id, text, metadata}api.py:557-577
documents.leann.passages.idxpickle 的 {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_compactis_pruned(api.py:629-634)——搜索侧靠它们决定怎么加载。

2.3 HNSW 后端这一侧

HNSWBuilder.build(hnsw_backend.py:66)干三件事:

  1. faiss.IndexHNSWFlat(dim, M, metric),默认 M=32efConstruction=200(hnsw_backend.py:52-56:83-84)。
  2. faiss.write_index 落盘成 <prefix>.index(hnsw_backend.py:89-91)。
  3. 按配置走剪枝: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 改 nullconvert_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-hashsha256(text)[:16]内容稳定,跨文件移动 / 重排不变

定义在 api.py:39-40,生成逻辑在 _generate_passage_id(api.py:500-511),校验在 api.py:396-405。老索引没有这个字段时,读侧一律按 sequential 处理(api.py:1217-1219 的注释与默认值)。

注意 add_textmetadata["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:520LeannBuilder.build_index
passage ID 生成packages/leann-core/src/leann/api.py:500_generate_passage_id
原文随机读packages/leann-core/src/leann/api.py:221PassageManager.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:66HNSWBuilder.build
CSR 转换packages/leann-backend-hnsw/leann_backend_hnsw/convert_to_csr.py:527convert_hnsw_graph_to_csr
只剪枝packages/leann-backend-hnsw/leann_backend_hnsw/convert_to_csr.py:408prune_hnsw_embeddings
原地剪枝packages/leann-backend-hnsw/leann_backend_hnsw/convert_to_csr.py:987prune_hnsw_embeddings_inplace
按 C++ 顺序写文件packages/leann-backend-hnsw/leann_backend_hnsw/convert_to_csr.py:184write_compact_format
null fourcc 常量packages/leann-backend-hnsw/leann_backend_hnsw/convert_to_csr.py:27NULL_INDEX_FOURCC