为什么是两路,而不是三路
混合检索的常见形态是三路:关键词、稠密向量、外加一路「学习到的稀疏向量」(比如 bge-m3 的 lexical head)。Trove 只有两路,第三路被主动退役了。
理由不是效果不好,而是信号不独立:learned-sparse 与 dense 出自同一个 backbone,是同一模型的两个 head。RRF 融合的前提是「各路会犯不同的错」, 同源的两路会一起犯错、一起漏掉同一个东西——融合它们只是把同一个判断投了两票。
store.py:49 RRF_K store.py:51 _CHANNEL_NAMES store.py:150 HybridStore
退役第三路时保留了一个痕迹:SQLite 兜底存储的迁移列表里,sparse 列与它的迁移步骤
仍然在(版本 2)。因为版本号记的是历史,不是配置——删掉一个迁移步骤,
会让停留在旧版本的数据库看起来「比代码还新」,从而触发拒绝启动的保护。
一个不再使用的列,比一个错乱的版本号便宜得多。
两路各自怎么做
| 通道 | 生产(PostgreSQL) | 兜底(SQLite) |
|---|---|---|
| keyword | pg_bm25(ParadeDB 的 BM25 索引),不可用时退化到 tsvector + GIN | FTS5 虚拟表 + bm25() 排序 |
| dense | pgvector HNSW 索引,ANN 查询 | Python 内做余弦相似度 |
pg_store.py:253 _fts_ids pg_store.py:284 _ann_ids sqlite_store.py:176 _fts_ids sqlite_store.py:197 _ann_ids
两路各自返回一个有序的 id 列表,然后交给融合。 注意返回的是顺序而不是分数——这是 RRF 的关键,见下节。
flowchart LR
Q["问题"]:::entry --> KW["keyword 通道
pg_bm25 / FTS5"]:::exec
Q --> DN["dense 通道
pgvector HNSW / 余弦"]:::exec
KW -- "有序 id 列表" --> RRF["加权 RRF
只用排名,不用原始分"]:::exec
DN -- "有序 id 列表" --> RRF
RRF --> N["按排序位归一化到 [0,1]"]:::exec
N --> RR{"精排?默认关闭"}:::entry
RR -- "deterministic / bge / http" --> OUT["候选交给下游打分融合"]:::entry
RR -- "默认:不精排,直接用融合顺序" --> OUT
classDef entry fill:#71717a14,stroke:#71717a59,stroke-width:1px;
classDef exec fill:#14b8a614,stroke:#14b8a699,stroke-width:1.5px;
store.py:237 recall store.py:269 channels
加权 RRF 与一个必须做的归一化
RRF(Reciprocal Rank Fusion)把多路排序合成一个分数:
score = Σ weight / (k + rank)。它只用排名不用原始分,
所以不需要把 BM25 的分数和余弦相似度校准到同一个尺度——这是它比「加权求和」更稳的原因。
store.py:93 rrf_scores store.py:173 _channel_weights factory.py:67 channel_cfg
两路、k=60 时,RRF 的原始分落在 [0.022, 0.033] 这个极窄的区间里。这意味着:把它当成相似度直接往下传,任何一个「和相似度做加权」的下游都会退化成一个常量偏移——0.5 的权重乘上去只是给所有候选加了个同样的数,排序完全没受影响。
所以命中分数在离开检索层之前会被按排序位归一化到 [0,1]:第 1 名恒为 1.0,其余线性递减。这样下游的融合权重才真的有作用。
store.py:119 rrf_order_scores store.py:138 rrf_fuse
召回窗口按库规模自适应
一个容易被忽略的性能陷阱:如果每次都把全部候选拿出来排序,KB 小的时候没问题, 大了之后每次检索都会变成全表扫描。
这里的做法是按数据源的文档总数分档。总数不超过 2000 时,
不裁剪、全量参与融合——因为库里总共就这么点东西,裁剪反而可能把一个本该命中的条目挡在外面。
超过之后才按 limit × 6 开窗(并保底 8 条)。
pg_hybrid.py:33 _EXHAUSTIVE_SCAN_MAX pg_hybrid.py:27 _RECALL_MULTIPLIER pg_hybrid.py:178 _recall_window
精排:能力都有,但默认关闭
精排(rerank)接在融合之后,用更贵的模型对候选重新排序。Trove 实现了三种后端, 但默认一个都不用。
| rerank_backend | 行为 |
|---|---|
| (留空)/ auto / none | 不精排——默认 |
| deterministic | 确定性 n-gram 覆盖率打分 |
| bge | 本地 BGE 交叉编码器 |
| http / cross-encoder | 远程精排服务 |
factory.py:31 _reranker_for rerank.py:41 DeterministicReranker rerank.py:97 CrossEncoderReranker
默认档曾经是确定性 n-gram 精排。问题在于:下游 _rank_examples 的相似度函数
和这个精排器用的是同一个函数、同样的源文本。融合两个同源的量等于原值——
那一级排序对结果没有任何影响,纯属把同一份计算做两遍。
关掉它之后,下游拿到的检索信号反而更「纯」了:一个来自检索、一个来自确定性打分,两者真的在互补。
另外,精排失败不会让检索失败——它有兜底,退回融合顺序。 这一点是刻意的:一个可选的增强环节,不该成为整条链路的单点。
后端怎么选
检索后端的切换是能力探测而不是硬编码的数据源类型判断: 配置里没指定时,如果这个数据源有独立的检索库 DSN,或者业务库本身是 PostgreSQL 且带向量能力, 就升级到 pg_hybrid 后端;否则用内置的 SQLite 路径。
registry.py:40 _effective_backend pg_hybrid.py:36 PgHybridKbBackend
pg_hybrid 后端还带一个优雅退化:如果这个数据源从未建立索引(库里没有它的文档), 示例与教训的检索会落回内置路径,而不是返回空。刚接入、还没索引完的数据源不会「突然什么都搜不到」。
评测:这部分完全不需要花 LLM 的钱
检索评测是零 LLM 成本的——它比对的是「该被召回的东西有没有被召回」, 跟生成质量无关。所以这部分可以放心反复跑,也是回归门能覆盖的指标来源。
| 脚本 | 测什么 |
|---|---|
| eval_hybrid_retrieval.py | Recall@k / MRR / nDCG@k,并给出精排前后的对比 |
| eval_bird_retrieval.py | 表级 schema 召回 + 分路消融(keyword-only / dense-only / 融合,同一金标对比) |
| tune_rrf.py | 在网格上搜 rrf_k 与各通道权重,以 MRR 为主目标 |
| eval_retrieval.py | 召回率 + 子结构覆盖 + 预算挤占 |
metrics.py:54 evaluate metrics.py:26 mrr metrics.py:35 ndcg_at_k metrics.py:15 DEFAULT_KS
分路消融值得单独说一句:它是直接调通道原语对比的,而不是把某一路的权重设成 0。 用权重 0 来「关闭」一路会混入 RRF 本身的效应(那一路的排名仍然存在、只是不加权), 得到的不再是「只有 dense 时效果如何」这个问题的答案。
eval_bird_retrieval.py:54 --scorecard tune_rrf.py:39 --scorecard eval_hybrid_retrieval.py:33 --scorecard
这些脚本的 --scorecard 产物是回归门的输入之一——也就是说,
改检索参数会被离线门拦住,不需要跑一次花钱的端到端评测。详见
评测与回归门。