首页/数据能力/混合检索

混合检索

知识库里的东西再多,检索不到就等于没有。知识库讲的是内容怎么来, 这一页讲内容怎么被找出来——以及三个不那么显然的设计决定。

为什么是两路,而不是三路

混合检索的常见形态是三路:关键词、稠密向量、外加一路「学习到的稀疏向量」(比如 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)。因为版本号记的是历史,不是配置——删掉一个迁移步骤, 会让停留在旧版本的数据库看起来「比代码还新」,从而触发拒绝启动的保护。 一个不再使用的列,比一个错乱的版本号便宜得多。

sqlite_store.py:56 Migration

两路各自怎么做

通道生产(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;
图 1 · 融合管线:两路只交排名,RRF 只管合成,归一化在离开检索层之前完成——精排是可选的最后一跳,失败则退回融合顺序。 上面这段以源码显示:图渲染库没加载起来(离线、CDN 被拦或浏览器禁用了 JavaScript 时会这样)。这段 Mermaid 文本本身就说明了上面那些框与连线。

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

RRF 原始分不能用,原因很具体

两路、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 产物是回归门的输入之一——也就是说, 改检索参数会被离线门拦住,不需要跑一次花钱的端到端评测。详见 评测与回归门。

继续读

想了解去哪
被检索的内容长什么样知识库
检索参数配在哪(不是 agent.yml)配置参考
回归门怎么消费 scorecard评测与回归门
检索日志记了什么可观测性
本页源码锚点的基准提交:anchor-base: ab6f9d9 GitHub 仓库 文档首页