数据截至 (上游 commit dc85934f318c)
LEANN — 架构与原理
30 秒导读: LEANN 是一个跑在个人电脑上的向量索引 / RAG 系统。别的向量库把每段文字的 embedding 都存进磁盘,LEANN 一个都不存——它只留一张裁过的近邻图和原文,搜索时图走到哪个节点、就当场把那段 原文重新算成向量。代价是每次查询多花算力,回报是索引体积降到传统方案的约 3%。
1. 这是什么(零基础也能懂)
1.1 一句话定义
LEANN 是一个低存储的本地向量索引,以及围绕它的一整套 RAG(检索增强生成:先检索资料、再把资料喂给大模型作答)工具链。
1.2 先说清两个词
- embedding(语义向量):把一段文字用模型转成一串浮点数,语义相近的文字,数字也相近。
- 向量索引:存放这些向量、并能快速找出"和查询最像的前 k 条"的数据结构。
1.3 它解决谁的什么问题
传统向量库的痛点是存储,场景化讲:
你想把 6000 万条文本块(邮件 + 浏览器历史 + 论文)做成能语义搜索的索引。按 768 维 float32 算,光向量本身就是几百 GB——笔记本放不下。README 给出的对比数字是:同一份 60M 语料,FAISS 要 201 GB,LEANN 要 6 GB(README.md:1281-1285)。
LEANN 的回答很直白:向量是可以从原文重新算出来的,那就别存了。
1.4 它能做什么
| 能力 | 具体形态 |
|---|---|
| 建索引 | leann build,或 Python 的 LeannBuilder |
| 语义搜索 | leann search,或 LeannSearcher |
| 基于自己文档的问答 | leann ask,或 LeannChat |
| 多轮推理检索 | leann react,ReActAgent(可选接网络搜索) |
| 接进编码 agent | 内置 MCP 服务,暴露 4 个工具给 Claude Code 一类客户端 |
| 增量更新 | 重跑 leann build 自动只处理变化的文件 |
| 现成数据连接器 | apps/ 下的邮件 / 浏览器 / 微信 / iMessage / 代码库等示例 |
1.5 用起来什么样
命令行三连(packages/leann-core/src/leann/cli.py:293、:509、:642 注册这三个子命令):
# ① 从一个目录建索引,my-docs 是索引名
leann build my-docs --docs ./documents
# ② 语义搜索
leann search my-docs "how are embeddings computed"
# ③ 拿这批文档做问答
leann ask my-docs "Where are prompts configured?"
Python 侧是同一套东西的三个类,均在 packages/leann-core/src/leann/api.py:
# 示意,非源码
from leann import LeannBuilder, LeannSearcher, LeannChat
builder = LeannBuilder(backend_name="hnsw") # 建
builder.add_text("LEANN stores a graph, not vectors.")
builder.build_index("demo.leann")
searcher = LeannSearcher("demo.leann") # 搜
hits = searcher.search("storage saving", top_k=3)
chat = LeannChat("demo.leann") # 问
print(chat.ask("How much storage does it save?"))
对应符号:LeannBuilder(api.py:378)、LeannSearcher(api.py:1154)、LeannChat(api.py:1672),三者由包入口统一导出(packages/leann-core/src/leann/__init__.py)。
1.6 一句话直觉
"不存菜,存菜谱。"
传统向量库像把每道菜都做好冻进冰箱——占地方。LEANN 只留食材(原文)和一张"哪道菜跟哪道菜像"的关系图,谁点单就现炒那几道。储物间省下来了,味道(检索质量)不变,代价是上菜时要开火。
本节到此不碰底层。下面进入大盘。
2. 顶层全景(它大概怎么转)
2.1 结构图
怎么读这张图:自上而下是调用方向。三种入口最终都落到
leann-core;core 左手接嵌入计算、右手接可插拔后端;最下面是磁盘上真正留下来的东西。
入口三选一
┌───────────┬────────────┬───────────┐
│ leann CLI │ Python API │ MCP 服务 │
└─────┬─────┴──────┬─────┴─────┬─────┘
└────────────┼───────────┘
▼
┌─────────────────────────┐
│ leann-core / api.py │
│ 建索引 · 搜索 · 问答 │
└────┬───────────────┬────┘
▼ ▼
┌───────────── ─────┐ ┌──────────────────────┐
│ 分块 + 嵌入计算 │ │ 后端注册表(自动发现) │
│ (把文字变向量) │ │ HNSW / IVF / DiskANN│
└──────────────────┘ └──────────────────────┘
│
▼
磁盘:裁过的图文件 + passages.jsonl(原文)
2.2 部件一句话职责
| 部件 | 干什么 | 在哪 |
|---|---|---|
| 核心 API | 对外三件套:LeannBuilder / LeannSearcher / LeannChat | packages/leann-core/src/leann/api.py |
| CLI | 把三件套包成 leann build/search/ask/react/watch/... | packages/leann-core/src/leann/cli.py:290 |
| 后端注册表 | 扫描已安装的 leann-backend-* 包并注册 | registry.py:61 autodiscover_backends |
| 后端契约 | 三个抽象 接口,规定后端要实现什么 | interface.py:7、:23、:96 |
| 嵌入服务 | 搜索期常驻进程,收 ZMQ 请求、现算 embedding | leann_backend_hnsw/hnsw_embedding_server.py:58 |
| 服务管理器 | 起 / 复用 / 停这个常驻进程 | embedding_server_manager.py:213 |
| 原文存取 | 用偏移表在 JSONL 里按 ID 随机读一行 | api.py:137 PassageManager |
| 变更检测 | 用扁平 Merkle 树判断哪些文件变了 | sync.py:178 FileSynchronizer |
| MCP 服务 | 4 个工具的 stdio JSON-RPC 服务 | mcp.py:53 TOOLS |
2.3 仓库布局(uv workspace)
LEANN/
├── pyproject.toml # 工作区根;[tool.uv.sources] 挂本地包
├── packages/
│ ├── leann-core/ # api.py / cli.py / registry.py / sync.py / mcp.py …
│ ├── leann-backend-hnsw/ # 默认后端(FAISS fork + ZMQ + CSR 重写)
│ ├── leann-backend-ivf/ # 支持原地增删的后端
│ ├── leann-backend-diskann/ # 超内存数据集后端(DiskANN fork)
│ ├── leann-backend-flashlib/ # 可选 GPU 后端,要 CUDA
│ ├── leann-backend-flashlib-ivf/ # 可选 GPU 后端,要 CUDA
│ └── leann/ # 元包:pip install leann 装的就是它
└── apps/ # 各数据源的 RAG 示例
根 pyproject.toml 的 [tool.uv.sources] 把 6 个本地包按 editable 路径挂进工作区:leann-core、leann-backend-diskann、leann-backend-hnsw、leann-backend-flashlib、leann-backend-flashlib-ivf、astchunk(pyproject.toml:97-103)。leann-backend-ivf 不在这张表里——它由元包按普通版本依赖拉;元包 leann 默认装 core + hnsw + diskann + ivf(packages/leann/pyproject.toml 的 dependencies)。两个 flashlib 后端只作为可选 extra 声明,细节见 03 的 §3.5。
重要的诚实提醒: leann-backend-hnsw/third_party/faiss 与 leann-backend-diskann/third_party/DiskANN 都是 git submodule,指向作者自己 fork 的 FAISS / DiskANN(.gitmodules)。本克隆里这些子模块没有 checkout,所以"图遍历时怎么触发 ZMQ 回调"的 C++ 实现无法在本克隆内核对。本文只对 Python 侧和二进制文件格式做断言,C++ 侧只描述它对外暴露的接口(如 set_zmq_port、SearchParametersHNSW)。
2.4 主线走一遍(高层,不进代码)
建索引(一次性):
- 把文档切块,每块写一行进
documents.leann.passages.jsonl,同时记下这一行的字节偏移。 - 一次性把所有块算成 embedding(此时不走常驻服务,直接本地算)。
- 交给后端建图(HNSW 默认是 FAISS 的
IndexHNSWFlat)。 - 把图文件重写一遍:邻接表压成 CSR,向量存储段整个丢掉。
- 写
documents.leann.meta.json记后端 / 模型 / 维度 / 各文件名。
搜索(每次查询):
- 确保嵌入服务在跑(能复用就复用已有 daemon)。
- 把 query 算成向量。
- 后端在图上遍历;每当需要某批节点的向量,C++ 通过 ZMQ 把节点 ID 发回 Python。
- Python 侧按 ID 取原文、现算 embedding、把距离算好回传。
- 拿到 top-k 的整数标签 → 经
ids.txt映射成 passage ID → 经偏移表回 JSONL 取原文。
第 3、4 步就是 LEANN 的全部魔法所在,细节见 02。
3. 巧妙之处(要带走的精华)
每条先说妙在哪,再给锚点。
① 把"存储"换成"算力"的取舍做到了文件格式一层。
不是在应用层跳过写向量,而是字节级重写 FAISS 索引文件:把 storage 段的 fourcc 改写成 b"null",后面的向量数据一个字节都不落盘(fourcc 与后续数据的写入在 convert_to_csr.py:232-239,判定写 null 的分支在 :904-907 与 :634-647)。这样 FAISS 自己的读写代码不用改。
② 邻接表压 CSR,顺手把占位槽扔掉。
FAISS 的 HNSW 邻接表是定长的,不满就填 -1。转换时按 level_neighbors_slice >= 0 过滤,只留真实邻居(convert_to_csr.py:818-825)。图本身也因此变小。
③ 反向回调:C++ 遍历,Python 供货。
距离计算需要向量时,C++ 把 [[节点ID...], [query向量]] 通过 ZMQ REQ 发给 Python,Python 查原文、算 embedding、直接把距离算好回传(hnsw_embedding_server.py:211-271)。省了一来一回传向量的带宽。
④ 嵌入服务按"配置指纹"复用。
指纹不只含模型名和模式,还含 passages 文件与偏移文件的 mtime + size 签名(embedding_server_manager.py:157-207、:349-369)。换了语料 → 指纹变 → 自动不复用旧 daemon,不会静默拿旧原文算向量。
⑤ 失败不崩,返回哨兵。
某个 passage 查不到或模型报错时,该位置填 1e9 当距离(hnsw_embedding_server.py:243-244),遍历继续。解包失败时按"上一次请求类型"构造形状正确的空回复(:180-193),避免 REQ/REP 套接字卡死。
⑥ 增量更新时先写原文、再往图里加点。
因为 recompute 模式下 index.add 会立刻通过 ZMQ 反查新 ID 的原文,所以必须先把新 passage 落盘(api.py:1056-1058)。失败则把 JSONL 截回原长度、偏移表回滚(api.py:1127-1135)。
⑦ 全量重建走影子目录 + 原子发布。
重建写进 .<name>.rebuild-<uuid> 临时目录,成功后先把旧目录 os.replace 成备份、再把新目录换上,任一步失败就把旧目录换回来(cli.py:91-110、:2664-2669、:2702-2708)。重建过程中老索引一直可用。
⑧ 归一化模型自动切 cosine。
检测到 OpenAI / Voyage / Cohere 一类输出已归一化的模型,自动把 distance_metric 设成 cosine 并告警(api.py:429-486)。这是一类很容易踩、又很难 debug 的精度坑。
4. 阅读地图
| 章节 | 一句话讲什么 |
|---|---|
| 01-storage-trick.md | 一份索引由哪 6 个文件组成、build 主线、CSR 重写与丢弃向量段——"省 97%"的根。 |
| 02-search-recompute.md | 一次查询从 query 到 SearchResult 的全链路,ZMQ 4 种消息协议、daemon 复用与容错。 |
| 03-backends-registry.md | leann-backend-* 自动发现、三个抽象接口,以及 HNSW / IVF / DiskANN 的取舍对比。 |
| 04-embedding-and-chunking.md | 传统分块 vs AST 分块、token 预算与截断、5 个嵌入 provider 与批大小自适应。 |
| 05-incremental-update.md | Merkle 变更检测、IVF 删加 / HNSW 只加 / 全量重建三条路径的判定与实现。 |
| 06-apps-cli-agent.md | CLI 子命令、MCP 服务、ReAct agent,以及 BM25 混合 / 元数据过滤 / grep 三条旁路。 |
建议路线: 只想懂"为什么这么省" → 01;想看一次查询怎么跑通 → 01 → 02;要加后端或换模型 → 03 → 04;关心"改了文件要不要重建" → 05;要把它接进自己的工具 → 06。
5. 边界与局限(诚实清单)
① 省的是向量,不是原文。
passages.jsonl 存的是完整明文。所以"97%"是相对"存全部 embedding"而言;文本本身的体积一分没少(api.py:557-577)。
② 查询要付重算的钱。
遍历路径上碰到的每个节点都要现算 embedding。这是纯粹的算力换存储,没有免费午餐。LEANN 用常驻 daemon + warmup(api.py:1227-1228)缓解冷启动,但单 次查询延迟天然高于"直接读向量"。
③ compact 的 HNSW 索引不能原地更新。
代码里直接抛错要求重建(api.py:902-907)。而 is_compact 默认为 True(hnsw_backend.py:52),所以默认配置下的 HNSW 索引,任何变更都是全量重建。
④ 剪枝过的索引必须开 recompute。
recompute_embeddings=False 配上 pruned 索引会直接 RuntimeError(hnsw_backend.py:210-214)——因为文件里根本没有向量可读。
⑤ HNSW 增量追加会覆盖调用方给的 passage ID。
追加路径强制把 ID 设成 str(index.ntotal + offset)(api.py:1050-1054),CLI 上游算好的 sha256 稳定 ID 在这条路径上被丢弃;IVF 路径则保留(api.py:962-968)。也就是说 passage_id_scheme="content-hash" 在 HNSW 追加时不生效。
⑥ use_grep=True 的文件名是写死的。
_find_jsonl_file 只找 documents.leann.passages.jsonl 这一个名字(api.py:1583-1586),非 CLI 命名的索引用不了 grep 模式。
⑦ 混合检索的分数融合假定"越大越好"。
融合是 vector_weight * 向量分 + (1-w) * BM25 分,然后降序取前 k(api.py:1416、:1425)。FTS5 的 bm25() 已被取负成"越大越好"(api.py:317-318、:362),内积度量也是越大越好;但若索引建成 distance_metric="l2",向量侧是"越小越好",线性融合与降序排序的语义就对不上了 (inferred:代码里没有按度量翻转符号的分支)。
⑧ 偏移表整表进内存。
PassageManager 把每个分片的 pickle 偏移字典整个 load 进来(api.py:215-219)。虽然刻意避免了合并成一张巨表(api.py:143-146 的注释),但内存仍随 passage 数线性增长。
⑨ 变更检测的 Merkle 树只有两层。
源码里自己标了 TODO:扁平两层结构,大型代码库需要改进(sync.py:125)。每次检测都要把所有候选文件读一遍算 sha256(sync.py:87-89、:227-234)。
⑩ README 说的 "high-degree preserving pruning" 在本克隆的 Python 层看不到。
该说法只出现在 README.md:50 和 README.md:1265。Python 侧能核对到的"剪枝"是两件事:CSR 去掉 -1 占位槽,以及丢弃向量存储段。DiskANN 侧有基于 LDG 的图分区(graph_partition.py:89)。真正的度数保留剪枝若存在,应在未 checkout 的 FAISS fork 里,本克隆无法核实。
6. 横向对比(同 shelf 的 rag-retrieval 兄弟)
| 项目 | 它在这条链路上管什么 | 与 LEANN 的关键差别 |
|---|---|---|
| chonkie | 只做分块 | LEANN 的分块是内建的一小块(chunking_utils.py);chonkie 把分块本身做成可组合的独立库 |
| llmware | 库 / 解析 / 向量库 / 检索全栈 | llmware 用外部向量库(Milvus/FAISS 等)存全部向量;LEANN 的立身之本正是不存向量 |
| agentset | 托管式 ingestion + 检索服务 | agentset 面向服务端多租户;LEANN 面向单机私有数据、零云依赖 |
| r2r | 面向生产的 RAG 服务端 | R2R 关注编排、权限、图谱;LEANN 关注索引本身的存储成本 |
| txtai | 嵌入式向量数据库 + 工作流 | txtai 同样单机友好,但走的是标准"存向量"路线;两者是同一场景的相反取舍 |
一句话定位: 同一条 RAG 链路上,别的项目在优化"怎么切、怎么排、怎么编排",LEANN 在优化索引本身能小到什么程度。它是这条货架上唯一把"存储成本"当成第一性问题来解的。
7. 代码地图(导航索引)
路径均相对克隆根;符号名比行号抗漂移,优先用符号 grep。
| 主题 | 文件 | 符号 |
|---|---|---|
| 建索引主类 | packages/leann-core/src/leann/api.py:378 | LeannBuilder |
| 建索引主流程 | packages/leann-core/src/leann/api.py:520 | LeannBuilder.build_index |
| 增量更新 | packages/leann-core/src/leann/api.py:836 | LeannBuilder.update_index |
| 搜索主类 | packages/leann-core/src/leann/api.py:1154 | LeannSearcher |
| 搜索入口 | packages/leann-core/src/leann/api.py:1240 | LeannSearcher.search |
| 原文随机读 | packages/leann-core/src/leann/api.py:137 | PassageManager / get_passage |
| BM25(FTS5)索引 | packages/leann-core/src/leann/api.py:310 | Fts5BM25Index |
| 问答封装 | packages/leann-core/src/leann/api.py:1672 | LeannChat |
| 后端自动发现 | packages/leann-core/src/leann/registry.py:61 | autodiscover_backends |
| 后端注册装饰器 | packages/leann-core/src/leann/registry.py:50 | register_backend |
| 后端契约 | packages/leann-core/src/leann/interface.py:7 | LeannBackendBuilderInterface 等 |
| 搜索器公共基类 | packages/leann-core/src/leann/searcher_base.py:12 | BaseSearcher |
| 嵌入服务进程管理 | packages/leann-core/src/leann/embedding_server_manager.py:213 | EmbeddingServerManager |
| HNSW 建图与 CSR | packages/leann-backend-hnsw/leann_backend_hnsw/hnsw_backend.py:49 | HNSWBuilder |
| 索引文件字节级重写 | packages/leann-backend-hnsw/leann_backend_hnsw/convert_to_csr.py:527 | convert_hnsw_graph_to_csr |
| ZMQ 嵌入服务 | packages/leann-backend-hnsw/leann_backend_hnsw/hnsw_embedding_server.py:58 | create_hnsw_embedding_server |
| IVF 原地增删 | packages/leann-backend-ivf/leann_backend_ivf/ivf_backend.py:200 | add_vectors / remove_ids |
| DiskANN 后端 | packages/leann-backend-diskann/leann_backend_diskann/diskann_backend.py:142 | DiskannBuilder / DiskannSearcher |
| 分块 | packages/leann-core/src/leann/chunking_utils.py:404 | create_text_chunks |
| 嵌入计算总入口 | packages/leann-core/src/leann/embedding_compute.py:422 | compute_embeddings |
| 文件变更检测 | packages/leann-core/src/leann/sync.py:178 | FileSynchronizer |
| CLI 建索引编排 | packages/leann-core/src/leann/cli.py:2458 | LeannCLI.build_index |
| MCP 工具定义 | packages/leann-core/src/leann/mcp.py:53 | TOOLS |
| ReAct agent | packages/leann-core/src/leann/react_agent.py:26 | ReActAgent |