数据截至 (上游 commit 059ecec2eeac)
搜索路由与混合分数融合
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 |
LogOdds | Bayesian(BB25) 分数的对数几率融合 | search/hybrid.py:108 |
Search.resolve | 把内部 index id 映射回用户 id | search/base.py:193 |
Query | 自然语言 → SQL 的翻译模型 | search/query.py:11 |
Terms | 从 SQL 的 similar() 子句抽回纯关键词 | search/terms.py:6 |
Ids | 把用户 id 解析成内部 iid | search/ids.py:6 |
Explain | 逐 token 遮罩,算每个词对 query 的重要度 | search/explain.py:6 |
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 做索引召回,再把候选交给数据库/图去过滤或遍历。看 dbsearch(search/base.py:232)里 Scan(self.search, ...):数据库路径把 search 当成一个"索引扫描函数"传进 Scan 复用。所以 search 才是所有路径最终的向量召回内核,本章聚焦它。
indexonly 这个开关值得一提:置 True 时即便有数据库也跳过出口②、强制走纯索引(search/base.py:32 构造时记录,__call__ 用它绕开 dbsearch)。
4. 第二层路由:search() 的三态切换(本章核心)
这节是全章重点:同一个
search(search/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()为真时才赋值