向量 + 全文混合检索:中文 FTS5 分词选型与 RRF 免标定融合

4 阅读8分钟

纯向量 RAG 用了两个月,我们最常被用户问到的一句话是:「我明明搜的是设备型号,为什么出来的全是似是而非的段落?」这篇讲混合检索的完整决策链:为什么必须加一路全文、中文分词在 FTS5 上踩了什么坑、两路结果为什么不能加权求和,以及一个把 64 维向量读成 256 维的真 bug。

纯向量强在语义,弱在符号

向量检索的语义泛化能力是它的立身之本:问「供电不足」能召回写着「电压跌落」的片段。但知识库问答里有另一类完全相反的需求——精确符号匹配:设备型号、出厂编号、英文缩写、参数名。这类 token 上,向量召回非常不稳:它不是召不回,而是有时候召回、有时候沉底,行为不可预测。

还有一个更工程化的问题。向量余弦相似度是「查询相关度」的相对度量,同一个分数在不同查询之间不可直接比较:0.72 在一个查询上可能是绝佳命中,在另一个查询上只是噪声。在检索管线里卡一个硬相似度阈值,就等于假设所有查询共享同一把尺子——结果就是正交问题上次相关结果被整段砍掉,答案区域空空如也。

所以需要一路正交的、认字面的召回通道:FTS5 全文检索。

中文分词的选型坑:trigram 对两字词零召回

FTS5 自带的 trigram 分词器看起来很美:按三元组切分,支持子串匹配,不用词典。真拿中文一跑就露馅——trigram 对长度小于 3 的查询词直接不命中,因为两字词根本切不出任何三元组。而中文知识库里两字词遍地都是:电压、功率、故障、报警。用户搜「电压报警」,四个字的词或许还行,搜「功率」就是零结果,没有任何调参空间。

引入外部分词器(jieba 一类)或中文词典可以解决问题,但我们从一开始就否决了:本地优先应用不能让一个全文索引背上词典维护、分词版本漂移和打包体积的债。

最终方案是 unicode61 加 CJK 单字一元切分:写入侧和查询侧对 CJK 字符统一做单字切分,拉丁词与数字保持整体,再交给 FTS5:

ZK-900 的额定电压  →  zk / 900 / 的 / 额 / 定 / 电 / 压
查询「电压」        →  电 / 压(两字 AND 命中)
查询「ZK-900」     →  zk / 900(精确符号命中)

两字词「电压」切成「电」「压」两个一元 token,查询时两路 AND,照样稳定命中。代价是索引膨胀:每个汉字都是一个索引项,倒排表明显变大。但个人知识库撑死几万条分片,这个膨胀在本机磁盘和内存上完全可接受——小规模下,内建、确定、零维护比紧凑重要。

触发器同步:双通道一致性不交给应用层

FTS 虚表以 chunk 主表为外部内容建表,同步靠数据库触发器而不是应用代码:主表插入一行,触发器把分词后的内容写进虚表;主表删除,触发器清掉对应索引项。chunk 与向量表、全文索引由此处在同一个数据库的写事务里,要么一起生效,要么一起回滚,不存在「向量库有、全文表没有」的撕裂窗口。

这个选择也排除了一类典型 bug:摄取流水线里只要有一个分支忘记调索引函数,双路召回就会静默退化成单路。把同步下沉到触发器之后,应用层根本没有「忘记同步」这个动作可以犯错。

摄取期还有一个基座性改动:chunk 落库时就写好页码与段落归属(PDF 页码、段落号),而不是检索时再回头推算。这是后续语句级引用能贯通到界面的地基,下一篇讲编译器时还会用到这套坐标。

两路结果怎么合:不能加权求和

双路召回之后,最直觉的合并方式是加权求和:最终分 = α × 向量分 + (1-α) × BM25 分。这条路走不通,原因有三个:

  1. 量纲不同:一边是归一化余弦,一边是 BM25 的内部打分,数值分布完全是两回事;
  2. 跨查询不可比:两路分数都随查询波动,同一个 α 在不同查询上含义不同;
  3. 权重只能靠拍脑袋:α=0.7 还是 0.6 没有任何标定依据,文档集一变就作废。

我们用的是 RRF(Reciprocal Rank Fusion,倒数排名融合):只认每一路内部的排名,完全不看原始分。

