Fork me on GitHub

分类 turbovec 下的文章

学习 turbovec 的 SIMD 搜索内核

在上一篇中,我们沿着 encode.rs 把量化算法走了一遍:随机旋转把每个坐标压到 Beta 分布上,Lloyd-Max 码本求出最优的标量量化器,TQ+ 校准做 5/95% 分位对齐,长度归一化修正补回内积的系统性偏差。这一整套流程解决的是精度和内存的问题,一个 1536 维的 float32 向量被压成 4-bit 编码,体积缩小到原来的八分之一,Recall@1 还能稳住甚至反超 FAISS。

不过压缩只是故事的一半。向量被压成了一串 4-bit 编码以后,搜索的时候这些编码到底是怎么被高速扫描的?10 万条向量、每条 1536 维、4-bit,落到内存里是 73.6 MB 的紧凑字节流,一次查询要在这上面算出 top-k。turbovec 之所以能在 Apple M3 Max 上单线程跑到约 2.0 ms、比 FAISS IndexPQFastScan 快 19%,靠的就是 search.rs 里那套手写 SIMD 内核。今天我们就来读这部分代码,把整个搜索内核拆开看。

搜索路径全景

先看一次 search 调用都发生了什么。代码入口在 lib.rs 的 search_with_mask,真正的计算委托给 search.rs 的 search 函数。整条路径如下:

search-pipeline.png

这几步都对应 search.rs 里的一段代码,我们顺着往下走。

第一步:批量旋转

上一篇讲过,库向量在编码时都乘过一个随机正交矩阵 Q,它是数据无关的常量,放在 OnceLock 缓存里,整个索引共用一份。库向量活在旋转后的坐标系里,查询自然也得旋转到同一个坐标系,内积才算得对。所以这里把查询也乘上同一个 Q,就是 search 函数开头的一次 GEMM:

// q_ref:把输入 queries 按行包成查询矩阵(nq 条、每条 dim 维)
let q_ref = faer::mat::from_row_major_slice::<f32, _, _>(queries, nq, dim);
// r_ref:同样包好的旋转矩阵 rotation
let r_ref = faer::mat::from_row_major_slice::<f32, _, _>(rotation, dim, dim);
// out_mut 是输出 q_rot;matmul 把 q_ref 乘上 r_ref.transpose()(即 rotation^T)写进去
// 整句就是 q_rot = queries @ rotation^T
faer::linalg::matmul::matmul(
    out_mut, q_ref, r_ref.transpose(), None, 1.0_f32,
    faer::Parallelism::Rayon(0),
);

关键在于这里是把所有查询堆成一个矩阵、和旋转矩阵做一次 GEMM,而不是逐查询做矩阵向量乘。这样能把 FMA 吞吐喂满,缓存里那一份旋转矩阵也在多条查询间复用。

GEMM(General Matrix Multiply,通用矩阵乘)是线性代数库里最核心的操作,算的就是两个矩阵相乘 C = A × B。把多条查询堆成一个矩阵、和旋转矩阵一次乘完,比逐条做矩阵向量乘更能榨干 CPU 的缓存和向量单元,是 BLAS 这类库重点优化的对象。FMA(Fused Multiply-Add,融合乘加)则是把一次乘法和一次加法合并成单条 CPU 指令,一次算出 a × b + c,既省一条指令又少一次中间舍入;矩阵乘的内层全是乘加,所以 FMA 的吞吐基本就决定了 GEMM 的速度。

第二步:TQ+ 逆校准

这一步同样是上一篇的对偶操作。编码时 TQ+ 对每个坐标做过一次仿射 (shift, scale),把库向量的经验分布拉回理论 Beta;库向量既然被这样变换过,查询侧就要施加配套的逆变换,才能让两边在校准后的空间里算出和原来一致的内积。

干这件事的是 calibrate_queries,它对每条查询走下面这个循环:

// q_row:这条旋转后的查询;calib_row:要写出去的校准后查询;bc:正在累加的偏置
for d in 0..dim {
    // tqplus_scale[d] 是编码时给该坐标乘过的缩放,逆过来就是除以它
    calib_row[d] = q_row[d] / tqplus_scale[d];
    // tqplus_shift[d] 是编码时加过的平移,在内积里只贡献一个与库向量无关的常数项,
    // 不必逐坐标改查询,把这些常数全累加进 bc 即可
    bc -= (q_row[d] as f64) * (tqplus_shift[d] as f64);
}
// 循环结束,bc 就是这条查询的偏置修正 bias_corr
*bias = bc as f32;

两样合起来,SIMD 内核拿到的就是一条普通的校准后查询 calib_row 和一个偏置 bias_corr,对 TQ+ 的存在毫无感知,照常算内积即可。

后面三步

剩下的三步是这篇的主角,留到后面几节展开:

  1. 生成 LUT:每条查询独立构建一张查找表,是整个内核的核心。
  2. 块级 SIMD 评分:按 32 向量一块、按平台分发到 NEON / AVX2 / AVX-512BW 内核。
  3. top-k 堆更新:评分和堆更新融合在一起,不必先把全库的分数都算出来存成一个大数组。

我们从 LUT 开始。

LUT 查表

向量搜索的内积,本质是把查询和库向量的每一维相乘再求和。库向量已经被量化成了码字(code),每个坐标只有 2^bits 种可能的取值。以 4-bit 为例,一个坐标的码字只有 16 种。那么 查询[d] * 重建值[code] 这个乘积,对固定的查询和固定的坐标 d,也只有 16 种结果。

既然只有 16 种结果,就没必要在搜索时反复做乘法,提前把这 16 个结果算好存成一张表就行。搜索时拿库向量的码字当下标去查表、累加,乘法被彻底消掉了。这张表就是 查找表(Look-Up Table,简称 LUT)

建表的逻辑在 build_query_neon_lut_from_slice。在供 SIMD 扫描的块布局里,一个字节装两个 4-bit 码字(也就是半字节,英文叫做 nibble),低 nibble、高 nibble 正好各对应一个坐标。我们把「一个字节、两个坐标」这一小撮叫一个字节组,1536 维就拆成 768 个字节组。函数为每条查询、每个字节组建两张 16 项子表,低 nibble、高 nibble 各一张,每个表项就是前面说的那个预计算乘积(查询[d] * 重建值[code]):

// dim_start 是第 g 个字节组的起始坐标(低 nibble 对应的那一维)
// 低 nibble 子表,16 个表项(这里按 4-bit 简化)
for nibble_val in 0u16..16 {
    // 表项 = 查询在该坐标的值 × 该码字对应的重建中心点 centroid
    let s = q_rot_row[dim_start] * centroids[nibble_val as usize];
    float_vals[g * 32 + nibble_val as usize] = s;
}

算出来的表项是 float,但后面 SIMD 查表要的是 u8,所以还得量化一遍。turbovec 借鉴了 FAISS 的 per-sub-table 量化:每张 16 项子表先减掉自己的最小值再做 u8 取整,所有表共享一个 scale = max_span / max_lut,这样可以避免用全局最小值时不同子表值域不一致带来的系统性舍入偏差。函数注释里也提到了这点:

/// Uses FAISS-style per-sub-table quantization: each 16-entry nibble
/// LUT subtracts its own min before u8 rounding, with a single
/// shared `scale = max_span / max_lut`.

这里 max_lut 固定为 127,是个关键数字。为什么是 127 而不是 128 呢?因为搜索时,一个字节里低 nibble、高 nibble 这两张子表会各查一次,两个结果要先在 u8 里相加,而 u8 最大只能装 255。两个表项各自最大都是 max_lut,相加不能溢出,2 * 127 = 254 刚好不超,换成 128 就是 2 * 128 = 256,把 u8 撑爆了。所以表项的上限只能取到 127。

表建好了,接下来就是怎么用一条指令把它查出来。SIMD 的字节洗牌指令恰好能用一条指令完成 16 路甚至 32 路的并行查表,这正是 FAISS FastScan 的核心技巧,turbovec 把它原样搬了过来。

SIMD(Single Instruction Multiple Data,单指令多数据) 是一类 CPU 指令,一条指令同时对一批数据做相同的运算。普通指令一次加一对数,SIMD 指令一次加 8 对、16 对甚至 32 对。

字节洗牌指令(byte shuffle) 也是一条 SIMD 指令,只不过它每个通道做的不是加减乘,而是"按下标取数":给一张 16 字节的小表和一组下标,每个通道各自拿本通道的下标去表里取出对应的那个字节。当这张小表正好是 LUT、下标正好是库向量的码字时,一条指令就让所有通道同时各查各的。

具体到这条字节洗牌指令,调用它的内建函数在 ARM 上叫 vqtbl1q_u8,x86 上叫 _mm256_shuffle_epi8(对应 PSHUFB 这条机器指令)。

内建函数(intrinsics) 是和单条 SIMD 指令一一对应的函数。在代码里写一个内建函数调用,编译器就把它翻译成对应的那条机器指令,不用手写汇编就能发出 SIMD 指令。这套名字不是某门语言专有的,而是 ARM、Intel 随指令集一起发布的标准命名(ARM 的以 v 开头、x86 的以 _mm 开头)。

这两个内建函数做的事情是一样的:拿一个 16 字节的表当查找表,拿另一个寄存器的每个字节当下标,一条指令把所有通道的查表结果同时取出来。这里的通道(lane)就是 SIMD 寄存器里切分出来的一个个数据槽,一个 128 位寄存器装 16 个字节就是 16 个通道,后面说的 16/32 路并行,路数就是通道数。

把库向量的 nibble 码字塞进下标寄存器,一条指令就完成了 16 路 LUT 查表。查出来的每一路,就是对应那条库向量在这个坐标上的一项内积贡献。把同一条库向量在所有字节组上查到的贡献累加起来,就凑成了它和查询的完整内积,也就是搜索要的相似度得分。这个"累加成得分"的步骤,就由下面各平台的内核来完成。

NEON 内核

