alitrack

duckdb-tantivy:中文多跳检索,裁判换成自己的

前情:《不用 TypeSafe:本地 DuckDB 跑 Jev,0 token 输出》——那次我们证明了 Jev 的判决契约可以本地化。这次把它装进一条多跳检索循环里,全部零件换成自家资产。

10 月底 Zleap AI 发了 SAG2 论文(arXiv:2610.22588),宣称不用任何 embedding 模型做多跳检索:SQLite 存原文 + BM25 取候选 + 一个托管"裁判模型"(JEV)逐段判断"这段是不是回答问题必需的证据",高分段落的词再拿去检索新段落,循环两轮。数字很猛:MuSiQue 召回 86.6,HotpotQA 99.0,而索引 11,656 段只花 2.36 秒、25.87 MiB、零模型调用。

我们把论文和代码拆了一遍(源码取证结论:数字真实,逐题重算对得上)。但拆到最后,发现它最大的软肋和最大的价值是同一件事:整条链路里唯一"懂语义"的,就是那个必须外发数据给第三方的闭源裁判。embedding-free 的实质,是把语义成本从离线索引搬到了查询时的外部 API。

于是就剩一个问题:裁判换成自己的,这套配方还成立吗?

这篇文章就是答案。成立。而且中文原生。

● ● ●

栈:三层,全是自家零件

DuckDB + duckdb-tantivy v0.3.0(新:持久索引,chinese/jieba 词级)
        ↓ BM25 候选 + 扩展词检索
SAG2 式编排循环(passage → term → passage,≤2 轮扩展)
        ↓ 逐段判决
本地裁判(OpenAI 兼容端点,任一能输出概率的模型)

对照 SAG2 原版:SQLite FTS5 → DuckDB+tantivy;托管 JEV → 本地端点;英文 → 中文。一行外部依赖都没有。

先补地基:tantivy 持久索引

duckdb-tantivy 原来的 bm25_search 每次调用都在内存里重建索引——10 万文档查一次 0.6 秒,单发查询无所谓,多跳循环一轮几十次查询就不可用了。所以第一步是给扩展加持久索引(对齐自家 duckdb-ivf 的 create/save/load 模式):

SELECT * FROM tantivy_create_index('docs', 'corpus',
    id_column := 'doc_id', text_column := 'body',
    language := 'chinese', path := '/data/idx/docs');
SELECT * FROM tantivy_load_index('docs', path := '/data/idx/docs');  -- 新进程直接复用
SELECT doc_id, score FROM tantivy_search_index('docs', '苹果 股票', top_k := 5);

实测(10 万行中文合成语料):建索引 0.7 秒,此后单查询毫秒级,对照每次重建的 0.6 秒/查询是两个数量级的差距。与 bm25_search 同参数结果逐位一致,跨进程 save→load 结果 bit-identical。

一个插曲值得记:验收时我看到 10 万行查询返回 doc 0/1/2、分数全 0,第一反应是 bug。工人有据反驳——我那份语料每行都恰好含一次"苹果""股票",全体文档同分(BM25 的 idf 在这种语料下趋零),tie-break 按 doc_id 升序完全符合契约。用稀有词复测,df=1 的"9699"精准命中 doc 9699、分数 11.11。benchmark 数字先怀疑语料,再怀疑引擎。

循环:论文的配方,一字不差的核心结构

SAG2 的循环本质是三步:BM25 取 top10 → 裁判给每段打"证据必要性"概率 → 从高分种子段落抽词(TF-IDF 加权,排除查询已有词)再检索,去重后进下一轮,最多两轮。我们的实现照抄这个结构,裁判换成 OpenAI 兼容端点上的通用模型(实测 Qwen 系小模型即可),prompt 只有一句关键话术——

你是证据判决器。只输出一个 0 到 1 之间的概率数字,表示候选段落是否为回答问题所必需的证据。

● ● ●

数字:100 文档合成基准,方向性结论

10 条中文多跳链(3 跳 ×3、2 跳 ×7,23 个证据段 + 76 个干扰段,干扰段和链共享实体词——同名公司、同名城市、同城展会):

指标
纯 BM25 top5
SAG2 循环
增量
Recall@5(证据级)
0.533
0.967
+0.433
二跳证据召回
0.154
0.923
+0.769
裁判调用
0
109 次,0 错误,均值 1.7s
—

看一条 3 跳链的实际执行(审计日志还原):

问:星澜科技总部所在省份位于我国哪个区域?
hop0  BM25「总部,星澜」→ 命中 doc1「星澜科技是一家总部位于杭州市的软件企业」(裁判 1.0)
hop1  种子抽词「杭州」→ 找回 doc2「杭州是浙江省的省会城市」
hop2  种子抽词「浙江省」→ 找回 doc3「浙江省位于我国东部地区」
终榜  [1, 3, 2, 85, 86] — 证据链完整,两个干扰段排在 gold 之后

纯 BM25 对这条链的 top5 只有 0.33(首跳段之外全靠运气)。两条 3 跳链都被逐跳完整找回。

一个比想象中重要的发现:裁判的 prompt 是决定变量

我们做了 A/B(同模型、同输入,只改一句判据):朴素版"是否必需证据"会把桥段判 0——比如问"A 公司总部在哪个省",上下文已有"A 总部在杭州"时,「杭州是浙江省会」这段被判为冗余。加一句"提供问题未含的新实体或新关系即视为必需",6/6 案例全部翻转正确,干扰段零误报。

SAG2 论文里 JEV 的 criterion 措辞被我们当成"营销细节"略过了,这一轮实测之后收回:判决器的判据措辞就是这条路的质量天花板。论文作者显然知道这一点。

诚实的边界

  • AND 语义
    :tantivy 默认查询是全词共现,中文长查询(整句问题)必 0 命中,靠"最稀有 2 词"回退阶梯解决——生产化应该上 OR 语义(SAG2 原版 FTS5 就是 OR)。
  • 分词器对齐
    :扩展侧 jieba-rs 与 Python jieba 词表有微差(人名切分不同),需要变体补丁;根治要在同侧分词。
  • 裁判冗余误判
    :上下文已含链上其余段时,中间桥段可能被判"冗余"(10 题里唯一没满分的题就是这情况)。单候选判决的语义局限,SAG2 原版的 evidence 配对批处理是它的解法,我们还没抄全。
  • 规模
    :100 文档/10 题是 PoC 级方向性验证,不是基准成绩。数字请当"配方成立"看,别当"产品指标"看。

● ● ●

为什么值得做

zg(zvec-grep,4.2k star)火了之后,"agent 需要一个本地检索底座"已经不需要论证。但 zg 是单发 hybrid(embedding 补语义),SAG2 这条线证明的是另一件事:多跳的语义可以不靠 embedding,靠循环+判决——而判决器恰好是我们上一篇就本地化了的东西。

两篇论文的配方 + 两个自家扩展 + 一个本地端点,拼出来的是一条零外部依赖、中文原生、全程可审计(每轮判决分数、扩展词、新候选全部落盘)的多跳检索链。embedding-free 不是省钱话术,是把语义成本搬到哪、交给谁的架构选择——现在这三个问题的答案都可以是:留在本地,交给自己。

(栈的三个组件已开源:持久索引与中文检索 → duckdb-tantivy v0.3.0(https://github.com/alitrack/duckdb-tantivy);多跳编排 PoC(本文全部数字可复现)→ sag2zh(https://github.com/alitrack/sag2zh)。)