alitrack

DuckDB 拼错也能搜到:typo 容错的反直觉坑

上个月我写过DuckDB 的中文搜索,默认是坏的(分词篇),讲的是中文「能不能搜到」——jieba 分词、ignore 正则那个坑。那篇文章解决的是分词。

这篇讲另一件事:拼错了还能不能搜到。

英文全文检索有个默认假设你很少想到:你打对了词。真实用户不是。crude 打成 cruide、sachin 打成 sahcin、Namibia 打一半成 Nam——这三个词 fts 的 BM25 一律 0 命中。搜索体验里「拼错也能搜到」是 Meilisearch、Elasticsearch 的招牌功能,我把它搬到了 DuckDB 里,纯 SQL + 一个 Rust 标量函数,不装 Python、不装 ES。

搬的过程中踩到一个反直觉的坑:把拼错的词纠正过来再搜,结果不一定更好,有时候更差。这才是这篇文章想留下的东西。

● ● ●

一个拼错的词,数据库怎么知道它「其实是哪个词」

fts 建索引时会顺手建一张词表 raw_dict——语料里出现过的每个原始词 + 它出现在多少篇文档里(document frequency)。120,000 篇英文新闻,这张表有 61,000 个词。

所以「纠正」这件事在数据库里能落地:拿拼错的词,去 raw_dict 里找编辑距离最近的那个真实词。cruide 离 crude 差 1,就纠成 crude。

「编辑距离」是个老概念:把一个词变成另一个词,最少要改几个字符(插入 / 删除 / 替换)。但有一个细节卡住了我。

真实键盘 typo 里最常见的一类是相邻字符打反了:sachin 打成 sahcin。普通编辑距离(Levenshtein)把「换位」当成两次操作(先删后插),距离 = 2。如果纠正只容忍 1 个编辑,这类最常见的拼写错误全纠不动。

Meilisearch 处理这类 typos 的语义是「转置算 1 个编辑」。我把这个语义用 Damerau-Levenshtein(OSA 变体)实现成了一个标量函数 ivf_levenshtein(a, b),纯 Rust,带界早退(距离超过阈值就停,不等它算到底):

SELECT ivf_levenshtein('sachin', 'sahcin');  -- 1(相邻字符换位,OSA 算 1 个编辑)
SELECT ivf_levenshtein('cruide', 'crude');   -- 1(多打了一个 e)

一个标量函数,整件事的地基就有了。

● ● ●

三个「防误纠」的细节

把编辑距离直接拿来纠,会矫枉过正。我加了三个约束,每个都有对应的翻车现场:

按词长分档阈值。 短词绝不能纠——the 容许 1 个编辑会变成 tea/the 乱成一锅。所以 ≤3 个字母的词永不纠正,4-5 个字母容忍 1 个编辑,6 个以上容忍 2 个。短词必须原样保留,否则「纠正」变成「毁词」。

大小写要归一。 查询词 Radcliffe 首字母大写,词典里存的是小写 radcliffe。如果不先把查询词 lower() 掉再比距离,光这个大小写差异就吃掉 1 个编辑预算,明明只差 1 个字母的词会因为「大写 + 拼错」被判成差 2 个、纠不动。

同义词优先于纠正。 我留了一张 synonyms(a, b) 表(双向),精确匹配的同义词对永远赢过编辑距离纠正。这是给人工维护的「curated 词对」留的最高优先级。

这三个约束 + 词表查找,包在一层 SQL 宏里。对一个查询串,逐词做:先查同义词,命中就用;没命中且词本身在词典里就原样保留;都不在就找最近的真实词。最后拼回查询串。

● ● ●

反直觉的坑:纠正了反而更差

现在你有了「把拼错的查询串纠正成正确的查询串」的能力。直觉上的用法:纠正完,拿去搜。

我第一版就这么做的。然后在 ag_news(120,000 篇新闻标题)上测,结果让我愣住:把「纯纠正后查」和「不纠正直接查」两条腿放在一起比,纯纠正后查的命中率,不升反降。

为什么?因为纠正会纠错。语料里没出现过的词(人名、新词),编辑距离会硬把它拽到某个「最近的真实词」上——而这个词通常是错的。一个错纠正,整条查询的相关文档全偏了,排名就毁了。