上一节讲的字节洗牌只是一次查表这个最小动作,真正的搜索要把库里成千上万个向量、每个向量几十上百个字节组都扫一遍,再把查到的贡献累加起来。把这套扫描加累加的流程在某个具体指令集上跑起来的,就是内核函数,也就是搜索路径里的第二步「块级 SIMD 评分」。这一步按平台分成三套内核:ARM 走 NEON,x86 走 AVX2 / AVX-512BW,本节先看 NEON,下一节看 AVX。

NEON 内核的主路径其实会把 4 条查询融合成一批来算(score_4query_block_neon,后面 x86 内核也是同样的思路);为了讲清楚单块的机制,下面看的是单查询版本 score_4bit_block_neon,4 查询版只是在它的基础上同时推进 4 条查询。这个函数一次处理一个 32 向量的块,把上一节那条洗牌指令放进循环里,沿字节组一组组扫过去,边查表边累加,块内的 32 个向量则靠 SIMD 通道一次并行算完,不用单独再开一层循环。

NEON 是 ARM 处理器的 SIMD 指令集,寄存器宽 128 位,能一次对 16 个字节(或 4 个 float32)做相同运算。苹果 M 系列、几乎所有手机 SoC 以及服务器端的 ARM 芯片都带它,aarch64 平台上的向量加速基本都靠它。下面用到的 vqtbl1q_u8vaddw_u8 这些内建函数,都是 NEON 指令。

我把这个函数的非核心部分精简掉,只保留主干:

let mask = vdupq_n_u8(0x0F);
let v_scale = vdupq_n_f32(scale);
let n_batches = (n_byte_groups + FLUSH_EVERY - 1) / FLUSH_EVERY;
let mut fa = [vdupq_n_f32(bias); 8];   // 8 个 float32x4 累加器,初值为 bias

for batch in 0..n_batches {
    let mut accum = [vdupq_n_u16(0); 4];   // uint16x8 累加器
    // 4 组展开的内循环,交错查表以隐藏 vqtbl1q_u8 的延迟
    while g + 3 < g_end {
        for (lp, cp) in [(lp0, cp0), (lp1, cp1), (lp2, cp2), (lp3, cp3)] {
            let lut_hi = vld1q_u8(lp);
            let lut_lo = vld1q_u8(lp.add(16));
            let c0 = vld1q_u8(cp);
            // 低 nibble 查 lut_lo,高 nibble 查 lut_hi,两次查表相加
            let s0 = vaddq_u8(vqtbl1q_u8(lut_lo, vandq_u8(c0, mask)),
                              vqtbl1q_u8(lut_hi, vshrq_n_u8(c0, 4)));
            accum[0] = vaddw_u8(accum[0], vget_low_u8(s0));   // 8→16 bit 加宽累加
            // ...
        }
        g += 4;
    }
    // 每批结束:uint16 → float,融合乘加进 fa
    for i in 0..4 {
        let lo = vcvtq_f32_u32(vmovl_u16(vget_low_u16(accum[i])));
        fa[i * 2] = vfmaq_f32(fa[i * 2], v_scale, lo);
    }
}

代码里 float32x4uint16x8 这类名字是 NEON 的向量类型,命名规则是「元素类型 + 通道数」:uint16x8 就是 8 个并排的 16 位无符号整数,float32x4 是 4 个并排的 float32,正好都占满一个 128 位寄存器。所谓"累加到 uint16x8 上",就是把查表结果累加进这样一个装着 8 个 16 位整数的寄存器,8 个通道各自独立累加。

这段代码里有两处工程细节值得展开。

第一处是FLUSH_EVERY 分批。内循环里每次查表的结果是 u8,NEON 用 vaddw_u8 把这些 u8 不断加宽累加到一个 uint16x8 累加器上。但 u16 最多装 65535,成百上千个字节组一路累下去会溢出,所以累加得分两层来做:内层先在 u16 累加器里快速攒,每攒满 FLUSH_EVERY 个字节组(lib.rs 里定为 256),就把 u16 累加器转成 float、乘以 scale、加进外层的 float 累加器 fa,再清零、接着攒下一批。这样 u16 累加器始终在安全范围内,整个块扫完,所有批的贡献都汇进了 fa

第二处是4 组展开隐藏延迟vqtbl1q_u8 这条查表指令有几个周期的延迟,如果一条接一条地依赖执行,流水线就会空等。内循环一次展开 4 个字节组、交错发射查表指令,让 CPU 在等前一条结果时就能去算后面几条,把延迟藏起来。处理完整的 4 组之后还有一段尾巴循环收拾剩下的 0~3 组。

最后把 8 个 float 累加器乘上逐向量的 vec_scales(就是上一篇讲的长度归一化修正),写出 32 个分数。整个块算完,乘法只在建表时做过一次,扫描阶段全是查表加累加。

AVX 内核

x86_64 平台要复杂一些,因为 x86 上的 SIMD 指令集分了好几代,不同年代的 CPU 支持到哪一代各不相同。所以这边不是一条内核走到底,而是在运行时用 is_x86_feature_detected! 探测 CPU 特性,按从快到慢的顺序挑一条路径,挑不到就一路回退,回退链是 AVX-512BW → AVX2 → 标量:

AVX(Advanced Vector Extensions) 是 Intel、AMD 在 x86 上的 SIMD 指令集,turbovec 用到两代:AVX2 寄存器宽 256 位,一次能处理 32 个字节;AVX-512BW 把寄存器加宽到 512 位,一次 64 个字节。它对应的查表指令是 _mm256_shuffle_epi8(AVX-512BW 则是 _mm512_shuffle_epi8),作用和 NEON 的 vqtbl1q_u8 一样,只是一次能处理的字节更多。

unsafe {
    if is_x86_feature_detected!("avx512bw") && is_x86_feature_detected!("avx512f") {
        search_multi_query_avx512bw(/* ... */);
    } else if is_x86_feature_detected!("avx2") {
        search_multi_query_avx2(/* ... */);
    } else {
        // 既没有 AVX-512BW 也没有 AVX2:逐查询标量评分
        for qo in 0..batch_nq {
            score_query_into_heap(/* ... */);
        }
    }
}

三条路径里,两条 SIMD 是性能主力,标量是兜底。

AVX2 路径:核心函数 search_multi_query_avx2 一次处理 4 条查询,用一个 4×4 的累加器 tile:4 条查询,每条查询 4 个 256 位累加器,4×4=16 个累加器全程待在寄存器里排成一个方阵,这个方阵就是 tile(高性能矩阵乘里「寄存器分块」的常用手法)。这么做是为了让库向量的码字只加载一次、nibble 只拆分一次,然后喂给 4 张不同的 LUT、更新到整个 tile 上,把加载和拆分的成本摊薄到 4 条查询头上。它的查表指令是 _mm256_shuffle_epi8,一次处理 32 个通道。和 ARM 不同的是,x86 这边的累加遇到了一个麻烦:_mm256_shuffle_epi8 的结果是 u8,但 AVX2 没有方便的 8→16 位加宽累加指令,只能在 16 位通道里累加,而相邻的两个 u8 会挤在同一个 u16 里互相干扰。turbovec 用了一个 SUB 技巧绕过去:

// 把高字节移到低位再相减,靠符号扩展恢复原值,避免 u16 溢出污染
lo_a0 = _mm256_sub_epi16(lo_a0, _mm256_slli_epi16(lo_a1, 8));

简单来说,就是把两个挤在一起的字节通过移位和减法重新分开。这套 SUB 技巧同样来自 FAISS FastScan。

AVX-512BW 路径search_multi_query_avx512bw 思路和 AVX2 一致,只是把寄存器换成 512 位:用 _mm512_inserti64x4 把两个块的 32 字节码区分别拼进一个 512 位寄存器的低、高 256 位,_mm512_shuffle_epi8 一条指令同时查两个块,LUT 则用 _mm512_broadcast_i64x4 广播到两个 256 位半区。

标量兜底:最后那个 else 分支处理两个 if 都不命中的情况,也就是一台既无 AVX-512 也无 AVX2 的老 x86 CPU。这时没有 SIMD 可用,就退回到 score_query_into_heap 逐条查询、逐个向量地标量评分。现代 CPU 基本都有 AVX2,这个分支很少走到,但留着它,能保证在任何 x86 机器上 search 都算得出正确结果,只是慢一些。

top-k 堆更新

这是搜索路径的第三步。内核每算完一个 32 向量的块,手里就有 32 个分数。一个直白的做法是把全库 N 个向量的分数都算出来、存进一个长数组,再排序取前 k 个。但那样要为 N 个分数分配内存、再扫一遍排序,纯属浪费。turbovec 的做法是边评分边维护一个 top-k 候选集,块一算完就地更新。

这个候选集代码里叫「堆」,但实现得很轻:一个大小为 k 的定长数组,外加一个变量记着当前数组里最小的那个分数(也就是「第 k 大」的门槛)。更新逻辑就两条:候选集还没装满 k 个时,分数直接塞进去;装满之后,只有当新分数比门槛还大,才替换掉最小的那一项,再重新扫一遍数组找出新的门槛。

这里还藏着一个 SIMD 小优化。候选集装满之后,对每一组分数先做一次阈值剪枝:把 8 个分数和门槛一起比一比,只要一个都没超过,整组直接跳过,连逐个比较都省了。

let v_hmin = _mm256_set1_ps(*hmin);                     // 当前门槛,广播成向量
let cmp = _mm256_cmp_ps(scores_v, v_hmin, _CMP_GT_OQ);  // 8 个分数各自和门槛比大小
if _mm256_movemask_ps(cmp) == 0 { continue; }           // 一个都没超过,整组跳过

库里绝大多数向量本来就进不了 top-k,靠这条剪枝,它们几乎不花什么力气就被刷掉了。所有块扫完,候选集里剩下的就是 top-k,最后排一次序,输出 scoresindices,整条搜索路径就走完了。

块级提前退出

上面走完的是一次普通搜索。如果查询还带上了 allowlist / mask,也就是第 2 篇讲的混合检索,内核里就多出一道优化,叫块级提前退出。它能在只有少量向量被放行时大幅省去无用功,函数是 block_has_allowed

