alitrack

duckdb_turbovec 向量索引小 10 倍,查询快 2.7 倍

DuckDB 官方系的 DuckIR 扩展上社区仓库了,主打 IVF 向量检索。我手上有一个自己的扩展 duckdb_turbovec,走的是极限压缩路线。两边拿同一份数据对了一遍,结论是:10 万条向量,turbovec 的索引体积只有 DuckIR 的十分之一,查询延迟反而更低;召回率曾经差 15 个点,但加上重排(rerank)之后,最新实测 0.996 对 0.998,基本追平,查询还快一点——这不是谁替代谁,是两个不同的档位。

● ● ●

缘起:DuckIR 是谁

DuckDB 公众号新文章介绍了 DuckIR,cwida 团队(DuckDB 母公司 duckdb labs 关联的官方研究组)的项目,已经进了 Community Extensions,INSTALL duckir 一行就能装。核心卖点三个:IVF 倒排文件索引、SAQ 标量量化(u8/u4 档位)、任意 SQL 谓词下推(官方宣称比 duckdb-vss 快 10 到 104 倍)。

路线图里 FTS 全文检索和混合检索排在 2027 年第一季度。也就是说,官方的混合检索还要等一年多。

我手里的 duckdb_turbovec 是基于 TurboQuant 论文(Zandieh 等,2025)的开源实现做的扩展,2/3/4-bit 量化,平面加 IVF 双模式。另外还有一个 duckdb-tantivy,用 tantivy 内核做 BM25 全文检索,支持 jieba 中文分词。这两个加起来,正好是混合检索的两半。

所以这次对比的目的很直接:搞清楚官方选手到底什么水平,我们的差异化在哪里,以及哪里需要向它学习。

● ● ●

先说数据

对比方法论是本地实测对跑,不是抄 README。两组数据:

  • 真实数据
    :MongoDB embedded_movies 数据集,1472 部电影的剧情简介,OpenAI embedding,1536 维。这组数据 DuckIR 官方 README 也用它做演示。
  • 合成数据
    :10 万条 1536 维向量,200 个簇,seed 固定。用来测规模。

机器是 i5-14600K、24GB 内存,8 线程。DuckIR 用 v2.0.0 的 DuckDB 装社区版,turbovec 用本地构建(两者 DuckDB 版本不一致,这是本次对比的一个妥协,下文有说明)。

真实数据(1472×1536)

DuckIR u8
DuckIR u4
turbovec flat 4-bit
turbovec IVF
建索引
0.21 s
0.09 s
0.29 s
0.36 s
索引体积
27.3 MB
—
2.8 MB3.0 MB
查询 p50
2.3 ms
2.3 ms
0.84 ms0.83 ms
recall@10
0.9980.998
0.888
0.849

小数据集上 turbovec 完全占优:体积小 10 倍,查询快 2.7 倍。1472 条的规模上直接全扫反而最快,DuckIR 的索引在这里是负担。

召回率是 turbovec 的弱项,0.888 对 0.998。这是 4-bit 量化的数学天花板,下面会讲我们怎么补救。

合成数据(100k×1536)

DuckIR
turbovec
建索引
4.4 s
33.8 s
索引体积
0.81 GB
0.082 GB
查询 p50
4.88 ms
1.77 ms
recall@10
0.973
见下文说明

大规模下格局变了:DuckIR 建索引快 8 倍(它的 k-means 是 C++ 高度优化过的),turbovec 的 Rust k-means 还在用采样播种加并行的朴素方案。

但体积和查询延迟依然是 turbovec 的主场:索引小 10 倍,查询快 2.7 倍。

召回率这里要说明:这组合成数据的簇内向量到查询点的距离差距极小(top-10 和 top-500 的余弦相似度差距在 0.01 量级),4-bit 量化噪声直接把这些差距抹平了。同簇命中率(返回的 50 条结果里属于真实 top-1 所在簇的比例)是 100%——聚类完全正确,纯粹是这个数据集对量化太苛刻。真实数据上的 0.888 才是 turbovec 的真实水位。

● ● ●

对比过程中修掉的三个 bug

拿自己的扩展跟官方选手对跑,最先暴露的是自己的问题。这轮实测直接抓出三个真 bug:

