alitrack

四个搜索引擎,没一个能搜中文(实测复盘)

上周刷到一篇小众爆文:一位开发者把 SQLite、DuckDB、Tantivy、PostgreSQL 四个"嵌入式"全文检索引擎拉到同一个基准上跑 BM25,结论是 SQLite 和 DuckDB 并列第一,PostgreSQL 垫底。文章标题起得很挑衅——《不用 Elasticsearch 的搜索》。

我顺手做了两件事:第一,把它复现了一遍;第二,加了一个原作者没测的东西——中文。

第二件事的结论,一句话就能讲完:四个引擎,没一个能搜中文。

不是"搜得差",是 nDCG 直接掉到 0.004 那种级别的"搜不了"。而这四个引擎里,有的已经号称"开箱即用"好几年了。

先把复现交代了,再讲中文怎么个全军覆没法。

● ● ●

一、复现:原文是对的

原文基准用 BEIR 的 SciFact 数据集(5,183 篇科学摘要、300 条查询),比 nDCG@10。我在自己的 WSL2 环境里把四个引擎全部跑了一遍:

引擎
原文 nDCG@10
我跑的
差距
SQLite FTS5
0.682
0.6817
0.0003
DuckDB FTS
0.681
0.6808
0.0002
Tantivy
0.665
0.6667
0.0017
PostgreSQL 17
0.560
0.5596
0.0004

四位全部逐位吻合。数据可信,harness 写得也干净——这年头能复现的基准文章,值得先点个赞。

但复现通过的那一刻,问题也来了:原文的配置,是这四个引擎的实力上限吗?

不是。我改了一个参数,排名就变了。

● ● ●

二、一个参数改写排行榜

DuckDB 内置 FTS 默认带英文停用词表。把它关掉——stopwords='none'——同一个基准,同一个引擎:

nDCG@10 从 0.6808 涨到 0.6924,单独登顶。

原因不复杂:停用词表把查询里的高频词过滤掉了,但查询里那些"被停用"的词在 BM25 里本来是有贡献的(它们 IDF 低,但不是零)。关掉之后,信号全保留。

更值得说的是另一个新选手。

● ● ●

三、DuckDB 里现在长了一个 Tantivy

写这篇文章的由头之一,是 DuckDB 的社区扩展生态里现在有一个 tantivy 扩展——把 Rust 那个快得离谱的搜索引擎内核直接编译成 DuckDB 扩展,SQL 里一句 bm25_search() 就能用。

我把它拉进了同一个基准:

引擎
nDCG@10
备注
DuckDB FTS(去停用词)0.6924
新榜首
duckdb-tantivy 扩展0.6839
超过原文全部四引擎
SQLite FTS5
0.6817
原榜首
DuckDB FTS(原文配置)
0.6808
Tantivy(独立进程)
0.6667
延迟仍是最快(0.31ms)
PostgreSQL 17
0.5596

2026 年 10 月的"不用 Elasticsearch"榜单,前两名都姓 DuckDB。

但这里必须诚实交底:tantivy 扩展的延迟是 385ms,比独立 Tantivy 慢三个数量级。因为它的架构是每次查询调用都在内存里重建索引——5,183 篇文档重建一次要几百毫秒。这是设计声明的限制(定位是中小表的精确搜索层),不是 bug,但你选型时得知道。

● ● ●

四、中文:全灭

好,前面都是英文。现在轮到这篇文章真正的主角。

中文基准我选了 C-MTEB 的 CmedqaRetrieval(医疗问答检索,100,001 篇文档、3,999 条查询、带人工标注的相关性)。这是标准中文检索基准,不是我自己编的题。

先测"开箱即用"——不加分词,直接把中文文本喂进去:

引擎(原生中文)
nDCG@10
空结果查询
DuckDB 内置 FTS
0.004
3,451 / 3,999
Tantivy 默认分词
0.0007
3,931 / 3,999
SQLite FTS5 trigram
0.0005
3,995 / 3,999

十万个文档的库,3,999 条查询,99% 的查询返回空结果。

为什么会这样?我挖到了最底层:直接去查 DuckDB FTS 索引的内部词表(fts_main_t.dict),结果是这样的——

李红艳在浙江大学生物信息实验室工作   ← 这是一个"词"
浙江大学的生物信息专业很好           ← 这也是一个"词"

整句中文,被当成了一个 token。

因为这些引擎的默认分词器都按空格和标点切词——这对英文天经地义,但中文没有空格。一句 16 个字的中文,在这些引擎眼里就是一个不可拆分的巨型单词。查询"浙江大学"当然匹配不上"李红艳在浙江大学生物信息实验室工作"这个词——字面上就不相等。

这不是哪个引擎的缺陷,是所有"默认按空格切词"引擎的共同盲区。

● ● ●

五、分词之后,都活了

解法是老老实实预分词:用 jieba 把文本切成空格分隔的词,再喂给引擎。

引擎(jieba 预分词)
nDCG@10
p50 延迟
duckdb-tantivy 扩展
0.1622
4.3s*
SQLite FTS5
0.1477
77ms
Tantivy
0.1442
1.9ms
DuckDB 内置 FTS
0.1385
78ms

*扩展的架构限制(见第三节),10 万文档每次调用重建索引。

