turbovec Python 索引实战指南:基于 TurboQuant 的 2–4bit 向量压缩与 SIMD 检索
【免费下载链接】turbovecA vector index built on TurboQuant, written in Rust with Python bindings项目地址: https://gitcode.com/GitHub_Trending/tu/turbovec
turbovec 是一个用 Rust 编写、提供 Python 绑定的向量索引库,它实现了 Google Research 提出的 TurboQuant 算法(一种无需训练阶段、数据无关的向量量化器)。本指南以 turbovec-python/README.md 为主线,完整覆盖TurboQuantIndex与IdMapIndex的使用、TQ+ 校准、混合检索过滤、增量持久化(sync)、SIMD 搜索内核原理,以及从源码构建与复现官方基准测试的方法。读完本文,你将能够在一套代码内完成"压缩入库 → 在线检索 → 过滤重排 → 崩溃安全持久化"的完整向量搜索链路,并理解每一步背后的底层实现。
为什么需要 turbovec:量化压缩的出发点
一个 1000 万文档的语料库,用 float32 存储原始向量需要约31 GB 内存。turbovec 用 2–4 bit/坐标的量化方案把同样的数据压到约4 GB,并且搜索速度在官方基准配置下可以快于 FAISS 的IndexPQFastScan。它的核心特性可以概括为五条:
- Online ingest(在线写入):直接
add向量即完成索引,没有 train 步骤、没有参数调优、也不因语料增长而重建索引。 - Fast SIMD search(SIMD 加速检索):手写内核覆盖 ARM 的 NEON SDOT/SMMLA 与 x86 的 AVX-512 VNNI +
vpermb,并带 AVX2 与标量回退。在官方测量配置(八个 cell、两种架构)下,4-bit 平均快于 FAISSIndexPQFastScan约 3.4×,2-bit 平均快约 23%。 - Incremental saves(增量保存):
sync(path)只持久化上次同步以来的变更,每次调用仅一次fsync,任意字节处崩溃都是安全的;无论索引多大,一次删除或小幅追加都只需毫秒级开销。write/load则保留用于整文件快照。 - Filter at search time(检索时过滤):向
search()传入 id 白名单(或 slot 位掩码),内核直接执行过滤,始终从允许集合中返回最多k个结果——不会过度抓取,选择性过滤下也没有召回损失。 - Pure local(纯本地):无托管服务,数据不会离开你的机器或 VPC,可搭配任意开源 embedding 模型搭建完全隔离的 RAG 栈。
Python 快速开始:TurboQuantIndex
安装与最小示例:
pip install turbovecfrom 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") # 更多变更之后:持久化的增量保存两条类型约束值得注意(源码 turbovec-python/src/lib.rs 中通过extract_f32_2d严格校验):
vectors和query必须是形状为(n, dim)的二维 float32 数组;其他 dtype 会被直接拒绝而不是静默转换,因此需要先np.asarray(x, dtype=np.float32)。- 数组必须 C-contiguous,否则报错并提示先调用
np.ascontiguousarray(...)(not_contiguous_err)。
关于参数约束,核心源码 turbovec/src/lib.rs 给出了确切的边界:
bit_width ∈ {2, 3, 4},越界返回ConstructError::BitWidthOutOfRange;dim必须是 8 的正整数倍且≤ 16384(常量MAX_DIM),dim % 8 != 0会返回DimNotPositiveMultipleOf8;- 向量坐标必须有限(拒绝 NaN / ±Inf),绝对值
≥ 1e16(MAX_INPUT_MAGNITUDE)会被拒绝,以避免 f32 范数求和溢出; - L2 范数
≤ 1e-10(MIN_INPUT_NORM)的向量没有可表示的"方向",会以 scale 0 存储、对所有查询得 0 分——这是有文档说明的行为而非错误,slot 仍计入len()。
稳定 ID:IdMapIndex
位置索引的 slot 在swap_remove后不稳定(被删 slot 会被最后一个向量顶替),因此需要跨删除保持稳定的外部 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") # 持久化增量保存,id 一并包含IdMapIndex是在TurboQuantIndex之上叠加的一个双向u64 id ↔ slot映射(源码 turbovec/src/id_map.rs)。从源码结构看,其复杂度保证为:
add_with_ids(n 个向量)—— O(n) 编码 + O(n) HashMap 插入;remove(id)—— O(1):一次 HashMap 查找 + 一次对顶替向量所在 slot 的更新 + 内部swap_remove;search—— 与内部索引相同,另加一次对返回 slot 索引的 O(nq·k) ID 翻译。
值得注意的是 id 映射的自定义哈希器IdHasher:它用"Fibonacci 乘法 + splitmix 风格 finalizer"代替 SipHash,因为外部 id 是调用方选择的 u64 而非攻击者控制的协议输入,而乘法只向上传播熵、低 bit 恒定(如shard << 32 | seq型复合 id)会让 hashbrown 退化为线性探测。源码注释记录了实测:100k 个i << 32型 id 的查找从 476 ms 降到 0.2 ms。若你的服务允许不受信任的调用方自由选择 id,仍可能构造哈希碰撞,这是文档中明确标注的应用侧假设。
IdMapIndex还支持id in idx成员判断、remove(id) -> bool(不存在时返回False)、contains(id),以及prepare()(额外预热id → slot映射,避免 load 后首次allowlist搜索付出一次性 O(n) 构建)。
混合检索:allowlist过滤与mask位掩码
把结果限制在另一个系统(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 向量为一个 block 的粒度上:没有任何允许 slot 的 block 会在任何 LUT 查找或计分之前被短路跳过;被计分 block 内不允许的单个 slot 则在堆插入时被丢弃。因此高选择性的白名单(只允许索引的一小部分)会避免大部分 SIMD 开销,而不是照常付全价再丢弃结果。
输出长度是min(k, n_allowed),其中n_allowed统计去重后被允许的向量数——当允许的向量少于k时,你得到的就是这么多结果,而不是填充的兜底值。对TurboQuantIndex则传入长度等于len(idx)的 boolmask(mask[i] == True的 slot 才参与计分)。完整的过滤语义在 docs/api.md 中有更细的说明,包括一个重要的坑:任何 mutation 都会使 mask 失效(swap_remove会重编号 slot,即使len(idx)不变),而IdMapIndex的 allowlist 因为按外部 id 命名而不存在此问题——被删 id 会抛KeyError(Rust 侧为SearchError::UnknownId)而不是悄悄解析到别的向量。
框架集成:LangChain / LlamaIndex / Haystack / Agno
四个框架集成都是各框架内置参考向量/文档存储的即插即用替代品:相同的公开接口、相同的持久化语义、相同的 retriever 与 pipeline 接线——换 import 即可保留整条流水线。对应的可选依赖在 pyproject.toml 中定义:
| 框架 | 安装 | 替换的类 |
|---|---|---|
| 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 |
Python 侧实现位于 turbovec-python/python/turbovec/(langchain.py、llama_index.py、haystack.py、agno.py),各框架的独立接入文档见 docs/integrations/。由 docs/api.md 可知,所有框架集成内部都使用IdMapIndex——正是为了跨删除保持稳定 id。
Rust API
cargo add turbovecuse 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();Rust 侧还提供 Python 没有的低层能力,例如TurboQuantIndex::from_parts(从内存中已解码的字段直接构造索引,跳过文件往返,适用于从数据库页读载荷的场景)与write_to_writer/load_from_reader。SearchResults结构体带有scores_for_query(qi)/indices_for_query(qi)行访问器;IdMapIndex对应的是IdSearchResults。并发方面,search取&self可多线程并发调用:旋转矩阵、Lloyd-Max 质心与 SIMD 块布局通过std::sync::OnceLock惰性初始化(见 turbovec/src/lib.rs 模块文档),第一个调用者承担一次性初始化成本,之后所有调用者无锁读取缓存;prepare()可提前支付该成本。
TQ+ 校准:让量化分布对齐你的数据
TQ+ 为每个坐标拟合一个(shift, scale)对,把所有存储向量编码进同一个校准坐标系。它在大多数测量数据上平均提升约 +2.5 点 R@10,在测量到的最各向异性数据(SIFT-128、2-bit)上最高约 +8.7 点。校准只来源于一个地方:显式调用idx.calibrate(sample)。索引永远不会自行拟合——从不校准的索引就是纯 TurboQuant,且编码字节与 add 的批次/顺序无关。idx.calibration_state报告两种状态(核心枚举CalibrationState定义于 turbovec/src/lib.rs):
| 状态 | 含义 |
|---|---|
"uncalibrated" | 未提交任何校准。功能完整,只是没有 TQ+ 的召回增益。 |
"calibrated" | 校准已提交,且每个存储行都按该校准编码——包括在calibrate调用之前添加的行(该调用会重新编码它们)。 |
采样是你的责任。calibrate会使用你给它的每一行数据。约 1024 行在大多数语料上就能达到对整个语料库拟合的 R@10 半分以内,2048 行在所有测量数据上都能做到——但它必须是索引将要承载向量的有代表性、随机的样本。相同规模的排序或聚类前缀样本,拟合出的分位数是偏移且过窄的,会主动摧毁召回。传入整个语料库永远安全。核心常量MIN_CALIBRATION_ROWS(结构性下限)与RECOMMENDED_CALIBRATION_ROWS在 turbovec/src/lib.rs 中导出。
calibrate可以随时、反复调用。对已填充的索引调用时,它会从存储的编码中重新编码每一行(无需原始向量)。但有三个注意事项:
- 用相同或相近的 pair 重新拟合是廉价的:编码会达到精确的不动点。
- 在大规模未校准写入之后再校准,会比先校准损失几个点的召回(重编码是第二次量化)。尽量在 add 之前校准。
- 偏差严重的早期校准无法通过重新拟合修复:过窄的拟合在编码时就把坐标裁剪到了外部质心,任何后来的 pair 都无法恢复被裁剪破坏的信息。这种情况必须用源向量重建。
校准会精确地随write/load、to_bytes/from_bytes、pickle 与 copy 往返;把索引清空到零向量也会保留已提交的校准。
工作原理:六步编码流水线
TurboQuant 的核心洞察是:每个向量都是高维超球面上的一个"方向"。随机旋转之后,每个坐标都服从已知分布——无论输入数据是什么。完整流程如下(编码实现见 turbovec/src/encode.rs):
- 归一化(Normalize)。把向量的长度(范数)剥离出来作为单个 float 存储。现在每个向量都是超球面上的单位方向。
- 随机旋转(Random rotation)。所有向量乘以同一个随机正交矩阵。旋转后每个坐标独立服从 Beta 分布,在高维下收敛到高斯 N(0, 1/d)。这对任何输入数据都成立——旋转使坐标分布可预测。
- 逐坐标校准 TQ+(Per-coordinate calibration)。第 2 步的 Beta 分布是渐近的——在有限维度下个别坐标会偏离标准形态(尤其在低位宽和词向量风格 embedding 上)。TQ+ 为每个坐标拟合两个标量——一个 shift 和一个 scale——把该坐标的经验分位数映射到码本最外侧质心上。概率水平来自码本本身(见
encode::tqplus_anchor),因此它随位宽变化(2-bit 约 0.933,4-bit 约 0.996)而非固定。校准公式为u_calibrated[d] = (u_rot[d] + shift[d]) * scale_tq[d];搜索侧对查询施加逆变换q_calib[d] = q_rot[d] / scale_tq[d]并加上逐查询偏置修正-<q_rot, shift>,净效果是同一内核、同一编码、更匹配的码本。显式调用index.calibrate(sample)一次即可(约 1024 行足够),之后校准被提交并由每次 add 复用。 - Lloyd-Max 标量量化(Lloyd-Max scalar quantization)。既然分布已知,就可以预计算每个坐标的最优分桶方式:2-bit 是 4 个桶,4-bit 是 16 个桶。Lloyd-Max 算法求出的桶边界与质心使均方误差最小。这些是一次性从数学中算出的,而非从数据中拟合。
- 位打包(Bit-pack)。每个坐标现在是一个小整数(2-bit 为 0–3,4-bit 为 0–15),紧凑地塞进字节。1536 维向量从 6,144 字节(FP32)变成 384 字节(2-bit)——16× 压缩。具体打包布局与几何关系由 turbovec/src/pack.rs 负责。
- 长度重归一化计分(Length-renormalized scoring)。标量量化会系统性地低估内积——重建的单位方向比原始方向略短。编码时对每个向量计算一个标量——旋转单位向量与其自身质心重建的内积——并把
||v|| / ⟨u, x̂⟩与该压缩向量一起存储。搜索内核在堆插入前把逐候选分数乘以该标量,把内积估计从向下有偏变为无偏,零搜索时间成本、零额外存储。召回增益在量化收缩最大的低位宽下最明显。编码成本是每向量额外一次 d 维点积;在 1M 向量、d=1536 上只是亚秒级的一次性入库代价。
搜索时不做逐向量解压,而是把查询旋转一次到同一域,直接对着码本值计分。计分内核使用 SIMD 内建指令(ARM 用 NEON;现代 x86 用 AVX-512BW,回退 AVX2,再回退到 pre-AVX2 CPU 上的标量路径),配合 nibble-split 查找表获得最大吞吐(调度与并行门控细节见 turbovec/src/search.rs,例如单查询并行阈值SINGLE_QUERY_PARALLEL_MIN_BLOCKS = 1024个 block)。
Lloyd-Max 码本的失真在信息论下界(Shannon 率失真极限)的 2.7× 以内;长度重归一化步骤则消除了 Lloyd-Max 码本对内积估计器引入的残余偏差。
持久化:write/load与增量sync
- 整文件快照:
write(path, *, durable=True)把全量索引序列化到单个.tv(或.tvim)文件,采用 fsync + 原子重命名。durable=False会跳过 rename 前的 fsync——更快,但断电可能丢失文件。durable=True保存后若 rename 后的目录 fsync 失败,保存仍算成功(文件已提交且可见),但会抛RuntimeWarning提示重命名可能无法在断电后存活。 - 增量保存:
write重写整个文件;sync(path)只写上次同步以来的变更到同一路径(第二种容器格式,magicTV7\0,专为反复的小提交设计)。加载过的索引会绑定其来源路径,从而持续增量前进而非重写。一次追加只写新的 32 行 block 加提交头;一次删除不写任何 block——它作为 redo op 骑在提交头上,后续 sync 再将其折叠进 block。全文件事件包括显式calibrate()(refit 会重编码每个存储编码)与累积删除超过头部的 op 容量;两者都会通过write()使用的同一临时文件 + rename 路径把文件整体重写压缩。 - 持久性保证:每次 sync 返回时都是持久的(无 fast 模式,fsync 是
sync_all而非 />图表绘制的是校准后的 TurboQuant(TQ+)。在 OpenAI d=1536 与 d=3072 上,TQ+ 在四个 cell 中的三个于 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 LUT),因为这是大多数用户会优先选择的默认生产级 PQ——比论文中的自定义 u8-LUT PQ 更强(FAISS 在计分时用更高精度 LUT,码本训练用 k-means++)。turbovec 在 OpenAI d=1536 / d=3072 上复现了论文的 TurboQuant 数字,并在低维 embedding 上取得与其他社区参考实现相近的数字。压缩率(Compression)
搜索速度(Search Speed)
- ARM(GCP c4a-standard-8, Google Axion, 8 vCPU):TurboQuant 在每个配置下都击败 FAISS FastScan,4-bit 平均 3.5×(各 cell 3.4–3.7×——SDOT/SMMLA 点积内核直接计分 vector-major 布局),2-bit 平均 26%(22–29%)。
- x86(Intel Xeon Platinum 8481C / Sapphire Rapids, 8 vCPU):同样每个配置都赢,4-bit 平均 3.4×(3.2–3.5×——vector-major 布局上的 AVX-512 VNNI 点积内核),2-bit 平均 20%(5–32%),其中
vpermbLUT 扫描承担了较短的 2-bit 累加循环。
插入与删除延迟(Insertion & Removal Latency)
与搜索 cell 同语料:10 万 OpenAI 向量、5 次中位数、计时循环包含调用方实际付出的 Python 调用开销。插入测量的是在已填充索引(构建不计时)上单向量
add()(n=1)与 100 向量批量add()(n=100)的逐向量延迟。单次add()落在 6.3–19.7 µs(取决于 cell),比 FAISS 单次 add 快 7.6–13.9×;100 向量批量摊薄到 4.6–16.3 µs/向量(比 FAISS 同批快 4.6–15.1×)。删除测量按 id 的逐次延迟:IdMapIndex.remove(id)(O(1) swap-and-pop 加 id 表簿记)在各 cell 上落在 0.44–1.22 µs(n=1)和 0.59–1.37 µs/次(n=100)。FAISS 对照是同一用户可见操作——在IndexIDMap之上的remove_ids,它每次调用都会重打包存储编码:10 万规模下单次删除 0.19–1.02 秒,且成本随编码尺寸翻倍——这就是删除图表使用对数轴的原因。图表展示单线程 cell(RAYON_NUM_THREADS=1);_mtcell 也做了测量,在 n=1 时与单线程一致(单次 add 是串行的)。保存与加载(Save & Load)
TurboQuant 序列化到单个
.tv文件(fsync + 原子重命名);FAISS 对照是精度匹配的IndexPQFastScan的write_index/read_index。Save (warm)指在搜索运行过之后写入(block 布局缓存已填充);Load → first search打开全新索引并计时首条查询(页缓存全程热,所以这是布局工作而非冷存储 I/O);Round-trip串联了 embedding 存储实际会付的 checkpoint/resume 周期——变更 1000 向量 → 保存 → 重开 → 服务首条查询,FAISS 没有该路径的测量等价物,因此仅对 TurboQuant 展示。完整结果见 benchmarks/results/ 下各speed_persist_*.json与speed_*_*.json。从源码构建
Python(经 maturin)
pip install maturin cd turbovec-python maturin build --release pip install target/wheels/*.whl包元数据见 turbovec-python/pyproject.toml:要求 Python ≥ 3.9,运行时仅依赖
numpy>=1.20;maturin 配置module-name = "turbovec._turbovec",python-source = "python"。Python 侧还提供BATCH_CHUNK_SIZE = 4096(见 turbovec-python/python/turbovec/init.py)——批处理切片默认值,让排队的 Ctrl-C 在切片间得到响应;__version__经 PEP 562 惰性解析,避免 import 时付出约 20 ms 的 metadata 开销。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 走标量回退。另外核心 crate 在非 64 位目标上会直接compile_error!拒绝编译(SIMD 内核与usize尺寸运算假设 64 位指针宽度)。运行基准测试
下载数据集:
python3 benchmarks/download_data.py all # all datasets 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 # all ARM speed for f in benchmarks/suite/speed_*x86*.py; do python3 "$f"; done # all x86 speed for f in benchmarks/suite/recall_*.py; do python3 "$f"; done # all recall python3 benchmarks/suite/compression.py # compression结果以 JSON 保存到 benchmarks/results/,重新生成图表:
python3 benchmarks/create_diagrams.py优化工作的快速基准
上述套件是所有已发布数字的来源(真实 embedding、FAISS 对照、固定形状、两台官方环境)。优化迭代的内循环还有一个 Rust harness,可在确定性合成向量上复现四个变异指标(冷批量 add、热追加、单条 add、remove),任何机器几秒内即可验证假设,无需数据集与 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 在矩阵中的每个 OS 上运行它并在不一致时失败,这正是跨平台编码字节一致性的检查方式。参考
- TurboQuant(ICLR 2026):本库实现的核心论文——在线向量量化,近最优率失真。
- RaBitQ(SIGMOD 2024):第 5 步采用的逐向量长度重归一化修正的来源。
- FAISS FastScan:turbovec 的 x86 SIMD 内核借鉴了 FastScan 的 pack 布局、nibble-LUT 计分与 u16 累加器策略。
更完整的 API 说明(方法表、文件格式字段图、load 性能、版本化与限制、低层
from_parts构造)见 docs/api.md;框架集成细节见 docs/integrations/;核心索引结构、CalibrationState与并发搜索不变式见 turbovec/src/lib.rs,搜索调度与 SIMD 分派见 turbovec/src/search.rs。【免费下载链接】turbovecA vector index built on TurboQuant, written in Rust with Python bindings
项目地址: https://gitcode.com/GitHub_Trending/tu/turbovec
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考