第一个是进程崩溃。 turboquant_build_ivf 在真实 OpenAI embedding 上稳定把整个 DuckDB 进程干掉。根因是三处 partial_cmp().unwrap():量化分数里混进 NaN 时 unwrap 直接 panic,而 Rust 标准库的 smallsort 对违反全序的比较器抛不可回退 panic,整个进程直接中止。修法是全部换成 total_cmp。这个 bug 在合成数据上永远不触发,真实数据一碰就炸——不跑真实数据根本发现不了。

第二个是升级上游。 修完自己的代码还崩,追进 turbovec crate 源码发现 0.9.0 内部同样有这个问题,而上游已经重写修复。从 0.9.0 升到 1.1.2,连带着"IVF 跨进程搜索返回 0 行"的怪病也消失了——同一个上游 bug 的两个面。

第三个是结果截断。 四个搜索函数都有一行 .min(2048),k 设 5000 会静默只返回 2048 行。删掉。

● ● ●

借鉴 DuckIR 做的三项改进

对跑不是为了分高下,是为了找差距。这轮参考 DuckIR 的设计给 turbovec 的 IVF 动了三刀:

第一刀砍存储碎片。 旧的 IVF 实现每个簇写一个索引文件,每个文件自带一份旋转矩阵和码本,固定开销约 1.6MB。32 个簇就是 53.8MB——比平面索引大 19 倍,完全背离了压缩的初衷。新格式把全部向量按簇连续存进一个带 ID 映射的索引,搜索时把探针簇的槽位区间拼成白名单做一次掩码搜索。效果:1.5k 文档 53.8MB 降到 3.0MB,10 万条 0.597GB 降到 0.082GB。旧格式索引照样能搜,自动检测。

第二刀换聚类算法。 旧的 k-means 初始化是拿前 k 个等距向量当质心,容易选到近重复的质心,聚类质量差。换成 k-means++ 播种(大规模时用一万条分层采样播种,全量轮盘是 O(n·k·dim),10 万规模要多花一分钟,不值得)。真实数据 recall@10 从 0.828 提到 0.849,探满所有簇时 0.894,贴着平面索引的天花板。

第三刀加索引缓存。 DuckDB 每条查询重新 bind 表函数,每次搜索都从磁盘重读索引文件。10 万条的索引 82MB,每查询白付 34 毫秒。加了个进程级缓存之后,重复查询 37.7ms 降到 1.77ms,21 倍。

顺带的收益:Lloyd 迭代的分配步用 rayon 并行化之后,10 万条建索引从 140.7 秒压到 33.8 秒。

● ● ●

召回率的补救:ensemble 与 rerank

ensemble:无原始向量时的召回旋钮

内核的 bit_width 硬限制在 2/3/4,没有原生 8-bit。要更高召回,第一条路是集成量化(ensemble):同一组向量建 m 个索引,每个索引用不同的确定性维度置换打散,搜索时把各成员的分数按向量平均。

原理是量化噪声在成员之间近似独立,平均后方差按 1/√m 下降。实测(真实数据,recall@10):

方案
recall
体积
查询 p50
单 4-bit
0.888
2.8 MB
0.84 ms
2 个成员
0.916
5.6 MB
1.12 ms
4 个成员
0.939
11.2 MB
1.48 ms

2 个成员恰好是"8-bit 等效体积"档,召回加 2.8 个点。4 个成员到 0.939 之后边际递减明显——量化噪声不再是主要误差源,聚簇和候选截断开始主导。

rerank:一击追平的重排

ensemble 的天花板说明纯量化路线到顶了。第二条路是业界验证过的重排(rerank):量化索引只负责提名候选,原始向量负责定名次。Qdrant 的二值量化、VectoGrid 的 1-bit 方案都靠这一招把召回拉回 0.99+。

实现上给两个函数各加了一个可选参数:建索引时 keep_raw=true 把原始 f32 向量一并存下(3.0MB 到 12.1MB),搜索时 rerank_factor=N 让量化索引先出 k×N 条候选,再对候选逐条算精确距离取真 top-k。实测(真实数据,recall@10):

配置
recall
查询 p50
4-bit IVF 裸搜
0.846
0.97 ms
+ rerank ×4
0.921
2.08 ms
+ rerank ×4,探 32 簇
0.9962.00 ms
DuckIR u8 参照
0.998
2.11 ms

0.996 对 0.998,延迟 2.0ms 对 2.1ms,体积 12.1MB 对 27.3MB——三线全部追平或占优。逻辑也很直白:量化负责把搜索范围从 100 万缩到几百,精确距离只算几百次,便宜得很。

