检索要回答的不是”哪段文字和问题最像”,而是”在这批资料里,哪几条最可能包含答案,而且我能说清为什么”。这两件事不一样。向量相似度对同义表达友好,却对手机号、错误码、人名、日期这类字面量无能为力;BM25 对词元精确,但中文分词一断错就全盘皆输;字符三 gram 能兜住非词字符串,却读不懂语义;视觉索引能找回照片和扫描页,却召不回纯文本。四条腿各有各的盲区,所以先让它们各自召回,再融合。

| 腿 | 存储 | 查询怎么来 | 强项 | 已知弱项 |
|---|---|---|---|---|
| dense | Qdrant kb_dense |
bge-small 编码查询,512 维余弦 | 语义泛化、博客类 | 按日期提问、编号/错误码 |
| sparse | Qdrant kb_sparse |
jieba 分词 + BM25 查询侧权重 | 关键词、人名、聊天、日期归一化 | 非词字符串、OOV |
| trgm | Postgres chunks |
word_similarity(q, content_norm) >= 0.25 |
子串、手机号、工号、错误码 | 只能 seq scan;自然语言召回窄 |
| visual | Qdrant kb_visual |
colSmol 查询编码,多向量 MaxSim | 照片、扫描页 | 对纯文本类 query 天然召不回 |
mode 只控制前三条:semantic 走 dense,exact 走 trgm,hybrid(默认)走 dense + sparse + trgm。视觉腿不受 mode 控制,由 visual_available() 决定是否加入:本地进程启用了视觉模型,或远端视觉服务可达且模型就绪。
每条数据库腿各拿一个会话,四条腿用 anyio.gather 真并发。共用调用方的 AsyncSession 会踩 SQLAlchemy 的并发限制:This session is provisioning a new connection; concurrent operations are not permitted。更糟的是它的表现——sparse/trgm 被记成 skipped,hybrid 静默退化成 dense 单腿,检索仍然 200,debug 面板也不报错。
代价是连接数。一次 hybrid 最多占”请求会话 + 3 条腿”四条连接;连接池要按并发检索数 × 4 估。单机个人库默认 5+5 足够,高并发部署要调大;池不够时请求排队,延迟变差但不至于算错。
每腿取多少候选:min(100, max(top_k*3, 30))。取浅了真值可能在第 11 名,取深了只多付每腿的 limit;而查询编码只做一次,加深几乎免费,所以加宽候选比调融合公式更划算。
四条腿的分数量纲完全不同:dense 是 0~1 的余弦,sparse 是随语料增长的无界 BM25 分,trgm 是 0.25~0.40 的窄带小数,visual 是 MaxSim。要加权求和就得先归一化,而归一化要么依赖单次查询的候选集(min/max),要么被某条腿的离群值带跑(z-score)。同一个 chunk 换一句问法就会换一个分数,排序跟着抖。
RRF 只看名次:
score(d) = Σ_leg weight_leg / (60 + rank_leg(d))
k=60 是原论文取值。它让第一名与第二名的分差只有约 0.26%,一条腿的头部优势压不过两条腿的共识——dense 和 sparse 都排第一的块,应该赢过”只有一条腿排第一、另一条腿排第 30”的块。
两个实现细节不能省:
text:<id>,视觉用 visual:<id>。key 作次序,同一个 query 两次请求的顺序会变,前端引用角标 [1][2] 刷新后会对调。跳过的腿不参与分母。视觉腿不可用时如果按”排了第 0 名”塞进去,会稀释所有结果;它没跑就是没跑。
等权 RRF 在分类别评测里没有赢过任何一类的最佳单腿。聊天类 sparse 1.00、dense 0.10;照片类文本三腿全 0、visual 1.00。一条固定权重不可能同时是这两类的最优。所以问题不是 k 值,而是”该按 query 类型换一套权重”。
路由是规则匹配,六类意图:
| 意图 | 触发词示例 | 动作 |
|---|---|---|
photo |
照片、图片、合影、拍的 | visual 权重 3.0,偏好 photo 库 |
scan |
扫描件、影印件、拍下来的那份 | visual 2.0 + sparse 1.2,偏好 page/table |
table |
哪一栏、怎么填、表头、单元格 | sparse 1.3 + trgm 1.2,偏好 table |
chat |
聊天、他说、提过、记不记得 | sparse 1.6,偏好 chat 库与 chat 块 |
date |
哪天、什么时候、去年、纪念日 | sparse 2.5;日期字面量本身也算 date |
general |
其余 | 等权,不过滤 |
三条纪律:
note 里说明”库里没有这类结果”。photo 的 3.0 是实测出来的,不是拍的:视觉权重 2.0 时融合池里 10/20 是照片,3.0 时 20/20、top10 里 10/10,再往上没有收益。这里还有一个容易误读的机制:权重决定”哪些结果能进融合池”,偏好只能重排已经在池里的。所以库比 top_k*2 小时,权重调多少看起来都一样。
精排默认关闭,按库开启(kb.rerank_model 非空)。它拿到的是 RRF 之后的候选,只能重排、不能召回;真值不在候选里就救不回来。CPU 上 base 每对 (query, doc) 约 30~60ms,默认的 v2-m3 约 114ms;20 个候选就是 1~2 秒量级,所以它不是默认路径。
不可用时返回 skipped 并保持原顺序,不静默降级。排序键是 (-score, index),同分保持原 RRF 顺序;这一点写成显式键,而不是依赖 sorted 恰好稳定。跨库检索一律不启用精排——不同库可能配了不同模型,排序会取决于”哪些库碰巧配了”。
证据质量用 RRF 自己的信号判三档:
| 档位 | 条件 | 含义 |
|---|---|---|
none |
没有任何命中 | 硬拒答:没有证据 |
weak |
头部最强证据只来自一条腿 | 单腿排序,可信度低 |
ok |
头部最强证据有 ≥2 条腿认可 | 正常 |
它不改变抽取式的行为(抽取式自己会在摘不出句子时拒答),但给外部 LLM 一个可依赖的”证据不足”信号:none 且不是抽取式时,直接拒答,不调模型。

