跳到主要内容

可插拔后端:HNSW / IVF / DiskANN 与自动发现

30 秒导读: LEANN 的向量存储不是写死一个引擎,而是一层后端抽象:任何后端只要实现三个抽象基类(ABC)、并把自己的包命名成 leann-backend-*,就会被自动发现、注册、按名字选用。默认后端是内存里的 FAISS HNSW;想原地增删不重建就换 IVF;数据大过内存就上盘上的 DiskANN。本章讲这层抽象怎么设计的、三个后端各自的取舍,以及增量更新在上层怎么被编排出来。

本章接着 01-storage-trick.md(图 + 选择性重算的存储魔法)和 02-search-recompute.md(一次查询的端到端 + ZMQ 按需重算)。recompute / pruned graph 的原理那两章讲过,这里不重复;本章只关心"存储引擎这一层是怎么做成可插拔的,以及三个引擎有什么不同"。


1. 这是什么(零基础也能懂)

一句话定义: "后端"就是真正存放和检索向量的那个引擎。LEANN 把它抽象成一个可替换的插件——上层(建索引、查询、聊天)只跟一个统一接口打交道,底下换成 HNSW、IVF 还是 DiskANN,上层代码一行不用改。

它解决什么问题: 不同场景对向量索引的要求天差地别——

  • 笔记本上几万条文档,追求查得快、装得进内存 → 图索引 HNSW 最合适。
  • 文件天天在改,希望改一个文件只更一小块、别每次全量重建 → 需要能原地增删的 IVF。
  • 上千万条向量,内存根本装不下 → 得把索引放硬盘上、按需读取的 DiskANN。

如果把这三种引擎硬编码在一起,代码会长满 if backend == "hnsw": ... elif ...。LEANN 的做法是:定一份接口契约,谁想加后端就去实现它;再靠一个命名约定自动把后端"招募"进来。

一句话直觉/类比: 像 USB。主机(LEANN 上层)只认 USB 口(三个 ABC);U 盘、硬盘、读卡器(HNSW/IVF/DiskANN)只要做成 USB 形状,插上就能用,主机不需要认识每一种设备的内部构造。

用起来什么样: 用户只在建索引时用一个字符串选后端,别的都一样:

# 示意,非源码:选后端只是换一个字符串
from leann import LeannBuilder

builder = LeannBuilder(backend_name="hnsw") # 默认,内存图索引
# builder = LeannBuilder(backend_name="ivf") # 想要原地增删
# builder = LeannBuilder(backend_name="diskann") # 数据大过内存
builder.add_text("...")
builder.build_index("my.leann")

backend_name 这个字符串,就是本章第 2 节要讲的"注册表键"。


2. 顶层全景:抽象层怎么把三个后端串起来

怎么读这张图: 左边是"招募"流程(启动时自动发现并注册后端),中间是三个 ABC 定义的契约,右边是三个真实后端各自的实现。上层只握着注册表里的一个工厂对象。

┌─────────────────────────────────────────┐
启动时(import leann) │ autodiscover_backends() │
│ 扫 leann-backend-* 包 → import 触发注册 │
└───────────────────┬─────────────────────┘
│ 填充

┌─────────────────────────────────────────┐
上层 LeannBuilder / │ BACKEND_REGISTRY │
LeannSearcher │ { "hnsw": …, "ivf": …, "diskann": … } │
按 backend_name 取 ────▶ (名字 → 工厂类) │
└───────────────────┬─────────────────────┘
│ 每个值都实现

┌─────────────── 三个 ABC 契约(interface.py)────────────┐
│ FactoryInterface ── builder() / searcher() 造实例 │
│ BuilderInterface ── build(data, ids, path) 建索引 │
│ SearcherInterface ── search(...) / compute_query_embed │
└──────┬───────────────────┬──────────────────┬──────────┘
▼ ▼ ▼
┌─────────┐ ┌──────────┐ ┌───────────┐
│ HNSW │ │ IVF │ │ DiskANN │
│ 内存·默认│ │ 可原地增删│ │ 盘上·超内存│
│ FAISS │ │ FAISS │ │ 图分区 │
│ HNSWFlat│ │ IVFFlat │ │ partitioner│
└─────────┘ └──────────┘ └───────────┘

部件一句话职责:

