数据截至 (上游 commit 059ecec2eeac)
向量化管线:文本/文档如何变成向量
30 秒导读: 检索系统要"用相似度找东西",前提是先把每段文本变成一串数字(向量)。这一章讲 txtai 的
vectors/目录——输入内容 → 数值向量的这一层。它做三件事:选一个后端模型去编码、对编出来的向量做统一后处理(截断 / 归一化 / 量化)、以及把海量向量流式写盘再内存映射回来以省内存。至于这些向量之后被建成什么索引、怎么打分、怎么融合,分别是03 / 04 的事。
本章属于 txtai 系列的一环。同组还有:index.md(全景与阅读地图)、01-embeddings-database.md(Embeddings 编排类与索引生命周期)、03-indexes-dense-sparse.md、04-search-and-fusion.md、05-sql-and-database.md、06-graph.md。
1. 这是什么(零基础也能懂)
一句话定义: 向量化层就是一台"文本进、数字出"的转换机——把 "the cat sat" 这样的字符串,变成 [0.013, -0.11, 0.42, ...] 这样一个几百维的浮点数组。
为什么需要它。 计算机不会"读懂"文字,但很会算数。只要把每段文字映射成同一空间里的一个点(向量),"语义相近"就能变成"两点距离近 / 夹角小",检索于是退化成一道数学题。这一层的职责,就是可靠、批量、省内存地把内容都摆到这个空间里。
它具体做什么:
- 选后端:根据配置(或从模型路径自动推断)挑一个编码后端——本地 Transformer、sentence-transformers、llama.cpp、外部 API、词向量……
- 编码:调后端把一批文本转成向量。
- 后处理:按需截断维度、做 L2 归一化、做标量量化压缩。
- 落盘与回读:把成批向量流式写到临时文件,再用内存映射(memmap)读回,避免一次性把几百万向量塞进内存。
一句话直觉: 把它当成一台"翻译机 + 流水线"。翻译机(后端模型)负责把语言翻成坐标;流水线(base 类)负责把坐标切齐、摆正、压缩、装箱发货。你换翻译机(后端),流水线不变——这正是这一层的设计核心。
两类产物,先记住区别:
| 稠密向量(dense) | 稀疏向量(sparse) | |
|---|---|---|
| 长什么样 | 定长数组,几乎每一维都非零,如 384/768 维全是小数 | 超高维但绝大多数是 0,只存"非零位置 + 值" |
| 存储结构 | numpy ndarray(memmap) | SciPy csr_matrix(压缩稀疏行) |
| 擅长 | 语义相似("含义接近") | 词面/词项匹配(类似关键词) |
| 本章对应 | vectors/dense/* | vectors/sparse/* |
两者的后处理规则不同:稠密能截断、能量化;稀疏两者都直接报错不支持(见 §6)。
2. 顶层全景(它大概怎么转)
怎么读这张图: 从左到右是一次批量向量化的数据流。中间那条竖线是"每段文本必经的四步管线";下方是可切换的后端;右侧是省内存的落盘/回读机制。
一次 index(documents) 调用
│
┌─────────────────┴────────────── ────┐
│ base.Vectors.index / vectors │ 按 batchsize 切批 + 流式落盘
└─────────────────┬──────────────────┘
│ 每批 documents
▼
prepare ──► ┌──────────────────────────────────────────┐
(分词/加指令)│ vectorize() 每段文本必经四步 │
│ ① encode ② truncate ③ normalize ④ quantize│
└───────────────────┬──────────────────────────┘
│ ① 调后端(可切换)
┌───────────┬─────────────┼─────────────┬───────────┐
▼ ▼ ▼ ▼ ▼
sbert / llama.cpp litellm words external
huggingface (本地GGUF) (远程API) (词向量+权重) (自定义函数)
└───────────┴─────────────┴─────────────┴───────────┘
│ 成批向量(numpy / CSR)
▼
saveembeddings ──► 临时/checkpoint 文件(spool)
▼
vectors(): np.memmap 逐批读回 ──► 交给索引层
部件一句话职责:
| 部件 | 干什么 | 在哪 |
|---|---|---|
Vectors(基类) | 定义整条管线:批处理、prepare、vectorize 四步、落盘/回读 | vectors/base.py:20 |
VectorsFactory | 按 method 或从 path 推断,选出稠密后端 | vectors/dense/factory.py:18 |
SparseVectorsFactory | 选稀疏后端 | vectors/sparse/factory.py:11 |
| 稠密后端 | 各自实现 encode,把文本变数组 | vectors/dense/*.py |
| 稀疏后端 | 实现 encode,把文本变 CSR 稀疏矩阵 | vectors/sparse/*.py |
Recovery | 从 checkpoint 断点续跑,复用已算好的批 | vectors/recovery.py:9 |
Reducer | 词向量场景下的 LSA 降噪(去主成分) | embeddings/index/reducer.py:20 |
主线走一遍(高层): 上游 embeddings/index/transform.py:100 把文档流交给 model.vectors(...);基类 index() 把文档切成批,逐批 prepare → vectorize(四步)→ saveembeddings 写盘;全部写完后 vectors() 用 np.memmap 把盘上的向量映射回来,连同 ids 和维度数一并返回给索引层。
3. 核心原理(逐个机制,由浅入深)
3.1 一段文本进来,先被"预处理":prepare 与 tokens
要解决的小问题: 不同后端要的输入格式不一样——有的要原始字符串,有的要求先分词,有的指令式模型还要在 文本前拼一句"任务说明"。得先把输入摆成后端认识的样子。
prepare 分两步做这件事,见 vectors/base.py:311 prepare:
- 可选分词(
tokens,vectors/base.py:336):若配置了tokenize,用Tokenizer.tokenize把字符串切成词再拼回空格串;若输入本来就是 token 列表,也拼成字符串。 - 可选加指令:若配了
instructions且当前category(如"query"/"data")命中,就把对应指令前缀拼到文本前(vectors/base.py:330)。这对应"查询和文档用不同 prompt"的检索模型。
# 示意,非源码:prepare 的核心逻辑
def prepare(data, category="query"):
data = tokens(data) # 可选分词,列表→空格串
if instructions and category in instructions and isinstance(data, str):
data = f"{instructions[category]}{data}" # 指令前缀
return data
category 这个参数贯穿全程,取值主要是 "query"(查询侧)和 "data"(入库文档侧),让同一个模型对查询和文档走不同处理。
3.2 管线的心脏:vectorize 的四步
要解决的小问题: 无论换哪个后端,编码之后的"整形"步骤应该统一,不该让每个后端各写一遍。
txtai 的做法是把编码与后处理分开:后端只管 encode(抽象方法,vectors/base.py:76),而所有向量都流经基类的 vectorize(vectors/base.py:357),固定走四步。
vectorize(data):
① embeddings = encode(data) # 调后端,唯一由子类实现的一步
② if dimensionality < 维度: truncate # 截断(仅对 MRL 模型有意义)
③ embeddings = normalize(embeddings) # L2 归一化
④ if qbits: embeddings = quantize # 标量量化压缩
为什么这样分层: 换后端 = 换 ①;②③④ 一行不用动。这就是 §1 说的"换翻译机、流水线不变"。稀疏子类通过覆写 ②③④ 来定制自己的规则(见 §6),但入口仍是同一个 vectorize。
batchtransform(vectors/base.py:212)是它的对外包装:先 prepare 每个文档,若发现输入已经是 numpy 数组就直接返回、跳过编码(vectors/base.py:228),否则才调 vectorize。这让"用户已自备向量"的场景零开销通过。