pub(crate) fn block_has_allowed(mask: Option<&[u64]>, base_vec: usize) -> bool {
    match mask {
        None => true,
        Some(m) => {
            let word = m[base_vec >> 6];
            let bit_offset = base_vec & 63;
            // 这一块对应的半个 u64 字里只要有一位是 1,就说明块内有放行的槽位
            ((word >> bit_offset) & 0xFFFF_FFFF) != 0
        }
    }
}

逻辑很直接:每个 32 向量块开始评分前,先看这一块里有没有任何一个被 allowlist 放行的槽位。allowlist 被打包成 u64 位图,一个块对应位图里的半个 u64 字,一次整数加载加一次判断就能决定整块跳不跳。没有放行槽位就直接 continue,这一块的查表、累加、解码全部省掉。

AVX-512BW 内核更进一步,因为它一次处理 64 向量的块对、刚好对齐一整个 u64 字,所以用 block_pair_has_allowed 一次跳过两个块。在 1% 选择率下,这套提前退出在 ARM 上带来 6.4 倍、x86 上带来 12.7 倍加速。不带 mask 的普通搜索完全不受影响,这个逻辑只在传了 mask 时才触发。

小结

今天我们读完了 turbovec 搜索内核这块最硬的代码:

  1. 搜索路径:查询旋转用一次 GEMM 批量完成,TQ+ 逆校准合并成逐查询偏置,逐查询建 LUT,再分发到平台内核做块级评分和 top-k 堆更新。
  2. LUT 查表:把 查询 * 重建值 的有限种结果预先存表,用 vqtbl1q_u8 / _mm256_shuffle_epi8 一条指令完成 16/32 路并行查表,这是 FAISS FastScan 的核心技巧。
  3. 三路内核:NEON 顺序扫描、4 组展开隐藏延迟、uint16 加宽累加不溢出;x86 用 4×4 tile 和 SUB 技巧;运行时按 AVX-512BW → AVX2 → 标量的顺序分派,标量分支兜底。
  4. 块级提前退出:带 mask 的混合检索下,用 u64 位图判断整块是否被放行,没放行就跳过整块的查表与累加,1% 选择率下能快 6–13 倍。

回头看这四篇,从快速入门、混合检索,到量化算法、SIMD 内核,从 Python API 一路读到 NEON 内建函数,由表及里算是走了一遍。

说句实话,最后这两篇钻进源码的解读相当晦涩,到现在我对这块的不少逻辑还是似懂非懂,学习和成文的过程很大程度上是借助 Claude 一点点啃下来的。但即便只是似懂非懂,把代码摊开读一遍,心里也比只会调 API 时踏实了一些。希望这个系列能给你一个大致的轮廓,下次在自己的检索系统里遇到内存或速度的瓶颈时,知道有 turbovec 这样一个选择,也对它内部的大致原理有个印象。

参考


学习 turbovec 的量化算法

前两篇我们把 turbovec 的 API 过了一遍:第 1 篇跑通了 addsearch,第 2 篇学了混合检索和框架集成。它们都接口层面,但有一个核心问题一直没回答:为什么 turbovec 不需要训练就能把向量压到 2-4 bit,还能在召回上追平甚至超过 FAISS 的 PQ?

答案在它背后那套免训练量化算法 TurboQuant。这个算法来自 Google Research 的论文,今年初刚被机器学习顶会 ICLR 接收,turbovec 是它第一个公开的工程实现。今天我们就从传统量化的负担讲起,再读论文的核心思想,最后对照 rotation.rscodebook.rsencode.rs 三个文件,把整条编码管道从头到尾理清楚。

传统量化的训练负担

第 1 篇我们算过那笔内存账:1536 维 float32 一条向量约 6 KB,千万级语料就是 31 GB。量化(Quantization)做的就是用更少的比特去近似表示原本的浮点数,把每个坐标从 32 bit 压到 2-4 bit,体积掉到 1/8 到 1/16,单机就能装下,代价是牺牲一点精度。问题在于,怎么压才能让精度损失尽量小。

向量量化最经典的方案是乘积量化(Product Quantization,简称 PQ)2011 年由 Jégou 等人提出,FAISS 里的 IndexPQIndexPQFastScan 都是它的实现。

PQ 的思路是分段查表。把一条 1536 维向量切成若干个子段,比如 8 段,每段 192 维;对每一段,用 k-means 在训练数据上聚出 256 个聚类中心,存成一张码本(codebook)。编码时,每段只记录它最近的那个中心的下标,一个 8 bit 的整数就够了。原本 192 个 float32 的子段,压成了 1 个字节。整个分段查表的过程如下图所示:

pq-diagram.jpg

这套方案压缩率很高,但有一个绕不开的前提:码本要从数据里学出来。这就带来两个负担:

  1. 训练阶段不可省:用 FAISS 的 PQ,必须先准备一批有代表性的向量,调用 index.train(xs) 跑 k-means 训练码本,之后才能调用 add 添加向量。数据量不足时训练不出高质量的码本。
  2. 数据漂移要重训:码本是对当前数据分布的拟合。如果后续加进来的向量分布发生变化,比如更换了 embedding 模型、业务语料迁移,旧码本就不再贴合,召回率随之下降,需要重新训练并重新编码整个索引库。

对于一个需要支持在线增量写入的向量库来说,这个训练环节是个明显的负担。我们希望第一条向量进来就能直接编码、直接检索,不必经历积累数据、训练、回填这一整套流程。

这就引出了另一条路线:数据无关量化(data-oblivious quantization)。它的目标是设计一个不依赖具体数据分布的量化器,码本可以提前算好、写死在代码里,任何数据进来都用同一套。TurboQuant 走的正是这条路。

TurboQuant 的核心思想

TurboQuant 论文的全名是《TurboQuant: Online Vector Quantization with Near-optimal Distortion Rate》,arXiv 编号 2504.19874,去年 4 月提交,今年初被 ICLR 接收。它要解决的问题是:在不看数据分布的前提下,怎么设计一个接近最优的标量量化器。

它的关键观察分三步。

第一步,随机正交旋转。对每一条归一化后的单位向量,乘上一个随机生成的正交矩阵。正交变换不改变向量长度,也不改变向量之间的内积,所以旋转之后做的所有检索运算,结果和旋转前等价。旋转只是换了一组坐标基。

第二步,旋转后坐标服从已知分布。这是整个算法的支点。一个 d 维单位向量随机旋转之后,它的每个坐标不再是任意的,而是服从一个确定的 Beta 分布。坐标的取值被摊平到了一个集中、对称、与数据无关的形状上。原始数据可能在某些维度上特别集中、某些维度上特别分散,但随机旋转把这种各向异性打散了,每个坐标看起来都像是从同一个分布里采出来的。

第三步,高维下坐标近似独立,可以逐坐标独立量化。维度越高,不同坐标之间的相关性越弱,近似相互独立。既然每个坐标都同分布、又近似独立,那就不需要像 PQ 那样为不同子段学不同的码本了。我们只要针对这一个 Beta 分布,求出一个最优的标量量化器,然后对所有坐标用同一套量化器即可。

这套量化器是离线就能算好的,因为 Beta 分布的形状只由维度 d 决定,和数据没有半点关系。论文给出的理论保证是:这样做的失真率(distortion rate)落在信息论下限,也就是香农下界的约 2.7 倍以内。一个完全不看数据、提前写死的量化器,做到了接近理论最优的精度。

下面我们把随机正交旋转、Beta 分布、求最优量化器用的 Lloyd-Max 算法这三个可能陌生的概念逐一讲清楚,再回到源码。

正交矩阵为什么不改变内积

第一步具体做的,是把每条向量先归一化成长度为 1 的单位向量,再统一乘上同一个随机生成的正交矩阵 Q。归一化的用意要到下一节才说得清,这里先解决一个更要紧的疑问:凭什么乘了 Q 之后,检索结果还和原来一样?

答案是旋转「不改变向量之间的内积」,这是后面所有推理的前提,先把它讲透。

正交矩阵是一个方阵 Q,它的各列都是两两垂直的单位向量。这等价于 Qᵀ·Q = I,也就是它的转置恰好等于它的逆。几何上,乘一个正交矩阵就是对整个空间做一次刚性的旋转(或翻转),只改变朝向,不做任何拉伸或压缩。

为什么旋转后内积不变?两个向量 xy 旋转后变成 QxQy,把它们的内积按定义展开:

<Qx, Qy> = (Qx)ᵀ(Qy) = xᵀ·QᵀQ·y = xᵀ·I·y = xᵀy = <x, y>

中间这一步用到的正是 QᵀQ = I。内积不变,向量长度(||x||² = <x, x>)自然也跟着不变。

几何上更直观,如下图所示:旋转把整个空间当成一个刚体一起转,任意两点之间的夹角和距离都原封不动。

rotation-inner-product.png

而向量检索靠的就是内积或距离来判断谁离查询更近,这些量旋转后完全不变,所以最近邻关系一个都不会错。这正是 turbovec 在编码时先对所有向量做一次旋转的依据:旋到新坐标系里做检索,和在原始坐标系里做,结果完全等价。也就是说,旋转换来了一个数据无关的规整分布,却没有付出任何检索精度的代价。

旋转后为什么是 Beta 分布

要讲清楚旋转的作用,得先弄明白一件事:一个均匀随机落在球面上的单位向量,它的单个坐标长什么样。我们从低维往高维看。

二维时,单位向量是圆上一点,横坐标 x₁ = cos θ。θ 是均匀的,但 x₁ 并不均匀。cos 在 ±1 附近平缓、在 0 附近陡峭,把均匀的角度压成了两端堆积的横坐标。可以想象一颗珠子在圆上匀速转,看它投在横轴上的影子:转到左右两侧时它几乎在竖直运动,影子在 ±1 附近停留很久;转到上下时它几乎在水平运动,影子飞快划过 0。停留得久的地方点就密,所以二维时坐标反而堆在 ±1。

