跳到主要内容

数据截至 (上游 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 / LeannChatpackages/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 请求、现算 embeddingleann_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-coreleann-backend-diskannleann-backend-hnswleann-backend-flashlibleann-backend-flashlib-ivfastchunk(pyproject.toml:97-103)。leann-backend-ivf 不在这张表里——它由元包按普通版本依赖拉;元包 leann 默认装 core + hnsw + diskann + ivf(packages/leann/pyproject.tomldependencies)。两个 flashlib 后端只作为可选 extra 声明,细节见 03 的 §3.5。

重要的诚实提醒: leann-backend-hnsw/third_party/faissleann-backend-diskann/third_party/DiskANN 都是 git submodule,指向作者自己 fork 的 FAISS / DiskANN(.gitmodules)。本克隆里这些子模块没有 checkout,所以"图遍历时怎么触发 ZMQ 回调"的 C++ 实现无法在本克隆内核对。本文只对 Python 侧和二进制文件格式做断言,C++ 侧只描述它对外暴露的接口(如 set_zmq_portSearchParametersHNSW)。

2.4 主线走一遍(高层,不进代码)

建索引(一次性):

  1. 把文档切块,每块写一行进 documents.leann.passages.jsonl,同时记下这一行的字节偏移。
  2. 一次性把所有块算成 embedding(此时不走常驻服务,直接本地算)。
  3. 交给后端建图(HNSW 默认是 FAISS 的 IndexHNSWFlat)。
  4. 把图文件重写一遍:邻接表压成 CSR,向量存储段整个丢掉。
  5. documents.leann.meta.json 记后端 / 模型 / 维度 / 各文件名。

搜索(每次查询):

  1. 确保嵌入服务在跑(能复用就复用已有 daemon)。
  2. 把 query 算成向量。
  3. 后端在图上遍历;每当需要某批节点的向量,C++ 通过 ZMQ 把节点 ID 发回 Python。
  4. Python 侧按 ID 取原文、现算 embedding、把距离算好回传。
  5. 拿到 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.mdleann-backend-* 自动发现、三个抽象接口,以及 HNSW / IVF / DiskANN 的取舍对比。
04-embedding-and-chunking.md传统分块 vs AST 分块、token 预算与截断、5 个嵌入 provider 与批大小自适应。
05-incremental-update.mdMerkle 变更检测、IVF 删加 / HNSW 只加 / 全量重建三条路径的判定与实现。
06-apps-cli-agent.mdCLI 子命令、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:50README.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:378LeannBuilder
建索引主流程packages/leann-core/src/leann/api.py:520LeannBuilder.build_index
增量更新packages/leann-core/src/leann/api.py:836LeannBuilder.update_index
搜索主类packages/leann-core/src/leann/api.py:1154LeannSearcher
搜索入口packages/leann-core/src/leann/api.py:1240LeannSearcher.search
原文随机读packages/leann-core/src/leann/api.py:137PassageManager / get_passage
BM25(FTS5)索引packages/leann-core/src/leann/api.py:310Fts5BM25Index
问答封装packages/leann-core/src/leann/api.py:1672LeannChat
后端自动发现packages/leann-core/src/leann/registry.py:61autodiscover_backends
后端注册装饰器packages/leann-core/src/leann/registry.py:50register_backend
后端契约packages/leann-core/src/leann/interface.py:7LeannBackendBuilderInterface
搜索器公共基类packages/leann-core/src/leann/searcher_base.py:12BaseSearcher
嵌入服务进程管理packages/leann-core/src/leann/embedding_server_manager.py:213EmbeddingServerManager
HNSW 建图与 CSRpackages/leann-backend-hnsw/leann_backend_hnsw/hnsw_backend.py:49HNSWBuilder
索引文件字节级重写packages/leann-backend-hnsw/leann_backend_hnsw/convert_to_csr.py:527convert_hnsw_graph_to_csr
ZMQ 嵌入服务packages/leann-backend-hnsw/leann_backend_hnsw/hnsw_embedding_server.py:58create_hnsw_embedding_server
IVF 原地增删packages/leann-backend-ivf/leann_backend_ivf/ivf_backend.py:200add_vectors / remove_ids
DiskANN 后端packages/leann-backend-diskann/leann_backend_diskann/diskann_backend.py:142DiskannBuilder / DiskannSearcher
分块packages/leann-core/src/leann/chunking_utils.py:404create_text_chunks
嵌入计算总入口packages/leann-core/src/leann/embedding_compute.py:422compute_embeddings
文件变更检测packages/leann-core/src/leann/sync.py:178FileSynchronizer
CLI 建索引编排packages/leann-core/src/leann/cli.py:2458LeannCLI.build_index
MCP 工具定义packages/leann-core/src/leann/mcp.py:53TOOLS
ReAct agentpackages/leann-core/src/leann/react_agent.py:26ReActAgent