跳到主要内容

搜索路由与混合分数融合

30 秒导读: 你调 embeddings.search("query"),txtai 内部要先决定"这一次到底走哪条路"——图搜索?数据库搜索?还是纯向量?走向量时又要判断只有关键词索引(稀疏)、只有 ANN(稠密)、还是两者都有(hybrid)。若是 hybrid,两路各出一份带分数的候选,还得用一套融合公式把两份分数合成一个排名。本章只讲这个路由入口分数融合,SQL 细节留给 05、图遍历留给 06


1. 这是什么(零基础也能懂)

一句话定义: 这是 txtai 检索的"调度中枢"——一个统一的搜索入口,负责判断该用哪种索引后端,并在同时用两种后端时把它们各自的分数融合成一个排名。

它解决什么问题。 一个 embeddings 实例可能同时挂着多种检索能力:

  • 稠密索引(ANN)——把文本变成向量、按语义相近度找("汽车"能召回"轿车")。
  • 稀疏索引(keyword scoring)——按关键词/词频找(BM25 那一类,"汽车"就是找含"汽车"字样的)。
  • 数据库(content store)——支持 SQL 过滤(where date > ...)。
  • 图网络——按关系遍历。

用户只调一个 search(),不关心底层挂了哪几样。这个模块的活,就是"看菜下单": 有什么后端、query 长什么样,决定这一次怎么查、结果怎么合。

一句话直觉/类比: 把它想成餐厅的领位员 + 调酒师。领位员(__call__)先看你这桌该去哪个区(图区/数据库区/向量区);到了向量区,调酒师(search + Hybrid)发现你既要"语义"又要"关键词"两种基酒,就按一套配方把两杯调成一杯端给你。

用起来什么样(最小示例):

from txtai import Embeddings

# 同时开启稠密(ANN)和稀疏(keyword)——这就构成 hybrid
embeddings = Embeddings(hybrid=True, content=True)
embeddings.index(["汽车很快", "天气不错", "轿车加速快"])

# 一个入口,内部自动分叉 + 融合
embeddings.search("车子跑得快", 2)
# -> [{"id": "0", "text": "汽车很快", "score": 0.71}, ...]

同一行 search(),若只开了 ANN 就是纯语义搜;只开 keyword 就是关键词搜;两个都开就自动变 hybrid。本节到此不碰代码细节,下面从顶层看它怎么转。


2. 顶层全景(它大概怎么转)

整个模块的核心是 Search 类(embeddings/search/base.py:15),它的 __call__第一层路由search 方法是第二层路由

2.1 部件一句话职责

部件干什么文件:符号
Search.__call__第一层路由:图 / 数据库 / 纯向量 三选一search/base.py:44
Search.search第二层路由:稀疏 / 稠密 / hybrid 三态切换search/base.py:85
Search.dense走 ANN 稠密向量搜索search/base.py:150
Search.sparse走 keyword/稀疏向量搜索search/base.py:173
Hybrid选融合策略并合并两路分数search/hybrid.py:8
LogOddsBayesian(BB25) 分数的对数几率融合search/hybrid.py:108
Search.resolve把内部 index id 映射回用户 idsearch/base.py:193
Query自然语言 → SQL 的翻译模型search/query.py:11
Terms从 SQL 的 similar() 子句抽回纯关键词search/terms.py:6
Ids把用户 id 解析成内部 iidsearch/ids.py:6
Explain逐 token 遮罩,算每个词对 query 的重要度search/explain.py:11

2.2 两层路由决策图

怎么读这张图:从上往下是决策顺序,命中一个分支就返回,不再往下走。左半是 __call__ 的第一层分叉,进入"纯向量"后才展开右侧 search 的第二层分叉。

embeddings.search() / batchsearch()


┌──────── Search.__call__ ────────┐ ← 第一层路由 (base.py:44)
│ │
① 是图查询? ──是──▶ graphsearch() ─────────▶ 图结果 (→ 06)
│否
② 有数据库且非 indexonly? ─是─▶ dbsearch() ─▶ dict结果 (→ 05)
│否
③ 否则 ─────────────────────▶ search()