维度一高,情况彻底反过来,坐标转而向 0 集中。最直接的理由是长度预算:单位向量满足 x₁² + … + x_d² = 1,这个总量要分给 d 个坐标。二维时一个坐标可以占掉大部分,比如 (1, 0),但 1536 维下平摊到每个坐标只剩 1/√1536 ≈ 0.025,紧贴着 0。要让某个坐标取到 ±1 附近,它必须占掉几乎全部预算、其余 1535 个一齐趋零,这在随机方向上微乎其微。于是高维坐标被压在 0 附近,呈一个尖钟形,如下图所示:

beta-distribution.jpg

这个尖钟形有个名字,叫 Beta 分布。

Beta 分布是定义在 [0, 1] 区间上的一族连续概率分布,由两个形状参数 αβ 控制。当 α = β 时分布对称,集中在中间的 0.5 附近;两个参数越大,分布越往中间挤、越尖。

数学上,单位球面上一个坐标的密度有个统一的表达式,归一化后正是映射到 [-1, 1] 的 Beta 分布:

p-beta.png

这个表达式把三个维度串成一条线:d = 2 时退化成 1/√(1 - x²),就是上面两端堆积的形状;d = 3 时恰好均匀;d ≥ 4 起变成中间高、两端低的钟形,维度越高越尖。对 1536 维,α = β = 767.5,钟形尖到坐标几乎全落在 0 附近一条窄缝里。

但要注意,上面这套结论有一个前提:向量是均匀随机地落在球面上的。真实 embedding 并不满足这个前提,它们抱团、偏向某些方向,也就是各向异性(anisotropy):某些维度的方差特别大,另一些几乎是常数。所以不旋转时,真实数据每个坐标的分布由数据自身决定,对不齐那个统一的 Beta:方差大的坐标超出码本的覆盖范围,方差小的坐标又用不满码本的精度,一张共享码本顾此失彼。

这正是必须旋转的原因。随机正交旋转把每条向量的坐标重新组合成所有原坐标的均匀混合,原本被少数维度占据的能量被摊回到全部坐标上。各向异性由此被抹平成各向同性(isotropy):所有坐标的方差都收敛到相同的 1/d,分布形状也都收敛到同一个 Beta。这一步并不创造 Beta 形状(那是高维球面本身的几何性质),而是把真实数据搬到能享用这个形状的位置上,从而让一张离线算好的码本精准地覆盖每一个坐标。

Lloyd-Max 最优标量量化器

知道了坐标的分布,下一步是为它找最优的量化方案。这里用的是 Lloyd-Max 算法。

标量量化器可以理解成一个逐坐标的「四舍五入器」:在数轴上预先定好若干个档位(重建中心点)和档位之间的分界线(边界),编码时把每个坐标值归到最近的档位,只存这一档的编号,用的时候再用档位的中心值近似还原。4-bit 就是 16 个档位,编号占 4 bit,原来一个 float32 坐标从 32 bit 压到了 4 bit。所谓「最优」,就是这些档位和分界线不能随便摆,要放在让平均误差(均方误差)最小的位置上。

Lloyd-Max 是一种求解最优标量量化器的经典迭代算法,1957 年由 Stuart Lloyd 提出、1960 年由 Joel Max 独立给出。给定一个连续分布和量化级数(比如 4 bit 就是 16 级),它求出一组区间边界(boundaries)和一组重建中心点(centroids),使得量化的均方误差最小。

它的迭代逻辑是两条最优性条件交替满足:

  1. 给定中心点,最优边界是相邻两个中心点的中点:一个值该归到哪个量化级,取决于它离哪个中心点更近,分界线自然落在中点上。
  2. 给定边界,最优中心点是该区间在分布下的条件期望:一个量化级的重建值,应该取落在这个区间内所有数据的加权平均,权重就是概率密度。

这两步切出的边界和中心点如下图所示:

lloyd-max.jpg

反复迭代这两步直到收敛,就得到了针对该 Beta 分布的最优 boundaries 和 centroids。注意整个过程只用到分布本身,不碰任何真实数据,所以离线算好就行。这正是 turbovec 不需要训练阶段的根本原因。

源码剖析

理论讲完,我们回到代码,看 turbovec 是怎么一步步把这些思想落地的。一条向量从输入到存储,要经过归一化、旋转、校准、量化、打包这几道工序,实现分散在 rotation.rscodebook.rsencode.rs 三个文件里。整条管道是一条直线:

输入向量(float32)
  → 输入验证(检测 NaN / Inf)
  → 归一化(提取范数,得到单位向量)
  → 正交旋转(乘随机旋转矩阵)
  → TQ+ 校准(逐坐标平移缩放)
  → Lloyd-Max 量化(边界扫描得码字)
  → 比特平面打包
  → 长度归一化修正(为每条向量算一个 scale)
  → 存储(packed_codes 与 scales)

旋转矩阵的确定性生成

第一道核心工序是旋转,实现在 rotation.rs,整个文件只有 50 行出头。核心函数 make_rotation_matrix 做了三件事:

pub fn make_rotation_matrix(dim: usize) -> Vec<f32> {
    let mut rng = ChaCha8Rng::seed_from_u64(ROTATION_SEED);

    // 1. 生成 dim x dim 的高斯随机矩阵
    let mut g = faer::Mat::<f64>::zeros(dim, dim);
    for j in 0..dim {
        for i in 0..dim {
            g.write(i, j, rng.sample(StandardNormal));
        }
    }

    // 2. QR 分解,Q 就是一个正交矩阵
    let qr = g.qr();
    let q_full = qr.compute_thin_q();
    let r = qr.compute_thin_r();

    // 3. 符号修正:Q = Q * diag(sign(diag(R)))
    let mut q = q_full;
    for j in 0..dim {
        let sign = if r.read(j, j) >= 0.0 { 1.0 } else { -1.0 };
        // ... 把对应列乘上 sign
    }
    // ... 转成行主序 f32 返回
}

这段代码我做了简化,省略了符号修正的内层循环和最后转 f32 的部分。它的逻辑分三步:

  1. 生成高斯随机矩阵:用 ChaCha8 这个伪随机数生成器,配合标准正态分布,填出一个 dim × dim 的随机矩阵。
  2. QR 分解取正交矩阵:对随机矩阵做 QR 分解,得到的 Q 是一个正交矩阵。这里用的是 faer 这个纯 Rust 的线性代数库做非主元 QR 分解。一个高斯随机矩阵的 QR 分解,Q 的分布是均匀的,正好满足我们要的随机正交旋转。
  3. 符号修正:QR 分解的结果不唯一,不同实现可能给出符号相反的列。这里强制把 Q 的每一列乘上 R 对角元的符号,让结果唯一、可复现。

QR 分解是线性代数里的一个经典操作:把任意一个矩阵 A 拆成两个矩阵的乘积 A = Q·R,其中 Q 是正交矩阵(各列两两垂直、长度为 1),R 是上三角矩阵。我们这里只取 Q,看中的就是它的正交性。对一个元素独立来自标准正态分布的随机矩阵做 QR 分解,得到的 Q 在所有正交矩阵里是均匀分布的,正好是我们想要的随机正交旋转。

这里最值得说的是开头那个 ChaCha8Rng::seed_from_u64(ROTATION_SEED)ROTATION_SEED 定义在 lib.rs 里,是一个固定常量:

const ROTATION_SEED: u64 = 42;

种子写死成 42,意味着同样维度下生成的旋转矩阵每次都完全一样。这是一个有意为之的工程设计,好处有两点:

  • 索引文件里不用存旋转矩阵。一个 1536 维的旋转矩阵是 1536 × 1536 个 float32,约 9 MB,要是每个索引文件都带一份就太浪费了。既然种子固定、生成过程确定,加载索引时按维度现场重算即可,磁盘上一个字节都不用存。
  • 写入端和搜索端天然一致add 时用什么旋转,search 时查询走的就是同一个旋转,不会因为读写分属两次进程而错位。

回到 lib.rsTurboQuantIndex 结构体,旋转矩阵正是用 OnceLock 缓存的:

pub struct TurboQuantIndex {
    // ...
    rotation: OnceLock<Vec<f32>>,
    boundaries: OnceLock<Vec<f32>>,
    centroids: OnceLock<Vec<f32>>,
    blocked: OnceLock<BlockedCache>,
}

rotationboundariescentroids 这几个缓存都只依赖 (dim, bit_width) 和那个固定种子,不依赖任何向量内容。它们在第一次 add 时按需算出,之后无锁读取;如果是加载后直接检索、没有经过 add 的索引,则在第一次 search 时补算。结构体里还有第四个缓存 blocked,是 SIMD 内核用的分块布局,只在第一次 search 时才构建。这部分缓存的并发设计,我们留到第 4 篇细讲。

码本的 Lloyd-Max 迭代

旋转之后,每个坐标独立同分布于 Beta((d-1)/2, (d-1)/2)codebook.rs 就在这个分布上跑 Lloyd-Max,求出量化用的 boundaries 和 centroids。对外的入口很简洁:

pub fn codebook(bits: usize, dim: usize) -> (Vec<f32>, Vec<f32>) {
    lloyd_max(bits, dim, 200, 1e-12)
}

最多迭代 200 次,收敛阈值 1e-12lloyd_max 内部的主循环就是前面讲的两条最优性条件交替:

fn lloyd_max(bits: usize, dim: usize, max_iter: usize, tol: f64) -> (Vec<f32>, Vec<f32>) {
    let a = (dim as f64 - 1.0) / 2.0;
    let beta = Beta::new(a, a).unwrap();
    let n_levels = 1usize << bits;

    // 初始化中心点:在 ±3 个标准差内均匀铺开
    // ...

    for _ in 0..max_iter {
        // 条件一:边界 = 相邻中心点的中点
        let boundaries: Vec<f64> = (0..n_levels - 1)
            .map(|i| (centroids[i] + centroids[i + 1]) / 2.0)
            .collect();
        // ...
        for i in 0..n_levels {
            // 条件二:新中心点 = 区间 [lo, hi] 上的条件期望
            // mean = ∫ x·pdf(x) dx / prob,用 adaptive Simpson 数值积分
            let mean = adaptive_simpson(
                |x| {
                    let t = (x + 1.0) / 2.0;
                    x * beta.pdf(t) / 2.0
                },
                lo, hi, 1e-14, 50,
            );
            new_centroids[i] = mean / prob;
        }
        // 收敛判断:最大变化量小于 tol 就停
        // ...
    }
    // ...
}