部件干什么在哪
三个 ABC定义后端必须实现的接口契约packages/leann-core/src/leann/interface.py
BACKEND_REGISTRY名字 → 工厂类 的全局字典registry.py:16
@register_backend装饰器,把工厂类塞进注册表registry.py:19
autodiscover_backends按命名约定扫包、import 触发注册registry.py:30
HNSW 后端默认,内存 FAISS HNSWpackages/leann-backend-hnsw/leann_backend_hnsw/hnsw_backend.py
IVF 后端可原地增删的 FAISS IndexIVFFlatpackages/leann-backend-ivf/leann_backend_ivf/ivf_backend.py
DiskANN 后端盘上、larger-than-memory,含图分区packages/leann-backend-diskann/leann_backend_diskann/

3. 接口契约:三个 ABC

这节讲后端必须签的"合同"interface.py 里三个抽象基类,任何后端都得实现。

3.1 三个 ABC 各管一段生命周期

后端的一生分三段:造实例、建索引、查询。三个 ABC 正好各管一段。

ABC抽象方法管什么
LeannBackendFactoryInterfacebuilder()searcher()工厂:造出建索引器 / 查询器
LeannBackendBuilderInterfacebuild(data, ids, index_path)把向量 + id 写成一个索引文件
LeannBackendSearcherInterface__init__search(...)compute_query_embedding_ensure_server_running加载索引、跑查询、算 query 向量

工厂接口是入口:两个都是 @staticmethod + @abstractmethod,一个造 builder、一个造 searcher(interface.py:96-109)。上层从注册表拿到的就是这个工厂类,再调 .builder(...) / .searcher(...)

3.2 建索引契约:一个 build 就够

LeannBackendBuilderInterface 只有一个抽象方法(interface.py:10-20):

def build(self, data: np.ndarray, ids: list[str], index_path: str, **kwargs) -> None:
# data: (N, D) 向量;ids: 每条向量的字符串 id;index_path: 落盘路径

契约刻意极简:给我向量矩阵、给我 id 列表、告诉我存哪,后端自己决定内部长什么样。后端专属的参数(HNSW 的 M、IVF 的 nlist……)全塞进 **kwargs,不进契约。

3.3 查询契约:search 的签名是重点

search 的签名(interface.py:44-72)是整个契约里信息量最大的地方——它把 02 讲的"按需重算"所需的开关都写进了参数:

def search(
self,
query: np.ndarray, # (B, D) 一批 query 向量
top_k: int, # 要几个近邻
complexity: int = 64, # 候选列表大小,越大越准越慢
beam_width: int = 1, # 并行搜索路径 / IO 请求数
prune_ratio: float = 0.0, # 用近似距离剪掉多少邻居(0~1)
recompute_embeddings: bool = False, # 用 ZMQ 现算 embedding,还是用存的 PQ 码
pruning_strategy: Literal["global","local","proportional"] = "global",
zmq_port: Optional[int] = None, # recompute 时必须给
**kwargs,
) -> dict[str, Any]: # 返回 {"labels": [...], "distances": [...]}

两个关键约定:

  • 返回值统一{"labels": [...], "distances": [...]}——不管底层是哪个引擎,上层拿到的形状一样。
  • recompute_embeddings + zmq_port 就是 02 里"按需重算"的入口:开了它,后端就去 ZMQ embedding server 现算向量,而不是读盘上存的量化码。

注意契约与实现的偏差(诚实): ABC 把这些参数写成"通用契约",但不是每个后端都全部支持。比如 IVF 的 search(ivf_backend.py:161-168)签名是 (query, top_k, complexity, nprobe, **kwargs)——它并不接 recompute_embeddings / prune_ratio,而是把 complexity 映射成 FAISS 的 nprobe(ivf_backend.py:175)。多出来的参数落进 **kwargs 被忽略。所以 ABC 是"最大公约数式的契约",后端各取所需。


4. 注册与自动发现:后端怎么被"招募"进来

这节讲上层怎么在不 import 任何后端的情况下,还能按名字拿到后端。答案是"装饰器注册 + 命名约定自动发现"两步。

4.1 一张全局表:BACKEND_REGISTRY

核心就是一个字典(registry.py:16):

BACKEND_REGISTRY: dict[str, "LeannBackendFactoryInterface"] = {}

键是后端名字("hnsw"),值是工厂类。上层用 BACKEND_REGISTRY.get(backend_name) 取工厂(api.py:419),取不到就报错。

4.2 装饰器注册:后端自报家门

后端类上贴一个 @register_backend("名字"),import 到它就自动进表。装饰器本体极短(registry.py:19-27):

def register_backend(name: str):
def decorator(cls):
BACKEND_REGISTRY[name] = cls # 把类塞进全局表
return cls
return decorator