┌──────── Search.search ────────┐ ← 第二层路由 (base.py:85)
│ │
指定了 subindex? ──是──▶ subindex() ──────▶ 子索引结果
│否
hybrid = ann AND scoring ?

┌───────────┼─────────────┐
▼ ▼ ▼
仅 ann 仅 scoring 两者都有(hybrid)
dense() sparse() dense()+sparse()
│ │ 各扩召回 limit*10
▼ ▼ │
纯 ANN 纯关键词 ▼
语义搜 搜索 Hybrid 融合 → 合成排名

主线走一遍(高层): search()/batchsearch() 都收敛到 Search.__call__。它先处理三种"特殊出口"(图、数据库),都不满足才落到 search 这个"默认向量出口"。search 再看挂了几种向量后端,决定单路还是双路;双路时才唤醒 Hybrid 做分数融合。记住这条主干,后面每一节都是在给它填肉。


3. 第一层路由:__call__ 的三个出口

这一节讲 Search.__call__search/base.py:44)怎么在图、数据库、纯向量之间选路。

3.1 先处理边界与默认值

进门先设默认、挡空:

# search/base.py:63-72(要点摘录)
limit = limit if limit else 3 # 默认取 3 条
weights = weights if weights is not None else 0.5

# 什么后端都没有 -> 每个 query 回一个空列表
if not self.ann and not self.scoring and not self.indexes and not self.database:
return [[]] * len(queries)

weights=0.5 是 hybrid 的默认权重(稠密、稀疏各占一半,见 §4.1)。这里也解释了为什么"空 embeddings"搜索不会崩、只回空。

3.2 三个出口按优先级排队

真正的分叉是三个 if顺序即优先级

# search/base.py:74-83
# 出口①:图搜索
if self.graph and self.graph.isquery(queries):
return self.graphsearch(queries, limit, weights, index)

# 出口②:数据库搜索(有 content store 且没强制 indexonly)
if not self.indexonly and self.database:
return self.dbsearch(queries, limit, weights, index, parameters)

# 出口③:默认——纯向量索引查询(稀疏/稠密/hybrid)
return self.search(queries, limit, weights, index)

三个出口的区别在于返回什么形状(这也是 __call__ docstring 明说的三种返回):

出口触发条件返回形状展开章节
① graphsearch有图且 query 被识别为图查询图结果对象06
② dbsearch有 database 且非 indexonly每 query 一个 dict(含字段)05
③ search上面都不满足(默认)每 query 一个 (id, score) 列表本章 §4

关键细节: 出口② 和 ① 都不是"纯"搜索——它们内部还会回头调 search 做索引召回,再把候选交给数据库/图去过滤或遍历。看 dbsearchsearch/base.py:232)里 Scan(self.search, ...):数据库路径把 search 当成一个"索引扫描函数"传进 Scan 复用。所以 search 才是所有路径最终的向量召回内核,本章聚焦它。

indexonly 这个开关值得一提:置 True 时即便有数据库也跳过出口②、强制走纯索引(search/base.py:32 构造时记录,__call__ 用它绕开 dbsearch)。


4. 第二层路由:search() 的三态切换(本章核心)

这节是全章重点:同一个 searchsearch/base.py:85)如何在"仅关键词 / 仅 ANN / 两者都开"之间切换。它的 docstring 一句话点破:只有稀疏就是关键词搜,只有稠密就是 ANN 搜,两者都有就是 hybrid。

4.1 hybrid 的判定:一个布尔与

切换的开关只有一行:

# search/base.py:107
hybrid = self.ann and self.scoring
  • self.ann——稠密 ANN 后端(search/base.py:35 从 embeddings 取)。
  • self.scoring——稀疏 scoring 后端;注意只有 embeddings.issparse() 为真时才赋值,否则是 Nonesearch/base.py:42)。

也就是说 scoring 若只是"词权重"(term weighting,非独立稀疏索引),这里就当它不存在——不会误触发 hybrid。这个细节把"用来算 IDF 权重的 scoring"和"能独立召回的稀疏索引"区分开了。