三个观察:

  1. 01全部回到 0.14-0.16 档
    ——与这个数据集公开的 BM25 基线(约 0.15)同档。中文检索管线 sanity 成立。
  2. 02引擎间差距缩到 2 个点以内
    。英文榜上 DuckDB 和 SQLite 差 0.1%;中文榜上决定质量的是分词,不是引擎。
  3. 03
    Tantivy 的延迟优势跨语言成立:1.9ms,比其他引擎快 40 倍。

● ● ●

六、唯一救不回来的:PostgreSQL

有一个引擎,分词也救不了。

PostgreSQL 用 jieba 预分词 + to_tsvector('simple') + GIN 索引,nDCG@10 = 0.0159。不是 0.15,是 0.0159,比其他引擎低一个数量级。

我逐层排查了原因。把单条查询的所有命中拉出来看:OR 查询命中 96,472 篇文档,标注相关的目标文档按 ts_rank 排在第 49,652 位。而同一个查询,BM25 系引擎把它排进前 30。

根因:ts_rank 没有 IDF 分量。

"的、了、是、在"这些高频助词,在 BM25 里会被 IDF 压到近乎零权重;在 ts_rank 里它们和"睾丸炎""腮腺炎"权重一视同仁。于是一篇匹配了一堆助词的无关文档,分数高过匹配了核心症状词的目标文档。排序被虚词淹没。

回头看英文榜上 PostgreSQL 垫底(0.560 vs 0.68),同一个根因——英文的 the/of/is 一样淹没排序。这不是中文特有的问题,是 ts_rank 这个排序函数在 BM25 时代的结构性落后。中文只是把它的病放大了十倍。

顺带一提:换 AND 查询也救不了——132/150 条查询直接空结果,nDCG 掉到 0.007。PG 全文检索这条路,在 BM25 语境下可以正式排除了。

● ● ●

七、彩蛋:三条 SQL 搞定中文检索

测到最后我发现了一条最短路径。DuckDB 的 luajit 社区扩展里有一个纯 Lua 的 jieba 移植(词典 34 万条词内嵌在单个 .lua 文件里,零外部依赖),于是中文检索可以全链路不出 DuckDB:

INSTALL luajit FROM community; LOAD luajit;
SELECT * FROM luajit_module(mode := 'install', sql_name := 'jieba');

-- 切词即列变换
INSERT INTO docs SELECT id,
  luajit_s('jieba', {v: body, op: 'join', sep: ' '}) ->> 'joined' FROM raw;
PRAGMA create_fts_index('docs', 'id', 'body');

-- 查询也用同一个分词器
SELECT id FROM (SELECT id, fts_main_docs.match_bm25(id,
  (SELECT luajit_s('jieba', {v: ?, op: 'join', sep: ' '}) ->> 'joined'),
  conjunctive := 0) s FROM docs)
WHERE s IS NOT NULL ORDER BY s DESC LIMIT 100;

实测:nDCG@10 = 0.1380,与 Python 外置分词管道(0.1385)打平——0.0005 的差距来自分词模式细节(Lua 版是精确模式,Python 版是搜索引擎模式),不是引擎。查询延迟同为 78ms。

代价只有一次性切词时间:10 万文档 411 秒(约 4ms 一篇)。

对"不想在数据库旁边再挂一个 Python 进程"的场景,这是目前部署摩擦最小的中文检索路线:两个扩展、三条 SQL,完事。

● ● ●

八、选型表(2026-10 版)

把 20 个配置压成一张决策表:

  • 英文、要质量
     → DuckDB 内置 FTS,记得关停用词(0.692)
  • 英文、要延迟和体积
     → Tantivy(0.31ms / 2.8MB)
  • 中文、数据在 DuckDB 里、不想加管道
     → luajit jieba + 内置 FTS(0.138,全 SQL)
  • 中文、要极致质量
     → jieba 分词 + tantivy 系(0.144-0.162)
  • 中文、没有 DuckDB
     → SQLite FTS5 + jieba(0.148,标准库就行)
  • 任何语言、都别用
     → PostgreSQL 原生全文检索(英文 0.560,中文 0.016,同根因:ts_rank 无 IDF)

● ● ●

尾声

这次调研的全部数据、复现脚本和中文基准 harness 都开源在 github.com/alitrack/fts-bench,四引擎英文复现、DuckDB 生态新选项、10 万文档中文基准、luajit 全 SQL 路线,一键可跑。

最后留一个开放的判断:四个引擎原生中文全灭,说明"支持中文"这件事,在嵌入式检索引擎的世界里还没有像 jieba-rs 这样的默认答案。谁先把中文分词做进引擎默认 tokenizer——比如 tantivy 的 Rust 生态其实已经有 jieba-rs——谁就能拿下"开箱即搜中文"这个空着的位子。

这不是预测,是一个待领取的 PR。

● ● ●

参考来源

  1. 01
    基准仓库与配套文章:https://github.com/nazmul284/search-without-elasticsearch
  2. 02
    本文全部实测数据与复现脚本:https://github.com/alitrack/fts-bench
  3. 03
    BEIR SciFact 数据集(BEIR 官方分发)
  4. 04
    C-MTEB CmedqaRetrieval 中文检索基准(Hugging Face: C-MTEB)
  5. 05
    jieba 中文分词(MIT 协议):https://github.com/fxsjy/jieba
  6. 06
    Tantivy 全文检索库:https://github.com/quickwit-oss/tantivy
  7. 07
    DuckDB luajit 扩展与 luajit-libs(jieba 的纯 Lua 移植):https://github.com/alitrack/duckdb-luajit-libs