这里有几个点比较有意思,我们逐一看下:

  1. 初始中心点在 ±3σ 内均匀铺开。这里的 σ 是这个 Beta 分布在 [-1, 1] 上的标准差,迭代从一组均匀铺开的初始猜测出发,逐步收敛到最优位置。
  2. 边界取中点(centroids[i] + centroids[i + 1]) / 2.0,对应最优性条件一。
  3. 中心点取条件期望:在每个区间 [lo, hi] 上算 ∫ x·pdf(x) dx / prob,对应条件二。其中 pdf(x)概率密度函数(probability density function),也就是前面那条 Beta 钟形曲线在 x 处的高度;∫ x·pdf(x) dx 把区间内每个 x 按密度加权累加,再除以区间总概率 prob,得到的就是落在这个区间里的值的平均,即条件期望。这个积分没有闭式解,代码用 adaptive_simpson 做自适应辛普森数值积分。

辛普森数值积分这块也单开一小段说一下。

辛普森法则(Simpson's rule)用抛物线去逼近曲线下的面积,比矩形法、梯形法精度高得多。自适应(adaptive)的意思是:先对整个区间估一次,再二分细算一次,如果两次结果差得超过容差,就继续递归二分,直到精度达标。这样曲线平缓处少算几次、陡峭处多算几次,既准又省。

codebook.rs 里的 adaptive_simpson_rec 就是这个递归过程,靠 (refined - whole).abs() < 15.0 * tol 判断是否需要继续细分。整个 Lloyd-Max 跑完,得到的 boundaries 和 centroids 就是这个维度、这个比特宽度下的最优标量量化器。它只和 (dim, bits) 有关,因此和旋转矩阵一样,只在内存里用 OnceLock 缓存、按需重算,无需写入索引文件。

完整编码管道

旋转矩阵和码本都备好了,encode.rs 里的 encode 函数把它们串起来,完成一条向量从浮点到比特的完整旅程。从函数签名就能看清它的输入和输出:

pub fn encode(
    vectors: &[f32], n: usize, dim: usize,
    rotation: &[f32],
    boundaries: &[f32], centroids: &[f32],
    bit_width: usize,
    existing_calibration: Option<(&[f32], &[f32])>,
) -> (Vec<u8>, Vec<f32>, Vec<f32>, Vec<f32>) {
    // 1. 归一化:逐行提取范数,得到单位向量
    norms.par_iter_mut()
        .zip(unit_flat.par_chunks_mut(dim))
        .enumerate()
        .for_each(|(i, (norm, unit_row))| {
            let row = &vectors[i * dim..(i + 1) * dim];
            let n_val = simd_norm(row);
            *norm = n_val;
            let inv = if n_val > 1e-10 { 1.0 / n_val } else { 0.0 };
            simd_scale(row, inv, unit_row);
        });

    // 2. 旋转:单位向量矩阵乘旋转矩阵的转置
    let rotated_mat = unit_mat.dot(&rot_mat.t());

    // 3. TQ+ 校准:拟合或复用逐坐标的 (shift, scale)
    let (shift, scale_tq) = match existing_calibration {
        Some((s, sc)) => (s.to_vec(), sc.to_vec()),
        None => compute_tqplus_calibration(rotated, n, dim),
    };

    // 4. 量化 + 打包 + 长度修正(融合在一个逐行处理的函数里)
    // ...
}

这段也做了简化,省去了校准值物化和并行打包的细节。整条管道是:

  1. 归一化:每行除以自己的范数,得到单位向量;范数 ||v|| 单独存下来,后面做长度修正要用。这里用 Rayon 把各行拆到多核上并行处理,因为行与行之间完全独立。
  2. 旋转:单位向量矩阵和旋转矩阵的转置做矩阵乘法,把每条向量旋到新坐标系。
  3. TQ+ 校准:逐坐标做一次平移和缩放,下一节细说。
  4. 量化 + 打包 + 长度修正:用 boundaries 做边界扫描得到每个坐标的码字,按比特平面打包成字节,同时算出长度修正的 scale。

第 4 步里的边界扫描量化方式有点意思。代码不是去比较坐标离哪个 centroid 最近,而是数它越过了几条边界fused_quantize_scale_pack 里的核心循环就是这么干的:

for j in 0..dim {
    // 数第 j 个坐标越过了几条边界,越过几条码字就是几
    let mut code = 0u8;
    for &b in boundaries {
        if rot_calib[j] > b { code += 1; }
    }
    // code 即这个坐标的量化码字,随后按比特平面打包进 packed_row
    // ...
}

因为 boundaries 是单调递增的,越过的边界数恰好等于该坐标落在第几个量化级,和找最近中心点等价,但用 SIMD 实现起来更顺手。

TQ+ 动态校准

到这里,纯理论版的 TurboQuant 已经能跑了。但 turbovec 在此基础上做了一个改进:TQ+ 校准。

它要解决的问题是理论和现实的偏差。论文说旋转后每个坐标服从规范的 Beta 分布,这是在数据各向同性的理想假设下推出来的。真实的 embedding 数据往往是各向异性的,旋转之后每个坐标的经验分布,和那个理论 Beta 分布之间还有残差。码本是按理论 Beta 拟合的,坐标分布一旦偏掉,码本就会失配,精度打折扣。

TQ+ 的修正办法很轻量:给每个坐标配两个自由参数,一个平移 shift、一个缩放 scale,把这个坐标的经验分布拉回到理论 Beta 上。具体做法是对齐 5% 和 95% 分位数:

fn compute_tqplus_calibration(rotated: &[f32], n: usize, dim: usize)
    -> (Vec<f32>, Vec<f32>)
{
    // 批量过小 → 返回恒等校准 (shift=0, scale=1)
    if n < TQPLUS_MIN_SAMPLES { return (shift, scale); }

    // 目标分布 Beta((d-1)/2, (d-1)/2) 的 5/95% 分位点
    let qc_lo = (2.0 * beta.inverse_cdf(0.05) - 1.0) as f32;
    let qc_hi = (2.0 * beta.inverse_cdf(0.95) - 1.0) as f32;
    let qc_span = qc_hi - qc_lo;

    // 逐坐标拟合 (shift, scale),Rayon 并行
    shift.par_iter_mut().zip(scale.par_iter_mut()).enumerate().for_each(
        |(d, (sh, sc))| {
            let mut coord: Vec<f32> = (0..n).map(|i| rotated[i * dim + d]).collect();
            coord.sort_unstable_by(/* ... */);
            let qe_lo = coord[lo_idx];   // 该坐标经验 5% 分位
            let qe_hi = coord[hi_idx];   // 该坐标经验 95% 分位
            let qe_span = qe_hi - qe_lo;
            if qe_span > 1e-6 {
                *sc = qc_span / qe_span;
                *sh = qc_lo / *sc - qe_lo;
            }
        },
    );
    (shift, scale)
}

逻辑分三步:先算出目标 Beta 分布的 5% 和 95% 分位点 qc_loqc_hi,这是理论目标;再对每个坐标,把它在这批数据里的所有取值排序,取出经验的 5% 和 95% 分位 qe_loqe_hi;最后解一个线性映射,让经验分位对齐到理论分位,得到这个坐标的 scaleshift。每个坐标互相独立,用 Rayon 并行铺开。

这里有两个工程细节要注意:

  • 样本太少就不校准TQPLUS_MIN_SAMPLES 是 1000,批量小于这个数时直接返回恒等校准(shift=0、scale=1)。encode.rs 的注释解释了原因:样本不足时分位数估计本身噪声太大,校准带来的精度反而被噪声吃掉,所以索引照常能用,只是这一批拿不到 TQ+ 的召回增益。
  • 只学一次,之后冻结。校准参数在第一次 add 时拟合出来,存进 TurboQuantIndextqplus_shifttqplus_scale 字段,后续 add 全部复用同一套,靠的就是 encode 那个 existing_calibration 参数。这样索引里所有向量都活在同一个校准坐标系里,增量写入也不会前后不一致。搜索时,校准的逆操作被施加到查询侧,整个 SIMD 内核一行都不用改。

效果上,TQ+ 在不同数据集上给 Recall@1 带来 0.3 到 1.8 个百分点的提升。比如 OpenAI 1536 维 2-bit 上 +1.5pp,3072 维 2-bit 上 +1.8pp。

长度归一化修正

最后还有一个修正,借鉴了 RaBitQ 论文的思路。

RaBitQ 是 2024 年提出的高维向量量化方法,论文《RaBitQ: Quantizing High-Dimensional Vectors with a Theoretical Error Bound for Approximate Nearest Neighbor Search》,arXiv 编号 2405.12497。它的贡献是给量化误差给出了可证明的理论上界,并用一个逐向量的标量修正(每条向量配一个)去消除内积估计的系统性偏差。Elastic 在他们的 search-labs 博客里写过一篇通俗易懂的科普。

要修正的问题是:标量量化会系统性地低估内积。我们归一化时把向量变成了单位向量,但量化重建出来的那个向量,长度通常比 1 短一点,因为重建中心点是区间的条件期望,会向分布中心收缩。重建向量偏短,算出来的内积就整体偏小。

turbovec 的修正是给每条向量存一个标量 scale,定义为:

scale = ||v|| / <u_rot, x_hat>

其中 ||v|| 是原始向量的范数,u_rot 是旋转后的单位向量,x_hat 是 Lloyd-Max 重建出来的向量。这个 scale 同时干了两件事:用 ||v|| 把单位向量还原回原始长度,再用 1 / <u_rot, x_hat> 抵消掉重建变短带来的低估。encode.rs 文件头的注释把这层意思说得很清楚:

Applying this scale at the final score-multiplication site in the SIMD kernel gives an unbiased estimator of <v, q>.

这里的 <v, q> 是库向量 v 和查询 q 的内积,也就是检索真正要算的那个相似度分数。所谓 unbiased estimator(无偏估计),是说内核在压缩码上算出来的近似分数乘上这个 scale 之后,平均而言正好落在真实的 <v, q> 上,把前面那个系统性低估抵消掉了。也就是说,这个修正放在 SIMD 内核打分的最后一步,乘一下 scale 就行,搜索延迟零增加。

回到源码,这个 scale 是在 fused_quantize_scale_pack 里算出来的:

fn fused_quantize_scale_pack(/* ... */ norm: f32, /* ... */) -> f32 {
    // 参数 `norm` 就是 `||v||`
    let mut inner = 0.0f64;
    for j in 0..dim {
        // 边界扫描得到 code(见前文),再把中心点还原回原始空间
        let centroid_in_orig = centroids[code] * inv_scale_tq[j] - shift[j];
        // 逐坐标累加,凑出原始空间的内积 <u_rot, x_hat>
        inner += rot_orig[j] * centroid_in_orig;
        // ... 比特平面打包 ...
    }
    norm / inner   // 返回 ||v|| / <u_rot, x_hat>,正是上面那个 scale
}

效果很可观:GloVe 200 维 2-bit 上,Recall@1 提升了 4.7 个百分点,而搜索延迟没有任何变化。一个只在编码期多存一个 float、搜索期多乘一次的小修正,换来近 5 个点的召回,性价比很高。

小结

今天我们把 turbovec 量化算法的来龙去脉走了一遍:

  1. 量化的动机:1536 维 float32 一条 6 KB,千万级语料就是 31 GB,压到 2-4 bit 能把内存砍到 1/8 到 1/16,单机就能装下。
  2. 数据无关量化:传统 PQ 要用 k-means 学码本,数据漂移还得重训。TurboQuant 走数据无关路线,码本提前算好、写死在代码里。
  3. TurboQuant 三步:随机正交旋转让每个坐标服从 Beta 分布,高维下坐标近似独立,于是对所有坐标用同一个 Lloyd-Max 最优标量量化器,失真在香农下界的约 2.7 倍以内。
  4. 三个核心源码文件rotation.rs 用 ChaCha8 加固定种子 42 加 faer QR 分解确定性地生成旋转矩阵,索引文件因此不用存它;codebook.rs 在 Beta 分布上跑 Lloyd-Max,边界取中点、中心点取条件期望、用自适应辛普森积分;encode.rs 把归一化、旋转、校准、量化、打包串成完整管道。
  5. 两个工程修正:TQ+ 校准为每个坐标拟合一组 5/95% 分位映射,把经验分布拉回理论 Beta,只在第一批数据上学一次后冻结,Recall@1 提升 0.3 到 1.8pp;长度归一化修正为每条向量存一个 ||v|| / <u_rot, x_hat>,抵消标量量化对内积的系统性低估,GloVe 2-bit 上 +4.7pp,搜索零开销。

算法解决了精度的问题,那速度又是从哪来的?turbovec 敢说比 FAISS 快,靠的是手写的三路 SIMD 内核。明天我们就来读 search.rs,看它怎么用 NEON、AVX2、AVX-512BW 在 32 向量块上做查表打分、提前退出,在压缩后的比特码上实现高效检索。

参考


学习 turbovec 的混合检索与框架集成

昨天我们快速入门了 turbovec,体验了 TurboQuantIndexadd / search / write / load 等基本接口,也简单认识了带稳定 ID 的 IdMapIndex,当时提过一句,它是后面做混合检索过滤的基础。不过当时只是照着接口跑了一遍,没有解释为什么 TurboQuantIndex 的下标不能当业务主键用,也没讲这层 ID 映射是怎么实现的,更没涉及按租户、按权限过滤这样的真实检索场景。

今天我们就把这些坑填上:先从 TurboQuantIndex 删除时的下标移动说起,弄清楚 IdMapIndex 的实现,再体验过滤搜索和两阶段混合检索,最后是四大框架的开箱集成。

TurboQuantIndex 的下标移动问题

先看 TurboQuantIndex 的删除方法 swap_remove。它的命名直接对标 Rust 标准库的 Vec::swap_remove:把最后一个向量挪进被删的槽位,再把长度减一。这样删除是 O(1) 的,代价是顺序不保证。

之所以这么设计,是因为所有向量的压缩编码紧挨着存在一个连续数组里,槽位号就是存储位置。从中间删一条,要么把后面的数据整体前移,复杂度 O(n),删得越靠前越慢;要么留下空洞,破坏检索时批量打分依赖的致密布局。而把最后一条搬过来补位,只需要拷贝一条向量的数据,数组始终致密。

举个例子,假设索引里有 6 个向量,槽位编号 0 到 5。当我们 swap_remove(2) 之后,原来在槽位 5 的向量会被搬到槽位 2:

idx = TurboQuantIndex(dim=1536, bit_width=4)
idx.add(vectors)                # 灌入 6 个向量,槽位 0 到 5

moved_from = idx.swap_remove(2) # 槽位 5 的向量补到槽位 2
# moved_from == 5

问题来了。如果你的业务库里用槽位号当外键,比如数据库某行记了 vector_slot = 5,删除后这一行指向的就不再是原来的向量了。search 返回的也是槽位下标,下一次查询拿到的 indices 跟你之前缓存的对应关系全乱套。

简单来说,TurboQuantIndex 的下标是位置性的,适合纯追加、不删除的场景,不适合拿来当业务主键。要在删除频繁的系统里稳定地标识一个向量,得换一个工具。

用 IdMapIndex 解决下标移动

IdMapIndex 就是为这个场景设计的。它在 TurboQuantIndex 外面包了一层双向映射:一边是你自己定的业务 ID(64 位无符号整数),一边是向量实际所在的槽位,两边可以互查。调用方始终用业务 ID 来标识向量,其他向量的增删不会改变这个 ID。官方文档把它类比成 FAISS 的 IndexIDMap2,哈希表支撑、按 ID O(1) 删除,熟悉 FAISS 的话可以直接对应过来。

它的设计思路在 id_map.rs 的模块注释里说得很清楚:

//! `IdMapIndex` wraps the positional index
//! with a bidirectional `id ↔ slot` mapping so callers can identify
//! vectors by a stable `u64` ID that doesn't change when other vectors
//! are inserted or removed.

结构体本身只持有两张表,向量存储、旋转、打分、序列化全部委托给内层的 TurboQuantIndex

pub struct IdMapIndex {
    inner: TurboQuantIndex,
    /// slot → external id
    slot_to_id: Vec<u64>,
    /// external id → slot
    id_to_slot: HashMap<u64, usize>,
}

Python 侧的基本用法上一篇已经见过,这里快速过一眼:

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)   # 返回的是你的 uint64 外部 ID
index.remove(1002)                         # 按 ID 删除,O(1)