4.2 单路召回:dense 或 sparse

判完 hybrid,两路各自按需跑:

# search/base.py:108-109
dense = self.dense(queries, limit * 10 if hybrid else limit) if self.ann else None
sparse = self.sparse(queries, limit * 10 if hybrid else limit) if self.scoring else None

三态就此成型:

self.annself.scoringhybrid实际行为
Falsedense 有值、sparse=None纯 ANN 语义搜
Falsesparse 有值、dense=None纯关键词搜
True两路都跑 → 进入融合
IndexNotFoundErrorsearch/base.py:122

4.3 hybrid 的"扩召回"技巧:limit * 10

上面那行藏着一个重要设计:hybrid 时每一路都取 limit * 10 条,而不是 limit 条。

为什么? 融合会重新排名。若两路都只各取 limit 条,融合后真正的 top-limit 很可能有些文档在某一路排在 limit 名开外、被提前砍掉,导致最终排名失真。多召回一层候选(10 倍)给融合公式留足"翻盘"空间,融合完再截断到 limit。这是 hybrid 检索里常见的 "over-fetch then re-rank" 手法。

纯搜索: ann ──取 limit── ▶ 直接返回
hybrid: ann ──取 limit*10──┐
kw ──取 limit*10──┴─▶ Hybrid 融合 ──排序──取 limit── ▶ 返回

4.4 单路时的收尾

非 hybrid 分支很直接——过滤空索引后二选一返回:

# search/base.py:121-126
if not sparse and not dense:
raise IndexNotFoundError("No indexes available")
return dense if dense else sparse

4.5 两个共同动作:过滤 0 分 + resolve id

无论走 dense 还是 sparse,两个方法末尾都做同样两件事:

① 砍掉 0 分及以下的结果search/base.py:169:189):

results = [[(i, score) for i, score in r if score > 0] for r in results]

resolve 把内部 id 映射回用户 idsearch/base.py:193):

# 内容禁用时,索引里存的是位置下标 i,要用 self.ids 映射回原始 uid
if not self.indexids and self.ids:
return [[(self.ids[i], score) for i, score in r] for r in results]
return results

indexids=True(构造时可设,search/base.py:31)会跳过这层映射,直接返回内部下标——这是给 dbsearch/graphsearch 等内部调用用的"生结果"通道,它们后面自己接数据库/图去做 id 关联。

4.6 subindex 与 batchsearch

search 开头还有一条支线:若显式指定了 index(子索引名),直接转 subindexsearch/base.py:128),交给对应子索引自己的 batchsearchresolve。子索引本身也是一个完整 embeddings,所以这里是递归复用同一套逻辑。至于顶层 embeddings.search vs batchsearch:前者是单 query 的便捷封装,两者最终都流入本章讲的 Search 实例,路由与融合逻辑完全共享。


5. 分数融合:三种策略与自动选择

这节讲 hybrid 命中后,Hybridsearch/hybrid.py:8)怎么把两路 (id, score) 合成一份排名。核心问题是:稠密的余弦相似度和稀疏的 BM25 分数,量纲根本不一样,凭什么能相加?

先看 search 里怎么唤起融合:

# search/base.py:112-119
if hybrid:
if isinstance(weights, (int, float)):
weights = [weights, 1 - weights] # 0.5 -> [0.5, 0.5],[稠密权, 稀疏权]
fusion = Hybrid(self.scoring)
return [fusion(vectors, weights, limit) for vectors in zip(dense, sparse)]

weights 是个单数就摊成 [w, 1-w]:稠密和稀疏的权重。zip(dense, sparse) 把两路结果按 query 配对,逐 query 融合。

5.1 按 scoring 类型自动选策略

Hybrid 的构造函数就是一个"策略选择器"——它不看数据,只看稀疏 scoring 的归一化配置来定策略:

# search/hybrid.py:26-31
if scoring.isbayes(): # BB25 / 贝叶斯归一化
self.method = self.logodds
elif scoring.isnormalized(): # 普通归一化(分数已在 0~1)
self.method = self.convex
else: # 未归一化(原始 BM25 之类)
self.method = self.rrf

这两个判定来自 scoring 后端(以 tfidf 为例,scoring/tfidf.py:244-248):isnormalized() 看是否开了 normalize,isbayes() 看 normalizer 是否是 BB25/贝叶斯模式(scoring/normalize.py:39,别名 bayes/bayesian/bayesian-bm25/bb25)。

为什么按归一化类型选?直觉是: 融合公式对"输入分数的尺度"有前提假设。

scoring 类型稀疏分数长什么样该用的融合直觉
未归一化原始 BM25,量纲乱、范围不定RRF不信任分数绝对值,只信排名
普通归一化已缩到 0~1,两路可比凸组合直接加权求和分数
BB25/贝叶斯已是校准过的概率对数几率当概率做贝叶斯合取

下面逐个看这三种公式的直觉。

5.2 凸组合 convex(归一化分数)

最直观:两路分数各乘权重后相加,同 id 累加。

# search/hybrid.py:76-83(要点)
uids = {}
for v, scores in enumerate(vectors): # v=0 稠密, v=1 稀疏
for uid, score in scores if weights[v] > 0 else []:
uids[uid] = uids.get(uid, 0.0) + score * weights[v]
return sorted(uids.items(), key=lambda x: x[1], reverse=True)[:limit]

weights[v] > 0 是个小优化:某一路权重为 0 就整路跳过,不白算。前提是两路分数都已归一化到可比区间(所以只在 isnormalized() 时用),否则大量纲的一路会碾压另一路。

直觉: 最终分 = 0.5×语义分 + 0.5×关键词分。凸组合 = 权重非负且和为 1 的加权平均。

5.3 倒数排名融合 rrf(未归一化分数)

当分数不可比时,干脆不看分数值,只看排名名次

# search/hybrid.py:98-105(要点)
uids = {}
for v, scores in enumerate(vectors):
for r, (uid, _) in enumerate(scores if weights[v] > 0 else []): # r 是名次(0起)
uids[uid] = uids.get(uid, 0.0) + (1.0 / (r + 1)) * weights[v]
return sorted(uids.items(), key=lambda x: x[1], reverse=True)[:limit]

注意 (uid, _)——分数被丢弃,只用位置 r。每路贡献 1/(排名):第 1 名给 1、第 2 名给 0.5、第 3 名给 0.33……再乘权重累加。一个文档若在两路都靠前,倒数相加就高。

直觉: "两个评委的打分标准没法直接比,但都排了个名次——我信名次不信分数。" 这是信息检索里经典的 RRF(原公式常带常数 k,此处 k=0、直接用 1/(r+1))。

5.4 对数几率 logodds(BB25 贝叶斯概率)

最复杂的一支,用于 BB25 已把分数校准成概率的场景,做"贝叶斯合取"。委托给 LogOdds 类(search/hybrid.py:108),三步走(__call__hybrid.py:124):

raw分数 ─▶ ① 收集每个doc的(稠密原分, 稀疏概率)
─▶ ② 校准稠密分:per-query 动态 sigmoid
─▶ ③ 加权平均 log-odds + 置信缩放 ─▶ sigmoid 回概率

核心思路: 概率 p 不能直接加权平均(0.9 和 0.9 合取应该更高,不是还等于 0.9)。先把概率转成 logit(对数几率) log(p/(1-p))——这是无界的、可加的空间;在 logit 空间加权平均;再 sigmoid 变回概率。

① 校准稠密分LogOdds.calibratehybrid.py:177):稠密是余弦相似度不是概率,先按这批候选算 medianstd,用 alpha=1/stdbeta=median,把每个稠密分转成 alpha*(score - median) 的 logit——让"中位候选"落在 logit 0(即概率 0.5)。这是 per-query 动态标定,随每次查询的分数分布自适应。

② 融合LogOdds.fusehybrid.py:202):