| 情况 | 这条腿的结果 | 检索整体 |
|---|---|---|
| 视觉服务未配置/未起/模型未加载 | skipped + 可操作原因 |
200,文本三腿照常 |
| 视觉编码超 2s 或并发排队超时 | skipped |
200 |
| dense 索引模型与当前配置不一致 | skipped + 修法 |
200,其它腿照常 |
| sparse 查询词全不在词表 | skipped(OOV 不是错误) |
200 |
| trgm 超过 800ms 语句超时 | skipped + 调参指引 |
200 |
| trgm query 短于 3 字 | skipped |
200 |
| 精排模型加载失败/推理失败 | skipped,保持原顺序 |
200 |
| 单腿抛异常 | skipped |
200 |
skipped 的文案必须能照着修。只写”失败”会让人查错方向:视觉腿在 API 进程不可用是正常状态,因为 API 从不加载 colSmol;把它说成”未启用”会让人去 API 里找一个不存在的开关。
38 条人工标注 query、chunk 级真值,2026-10-15:
| 腿 | Hit@1 | Hit@5 | Hit@10 | MRR |
|---|---|---|---|---|
| dense | 0.3158 | 0.6316 | 0.7368 | 0.4543 |
| sparse | 0.4211 | 0.7632 | 0.8421 | 0.5630 |
| trgm | 0.2368 | 0.3158 | 0.3947 | 0.2871 |
| hybrid | 0.5000 | 0.7368 | 0.8421 | 0.5948 |
| hybrid + rewrite | 0.5526 | 0.7105 | 0.8421 | 0.6393 |
照片类 8 条:文本三腿全 0,visual Hit@1 0.75 / Hit@5 1.00;扫描件 4 条:文本三腿全 0,visual 1.00/1.00。所以整体数字不能拿来做取舍,只有分类别数字可比——照片题的 0 分不是”检索坏了”,是文本腿本来就不该召回照片。
调参的顺序应该是:先看分类别哪条腿最强,再看路由有没有把 query 分对,最后才看融合权重。直接调 RRF 的 k 或给 sparse 乘一个系数,解决不了”照片不在文本候选里”这种召回层面的缺口。
trgm 腿只能走全表扫描。word_similarity 用不上 gin_trgm_ops;试过 ILIKE '%整句%' 和整段 similarity(),前者要求 query 是原文连续子串,后者对 500 字 chunk 恒在 0.05~0.11,两种都漏召回。漏召回比慢更难发现——用户只会觉得”搜不到”。所以这一腿保留 seq scan,用 800ms 语句超时兜住大库;真扛不住时的正解是物化 token 集合或换外部搜索引擎,不是把召回条件换回 ILIKE。
word_similarity 与 similarity 只差一个词,排序结果完全不同:对 8 字提问、500 字 chunk,similarity() 的 top1 是一段偏离答案的表格标题(0.111),word_similarity() 的 top1 才是含答案的那段(0.400)。用错不报错,只会稳定地排错序,所以这里有一条单测把编译出的 SQL 钉住。