assert 1002 not in index                   # 被删的 ID 已经不在
assert 1003 in index                       # 其余 ID 照旧,__contains__ 语法糖
scores, ids = index.search(query, k=10)    # 仍可正常检索,结果里不会再出现 1002

删完之后查一下,被删的 ID 已经不在,其余 ID 照旧可查。

这里有几个点值得展开。

add_with_ids 接收一批向量和等长的 uint64 ID 数组。turbovec 会在真正写入前先做一遍校验,既拒绝索引里已存在的 ID,也拒绝这一批内部重复的 ID,避免写了一半才发现 ID 冲突。

remove(id) 是 O(1) 的,这是 IdMapIndex 相比裸 TurboQuantIndex 真正解决问题的地方。它内部仍然走 swap_remove,但会同步修正映射表:被删 ID 从两张表里抹掉,原来最后一个向量搬到空出的槽位后,它对应的 ID 也跟着更新指向新槽位。底层那次 swap_remove 照样会挪动槽位,但这次挪动被映射表吸收了,对外暴露的 ID 始终不变。所有框架集成内部用的都是 IdMapIndex,原因正是它。

使用 mask 与 allowlist 实现过滤搜索

光有稳定 ID 还不够。真实查询往往要先把候选集缩小到一个子集,再在子集里找最近邻。比如多租户系统只能返回当前租户的文档,访问控制场景只能返回有权限看到的内容,时间窗口检索只看最近 7 天的数据。

turbovec 的两个索引类型 TurboQuantIndexIdMapIndex 各提供了一种过滤入口。

TurboQuantIndex.search 接收一个 mask 参数,它是一个长度等于 len(idx) 的布尔数组,只有 mask[i] == True 的槽位才参与打分:

mask = np.ones(len(idx), dtype=bool)
mask[disabled_slots] = False
scores, slots = idx.search(query, k=10, mask=mask)

IdMapIndex.search 则接收一个 allowlist,它是一组外部 uint64 ID,结果被限制在这些 ID 内:

allowed = np.array([1003, 1010, 1042], dtype=np.uint64)
scores, ids = idx.search(queries, k=10, allowlist=allowed)

allowlist 在内部会先把每个 ID 翻译成对应的槽位、拼成一个布尔 mask,也就是说,两种方式背后是同一个机制,区别只是 mask 按槽位寻址,allowlist 按外部 ID 寻址。

这里有个容易被忽略的细节。turbovec 的过滤是前过滤而不是后过滤。后过滤是先搜出 top-k 再丢掉不合规的,结果常常不足 k 个。turbovec 在打分阶段就将禁止的向量过滤掉,所以你拿到的是过滤集合里完整的 top-k。

两阶段混合检索实战

把稳定 ID 和过滤搜索拼起来,就是一套实用的两阶段混合检索。第一阶段用外部系统(SQL、BM25、标签库等)筛出候选 ID 集合,第二阶段在候选集内做稠密向量重排。

整个流程如下图所示:

two-steps.png

我们用一个多租户的例子来体验。每个租户只能检索属于自己的文档,租户隔离由 SQL 元数据保证,语义相关性由向量打分保证:

import numpy as np
from turbovec import IdMapIndex

idx = IdMapIndex(dim=1536, bit_width=4)
idx.add_with_ids(vectors, ids)