# hybrid.py:225-234(双路都命中时的要点)
logitdense = densealpha * (rawdense - densemedian) # 稠密 -> logit
logitdense = max(min(logitdense, 500), -500) # 夹紧防溢出
psparse = min(max(psparse, EPSILON), 1 - EPSILON) # 稀疏概率夹到开区间
logitsparse = math.log(psparse / (1.0 - psparse)) # 稀疏概率 -> logit
lbar = weights[0]*logitdense + weights[1]*logitsparse # 加权平均 log-odds
fused[uid] = lbar * scale # 置信缩放

scale = n**alphahybrid.py:216-217,此处 n=2, alpha=0.5,即 √2)是置信缩放:两路都命中的文档,其融合 logit 会被放大,体现"两个独立信号都指向它 → 更有信心"。只命中单路的文档(hybrid.py:236-245)不加这个放大,只按各自权重缩放。

最后一步把所有 logit 用 sigmoid 转回概率(hybrid.py:248):1/(1+e^-score)

诚实标注: LogOdds 的 docstring(hybrid.py:112-119)引用了 "From Bayesian Inference to Neural Computation (Jeong, 2026)" 与 BB25 参考实现(scoring/normalize.py:16-18 列了 GitHub 链接)。这些是论文/实现出处,本章只依据仓库内实际代码逻辑描述其行为,未核对外部论文。


6. 辅助部件:query / terms / ids / explain

这几个是搜索入口周边的小工具,本章只交代它们在路由里的角色,不展开。

6.1 Query —— 自然语言转 SQL

Querysearch/query.py:11)是个 seq2seq 翻译模型,把自然语言变成 SQL。它在 parsesearch/base.py:264)里被用到:若一条 query 解析后不含 select,就先过 Query 翻译成 SQL 再重解析。T5 模型默认前缀 "translate English to SQL: "query.py:31)。clean 做一点收尾(把 $= 还原成 <=query.py:72)。SQL 解析细节见 05

6.2 Terms —— 从 SQL 抽回关键词

Termssearch/terms.py:6)反过来:从一条 SQL 的 similar() 子句里把纯关键词文本抠出来(terms.py:41" ".join 各 similar 子句)。用于需要"这次搜索的关键词是什么"的场景。没有数据库时原样返回 query。

6.3 Ids —— 用户 id 解析内部 id

Idssearch/ids.py:6)把用户给的 id 解析成内部 iid。有数据库就查数据库(ids.py:34),否则线性扫 self.ids 数组(scanids.py:45)。返回 {uid: [iid...]} 因为一个用户 id 可能对应多个内部片段(一对多)。

6.4 Explain —— 逐 token 重要度

Explainsearch/explain.py:11)做可解释性:对每条命中文本,逐个遮罩(mask)掉一个 token,重算相似度,用"分数掉了多少"衡量该 token 的重要度(explain.py:106-114)。掉得越多说明这个词越关键。若有数据库,先从 SQL 的 similar 子句抽出真正的 query 文本再解释(explain.py:90-95)。它调 batchsearch/batchsimilarity,是搜索之上的分析层。


7. 巧妙之处(可借鉴)

  • 路由即优先级的三段 ifsearch/base.py:74-83)——图 > 数据库 > 向量,命中即返回。没有复杂的策略对象,就是顺序短路,读代码一眼看懂"这次走哪条"。

  • hybrid 的 over-fetch(limit*10search/base.py:108-109)——融合会重排名,单路只取 limit 会误删翻盘候选。多召回一层再截断,是 hybrid 检索的通用正确性保障。

  • 融合策略靠 scoring 配置自选,而非用户手选search/hybrid.py:26-31)——把"分数尺度"和"该用哪种融合"绑定:未归一化→只信排名(RRF),归一化→加权和(convex),概率→贝叶斯合取(logodds)。用户改 normalize 配置,融合策略自动跟着变,不易配错。

  • logit 空间做概率合取 + 置信缩放search/hybrid.py:225-234)——概率不能直接平均,转 logit(无界可加)后平均再 sigmoid 回来;双路命中额外乘 √2 放大,把"两个独立信号一致"编码成更高置信。

  • scoring 仅在 issparse() 时才参与 hybridsearch/base.py:42)——区分"能独立召回的稀疏索引"和"只算词权重的 scoring",避免后者误触发 hybrid。