我列的纠不动的词,全栽在这:Radcliffe→Radcliefe、Virgin→Virtin、Archos→Archso,都是人名 / 语料没见过的词。61,000 词的词表,纠正召回只有 13/50——这 37 个纠不动的词里,错纠正比不纠正更糟。

正确姿势是 expand-not-replace(扩展,而非替换):原查询 + 纠正后的查询,两条腿一起搜,结果融合排序(Reciprocal Rank Fusion,一种把多份排序列表合成一份的常见方法)。这样:

  • 纠正对了 → 纠正那条腿捞回正确的文档,排名变好;
  • 纠正错了 → 原查询那条腿还在,坏纠正只会被融合稀释,毁不掉原排名。

坏纠正从「致命」降级成「无害」。这是 Meilisearch 那种「按编辑距离档位加权排序」的 SQL 等价物,不用动索引,纯查询期。

实测(ag_news,120,000 篇,纯 fts 无向量,详见文末复现):

场景
不纠正
纯纠正
原查询+纠正融合
短查询,1 个拼错词(50 条)
0.838
—
0.880
长查询,~60% 词被破坏(50 条)
0.884
0.954
0.962

短查询(真正的 typo 主战场)上,融合比不纠正高 4 个点;长查询上纯纠正已经救得回来,融合再多一点,且全程 ≥ 纯纠正——这就是 expand-not-replace 的设计目标。

(带向量语义兜底的 nDCG@10 数字在独立 POC 里:短查询 1 typo,融合臂 0.705,加上向量 0.909。本包不带向量、不复现那部分,引用时请标注来源。)

● ● ●

prefix:截断词的两极

用户不只拼错,还会打一半就回车:Namibia 打到 Nam、Internet 打到 Int。fts 本身不做前缀匹配(查 Nam 命中 0),得手动展开。

做法:拿截断词的 3 字符前缀,去词表里把共享这个前缀的词捞回来(按文档频率取前 5),拼进查询。效果两极分化,这是我测出来的最清晰的「旋钮不是开关」:

截断词
不展开
prefix 展开
说明
namibia → nam
0.0
0.8
高区分词,截断救得回
internet → int
0.0
0.1
中间档
profit → pro
0.0
0.0
常见词,top-5 拉进一堆高频词稀释,净负

nam 展开成 named names naming namesake namibia,里面就有真答案,命中 0→0.8;但 pro 展开成 profit products profile program profits,全是高频词,正确的那篇被淹没了。

所以 prefix 对人名 / 高区分词是救星,对常见词是稀释。它是个可选臂——自动补全这种场景(用户还在打字)值得开,普通搜索场景默认关。我没有把它默认打开,因为默认开会让常见词查询整体变差。

● ● ●

一个通用教训

这篇文章真正想留的,不是 typo 容法搬进了 DuckDB,是那个反直觉坑教的:「把 X 修对了再处理」不等于「比不修好」。

我直觉上认为「拼错的词纠成对的词」一定是净收益——把错误修成正确,怎么会更差?错在「修」本身会引入新的错误(错纠正),而错误一旦进查询就污染整条排名。所以正确的设计不是「替换」,是「并行保留原信号 + 融合」——让坏修复无害化,而不是假设修复永远对。

这一课不新鲜:任何「先预处理再主流程」的链路里,预处理都可能引入新噪声。融合 / 多信号 / 保留原始,比「修对再走」更稳。我是又交了一遍学费,顺手把学费单贴出来。

● ● ●

复现

自包含素材包(D:\wsl2\win\duckdb-fts-typo-pack\):bash run.sh(WSL 内)一键复现上表全部数字。唯一外部依赖 uv(拉一个钉死版本的 duckdb 环境)。不需要源仓库、不需要向量。

● ● ●

参考来源

编辑距离 + typo 容错宏层 + ivf_levenshtein 扩展,仓库 alitrack/duckdb-ivf:

https://github.com/alitrack/duckdb-ivf

DuckDB fts 官方文档(raw_dict / term_prefixes 词表结构):

https://duckdb.org/docs/current/core_extensions/full_text_search.html

Meilisearch typo tolerance(min_word_length_for_typos / 转置=1 编辑的语义来源):

https://www.meilisearch.com/docs/learn/core_concepts/typo_tolerance

上一篇文章《DuckDB 的中文搜索,默认是坏的》(分词篇,本篇是它的「容错」续集):

https://mp.weixin.qq.com/s/Co7-ymzZAm2SmeV9-53rcQ