# 阶段一:外部系统把候选缩小到当前租户的文档 id
allowed = np.array(
    db.execute("SELECT id FROM docs WHERE tenant=?", (t,)).fetchall(),
    dtype=np.uint64,
)

# 阶段二:在候选集内做稠密重排
scores, ids = idx.search(query, k=10, allowlist=allowed)

可以看到,租户边界完全由那条 SQL 决定,向量索引不需要知道租户的概念。SQL 擅长结构化条件过滤,向量索引擅长语义相似度排序,两者各做各擅长的事。前过滤的设计保证了一点:哪怕某个租户只有几十篇文档,只要这几十篇里有相关的,你照样能拿满 k=10 个结果,不会因为全局 top-k 里挤满了别的租户的文档而被挤空。

开源框架集成

自己调 IdMapIndex 固然灵活,但如果你的项目已经跑在某个 RAG 框架上,更省事的是直接换掉它的内置向量库。turbovec 为四大框架提供了 drop-in 集成,每个集成内部都用 IdMapIndex 存储,对外保持原框架的接口不变。

drop-in 全称 drop-in replacement(直接替换),意思是新组件和原组件的接口、行为完全兼容,接入时只需要换掉 import,其余代码一行不用改。turbovec 的四个集成正是这样:换个向量库的导入,你的检索管道照旧工作,换来的是内存占用降一个量级。

它们各自替代的类如下:

drop-in.png

下面以 LangChain 为例看一段最简示例。安装时在方括号里带上框架名,pip 会把对应的依赖一并装好:

$ pip install turbovec[langchain]

turbovec.langchain.TurboQuantVectorStore 实现了 LangChain VectorStore 接口,维度从嵌入模型推断,不用提前指定:

from langchain_huggingface import HuggingFaceEmbeddings
from turbovec.langchain import TurboQuantVectorStore

# 一批待检索的文档,实际项目里通常来自知识库或文档切片
texts = [
    "TurboQuant is a data-oblivious vector quantizer that needs no training phase.",
    "Product Quantization must train a codebook on representative data before adding vectors.",
    "FAISS is Meta's open-source library for efficient vector similarity search.",
    "HNSW is a graph-based algorithm for approximate nearest neighbor search.",
    "An inverted index is the core data structure of traditional full-text search.",
    "BM25 is a classic keyword-based ranking function used for first-stage retrieval.",
    "Hybrid retrieval combines keyword filtering with dense vector reranking.",
    "RAG augments large language models with knowledge retrieved from external sources.",
    "A vector database stores and searches high-dimensional embedding vectors.",
    "Cosine similarity measures how close the directions of two vectors are.",
]

embeddings = HuggingFaceEmbeddings(model_name="BAAI/bge-base-en-v1.5")
store = TurboQuantVectorStore.from_texts(
    texts=texts,
    embedding=embeddings,
    bit_width=4,
)
retriever = store.as_retriever(search_kwargs={"k": 3})

# 检索与查询语义最相关的前 3 条文档
docs = retriever.invoke("a quantization method that compresses vectors without training")
for i, doc in enumerate(docs, 1):
    print(f"{i}. {doc.page_content}")

运行后会按语义相关性返回前 3 条,第一条正是讲 TurboQuant 免训练量化的那句:

1. TurboQuant is a data-oblivious vector quantizer that needs no training phase.
2. A vector database stores and searches high-dimensional embedding vectors.
3. Product Quantization must train a codebook on representative data before adding vectors.

可以看到,from_texts 建库、as_retriever 拿检索器,这套写法和 LangChain 原生的 InMemoryVectorStore 一模一样,把导入换成 turbovec 即可,拿到检索器之后怎么用都不变。底层换成了 4-bit 量化存储,内存占用直接降下来,这正是 drop-in 的意义。

另外三个集成的用法大同小异,都是按同样的方式装好对应的包(turbovec[llama-index]turbovec[haystack]turbovec[agno]),再把框架内置的向量库换成 turbovec 提供的类,其余管道代码不动。各家在过滤算子、去重策略等方面有一些贴合自身生态的细节差异,具体接口以各自的官方文档为准,这里不再逐一展开。

小结

今天我们体验了 turbovec 面向真实业务的几项功能:

  1. 稳定 IDTurboQuantIndex 的槽位会因 swap_remove 移动,不适合当业务主键;IdMapIndex 用一层业务 ID 和槽位的双向映射提供稳定外部 ID
  2. 过滤搜索TurboQuantIndex 用布尔 maskIdMapIndexallowlist,两者翻译成同一套前过滤内核,能在过滤集合里凑满 top-k,而不像后过滤那样常常返回不足 k 个
  3. 两阶段混合检索:第一阶段 SQL 等外部系统筛候选 ID,第二阶段稠密重排,结构化过滤与语义排序各司其职,多租户隔离天然落在 SQL 一侧
  4. 框架集成:LangChain、LlamaIndex、Haystack、Agno 四大框架 drop-in,内部统一用 IdMapIndex,换个 import 就能接入

功能层面我们基本都体验完了,从压缩、检索到过滤、集成。不过到现在为止,免训练量化对我们还是个黑盒:为什么不需要 train 就能把向量压到 2-4 bit,还能在召回上追平甚至超过 FAISS 的 PQ?明天我们就深入 TurboQuant 算法本身,看看随机旋转、Beta 分布和 Lloyd-Max 码本背后的数学原理。

参考


turbovec 快速入门

做 RAG 的朋友多半都算过这样一笔账:一条 768 维的 embedding 向量,用 float32 存就是 768 × 4 = 3072 字节,正好 3 KB;语料攒到 1000 万条,光向量就要占 31 GB 内存。768 维是开源模型很常见的维度,BGE 的 bge-base-zh-v1.5、Sentence Transformers 的 all-mpnet-base-v2 都是这个规格。换更高维的模型,这笔账还要成倍往上涨:BGE-M3 是 1024 维(41 GB),OpenAI 的 text-embedding-3-small 是 1536 维(61 GB),text-embedding-3-large 到了 3072 维,直接奔 123 GB 去了。对于做 RAG 的团队来说,光是把向量塞进内存就是一笔不小的开销,更别提还要留余量给检索本身。

常见的解法是向量量化(vector quantization),用更少的比特近似表示每个向量,FAISS 的 Product Quantization(PQ)就是这条路线的代表。不过 PQ 是有代价的:建索引前要先用一批代表性数据训练码本,语料分布变化大了还可能要重训。今天我们要介绍的主角 —— turbovec,走了一条不一样的路。它是一个用 Rust 实现、带 Python 绑定的向量索引库,核心算法来自 Google Research 的 TurboQuant 论文(arXiv:2504.19874,已被 ICLR 2026 接收),不需要训练,也不需要重建,向量加进去就能搜。

turbovec-intro.png

它的项目首页放了一句颇有冲击力的话:

A 10 million document corpus takes 31 GB of RAM as float32. turbovec fits it in 4 GB - and searches it faster than FAISS.

这里的 31 GB 正是我们开头算的那笔账:1000 万条 768 维向量,用 float32 存要占 31 GB 内存,turbovec 能把它压到 4 GB,而且检索速度比 FAISS 还快。4 GB 是这么来的:TurboQuant 算法把每个坐标从 float32 的 32 bit 量化到 2~4 bit,压缩 8~16 倍,31 GB 的数据 4-bit 量化后约 3.9 GB;如果用 2-bit 还能再减半,只剩 2 GB 不到。正是由于它内存占用小、速度又快,最近几个月接连被几家海外科技媒体报道。

turbovec-memory-compression.jpg

它的几个核心特性是:

  • 在线量化(online quantization):添加向量即建索引,没有单独的训练阶段,也不需要随语料增长重建索引
  • 比 FAISS 更快:手写 NEON(ARM)、AVX2 / AVX-512BW(x86)三路 SIMD 内核,运行时自动选择,ARM 上所有配置比 FAISS 的 IndexPQFastScan 快 10%~19%
  • 搜索时过滤search() 支持传入 id allowlist 或 bitmask,量化内核直接在块级别处理过滤,做混合检索时不用先取后筛
  • 纯本地运行:不依赖托管服务,数据不出本机或 VPC,配合任意开源 embedding 模型就能搭一套完全离线的 RAG 栈

与 FAISS 的对比定位

FAISS 是向量检索领域的老牌选手,由 Meta 开源,功能非常全。turbovec 没有想做一个大而全的库去取代 FAISS,它的对标对象很明确,就是 FAISS 里走量化快速扫描路线的 IndexPQFastScan

两者最核心的差别在于有没有训练阶段

FAISS 的 PQ 是数据相关(data-dependent)的量化。它需要先用一批代表性数据跑 train(),学出一套码本(codebook),之后才能 add() 向量、search() 查询。码本学得好不好,直接影响召回质量;语料分布变化大了,码本还可能要重学。

turbovec 用的 TurboQuant 是数据无关(data-oblivious)的量化。它的思路是:先对输入向量做一次随机正交旋转,旋转之后每个坐标会服从一个集中的 Beta 分布;在高维空间里不同坐标近似独立,于是可以对每个坐标独立套用一个最优标量量化器。整个过程不依赖具体数据,所以不需要训练、不需要学码本,向量加进去就直接建好了索引。

它们两在使用上的主要差别如下表所示:

faiss-vs-turbovec.png

数据无关并不意味着 turbovec 完全不看数据。它在第一次 add() 时会做一次 TQ+ 校准,从首批向量里学一组 per-coord 的微调参数,但只学这一次,之后不再变。这部分细节我们留到后面讲量化算法的文章里展开。

安装

turbovec 同时面向 Python 和 Rust 用户。Python 这边直接 pip 安装:

$ pip install turbovec

它通过 maturin 把 Rust 核心编译成原生扩展,再用 PyO3 做绑定,所以装好之后就是一个普通的 Python 包,不需要额外的运行时。