8. 边界与局限

  • 本章不含 SQL 解析细节。 parse/limit/dbsearchsearch/base.py:210 起)只讲到"路由入口"层;database.parsesimilar() 子句、bind 参数、Scan 分组的完整机制在 05

  • 本章不含图遍历细节。 graphsearchsearch/base.py:299)如何把索引召回结果喂给图做遍历/主题建模,在 06

  • 融合只对 hybrid(两路都开)生效。 单路搜索直接返回,不经过 Hybrid。若只挂了一种索引,weights 参数基本无意义。

  • weights[v] > 0 的短路hybrid.py:78:100)意味着某路权重设成 0 就等于关掉该路——但两路都被扩召回过(limit*10 已经查了),扩召回的开销省不掉。

  • 0 分过滤是硬阈值search/base.py:169:189)——稠密里 score > 0 才留。若某种相似度可能取负值又有意义,会被这层无差别砍掉。

  • LogOdds 的理论依据引自外部论文hybrid.py:112),本章依据仓库内代码描述其行为,未验证论文本身;BB25 的正确性最终取决于 scoring 层的归一化实现(见 03)。


9. 横向对比(同组其它章)

想了解去哪章
Embeddings 编排类、索引生命周期01-embeddings-database.md
文本如何变成向量(向量化管线)02-vectorization.md
稠密 ANN / 稀疏 scoring 两类后端本身03-indexes-dense-sparse.md
SQL 驱动检索、content store、similar 子句05-sql-and-database.md
图网络、关系检索、主题建模06-graph.md
全景与阅读地图index.md

本章是这些后端的汇聚点:03 造出的两类索引,在这里被 04 的路由挑选、被融合公式合并;05/06 则是本章两个"特殊出口"的下游展开。


10. 代码地图(导航索引)

主题文件路径符号名
第一层路由(图/数据库/向量)src/python/txtai/embeddings/search/base.pySearch.__call__
第二层路由(稀疏/稠密/hybrid)src/python/txtai/embeddings/search/base.pySearch.search
hybrid 判定src/python/txtai/embeddings/search/base.pySearch.searchhybrid = self.ann and self.scoring
稠密 ANN 召回src/python/txtai/embeddings/search/base.pySearch.dense
稀疏关键词召回src/python/txtai/embeddings/search/base.pySearch.sparse
内部 id → 用户 idsrc/python/txtai/embeddings/search/base.pySearch.resolve
数据库出口src/python/txtai/embeddings/search/base.pySearch.dbsearch
图出口src/python/txtai/embeddings/search/base.pySearch.graphsearch
融合策略选择src/python/txtai/embeddings/search/hybrid.pyHybrid.__init__
凸组合融合src/python/txtai/embeddings/search/hybrid.pyHybrid.convex
倒数排名融合src/python/txtai/embeddings/search/hybrid.pyHybrid.rrf
对数几率融合src/python/txtai/embeddings/search/hybrid.pyHybrid.logodds / LogOdds
稠密分校准src/python/txtai/embeddings/search/hybrid.pyLogOdds.calibrate
logit 融合与置信缩放src/python/txtai/embeddings/search/hybrid.pyLogOdds.fuse
融合策略判定依据src/python/txtai/scoring/base.pyScoring.isnormalized / Scoring.isbayes
BB25 模式判定src/python/txtai/scoring/normalize.pyNormalize.isbayes
自然语言 → SQLsrc/python/txtai/embeddings/search/query.pyQuery.__call__
SQL → 关键词src/python/txtai/embeddings/search/terms.pyTerms.__call__
用户 id 解析src/python/txtai/embeddings/search/ids.pyIds.__call__
逐 token 重要度src/python/txtai/embeddings/search/explain.pyExplain.explain
索引未找到异常src/python/txtai/embeddings/search/errors.pyIndexNotFoundError