数据截至 (上游 commit 059ecec2eeac)
两类索引后端:稠密 ANN 与稀疏 scoring
30 秒导读: 上一章(02-vectorization)把文本变成了数字。这一章讲数字如何被组织成"能搜"的数据结构。txtai 有两条完全独立的路:稠密向量塞进 ANN(近似最近邻,靠几何距离找相似)、词和稀疏维度塞进 scoring(倒排表 + BM25 加权,靠词命中打分)。两者各有一堆可插拔后端。这一章只讲"索引这一层长什么样、每种后端怎么选";怎么把两条腿的结果拼成一次搜索,留给 04-search-and-fusion。
1. 这是什么(零基础也能懂)
先建立一个最朴素的画面。你有一百万条文本,已经变成了一百万个东西。现在有人拿一条查询进来,要你从一百万里挑出最相关的 10 条。
最笨的办法: 把查询和每一条逐个比一遍,排序,取前 10。一百万次比较——慢,而且每加一条数据就更慢。
索引(index)就是让这一步变快的数据结构。 它提前把数据组织好,查询进来时不用扫全库,只碰"可能相关"的一小撮。这一章讲的就是 txtai 里这层数据结构。
两种"相关",两种索引
txtai 对"相关"有两套完全不同的定义,于是有两套索引:
| 稠密索引(ANN) | 稀疏索引(scoring) | |
|---|---|---|
| 数据长什么样 | 一条数据 = 一个几百维的稠密向量(每维都有值) | 一条数据 = 一袋词,或一个绝大多数维度为 0 的稀疏向量 |
| "相关"的定义 | 向量在空间里离得近(几何距离/夹角小) | 查询的词命中了文档,且命中的是"稀有而重要"的词 |
| 擅长 | 语义近似("汽车"≈"轿车",哪怕不同字) | 精确关键词、专有名词、罕见术语 |
| 代表实现 | Faiss、HNSW、Annoy | BM25 倒排表、Postgres 全文检索 |
| 本章对应目录 | src/python/txtai/ann/ | src/python/txtai/scoring/ |
一句话直觉:稠密索引像"按含义找",稀疏索引像"按字眼找"。 前者是几何问题(谁离我近),后者是记账问题(哪些文档里出现过这个词、出现了几次)。
为什么要两套
因为它们互相补短。稠密向量抓得住"意思对但字不一样"(近义、改写),但对精确的编号、人名、罕见词反而钝;稀疏倒排恰恰在这些精确词上最锐利。所以严肃的检索系统往往两条腿都要——这也是为什么 txtai 把它们做成并列的两个子系统,而不是二选一。
这一章我们只把每条腿单独讲透。两条腿的分数怎么合成一个排序,是 04 的事。
2. 顶层全景(它大概怎么转)
两个子系统的形状惊人地一致:一个抽象基类定义"索引该有哪些动作",一个工厂按配置挑后端,后面挂着一排可插拔的具体后端。
┌───────────────────────────────┐
稠密向量 (dense) ───▶ │ ann/base.py : ANN (抽象基类) │
│ index/append/search/delete │
│ save/load/count/setting │
└───────────────┬───────────────┘
│ ANNFactory 按 config["backend"] 选
┌────────────┬───────────┬────────┼────────┬──────────┬───────────┐
faiss hnsw annoy numpy torch pgvector sqlite ...
(IVF/Flat) (图) (树) (暴力点积) (GPU) (Postgres) (sqlite-vec)
┌───────────────────────────────┐
词 / 稀疏向量 ───▶ │ scoring/base.py : Scoring │
│ insert/index/upsert/weights │
│ search/batchsearch │
│ issparse/isnormalized/isbayes│
└───────────────┬───────────────┘
│ ScoringFactory 按 config["method"] 选
┌────────────┬───────────┬────────┼────────┬───────────────────────┐
bm25 tfidf sif sparse pgtext
(倒排+BM25) (倒排+TFIDF) (词频) (稀疏向量) (PG 全文检索)
│
稀疏向量后端复用 ann/sparse/
┌─────────┴─────────┐
ivfsparse pgsparse
怎么读这张图:从上到下三层—— 抽象接口 → 工厂路由 → 具体后端。 左半(ann)和右半(scoring)是两个平行子系统,唯一的交叉点在右下角:scoring 的 sparse 方法把稀疏向量交给 ann/sparse/ 去建索引。
各部件一句话职责:
| 部件 | 干什么 | 在哪 |
|---|---|---|
ANN | 稠密索引的抽象契约 | ann/base.py:11 |
ANNFactory | 按 backend 名造稠密索引 | ann/dense/factory.py:21 |
Scoring | 稀疏/关键词索引的抽象契约 | scoring/base.py:6 |
ScoringFactory | 按 method 名造 scoring 索引 | scoring/factory.py:15 |
SparseANNFactory | 造稀疏向量 ANN(供 sparse 用) | ann/sparse/factory.py:12 |
Normalize | 把原始分数拉到 0–1 可比刻度 | scoring/normalize.py:11 |
3. 稠密侧:ANN 抽象与后端
3.1 ANN 基类——所有稠密后端的共同契约
先看接口,再看实现,是理解这一层最省力的顺序。ann/base.py:11 的 ANN 类没有任何算法,它只规定"一个稠密索引必须会做哪几件事":
| 方法 | 契约 | 位置 |
|---|---|---|
index(embeddings) | 从零建索引 | ann/base.py:41 |
append(embeddings) | 往已有索引追加 | ann/base.py:51 |
search(queries, limit) | 批量查,返回 [(id, score)] | ann/base.py:71 |
delete(ids) | 删除若干 id | ann/base.py:61 |
count() | 有效条数(排除已删) | ann/base.py:85 |
save(path) / load(path) | 落盘 / 读盘 | ann/base.py:95、:31 |
setting(name, default) | 读后端专属配置 | ann/base.py:112 |
metadata(settings) | 盖构建元数据(时间/版本) | ann/base.py:131 |
两个非抽象的公共方法值得记住,它们在每个后端里被反复用:
setting 从"后端自己的那段配置"里取参数。注意它是嵌套查找——先用 config["backend"](比如 "faiss")取出该后端的子配置,再从里面找 name:
# ann/base.py:112 setting —— 示意,简化自源码
def setting(self, name, default=None):
backend = self.config.get(self.config["backend"]) # 取 config["faiss"] 这一段
setting = backend.get(name) if backend else None
return setting if setting or (backend and name in backend) else default
metadata 在每次 index/append 后盖一个时间戳和构建环境(Python 版本、系统、txtai 版本),写进 config["build"] 和 config["update"](ann/base.py:131)。这让保存下来的索引能自描述"我是什 么时候、用什么建的"。
一个贯穿所有后端的共同约定:id 就是向量在数组里的位置。index 时用 0..N-1 当 id,append 时用 config["offset"] 当起点继续往后编号(见 faiss.py:75、hnsw.py:66)。这样 ANN 只管几何,真正的"内部 id ↔ 外部 id ↔ 文档内容"映射交给 Embeddings 编排层去维护(见 01-embeddings-database)。
3.2 ANNFactory——一个 if/elif 决定用哪种几何
工厂本身极简:读 config["backend"],if/elif 分派到具体类。关键是默认值——有 Faiss 就用 Faiss,否则退回纯 NumPy 暴力搜索:
# ann/dense/factory.py:37 —— 默认后端选择
backend = config.get("backend", "faiss" if FAISS else "numpy")
认不出的名字会走 Resolver 尝试按类路径动态加载(ann/dense/factory.py:73),所以你也能挂自定义后端。
3.3 各后端一句话对比
这些后端解决的是同一个问题——在稠密向量空间里找最近邻——但在"精确 vs 近似""内存 vs 磁盘/DB""要不要 GPU""要不要量化压缩"上做了不同取舍:
| 后端 | 底层库 | 核心数据结构 | 一句话定位 | 位置 |
|---|---|---|---|---|
faiss | Faiss | 小库 Flat 暴力,大库自动 IVF 倒排单元 | 默认首选;规模自适应,支持标量/二值量化 | ann/dense/faiss.py:34 |
hnsw | hnswlib | 分层可导航小世界图 | 高召回、快查询,删除靠打标记 | ann/dense/hnsw.py:22 |
annoy | Annoy | 随机投影树森林 | 只读友好、内存映射;不支持增量 append/delete | ann/dense/annoy.py:17 |
numpy | NumPy | 一整块二维数组 | 无依赖的精确暴力点积,基准线 | ann/dense/numpy.py:17 |
torch | PyTorch | 张量(可上 GPU) | numpy 的 GPU 版,支持 4/8 bit 量化矩阵乘 | ann/dense/torch.py:31 |
pgvector | pgvector | Postgres 表 + HNSW 索引 | 索引存进关系库,和内容同库 | ann/dense/pgvector.py:28 |
sqlite | sqlite-vec | SQLite vec0 虚拟表 | 单文件、可嵌入、支持 int8/二值量化 | ann/dense/sqlite.py:19 |
ggml | ggml/GGUF | GGUF 张量 | 用 ggml 后端做相似度,产物是 GGUF 文件 | ann/dense/ggml.py:25 |
turbovec | turbovec | IdMap 量化索引 | 位宽可调(默认 4bit)的压缩向量索引 | ann/dense/turbovec.py:21 |
torch 直接继承 numpy(ann/dense/torch.py:31 的 class Torch(NumPy)),只把数组函数换成 torch 版并加量化;pgsparse 也继承 pgvector。这种"继承复用"在两个子系统里都很常见。
3.4 一条贯穿所有后端的暗线:内积 = 余弦
上一章说过 txtai 的向量默认归一化。这里就是它的回报:在归一化向量上,内积(点积)恰好等于余弦相似度。于是几乎每个后端都直接用内积当距离,省掉一次归一化除法。这句注释在源码里出现了一遍又一遍:
- Faiss:用
METRIC_INNER_PRODUCT建索引(ann/dense/faiss.py:182); - HNSW:
self.config["metric"] = "ip"(ann/dense/hnsw.py:40); - Annoy:
self.config["metric"] = "dot"(ann/dense/annoy.py:35); - NumPy:
self.dot(queries, self.backend.T)直接点积(ann/dense/numpy.py:75); - pgvector:
max_inner_product(ann/dense/pgvector.py:285,createindex用 HNSW 见:133)。
score 的口径也被统一成"越大越相似、约在 0–1"。 各后端在返回前做一次小转换,把底层库的原始输出拉到这个约定上:
- HNSW 拿到的是距离,返回前转成
1 - distance(ann/dense/hnsw.py:95); - pgvector 因为 Postgres 只能升序扫索引,存的是负内积,
score()再取反成正(ann/dense/pgvector.py:307); - 量化(二值)索引统一返回汉明相似度
1.0 - 汉明距离/总位数(faiss.py:234、numpy.py:177的hammingscore)。
记住这个"分数口径"很重要——它是下一章能把稠密分和稀疏分放在一起融合的前提。