三个后端各自贴了标签:@register_backend("hnsw")(hnsw_backend.py:38)、@register_backend("ivf")(ivf_backend.py:85)、@register_backend("diskann")(diskann_backend.py:131)。注意:注册的前提是这个模块被 import 过——没 import,装饰器就不会执行,表里也就没有它。这引出下一步。

4.3 自动发现:按命名约定扫包再 import

谁去 import 那些后端模块?autodiscover_backends()(registry.py:30-49)。它的逻辑分两步:

第一步——扫描:遍历环境里所有已安装的 Python 发行包,挑出名字以 leann-backend- 开头的,把包名里的 - 换成 _ 得到模块名。

for dist in importlib.metadata.distributions():
dist_name = dist.metadata["name"]
if dist_name is None:
continue
if dist_name.startswith("leann-backend-"): # 命名约定
backend_module_name = dist_name.replace("-", "_")
discovered_backends.append(backend_module_name)

第二步——import 触发注册:排序后逐个 import,import 的副作用就是执行模块里的 @register_backend,把后端塞进表。import 失败(比如某后端的 C++ 依赖没装)就静默跳过:

for backend_module_name in sorted(discovered_backends): # 排序 → 确定性加载
try:
importlib.import_module(backend_module_name) # 触发 @register_backend
except ImportError:
pass # 缺依赖的后端悄悄跳过,不影响其他后端

这一切在什么时候发生?import leann 的那一刻——leann/__init__.py:31 顶层直接调了一次 autodiscover_backends()。所以用户只要 import leann,所有装好的后端就已经在表里待命了,不需要手动 import 任何 leann_backend_*

这套设计的妙处: 加一个新后端,不用改 leann-core 任何代码——只要发一个叫 leann-backend-xxx 的包、类上贴 @register_backend("xxx")。装上就自动被发现、注册、可用。这是真正的"可插拔"。


5. 三后端对比:各自的取舍

这节是本章的核心——三个后端针对不同场景做了完全不同的取舍。先看总表,再逐个讲差异点。

维度HNSW(默认)IVFDiskANN
底层引擎FAISS IndexHNSWFlat(图)FAISS IndexIVFFlat + DirectMap.HashtableDiskANN 盘上图索引
默认?✅ 是
数据放哪内存内存硬盘(larger-than-memory)
适合规模几万~几十万,装得进内存中等,需要频繁增删千万级,超内存
增量能力只能 add-only(非 compact);改/删要全量重建原地 add / remove,不重建无原地增删,靠上层重建
存储技巧pruned graph + 重算(见 01);compact 用 CSRid_map JSON 双向映射图分区(partitioner)重排盘上布局
关键类/符号HNSWBuilderHNSWSearcherIVFBuilderadd_vectorsremove_idsDiskannBuilderGraphPartitioner

5.1 HNSW:默认的内存图索引

HNSW(Hierarchical Navigable Small World,分层可导航小世界图)是默认后端。HNSWBuilder.build(hnsw_backend.py:66)建一个 FAISS IndexHNSWFlat(hnsw_backend.py:83),然后根据配置做两种存储处理之一(hnsw_backend.py:102-105):

  • is_compact=True(默认):转成 CSR 压缩格式(_convert_to_csr),体积最小但只读、不能增量更新
  • is_compact=Falseis_recompute=True:调 prune_hnsw_embeddings_inplace 剪掉存的向量,查询时靠重算补回——这就是 01 讲的省 97% 那招。

HNSW 的增量短板:compact 索引根本不支持原地更新(api.py:904-907 会直接报错要求重建);非 compact 也只支持 add-only。这正是 IVF 存在的理由。

5.2 IVF:为"原地增删"而生

IVF(Inverted File,倒排文件)后端的存在只有一个目的:改一个文件,只更新那一小块,不重建整个索引

关键设计在建索引时就埋好(ivf_backend.py:126-130):用 IndexIVFFlatDirectMap.Hashtable,并用 add_with_ids 给每条向量绑一个整数 id。

ivf = faiss.IndexIVFFlat(quantizer, dim, self.nlist, metric_enum)
ivf.train(data)
ivf.set_direct_map_type(faiss.DirectMap.Hashtable) # 关键:让 remove_ids 能用
faiss_ids = np.arange(n, dtype=np.int64)
ivf.add_with_ids(data, faiss_ids)

文件顶部的注释点破了取舍(ivf_backend.py:1-6):特意用 IndexIVFFlat + DirectMap.Hashtable(而非 IndexIDMap2),就是为了让 remove_ids 正确工作。

