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)
| 2.8 MB | 3.0 MB | |||
| 0.84 ms | 0.83 ms | |||
| 0.998 | 0.998 |
小数据集上 turbovec 完全占优:体积小 10 倍,查询快 2.7 倍。1472 条的规模上直接全扫反而最快,DuckIR 的索引在这里是负担。
召回率是 turbovec 的弱项,0.888 对 0.998。这是 4-bit 量化的数学天花板,下面会讲我们怎么补救。
合成数据(100k×1536)
| 4.4 s | ||
| 0.082 GB | ||
| 1.77 ms | ||
| 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):
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):
| 0.996 | 2.00 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 都是检索扩展,为什么不合成一个?
我的判断是近期不合并,但要做协议层的互通。理由:
- 01技术栈完全异构
。tantivy 是 Rust 生态最成熟的全文检索内核,BM25、分词、索引持久化都是它管的;turbovec 是量化向量检索。合并意味着两套索引格式、两套生命周期、两套错误处理塞进一个二进制,维护成本大于收益。 - 02社区注册表的名字就是分发渠道
。两个扩展各自占名,各自有独立的下载榜位置。合并成一个反而丢了一个曝光入口。 - 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 挑刺。
● ● ●
参考来源
- 01
DuckIR 项目主页(含 README 与路线图) https://github.com/cwida/DuckIR - 02
TurboQuant 论文:Online Vector Quantization with Near-optimal Distortion Rate(Zandieh 等,2025) https://arxiv.org/abs/2504.19874 - 03
duckdb_turbovec 源码仓库 https://github.com/alitrack/duckdb_turbovec - 04
duckdb-tantivy 源码仓库 https://github.com/alitrack/duckdb-tantivy - 05
MongoDB embedded_movies 数据集 https://huggingface.co/datasets/MongoDB/embedded_movies - 06
DuckDB Community Extensions https://community-extensions.duckdb.org