至此 turbovec 有了一个完整的召回率旋钮:2-bit 0.71、4-bit 0.89、双成员 0.92、四成员 0.94、rerank 0.996,体积线性换召回,用户按场景选档。

● ● ●

诚实的差距清单

DuckIR 的优势同样真实,这些是 turbovec 目前做不到的:

  • 谓词下推
    :DuckIR 的索引感知任意 SQL 过滤(WHERE year > 2000 这种),实测 p50 12.9ms 且不丢召回。turbovec 的白名单机制理论上能承接,但还没接 SQL 层。
  • CREATE INDEX 集成
    :DuckIR 用标准 DDL 建索引,跟表走、事务安全、自动更新。turbovec 还是文件路径模式,用户要自己管理索引生命周期。
  • 建索引速度
    :大规模下 DuckIR 快 8 倍,这是 C++ k-means 与朴素 Rust 实现的差距。
  • rerank 需要额外的原始向量副本
    :0.996 的召回是花 12.1MB 买回来的(仍比 DuckIR 小一半多),且建索引时要指定 keep_raw=true。DuckIR 的 u8 量化不需要这个开关,开箱就是 0.998。

还有一个工程上的妥协要交代:turbovec 的扩展目前构建在 DuckDB v1.5.6,DuckIR 用的 v2.0.0,两边版本没对齐。功能对比不受影响,但绝对数字有少量版本因素混在里面。

● ● ●

两个扩展要不要合并

经常有人问:duckdb-tantivy 和 duckdb_turbovec 都是检索扩展,为什么不合成一个?

我的判断是近期不合并,但要做协议层的互通。理由:

  1. 01技术栈完全异构
    。tantivy 是 Rust 生态最成熟的全文检索内核,BM25、分词、索引持久化都是它管的;turbovec 是量化向量检索。合并意味着两套索引格式、两套生命周期、两套错误处理塞进一个二进制,维护成本大于收益。
  2. 02社区注册表的名字就是分发渠道
    。两个扩展各自占名,各自有独立的下载榜位置。合并成一个反而丢了一个曝光入口。
  3. 03DuckIR 自己也是分开的
    。官方的 FTS 和向量检索在路线图上就是两个模块两个阶段。行业共识是词法和语义检索底层引擎就该分开,上层做融合。

真正该做的是上层的融合函数:RRF(Reciprocal Rank Fusion)把两个扩展的结果按排名倒数融合。这个函数放哪个扩展都不合适,属于胶水层——我们计划做成一个独立的小扩展,或者作为两个扩展共同的可选依赖。DuckIR 的混合检索要 2027 年 Q1,这中间一年多的窗口,够把这个生态位先占了。

● ● ●

结尾

回到开头的场景:DuckIR 上社区仓库那天,我第一反应是"官方下场,我们的扩展还有活路吗"。跑完这轮数据,答案清楚了——10 倍的体积差和 2.7 倍的查询速度差是真实的护城河,召回的 15 个点差距用重排(rerank)追平了(0.996 对 0.998,查询还快一点),而谓词下推和 DDL 集成是接下来要补的课。

官方选手进入赛道最好的结果不是把你挤走,是逼你把自己该修的 bug 修了、该抄的作业抄了。这轮对跑修掉的三个 bug、砍掉的三刀,没有 DuckIR 当参照物,可能还要在真实数据上再炸几次才被发现。

文中所有数字来自本轮实测,原始数据(JSON 格式)和基准脚本都在基准目录里,评论区留言可以提供复现步骤。两个扩展都是开源的:duckdb_turbovec 和 duckdb-tantivy,欢迎来 GitHub 挑刺。

● ● ●

参考来源

  1. 01
    DuckIR 项目主页(含 README 与路线图) https://github.com/cwida/DuckIR
  2. 02
    TurboQuant 论文:Online Vector Quantization with Near-optimal Distortion Rate(Zandieh 等,2025) https://arxiv.org/abs/2504.19874
  3. 03
    duckdb_turbovec 源码仓库 https://github.com/alitrack/duckdb_turbovec
  4. 04
    duckdb-tantivy 源码仓库 https://github.com/alitrack/duckdb-tantivy
  5. 05
    MongoDB embedded_movies 数据集 https://huggingface.co/datasets/MongoDB/embedded_movies
  6. 06
    DuckDB Community Extensions https://community-extensions.duckdb.org