它对外暴露两个模块级函数,做原地增删:

  • add_vectors(index_path, embeddings, passage_ids)(ivf_backend.py:200):读出索引,add_with_ids 追加新向量,写回,并更新 id_map。
  • remove_ids(index_path, passage_ids)(ivf_backend.py:238):把 passage id 翻成整数 id,调 FAISS index.remove_ids(sel)(ivf_backend.py:268)真正删掉,再写回。

id_map 是桥梁: FAISS 只认整数 id,LEANN 上层用字符串 passage id。IVF 用一个 JSON(<prefix>.ivf_id_map.json)存 id_to_passage / passage_to_id 双向表 + next_id 计数器(_save_id_map,ivf_backend.py:68)。删除时 next_id 不回退(ivf_backend.py:275),保证新加的向量永远拿到全新整数 id,不会撞上已删的旧 id。

5.3 DiskANN:数据大过内存时的盘上索引

DiskANN 后端针对"索引装不进内存"的场景:索引主体放硬盘,查询时按需读盘。它的特色是图分区(graph partition)——把盘上图重排布局,让搜索时的磁盘 IO 更集中、更快。

分区不是 Python 干的,而是调外部可执行文件GraphPartitioner(graph_partition.py:18)是这些可执行文件的 Python 包装:

  • _get_executable_path(graph_partition.py:36)去 third_party/DiskANN/graph_partition/build/.../ 找两个可执行文件:partitionerindex_relayout;找不到就尝试自动编译(_build_executables,graph_partition.py:59)。
  • partition_graph(graph_partition.py:89)先用 subprocess.runpartitioner(graph_partition.py:195)算分区,再跑 index_relayout(graph_partition.py:217)按分区重排索引文件,最后把结果拷成 <prefix>_disk_graph.index<prefix>_partition.bin(graph_partition.py:229-233)。

什么时候触发分区? 建索引时,只有开了 is_recompute 才自动分区(diskann_backend.py:348-361):

if build_kwargs.get("is_recompute", False):
from .graph_partition import partition_graph
disk_graph_path, partition_bin_path = partition_graph(
index_prefix_path=absolute_index_prefix_path,
output_dir=str(absolute_index_dir),
partition_prefix=index_prefix,
)

DiskANN 没有 IVF 那样的原地增删;它的"更新"走上层重建路径。


6. 增量更新的高层编排:LeannBuilder.update_index

5.2 讲的 add_vectors / remove_ids后端级的零件。真正把"文件改了 → 索引怎么增量更新"编排起来的,是上层的 LeannBuilder.update_index(api.py:836)。这节讲这套编排。

6.1 核心策略:remove-then-add + 压缩 passages

一次增量更新要同时维护三样东西:向量索引、passages.jsonl(原始文本块)、passages.idx(offset map,id → 文件偏移)。三者必须一致。update_index 的编排是:

改动的文件


① IVF remove_ids(旧 chunk 的 passage id) → 删索引里的旧向量
│ (ivf_remove_ids,api.py:873-875)

② offset_map.pop(旧 id) + existing_ids 减掉 → 逻辑上抹掉旧条目
│ (api.py:887-889)

③ _compact_passages 压缩 passages.jsonl → 物理上清掉旧文本、重建 offset
│ (api.py:892 → _compact_passages,api.py:811)

④ 对新 chunk 做 add(算 embedding + add_vectors) → 加新向量、追加新文本

为什么要"先删后加"而不是直接覆盖? 因为 FAISS 索引和 passage 存储是两套东西:光删索引里的向量,passages.jsonl 里的旧文本还在。若只追加不清理,同一段文本会在文件里出现在多个 offset,造成重复。所以第 ③ 步 _compact_passages 必须重写 passages.jsonl,只保留 offset_map 里还活着的条目,并重建 offset(api.py:811-834):

# _compact_passages:只留 offset_map 里还引用的条目,重写文件 + 重建 offset
for _pid, offset in sorted(offset_map.items(), key=lambda x: x[1]):
... # 按旧 offset 读出还活着的行
with open(tmp_file, "w") as f:
for line in live_entries:
new_offset_map[data["id"]] = f.tell() # 重新记录新偏移
f.write(...)
tmp_file.replace(passages_file) # 原子替换

6.2 后端能力决定编排走哪条路

update_index 里有明确的后端分支——它诚实地反映了 5.1/5.2 的能力差异:

  • 删除只对 IVF 有效:if remove_passage_ids and backend_name == "ivf"(api.py:871),内部 import leann_backend_ivf.remove_ids;没装 IVF 就报错(api.py:883-886)。
  • compact HNSW 直接拒绝原地更新(api.py:904-907):"Compact HNSW indices do not support in-place updates. Rebuild required."
  • 更新前还校验后端一致:用 A 后端建的索引不能用 B 后端更新(api.py:861-864)。

