alitrack

一条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 的意义不在"现在更快",而在两个暴力扫没有的东西:

  1. 01速度旋钮
    :nprobe 从 350(等于全搜)调到 8,提速 14.5 倍,recall@10 保持 1.00。规模再大 100 倍,这个旋钮就值钱了。
  2. 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-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 向量
真实 query 向量
ANN 向量单路(nprobe=8)
0.934
0.849
(单条最低 0.152)
混合(向量 + BM25 融合)
0.954
0.872
(单条最低 0.417)
验收:诚实的数字

验收:诚实的数字

两个结论:

  1. 01该信的是 0.849,不是 0.934。
     真实短语查询才暴露 IVF 近似的真实误差;
  2. 02混合检索的价值在真实场景下被实锤
    :20 条 query 里 13 条混合 ≥ ANN。最典型的一条:query "corporate merger deal",ANN 单路 nDCG 只有 0.152(几乎全错),融合后拉回 0.417——向量路翻车,BM25 路兜底。

单路向量检索在短 query、term 敏感 query 上会翻车,这是它的结构性弱点,BM25 路就是为接住它存在的。这一条,是混合检索存在的意义。

● ● ●

六、踩过的三个坑

  1. 01quack-rs 的具名参数对字符串不通
    :i64 / bool 具名参数正常,Varchar 具名参数到 bind 时是 null。这个框架之前所有具名参数都是数值/布尔,第一次用字符串具名参数就踩了。绕过:字符串参数全部走位置参数。
  2. 02表函数参数必须是常量
    :C-API 表函数参数里放子查询或宏调用,报 Table function cannot contain subqueries。所以"查询时从配置表读部署参数"走不通——参数固化进宏,一个(索引, 表)一个宏。这是设计约束,不是 bug。
  3. 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 复现。