Turbovec:基于 Google TurboQuant 的 Rust 向量检索库
一千万文档的语料库用 float32 占 31 GB 内存,turbovec 只需 4 GB 就能装下,而且搜索速度比 FAISS 还快。
turbovec 是一个 Rust 写的向量索引,带 Python 绑定,基于 Google Research 的 TurboQuant 算法——一种数据无关(data-oblivious)的量化器,畸变接近最优,且无需单独的训练阶段。
- 在线导入。加入向量即刻建索引——没有训练步骤,无需调参,语料增长时也不用重建。
- 高速 SIMD 搜索。手工编写的内核——ARM 上的 NEON SDOT/SMMLA,x86 上的 AVX-512 VNNI 与
vpermb,并配备 AVX2 与标量回退——在所有测试配置下都优于 FAISS IndexPQFastScan:在两种架构上,4-bit 平均快 3.4 倍,2-bit 平均快 23%(各宽度八个测试单元的综合结果)。 - 增量保存。
sync(path)只持久化自上次同步以来变更的部分——一次 fsync 调用即可保证崩溃安全;无论索引多大,删除或少量追加都在毫秒级完成。write/load仍用于整体快照。 - 在检索时过滤。向
search()传入一个 id 白名单(或槽位位掩码),内核会直接按它执行。你始终能从允许的集合中得到最多k个结果——不会过度拉取,也不会因选择性过滤而损失召回率。 - 完全本地化。无需托管服务,数据不离开你的机器或 VPC。搭配任意开源 embedding 模型,即可搭出完全物理隔离的 RAG 流水线。
正在构建对隐私、内存或延迟有要求的 RAG?你来对地方了。
Python
pip install turbovec
from turbovec import TurboQuantIndex
index = TurboQuantIndex(dim=1536, bit_width=4)
index.add(vectors)
index.add(more_vectors)
scores, indices = index.search(query, k=10)
index.write("my_index.tv")
loaded = TurboQuantIndex.load("my_index.tv")
index.sync("my_index.tv") # 在更多变更后:持久的增量保存
vectors 和 query 是形状为 (n, dim) 的二维 float32 数组——其他 dtype 会被直接拒绝而非静默转换,因此如有需要先用 np.asarray(x, dtype=np.float32) 做一次类型转换。
需要能跨删除操作保持稳定的 id?使用 IdMapIndex:
import numpy as np
from turbovec import IdMapIndex
index = IdMapIndex(dim=1536, bit_width=4)
index.add_with_ids(vectors, np.array([1001, 1002, 1003], dtype=np.uint64))
scores, ids = index.search(query, k=10) # ids 即你的 uint64 外部 id
index.remove(1002) # 按 id 实现 O(1) 删除
index.write("my_index.tvim")
loaded = IdMapIndex.load("my_index.tvim")
index.sync("my_index.tvim") # 持久的增量保存,包含 ids
混合检索(带过滤的搜索)
将结果限制在由其他系统(SQL、BM25、ACL、时间窗口……)生成的候选集合内:
import numpy as np
from turbovec import IdMapIndex
idx = IdMapIndex(dim=1536, bit_width=4)
idx.add_with_ids(vectors, ids)
# 阶段 1:由外部系统收窄到候选 id。
allowed = np.array(db.execute("SELECT id FROM docs WHERE tenant=?", (t,)).fetchall(),
dtype=np.uint64)
# 阶段 2:在候选集合内进行稠密重排。
scores, ids = idx.search(query, k=10, allowlist=allowed)
过滤发生在 SIMD 内核内部,以 32 向量块为粒度:没有任何允许槽位的块会在执行任何 LUT 查询或打分计算之前就被短路跳过,而参与打分的块中单个非允许槽位会在堆插入时被丢弃。因此,对于选择性白名单(索引中只允许一小部分向量)的场景,可以避免绝大部分 SIMD 开销,而不是先付出代价再丢弃结果。
输出长度为 min(k, n_allowed),其中 n_allowed 统计的是不同的被允许向量——当被允许的向量数量少于 k 时,你就只能得到那么多结果,而不会用填充回退值来凑数。
完整参考见 docs/api.md。
框架集成
作为各框架内置参考向量库/文档库的即插即用替代品。相同的公共接口,相同的持久化语义,相同的检索器与流水线连接方式——只需替换导入即可,原有流水线无需改动。
- LangChain —
pip install turbovec[langchain]· 替换langchain_core.vectorstores.InMemoryVectorStore - LlamaIndex —
pip install turbovec[llama-index]· 替换llama_index.core.vector_stores.SimpleVectorStore - Haystack —
pip install turbovec[haystack]· 替换haystack.document_stores.in_memory.InMemoryDocumentStore - Agno —
pip install turbovec[agno]· 替换agno.vectordb.lancedb.LanceDb
Rust
cargo add turbovec
use turbovec::TurboQuantIndex;
let mut index = TurboQuantIndex::new(1536, 4).unwrap();
index.add(&vectors);
let results = index.search(&queries, 10);
index.write("index.tv").unwrap();
let loaded = TurboQuantIndex::load("index.tv").unwrap();
如果需要在删除后仍能保持稳定的外部 ID:
use turbovec::IdMapIndex;
let mut index = IdMapIndex::new(1536, 4).unwrap();
index.add_with_ids(&vectors, &[1001, 1002, 1003]).unwrap();
let (scores, ids) = index.search(&queries, 10);
index.remove(1002);
index.write("index.tvim").unwrap();
let loaded = IdMapIndex::load("index.tvim").unwrap();
召回率
TurboQuant 对比 FAISS IndexPQ(LUT256,nbits=8)——即论文第 4.4 节的基线。100K 向量,k=64。FAISS PQ 子量化器数量已调至与 TurboQuant 的比特率匹配(2-bit 时 m=d/4,4-bit 时 m=d/2)。
图表绘制的是校准后的 TurboQuant(TQ+)。在 OpenAI d=1536 和 d=3072 上,TQ+ 在四组中三组的 R@1 上击败 FAISS(领先 0.9–2.9 个点;d=1536 4-bit 落后 0.7 个点),两者到 k=8 时均达到 1.0(k≤4 时已 ≥0.997)。GloVe d=200 是更棘手的场景——低维度下渐近 Beta 假设的拟合较松。TQ+ 在两种比特宽度下的 R@1 均领先 FAISS(4-bit 领先 1.9,2-bit 领先 0.8),而 FAISS 在 2-bit 从 k≈8 起保持微弱优势。未校准数据见 JSON 文件(tq_recalls)。
关于基线方法的说明。我们对比的对象是 FAISS IndexPQ(LUT256,nbits=8,float32 查找表),因为这是大多数用户会直接选用的默认生产级 PQ 实现。这比 TurboQuant 论文中的自定义 u8-LUT PQ 基准更强——FAISS 在打分阶段使用更高精度的查找表,并用 k-means++ 训练码本。我们在 OpenAI d=1536 / d=3072 上复现了论文中的 TurboQuant 指标,在低维嵌入上与其他社区参考实现的结果相当(参见 turboquant-py 在 d=384 的数据)。在 GloVe(d=200)——也就是渐近 Beta 假设最不成立的低维场景下,TurboQuant 在 4-bit 时优于 FAISS,但 2-bit 时落后;TQ+ 校准在 R@1 上追回了 2-bit 的差距(0.572 对 FAISS 的 0.564),而在更大的 k 值上 FAISS 仍保持微弱优势。
完整结果:d=1536 2-bit,d=1536 4-bit,d=3072 2-bit,d=3072 4-bit,GloVe 2-bit,GloVe 4-bit。
压缩
检索速度
所有基准测试:100K 向量,1K 查询,k=64,取 5 次运行的中位数。
ARM(GCP c4a-standard-8,Google Axion,8 vCPUs)
在 ARM 上,TurboQuant 在所有配置下都优于 FAISS FastScan,4-bit 时平均快 3.5 倍(各 cell 在 3.4–3.7 倍之间 —— SDOT/SMMLA 点积内核可直接对向量优先布局打分),2-bit 时快 26%(22–29%)。x86(Intel Xeon Platinum 8481C / Sapphire Rapids,8 vCPUs)
在 x86 上,TurboQuant 同样赢下所有配置,4-bit 时平均快 3.4 倍(各 cell 在 3.2–3.5 倍之间 —— AVX-512 VNNI 点积内核作用于向量优先布局),2-bit 时快 20%(5–32%),其中vpermb LUT scan 承担了较短的 2-bit 累加循环。
插入与删除延迟
语料库与搜索测试一致:10 万条 OpenAI 向量,取 5 次运行的中位数,计时循环包含调用方每次操作实际承担的 Python 调用开销。插入测试衡量每个向量 add() 的延迟,索引已预热并填充(构建过程不计时),分别测试 n=1(单向量 add())和 n=100(100 向量批量插入,展示批量分摊每次调用开销的效果),对比对象是向已训练、已填充的 FAISS IndexPQFastScan 执行 add()(训练过程不计时)。单次 add() 耗时 6.3–19.7 µs(视测试场景而定),比 FAISS 单次 add 快 7.6–13.9 倍;100 向量批量插入时,TurboQuant 可摊薄到每个向量 4.6–16.3 µs,比同等批量操作 FAISS 快 4.6–15.1 倍。删除测试衡量按 id 删除的每次操作延迟,n=1 为稳态下的每次操作速率(基于 1000 次删除的均值),n=100 为新索引上前 100 次删除的耗时:IdMapIndex.remove(id)——O(1) 的交换弹出加上 id 映射的簿记开销——每次操作耗时 0.44–1.22 µs 和 0.59–1.37 µs(视场景而定)。FAISS 列对应同一用户可见操作,即对 IndexPQFastScan 上的 IndexIDMap 调用 remove_ids,该操作每次调用都会重排存储的编码:在 10 万条数据下,单次删除耗时 0.19–1.02 秒,且成本随编码大小翻倍——这也是删除图表采用对数刻度轴的原因。图表展示的是单线程场景(RAYON_NUM_THREADS=1);_mt 多线程场景也已测量,在 n=1 时结果一致,因为单次 add 本质上是串行的。脚本位于 benchmarks/suite/。
ARM(GCP c4a-standard-8,Google Axion,8 vCPU)
完整数据:d=1536 2-bit insert、d=1536 4-bit insert、d=3072 2-bit insert、d=3072 4-bit insert,以及对应的speed_remove_* 和 _mt 文件。
x86(Intel Xeon Platinum 8481C / Sapphire Rapids,8 vCPUs)
完整数据:d=1536 2-bit insert、d=1536 4-bit insert、d=3072 2-bit insert、d=3072 4-bit insert,以及对应的 speed_remove_* 和 _mt 文件。
保存与加载
语料与搜索单元一致:10 万条 OpenAI 向量,取 5 次运行的中位数。TurboQuant 通过 fsync + 原子重命名序列化到单个 .tv 文件;FAISS 则使用精度匹配的 IndexPQFastScan(子量化器数量与 TurboQuant 的比特率对齐,与搜索单元保持一致)进行 write_index / read_index。Save(热)是在已执行搜索之后写入,因此分块布局缓存已被填充。Load → 首次搜索打开一个全新索引并计时首次查询——这里将单纯的反序列化(页缓存在整个过程中保持温热,因此这部分是布局开销而非冷存储 I/O)与首次查询开销区分开来。Round-trip串联起一个向量存储实际承担的 checkpoint/resume 流程——修改 1K 条向量 → 保存 → 重新打开 → 服务首次查询;FAISS 在该路径上没有对应的衡量指标,因此仅展示 TurboQuant 的结果。在较小负载下,round-trip 时间可能低于独立的修改后("脏")写入:两者在不同的测试步骤中计时,且在小文件尺寸下,独立脏写入步骤中的 fsync 占主导地位并使其数值偏高——这是测试框架本身的测量假象,并非组合路径中的重打包收益。单线程单元固定 RAYON_NUM_THREADS=1。脚本:benchmarks/suite/。
ARM(GCP c4a-standard-8,Google Axion,8 vCPUs)
完整结果:d=1536 2-bit 持久化 ST、MT,d=1536 4-bit 持久化 ST、MT,d=3072 2-bit 持久化 ST、MT,d=3072 4-bit 持久化 ST、MT。x86(Intel Xeon Platinum 8481C / Sapphire Rapids,8 vCPUs)
完整结果:d=1536 2-bit persist ST、MT、d=1536 4-bit persist ST、MT、d=3072 2-bit persist ST、MT、d=3072 4-bit persist ST、MT。
工作原理
每个向量都是高维超球面上的一个方向。TurboQuant 利用一个简单原理压缩这些方向:对所有向量应用随机旋转后,无论输入数据如何,每个坐标都会服从已知分布。
1. 归一化。移除每个向量的长度(范数),并将其保存为单个浮点数。这样,每个向量都成为超球面上的一个单位方向。
2. 随机旋转。将所有向量乘以同一个随机正交矩阵。旋转后,每个坐标独立服从 Beta 分布,并在高维空间中趋近于高斯分布 N(0, 1/d)。这一性质对任何输入数据都成立——随机旋转让坐标分布变得可预测。
3. 逐坐标校准(TQ+)。第 2 步得到的 Beta 分布是渐近的——在有限维度下,各坐标会偏离规范形态(尤其是低比特和词向量风格的 embedding)。TQ+ 为每个坐标拟合两个标量——一个偏移和一个缩放——把每个坐标的经验分位数映射到码本的最外侧质心上。概率水平由码本决定,因此会跟随比特宽度变化(2 比特时约 0.933,4 比特时约 0.996),而非固定值。Lloyd-Max 码本随后对其所面向的目标分布进行量化。该拟合显式执行:在添加向量前,调用一次 index.calibrate(sample),传入随机且具有代表性的向量样本(约 1024 行就够——这个规模的抽样与在整个语料上拟合效果一致);校准完成后即提交,后续每次 add 都会复用,无需重训、无需重建、没有独立的训练阶段。未经校准的索引就是原版 TurboQuant。index.calibration_state 会返回 "uncalibrated" 或 "calibrated"。召回率提升:在漂移最严重的单元上(例如 2 比特下的 GloVe),@1 最高可达 +2.2 个百分点。
4. Lloyd-Max 标量量化。由于分布已知,我们可以预先计算每个坐标的最优分桶方式。2 比特对应 4 个桶,4 比特对应 16 个桶。Lloyd-Max 算法能找到使均方误差最小的桶边界和质心。这些值由数学推导一次性得出,而非从数据中学习。
5. 比特打包。此时每个坐标都是一个很小的整数(2 比特下为 0-3,4 比特下为 0-15)。把它们紧凑地打包进字节里。一个 1536 维向量从 6,144 字节(FP32)压缩到 384 字节(2 比特),即 16 倍压缩。
6. 长度重归一化打分。标量量化系统性地低估内积——重建后的单位方向略短于原始方向。我们在编码阶段为每个向量计算一个标量,即旋转后单位向量与其自身质心重建的内积,并将 ||v|| / ⟨u, x̂⟩ 与每个压缩向量一并存储。搜索内核在堆插入前将每个候选分数乘以该标量,使内积估计器从向下偏差转为无偏差,且搜索时零开销、零额外存储。召回率的提升在低比特宽度时最为明显,因为此时量化收缩最严重。
编码开销:每个向量多一次 d 维点积以计算 ⟨u, x̂⟩。在 d=1536 的 100 万向量上,这额外的编码时间不足一秒——这是一次性代价,仅在入库时付出,查询时无需承担。
搜索。我们无需解压每个数据库向量,而是将查询旋转到相同空间域,直接与码本值打分。打分内核使用 SIMD 内建函数(ARM 上用 NEON;现代 x86 上用 AVX-512BW,回退到 AVX2,再回退到不支持 AVX2 的 CPU 上的标量路径),配合半字节拆分查找表以实现最大吞吐。
Lloyd-Max 码本的失真在信息论下界(Shannon 失真率极限)的 2.7 倍以内;长度重归一化步骤消除了 Lloyd-Max 码本本身在内积估计器上引入的残余偏差。
构建
Python(通过 maturin)
pip install maturin cd turbovec-python maturin build --release pip install target/wheels/*.whl
Rust
cargo build --release
所有 x86_64 构建均通过 .cargo/config.toml 以 x86-64-v2(SSE4.2 基线,Nehalem 2008+)为目标,因此任何 x86-64-v2 CPU 都可运行整个 crate。AVX-512 和 AVX2 内核通过 #[target_feature] 门控,并在运行时通过 is_x86_feature_detected! 选择,因此无论编译基线如何,在支持这些特性的硬件上都会自动启用;两者都不支持的 CPU 则运行标量回退路径。
运行基准测试
下载数据集:
python3 benchmarks/download_data.py all # 全部数据集 python3 benchmarks/download_data.py glove # GloVe d=200 python3 benchmarks/download_data.py openai-1536 # OpenAI DBpedia d=1536 python3 benchmarks/download_data.py openai-3072 # OpenAI DBpedia d=3072
每个基准测试都是 benchmarks/suite/ 中的独立脚本,可以单独运行:
python3 benchmarks/suite/speed_d1536_2bit_arm_mt.py python3 benchmarks/suite/recall_d1536_2bit.py python3 benchmarks/suite/compression.py
运行某一类别的全部基准:
for f in benchmarks/suite/speed_*arm*.py; do python3 "$f"; done # 全部 ARM 速度测试 for f in benchmarks/suite/speed_*x86*.py; do python3 "$f"; done # 全部 x86 速度测试 for f in benchmarks/suite/recall_*.py; do python3 "$f"; done # 全部召回率测试 python3 benchmarks/suite/compression.py # 压缩率测试
结果以 JSON 格式保存到 benchmarks/results/,重新生成图表:
python3 benchmarks/create_diagrams.py
优化工作的快速测试工具
上述测试套件是所有公开数据的来源——真实嵌入、FAISS 对比、固定维度、在两套官方环境中运行。为了让优化迭代更快,还有一个 Rust 测试工具能在确定的合成向量上复现四个变更指标(冷启动批量添加、热追加、单条添加、删除),这样无需数据集和 FAISS,几秒内就能在任何机器上验证假设:
cargo run --release --example insert_bench -- --dim 1536 --bits 2 RAYON_NUM_THREADS=1 cargo run --release --example insert_bench
这只是一个筛选工具,不是公开数据的来源。
examples/encode_hash 会针对固定输入打印编码管道每个阶段的哈希值;CI 在矩阵中的每个操作系统上运行该命令,一旦结果不一致就会失败,这就是用来校验编码跨平台字节一致性的方法。
参考
- TurboQuant: Online Vector Quantization with Near-optimal Distortion Rate(ICLR 2026)—— 本文实现所依据的论文
- RaBitQ: Quantizing High-Dimensional Vectors with a Theoretical Error Bound for Approximate Nearest Neighbor Search(SIGMOD 2024)—— 第 5 步所采用的逐向量长度重归一化校正的来源
- FAISS 中 PQ 与 AQ 码的快速累加 —— turbovec 的 x86 SIMD 内核借鉴了 FastScan 的打包布局、半字节 LUT 打分以及 u16 累加器策略