6.3 leann build 的幂等 / 增量语义

CLI 把这套编排包成了"幂等的 build"。leann build 对已存在的索引默认做增量更新而非全量重建(cli.py:339--force 帮助文本:不加 --force 就是增量、只加新文件)。

CLI 先用内容哈希检测出新增 / 删除 / 修改三类文件(_detect_changes 相关,cli.py:1964),再按后端能力选路径:

  • add-only 路径 _incremental_add_only(cli.py:2053):对 HNSW、IVF 都能用,只把新文件的 chunk 走 builder.update_index
  • IVF remove-only 快路径 _incremental_ivf_remove_only(cli.py:2076):文件被删时,不重新加载/分块文档,直接算出要删的 chunk id,走 update_index(..., remove_passage_ids=...)
  • IVF 完整增量 _incremental_ivf_update(cli.py:2117):对修改的文件做 remove-then-add(先删旧 chunk 再插新 chunk)。

所以从用户视角:反复 leann build my-docs --docs ./documents/ 是安全的——没变的文件跳过,新文件增量加,改/删的文件(IVF 下)增量更新;想强制从头重建才加 --force


7. 顺带一提:flashlib / flashlib-ivf(GPU kNN)

除三个主后端外,还有两个 GPU 后端,同样靠命名约定被自动发现:flashlib(@register_backend("flashlib"),精确 flash-knn,GPU 上暴力最近邻,flashlib_backend.py:86)和 flashlib_ivf(@register_backend("flashlib_ivf"),GPU 版 IVF-Flat 近似搜索,是 FAISS ivf 后端的 GPU 对应物,flashlib_ivf_backend.py:121)。两者都要求搜索时有 CUDA GPU,建索引/查询逻辑与前述后端同构,不再展开。


8. 边界与局限(诚实)

  • 不同后端的 search 参数不齐: ABC 契约是最大公约数,IVF 不认 recompute_embeddings / prune_ratio(见 3.3),用错参数会被静默忽略。
  • 原地增删是 IVF 独有: HNSW compact 完全不能原地更新;DiskANN 也无原地增删。跨后端迁移得重建。
  • DiskANN 依赖外部可执行文件: 分区靠 partitioner / index_relayout 二进制,缺了就得能自动编译(graph_partition.py:49-57),对环境要求高。
  • 自动发现是"静默失败"的: import 某后端失败(缺 C++ / GPU 依赖)会被 except ImportError: pass 吞掉(registry.py:46-48),该后端就是"不存在";好处是不拖垮其他后端,代价是排查时不易察觉。

9. 代码地图(导航索引)

主题文件路径符号名
三个 ABC 契约packages/leann-core/src/leann/interface.pyLeannBackendFactoryInterface / LeannBackendBuilderInterface / LeannBackendSearcherInterface
search 契约签名packages/leann-core/src/leann/interface.py:44LeannBackendSearcherInterface.search
全局注册表packages/leann-core/src/leann/registry.py:16BACKEND_REGISTRY
注册装饰器packages/leann-core/src/leann/registry.py:19register_backend
自动发现packages/leann-core/src/leann/registry.py:30autodiscover_backends
发现的触发点packages/leann-core/src/leann/__init__.py:31autodiscover_backends()(顶层调用)
注册表取用packages/leann-core/src/leann/api.py:419BACKEND_REGISTRY.get(backend_name)
HNSW 后端packages/leann-backend-hnsw/leann_backend_hnsw/hnsw_backend.py:38HNSWBackend / HNSWBuilder / HNSWSearcher
IVF 后端packages/leann-backend-ivf/leann_backend_ivf/ivf_backend.py:85IVFBackend / IVFBuilder / IVFSearcher
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:131DiskannBackend / DiskannBuilder
DiskANN 图分区packages/leann-backend-diskann/leann_backend_diskann/graph_partition.py:18GraphPartitioner / partition_graph
增量更新编排packages/leann-core/src/leann/api.py:836LeannBuilder.update_index
passages 压缩packages/leann-core/src/leann/api.py:811LeannBuilder._compact_passages
CLI 增量分派packages/leann-core/src/leann/cli.py:2053_incremental_add_only / _incremental_ivf_remove_only / _incremental_ivf_update
GPU 后端packages/leann-backend-flashlib*/…@register_backend("flashlib") / @register_backend("flashlib_ivf")