一条SQL,文本进去,答案出来:混合检索装进DuckDB
一条SQL,文本进去,答案出来:混合检索装进DuckDB
最近做了一个对标:zvec-grep(下称 zg)——一个把 ripgrep 风格全文检索 + BM25 排序 + 向量语义检索 三件套融合进一条命令的本地检索工具。它的思路我很喜欢:数据不出本机、不依赖云服务、全链路进程内。
于是我自己给自己出了道题:这套检索能力,能不能在 DuckDB 生态里用扩展复现一遍?
答案是能。最终形态是三个扩展,全部跑在 DuckDB 进程里,零外部服务、零网络请求:
- 向量检索
: duckdb-ivf—— 自研 Rust 扩展,IVF 向量索引(含持久化) - 全文检索
: fts—— DuckDB 官方扩展,BM25 - 文本向量化
: duckdb-ml—— 自研扩展,进程内 ONNX 推理,不部署推理服务
而用户最终只需要写这一句:
SELECT * FROM ztg_search_news('crude oil prices');文本进去,top-k 出来。不用自己预先算 query 向量,不用发任何网络请求。
三件套全在一个进程里
● ● ●
一、混合检索 = 两路检索 + 一次融合
混合检索的结构其实很简单:
- 向量路
:query 过 embedding 模型变成向量,在文档向量索引里做 ANN(近似最近邻); - 全文路
:query 的 term 直接走 BM25 排序; - 融合
:RRF(倒数排名融合)——同一个文档在两条路里各有一个名次,分数 = 1/(60 + 名次) 相加。
两路互补是它存在的理由:向量擅长同义改写("油价上涨"能命中 "crude oil price hike"),BM25 擅长精确词匹配(型号、专名、代码)。单路都有盲区,融合之后互相兜底。
zg 的三件套,对应到这里就是上面三个扩展。关键差异:主流方案是"向量库 + ES + 推理服务"三个外部服务,这里三个组件全在 DuckDB 进程内——这就是 local-first 的代价与收益。
● ● ●
二、duckdb-ivf:一个写成 DuckDB 扩展的 IVF 索引
IVF(Inverted File Index)是最标准的向量索引:把 N 个向量用 k-means 聚成 K 个簇,查询时只搜最近的 nprobe 个簇,而不是全量扫。
在 12 万条这种规模,暴力全扫也不算贵。做 IVF 的意义不在"现在更快",而在两个暴力扫没有的东西:
- 01速度旋钮
:nprobe 从 350(等于全搜)调到 8,提速 14.5 倍,recall@10 保持 1.00。规模再大 100 倍,这个旋钮就值钱了。 - 02持久化
:索引存盘 494MB / 1.05s,下次启动 ivf_load1.11s 加载,逐位一致、免重训——训练 156s 的活,变成 1.1s 的活,141 倍。
技术上它是走 C-API 写的扩展:对外是一组表函数(ivf_create_index / ivf_search / ivf_save / ivf_load / ivf_hybrid_search …),内部开一个自己的"会话连接"去读被索引的表。这个"会话连接"带来两条必须记住的规矩:
被索引的表必须是永久表(会话连接看不到调用方的 temp table); 表函数的参数必须是 bind 时就确定的常量——往参数里塞子查询,直接报 Table function cannot contain subqueries(后面"坑"里细说)。
duckdb-ivf 实测数字
● ● ●
三、duckdb-ml:embedding 在进程内算掉
三件套的第三件是 embedding 模型。惯常做法是部署一个推理服务、发 HTTP 过去——这条链路里不进任何网络请求。duckdb-ml 把 ONNX 模型直接跑在进程内:
SELECT * FROM ml_load_sentence('minilm', 'model.onnx', 'tokenizer.json', 128);
-- 之后 ml_embed_sentence('minilm', '任意文本') 就是一个普通标量函数验证:ML 函数产出的向量与 ONNX Runtime 直接算的向量余弦相似度 1.000000(逐位级一致),不是"差不多对"。
吞吐要诚实说:CPU 上 MiniLM-384d、batch=1,97 docs/s。所以 5000 条语料一分钟内 embed + 建完索引(索引文件 8MB);12 万条全量是约 3 小时的活——PoC 用 5000 子集验证机制,120k×bge-m3-1024d 的索引(训练 156s、存盘 494MB)用来验证性能数字。两条口径分开,不混着报。
● ● ●
ivf_hybrid_search 把两路接起来:FTS 路取 BM25 top-50,向量路取 IVF top-50,RRF(k=60,Meilisearch 的惯例值)融合出 top-k。表上没有 FTS 索引时自动降级为纯向量,不报错——单路场景不该被当成异常。
再往上一层:query 不必预先算向量,直接给文本。ivf_hybrid_embed 接收 query 文本,内部执行一条"字符串表达式"去调 ml_embed_sentence——embedding、ANN、BM25、融合发生在同一条 SQL 里。
最顶上是一个宏,整个部署就是一个 SQL 文件:
-- 部署(ztg.sql,全部是 SQL,共 6 步):
-- 1. LOAD ivf / ml / fts 2. ml_load_sentence
-- 3. 建语料表 4. ivf_load(首训变体注释在旁边)
-- 5. create_fts_index 6. 下面的宏
CREATE OR REPLACE MACRO ztg_search_news(q, top_k := 10) AS TABLE
SELECT id, score, bm25_rank, vec_rank
FROM ivf_hybrid_embed(
'newsm', 'news', 'id', 'text', q,
concat('ml_embed_sentence(', chr(39), 'minilm', chr(39), ', ', chr(39), q, chr(39), ')'),
top_k := top_k, per_path := 50, nprobe := 8, k_rrf := 60);
-- 之后只写这一句:
SELECT * FROM ztg_search_news('crude oil prices'); -- 5k 规模下约 45ms/条
改一下文件里标 (EDIT) 的路径,执行,完事。没有部署脚本,没有服务,没有配置中心。
● ● ●
五、验收:报诚实的数字
这里要讲一件"翻车后纠正"的事,因为它是全文最有信息量的数字。
我第一版 nDCG@10 报的是 ANN 0.934 / 混合 0.954(nDCG@10:衡量"前 10 名结果排得好不好",1 为完美)。但当时 query 的向量是构造的——拿 BM25 命中的 top-5 文档的 embedding 取均值当 query 向量。这相当于"用题库里找出的答案去拼考题",分数天然虚高。
换成真实 query 向量(query 短语直接过 bge-m3)重算:
| 真实 query 向量 | ||
|---|---|---|
| 0.849 | ||
| 0.872 |
验收:诚实的数字
两个结论:
- 01该信的是 0.849,不是 0.934。
真实短语查询才暴露 IVF 近似的真实误差; - 02混合检索的价值在真实场景下被实锤
:20 条 query 里 13 条混合 ≥ ANN。最典型的一条:query "corporate merger deal",ANN 单路 nDCG 只有 0.152(几乎全错),融合后拉回 0.417——向量路翻车,BM25 路兜底。
单路向量检索在短 query、term 敏感 query 上会翻车,这是它的结构性弱点,BM25 路就是为接住它存在的。这一条,是混合检索存在的意义。
● ● ●
六、踩过的三个坑
- 01quack-rs 的具名参数对字符串不通
:i64 / bool 具名参数正常,Varchar 具名参数到 bind 时是 null。这个框架之前所有具名参数都是数值/布尔,第一次用字符串具名参数就踩了。绕过:字符串参数全部走位置参数。 - 02表函数参数必须是常量
:C-API 表函数参数里放子查询或宏调用,报 Table function cannot contain subqueries。所以"查询时从配置表读部署参数"走不通——参数固化进宏,一个(索引, 表)一个宏。这是设计约束,不是 bug。 - 03转义被吞
:表函数宏里 ''字符串转义会被 parser 吃掉(unterminated string literal)。用chr(39)拼引号绕开。副作用:query 文本里含单引号无法往返,文档里标成已知限制。
● ● ●
七、写在最后
对标 zg,不是为了造一个山寨——zg 本身是现成工具。我想回答的是一个具体问题:检索能力如果要长在 DuckDB 里、进程内、不依赖任何外部服务,embedding → ANN → BM25 → 融合这条链路,能不能用自研扩展完整搭起来?
答案:能。三个组件全在进程内,索引可持久化(重启免重训),入口是一条 SQL,验收数字用真实 query 向量算的,全部可复现。
剩下的问题只有一个分发层面的:如果客户坚持"一个二进制",走 DuckDB 官方的 EXTENSION_CONFIGS 路线把三个扩展静态链进一个 duckdb 可执行文件即可——那是构建问题,不是代码问题。
数据本地,检索链路进程内,增值层才是你该锁的东西。 这是这个项目从头到尾的原则,也是它和"向量库 + ES + 推理服务"方案最本质的区别。
代码:github.com/alitrack/duckdb-ivf(扩展 + ztg.sql 单文件部署);PoC:ag_news 120k,bge-m3-1024d / MiniLM-384d。所有数字可由 test_e2e.py、ndcg_real.py、e2e_inproc.py 复现。