score(d) = Σ  1 / (k + rank_路(d))      实践中 k = 60

一个片段在向量路排第 2、在全文路排第 5,它的融合分就是 1/(60+2) + 1/(60+5);某一路没召回它,该路贡献为零。没有权重、没有阈值、不需要任何标定,库加减、查询变换都不影响公式的含义。k=60 是文献中的常用常数,作用是压住头名的垄断优势,我们直接沿用而没有自创。

RRF 之后还有一层确定性的规则重排:统计查询词在片段中的覆盖度(命中了几个查询词)、命中出现的位置(标题区与段首加权),把真正逐字包含查询词的片段往顶上加权。RRF 负责「民主计票」,规则重排负责纠正向量路天然偏好语义泛化的倾向。整个混合管线被重构成可插拔阶段,每一段都是输入输出明确的纯函数,单测可以逐阶段钉死。

硬阈值的教训:砍掉正交结果等于交白卷

混合检索上线初版时保留了旧链路的硬相似度下限:向量分低于阈值的候选直接丢弃。eval 里有一组正交测试——问题与文档只有侧面相关性,正确行为是召回次相关片段并由模型组织一个保守回答。硬阈值把这些次相关结果全砍了,答案区为空,表现比旧链路还差。

改法是软阈值保底:阈值不再是生死线,低于阈值的候选不直接丢弃,而是降权后保留一个保底名额,保证管线在「没有强命中」时仍有材料可用,让模型自己决定材料够不够、要不要声明信息不足。检索层替模型做「这肯定不相关」的二元判断,在本地单机、查询分布不可控的场景里是傲慢的。

真 bug:vec0 虚表读出来是 BLOB,不是 JSON

做重复文档检测时踩到这个版本最典型的一个嵌入式坑。sqlite-vec 的 vec0 虚表,写入走的是 JSON 文本,读出却是 float32 小端 BLOB:64 维向量读出来是 256 个字节。旧的反序列化只处理了字符串和普通数组两种形态,Buffer 落进来之后被按字节遍历,一个 64 维向量被展开成 256 维。重复检测 API 直接报错:

查询向量维度 256 与表维度 64 不一致

修法是用 DataView 按 float32、小端逐个解码:

// vec0 读出的是 float32 little-endian BLOB,不是 JSON
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
const dim = view.byteLength / 4;
const vec = new Float32Array(dim);
for (let i = 0; i < dim; i++) {
  vec[i] = view.getFloat32(i * 4, /*littleEndian*/ true);
}

光修解码还不够。这类序列化形态问题不写测试,下次升级扩展还会再犯。我们加了一个闭环回归测试:从虚表读出的向量,立刻回喂给 KNN 检索去匹配它自己,相似度必须约等于 1。写入形态、读出形态、维度三者被这一个断言同时钉死。教训写下来只有一句:嵌入式向量库的序列化形态不能靠读文档想象,必须写测试钉死。

eval 反向验证:断言必须能「失败」才算数

混合检索的 eval 不是「开关全开跑一遍全绿」就完事。知识 golden 集设计了三开关矩阵:同一批题在三种开关态下分别判定——

开关态判定
none:混合检索与编译全关,纯向量,等价旧链路15/15
fts:混合检索开、编译关11/11,另 4 题 SKIP
compile:混合检索与编译全开8/8,另 7 题 SKIP

标注题(「这道题专门验证某个开关」)只在它标注的开关态做反向判定,其余状态显式 SKIP;无标注的护栏题在所有状态下都必须正向通过。反向验证的意义是:一道号称「需要 FTS 才能答对」的题,在关掉 FTS 时必须真的答错,否则这道题在开着 FTS 时的「通过」就是恒真断言,什么都证明不了。

矩阵逼出了一个真实的标注错误。有一道摘要题最初标成「关掉编译才失败」,但细想就知道根基不成立:编译器产出的摘要条目本来就是从原文摘出的原句,只要 FTS 开着,原文句子必然被逐字召回,「关编译」根本制造不出降级信号。这道题后来改标到纯向量态——只有在没有精确词通道时,它才会被干扰文档压顶而失败。标注本身也需要被证伪,这是这批 eval 给我们最大的方法论收获。