PyO3 是 Rust 生态里最主流的 Python 绑定库,它处理了两种语言之间的类型转换、内存管理和 GIL 交互,让你可以用 Rust 编写原生的 Python 扩展模块,也可以反过来在 Rust 程序里调用 Python 代码。maturin 则是与之配套的构建工具,能把基于 PyO3 的 Rust crate 打包成标准的 wheel 并发布到 PyPI,本地开发时一条 maturin develop 就能编译并装进当前虚拟环境。这套 Rust + PyO3 + maturin 的工具链如今在 Python 生态里相当流行,pydantic-corepolars 等明星项目用的都是它。

如果你是 Rust 用户,想直接在自己的工程里用核心库,则用 cargo 添加依赖:

$ cargo add turbovec

下面的体验主要以 Python 为例。

基础用法体验

我们从最简单的场景开始:建索引、添加向量、查询。这里用 numpy 生成一批随机向量来演示,实际项目里把它换成 OpenAI、BGE 之类模型产出的 embedding 即可。

建索引与添加向量

import numpy as np
from turbovec import TurboQuantIndex

# 维度 1536(对齐 OpenAI text-embedding-3-small),4-bit 量化
index = TurboQuantIndex(dim=1536, bit_width=4)

# 假设这是一批文档向量,实际中来自 embedding 模型
vectors = np.random.randn(10000, 1536).astype(np.float32)
index.add(vectors)

# 还能继续增量添加,不需要重建
more_vectors = np.random.randn(5000, 1536).astype(np.float32)
index.add(more_vectors)

print(len(index))   # 15000

可以看到,整个流程没有 train() 这一步,add() 完就能用。两次 add() 之间也不需要做任何额外处理,这就是在线量化带来的便利。

构造函数有两个关键参数:

  • dim:向量维度。也可以传 None 做惰性构造,等第一次 add() 时再根据数据确定维度
  • bit_width:每个坐标量化用的比特数,可选 234,默认 4。这是使用 turbovec 时最需要权衡的参数:比特数越低,索引越小,但量化损失越大、检索的召回率也越低。一般 4-bit 是多数场景的默认选择;内存极度敏感、可以接受召回率略降时用 2-bit;3-bit 则是介于两者之间的折中。两者在召回率和压缩比上的具体差距,后面性能一节有实测数据

查询

添加完向量后,用一个查询向量做检索:

# 查询也要是二维的,1 条查询就是形状 (1, 1536)
query = np.random.randn(1, 1536).astype(np.float32)

# 返回 top-10 的分数和下标
scores, indices = index.search(query, k=10)

print(indices[0])   # 命中的向量下标
print(scores[0])    # 对应的相似度分数

要注意的是,search() 是批量接口,接收形状为 (n_queries, dim) 的二维数组,单条查询也要写成 (1, dim) 的形式,直接传一维向量会报 TypeError。返回的 scoresindices 同样是二维的,形状为 (n_queries, k)scores 是相似度分数,indices 是命中向量在索引中的下标,单条查询取第 0 行即可。

持久化

索引可以写到磁盘,之后再加载回来,省去重新建索引的开销:

index.write("my_index.tv")

loaded = TurboQuantIndex.load("my_index.tv")
scores, indices = loaded.search(query, k=10)

.tv 是 turbovec 的索引文件格式,当前为 v3 版本,带 magic 头和版本号。

IdMapIndex:稳定 ID

TurboQuantIndex 返回的是内部下标,删除向量后下标会变。如果你需要给每个向量挂一个稳定的外部 ID(比如数据库主键),用 IdMapIndex

import numpy as np
from turbovec import IdMapIndex

index = IdMapIndex(dim=1536, bit_width=4)

# 3 条向量,配上 3 个业务侧的 uint64 ID
docs = np.random.randn(3, 1536).astype(np.float32)
index.add_with_ids(docs, np.array([1001, 1002, 1003], dtype=np.uint64))

scores, ids = index.search(query, k=10)   # 返回的是你的 uint64 外部 ID
index.remove(1002)                         # 按 ID 删除,O(1)
print(1002 in index)                       # False,支持成员判断

index.write("my_index.tvim")
loaded = IdMapIndex.load("my_index.tvim")

IdMapIndexsearch() 返回的 ids 就是你传入的外部 ID,remove() 按 ID 删除且是 O(1) 操作。它还支持 id in index 这样的成员判断。持久化用的是单独的文件格式 .tvim,和 TurboQuantIndex.tv 互相区分。IdMapIndex 也是后面做混合检索过滤的基础,相关内容我们下一篇再细看。

性能数字

下面我们来看仓库给出的几组基准数据。速度基准的对比对象是 FAISS 的 IndexPQFastScan,召回率基准的对比对象是 FAISS 的 IndexPQ,并且把 PQ 的参数调到让两边压缩后的索引大小完全相同,保证是在同等存储预算下比召回率。数据集用了 ann-benchmarks 的 GloVe d=200,以及 DBpedia 子集的 OpenAI embeddings d=1536 / 3072,各 10 万向量。下面引用的数字均取自仓库 benchmarks/results/ 目录下的 JSON 文件。

ann-benchmarks 是近似最近邻搜索领域最常用的基准测试套件,收录了 GloVe、SIFT 等一批标准数据集,并对主流 ANN 库做统一评测,论文和开源项目做性能对比时基本都会引用它。DBpedia 是一个从维基百科抽取结构化信息构建的开放知识库,这里用的是它的文本条目经 OpenAI embedding 模型向量化后得到的数据集,向量维度高、语义真实,常用来评测接近生产场景的检索效果。

召回率

召回率(recall)衡量的是量化后检索结果跟精确检索的吻合程度。下表是 Recall@1 的对比(10 万向量):

recall-table.png

表头的 @1 是 Recall@k 记号在 k=1 时的写法:只看第 1 个返回结果,统计它命中真实最近邻的查询占比。之所以用 @1 对比,是因为它对量化误差最敏感,k 放宽之后差距会迅速消失,benchmarks/results/ 里的 JSON 记录了 @1 到 @64 的完整曲线,在 OpenAI 数据集上到 @4 两边就都收敛到 1.0 了。

另外,差异列的 pp 是 percentage point(百分点)的缩写,指两个比例直接相减的绝对差。比如 0.891 对 0.872,差异就是 1.9pp。不写成 1.9% 是为了避免歧义,百分号容易被理解成相对提升。

可以看到,在高维的 OpenAI 向量上,turbovec 的召回率全面略优于 FAISS,2-bit 时领先 1.7~1.9pp,4-bit 时领先 0.2~0.8pp。要注意的是,在低维的 GloVe d=200 上优势就不明显了:4-bit 领先 0.9pp,2-bit 则与 FAISS 基本打平(0.5637 vs 0.5643)。这跟 TurboQuant 的原理有关:它依赖高维下旋转后坐标分布趋于规整的渐近假设,维度低时这个假设没那么成立。

仓库的 docs 目录里附了三张召回率对比图,分别对应 GloVe d=200、OpenAI d=1536 和 d=3072 三个数据集,下面是其中 OpenAI d=1536 这张:

turbovec-recall-chart.png

速度

速度测的是单次查询的中位数耗时(10 万向量、1000 次查询、k=64,取 5 轮中位数),ARM 平台是 Apple M3 Max,x86 平台是 Intel Sapphire Rapids(8 vCPU)。下表汇总了全部 16 组配置,每格是 turbovec 和 FAISS 的毫秒数对比:

speed-table.png

可以看到,ARM 上 turbovec 的手写 NEON 内核全面领先,所有配置快 10%~19%,4-bit 的优势比 2-bit 更明显。x86 上则分化:4-bit 配置小幅领先 2%~5%(d=3072 多线程持平),2-bit 配置全面落后 3%~8%。也就是说 turbovec 并非处处更快,如果你的场景是 x86 加 2-bit,FAISS 反而略占优。

仓库的 docs 目录里附了四张速度对比图,按 ARM / x86、单线程 / 多线程分成四组,下面是其中 ARM 单线程这张:

turbovec-speed-chart.png

上面这些速度差异,主要来自检索最内层那段循环的实现:一次查询要给索引里的每条压缩向量算一个相似度分数,10 万向量就是 10 万次计算,检索耗时几乎全花在这里。turbovec 用 SIMD 指令手写了这段循环,ARM 上是 NEON 版本,x86 上是 AVX-512 版本外加 AVX2 回退,运行时根据 CPU 自动选择。这部分实现细节我们留到系列后面的文章专门拆解。

压缩比

最后看不同 bit_width 下的压缩比(10 万向量):

compress-table.png

项目首页那句宣传词,本质上就是 8 倍压缩(31 GB → 4 GB)加上速度领先的组合。从这组数据看,4-bit 大约 8 倍、2-bit 大约 16 倍的压缩比是站得住的。

值得注意的是,官方文档里 always faster than FAISS 的说法并不在所有场景都成立(比如前面 x86 2-bit 的四组配置和 GloVe 2-bit 召回率就没有优势),基准数据跟硬件、数据集、参数都强相关,用到自己的场景前最好亲自测一遍。

小结

通过这篇文章,我们对 turbovec 有了一个整体认识:

  1. 项目定位:Rust 实现、带 Python 绑定的向量搜索引擎,核心是 Google Research 的 TurboQuant 算法(ICLR 2026),专注于用 2~4 bit 量化把向量内存压下来,同时保持检索速度
  2. 与 FAISS 的核心差异:TurboQuant 是数据无关量化,没有训练阶段、不需要学码本,向量加进去即建索引,对标 FAISS 的 IndexPQFastScan
  3. 基础用法TurboQuantIndexadd / search / write / load 四个方法,bit_width 在 2/3/4 之间权衡压缩比和召回率,需要稳定 ID 时用 IdMapIndex
  4. 性能表现:高维向量上召回率略优于 FAISS,速度 ARM 上全面领先、x86 上互有胜负,压缩比与宣传相符;低维数据和 x86 2-bit 是它的弱项,用之前最好在自己的场景实测

到这里我们只用到了 turbovec 最基础的稠密检索。实际做 RAG 时,往往还需要按租户、按权限、按时间窗口先把候选集圈出来,再在候选集里做向量重排,也就是混合检索。明天我们就来体验 turbovec 的混合检索能力,以及它跟 LangChain、LlamaIndex 等框架的集成。

参考