跳到主要内容

向量化管线:文本/文档如何变成向量

30 秒导读: 检索系统要"用相似度找东西",前提是先把每段文本变成一串数字(向量)。这一章讲 txtai 的 vectors/ 目录——输入内容 → 数值向量的这一层。它做三件事:选一个后端模型去编码、对编出来的向量做统一后处理(截断 / 归一化 / 量化)、以及把海量向量流式写盘再内存映射回来以省内存。至于这些向量之后被建成什么索引、怎么打分、怎么融合,分别是03 / 04 的事。

本章属于 txtai 系列的一环。同组还有:index.md(全景与阅读地图)、01-embeddings-database.md(Embeddings 编排类与索引生命周期)、03-indexes-dense-sparse.md04-search-and-fusion.md05-sql-and-database.md06-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
VectorsFactorymethod 或从 path 推断,选出稠密后端vectors/dense/factory.py:17
SparseVectorsFactory选稀疏后端vectors/sparse/factory.py:10
稠密后端各自实现 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() 把文档切成批,逐批 preparevectorize(四步)→ saveembeddings 写盘;全部写完后 vectors()np.memmap 把盘上的向量映射回来,连同 ids 和维度数一并返回给索引层。


3. 核心原理(逐个机制,由浅入深)

3.1 一段文本进来,先被"预处理":preparetokens

要解决的小问题: 不同后端要的输入格式不一样——有的要原始字符串,有的要求先分词,有的指令式模型还要在文本前拼一句"任务说明"。得先把输入摆成后端认识的样子。

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。这让"用户已自备向量"的场景零开销通过。

3.3 后端怎么选:VectorsFactory 与从路径推断

要解决的小问题: 用户往往只给一个模型路径,不写 method。系统得自己看出"这是个 GGUF 文件 / 是个 API 名 / 是 HF 模型"。

VectorsFactory.method(vectors/dense/factory.py:74)先看配置里有没有显式 method;没有就按 path 依次探测(vectors/dense/factory.py:91):

若无 method 且有 path,依次问:
LiteLLM.ismodel(path)? → "litellm" (能被识别为某 API provider)
LiteRT.ismodel(path)? → "litert" (以 .tflite 结尾)
LlamaCpp.ismodel(path)? → "llama.cpp" (以 .gguf 结尾)
Model2Vec.ismodel(path)? → "model2vec" (config.json 里 model_type)
WordVectors.ismodel(path)? → "words" (SQLite 库 或 staticvectors)
都不是 → "transformers" (兜底:本地 HF 模型)

create(vectors/dense/factory.py:22)再按算出的 method 实例化对应类;认不出的字符串会走 resolve 当自定义类去动态加载(vectors/dense/factory.py:110)。

3.4 L2 归一化:把"长度"从相似度里剔除

要解决的小问题: 若不归一化,向量长度会掺进相似度里,长文本可能只因"向量更长"而显得更相关。归一化后所有向量落到单位球面上,点积就等价于余弦相似度。

normalize(vectors/base.py:434)对矩阵按行除以其 L2 范数,对单个向量则整体除。它是**就地(in-place)**操作。之所以要归一化,是因为下游 dot(vectors/base.py:233)直接用点积算分——它在注释里明说"假设输入都已归一化"。

3.5 标量量化:用 uint8 把向量压小

要解决的小问题: 一个 768 维 float32 向量占 3072 字节;上百万个就是 GB 级。若能接受一点精度损失,把每维压到几 bit,内存和磁盘能省好几倍。

配置里的 quantize 是想要的 bit 数;构造时被夹到 1–8 之间存为 self.qbits(vectors/base.py:61):

# 源码:vectors/base.py:61 —— 只接受 1..8 的整数,布尔值被排除
self.qbits = max(min(quantize, 8), 1) if isinstance(quantize, int) and not isinstance(quantize, bool) else None

quantize(vectors/base.py:453)的算法(以归一化后的向量为输入):

  1. factor = 2^(qbits-1) 作为量程中点。
  2. 把浮点值乘以 factor、裁剪到 [-factor, factor-1]、再加 factor 平移到非负,转成 uint8(vectors/base.py:468)。
  3. np.unpackbits 把每个 uint8 拆成 8 个 bit,只保留末尾 qbits 位(vectors/base.py:476,例如 3-bit 就丢掉高 5 位)。
  4. np.packbits 把这些有效 bit 重新紧密打包成 uint8 数组返回。

配套地,上游在量化开启时会把 memmap 的 dtype 设为 np.uint8(否则 np.float32),见 embeddings/index/transform.py:97注意:量化只对稠密向量有效;稀疏子类的 quantize 直接抛错(见 §6)。

3.6 维度截断:只对 MRL 模型有意义

truncate(vectors/base.py:418)就是 embeddings[:, :dimensionality]——砍掉尾部维度。它的注释点明这只对把重要信息放在前面维度的模型有意义(如 Matryoshka Representation Learning,套娃式表征)。对普通模型直接截断会丢信息,所以它由 dimensionality 配置显式触发,且仅当目标维度小于实际维度时才做(vectors/base.py:382)。


4. 深入实现:批处理、落盘与 memmap

这一节讲"几百万文档怎么不撑爆内存"。核心思路一句话:边算边写盘,算完再内存映射读回。

4.1 切批 + 流式落盘:index

index(vectors/base.py:112)把文档流按 batchsize(默认 500)攒批,每满一批就调 batch(vectors/base.py:284)编码并立即写盘,而不是在内存里堆着:

# 示意,非源码:index 的骨架
with spool(checkpoint, vectorsid) as output: # 打开落盘文件
batch = []
for document in documents:
batch.append(document)
if len(batch) == batchsize:
uids, dimensions = self.batch(batch, output, recovery) # 编码 + 写盘
ids.extend(uids); batches += 1
batch = []
if batch: # 收尾最后一批
...
return (ids, dimensions, batches, stream) # 只返回元数据 + 文件路径

注意它返回的不是向量本身,而是 ids、维度、批数、以及落盘文件路径 stream。真正的向量还躺在盘上。

batch(vectors/base.py:284)内部:抽出 idsprepare 每个文档(category 固定 "data")→ 优先从 recovery 读、否则 vectorizesaveembeddings 追加写盘。读/写盘用的是 numpy 的 np.save/np.load(vectors/base.py:394vectors/base.py:407)。

4.2 memmap 回读:vectors

vectors(vectors/base.py:155)先跑 index 落盘,再用 np.memmap 分配一块 (总数, 维度) 的磁盘映射数组,然后逐批把盘上向量读回、写进 memmap 的对应切片:

# 源码要点:vectors/base.py:177
embeddings = np.memmap(buffer, dtype=dtype, shape=(len(ids), dimensions), mode="w+")
with open(stream, "rb") as queue:
x = 0
for _ in range(batches):
batch = self.loadembeddings(queue) # 从 spool 读一批
embeddings[x : x + batch.shape[0]] = batch
x += batch.shape[0]

buffer 是外部传入的 memmap 文件路径,dtype 由上游按是否量化决定(uint8 / float32)。用 memmap 的好处:返回的 embeddings 在语义上是个完整大数组,但实际数据在磁盘上,按需分页进内存,索引层可以顺序消费而不必全量驻留。未开 checkpoint 时,临时 spool 文件在读完后被删除(vectors/base.py:186)。

4.3 断点续跑:vectorsidspoolRecovery

要解决的小问题: 给千万级文档建库要跑很久,中途崩了不该从零再来。

三块拼图:

  • spool(vectors/base.py:264):决定向量写到哪。开了 checkpoint 就写到 {checkpoint}/{vectorsid} 这个稳定文件;否则写临时文件。
  • vectorsid(vectors/base.py:248):把决定"这次向量化身份"的配置项(pathmethodtokenizermaxlengthquantize 等)拼成 JSON,算一个确定性 UUID5。配置一样 → id 一样 → 能复用同一个 checkpoint 文件;配置一变(比如换了模型),id 变,旧 checkpoint 自然作废。
  • Recovery(vectors/recovery.py:9):若 checkpoint 文件已存在,构造时把它复制成 recovery 文件并打开;之后每次 batchrecovery()(vectors/recovery.py:39)就从这个副本里读出上次已算好的一批,省去重新编码。读到文件尾(EOFError)就清理并返回 None,后续批回落到正常 vectorize

这套设计让重启后的向量化"接着上次的批往下算",而不是从头。


5. 各后端逐一对比(每个一句话)

所有稠密后端都继承 Vectors,只实现自己的 loadmodel / encode;差别全在"用什么去编码"。

后端类文件:符号一句话:它用什么编码
STVectorsdense/sbert.py:18sentence-transformers(SBERT);按 category 分别调 encode_query/encode_document,多 GPU 时起进程池
HFVectorsdense/huggingface.py:10本地 HF Transformer + 池化层(mean/cls pooling),经 PoolingFactory 构建
LiteLLMdense/litellm.py:21远程 embedding API(OpenAI 等),loadmodel 返回 None,encode 直接发 API 请求,批处理在服务端
LlamaCppdense/llama.py:23本地 .gguf 模型经 llama.cpp,encodemodel.embed,批处理靠 n_batch
LiteRTdense/litert.py:24本地 .tflite 模型 + tokenizers,自己做定长 padding/截断,手工搬运 tensor buffer 分批推理
Model2Vecdense/m2v.py:19Model2Vec 静态蒸馏模型(StaticModel),轻量快速
WordVectorsdense/words.py:71词向量:逐 token 查表,再用 scoring 词权重做加权平均得句向量;支持多进程池
Externaldense/external.py:15用户自带的 transform 函数/API;encode 调它,再统一 cast 成 float32

WordVectors 值得单独说两句——它是唯一深度用到 scoring 的后端。encode(dense/words.py:130)对每段文本:查出每个 token 的词向量,用 scoring.weights(tokens) 拿到词权重(类似 TF-IDF 的"这个词多重要"),做加权平均;没有权重就退化成简单平均(dense/words.py:145-148)。它还覆写了 index(dense/words.py:154)用多进程池并行编码,并覆写 tokens(dense/words.py:208)跳过基类分词。

这条线还牵出 LSA 降噪:词向量的句表示常被高频常用词"污染",Reducer(embeddings/index/reducer.py:20)用 TruncatedSVD 拟合出主成分,再在 __call__(embeddings/index/reducer.py:39)里把这几个主成分从每个向量里减掉,平滑掉"没信息量的common词"带来的噪声。Reducer 属于索引层的一环(呼应 03),这里只点出它和词向量后端的呼应关系。


6. 稀疏向量:同管线,不同规则

稀疏后端 SparseVectors(vectors/sparse/base.py:20)同样继承 Vectors,复用整条批处理/落盘框架,但把和"稠密"绑死的几步覆写掉:

  • encode(vectors/sparse/base.py:35):先调父类 encode 得到一个 torch 稀疏张量,取出非零的 indices/values,转成 SciPy csr_matrix(压缩稀疏行格式,只存非零)。
  • vectors(vectors/sparse/base.py:47):回读时不用 memmap,而是把每批 CSR 用 scipy.sparse.vstack 纵向拼起来——稀疏矩阵没法简单 memmap。
  • truncate / quantize:直接 raise ValueError(vectors/sparse/base.py:71:78)——稀疏向量"截断维度""标量量化"都无意义。
  • normalize(vectors/sparse/base.py:74):默认不归一化(defaultnormalize 返回 False,vectors/sparse/base.py:81),因为稀疏向量通常不归一化时检索更好;可配置开启。
  • dot(vectors/sparse/base.py:62):改用 safe_sparse_dot 算稀疏点积。

具体实现只有一个:SparseSTVectors(vectors/sparse/sbert.py:17),用 sentence-transformers 的 SparseEncoder;它多重继承 SparseVectors 和稠密的 STVectors,复用后者的 GPU/池化装载逻辑,只把 encoder 换成 SparseEncoder,并在余弦相似时默认开归一化(vectors/sparse/sbert.py:32)。工厂 SparseVectorsFactory.create(vectors/sparse/factory.py:15)默认就走 sentence-transformers。


7. 边界与局限(这一章刻意不讲什么)

  • 不讲这些向量之后建成什么索引结构(Faiss/HNSW 等 ANN、稀疏 scoring 索引)→ 见 03-indexes-dense-sparse.md
  • 不讲 BM25 / 词项打分的具体公式(词权重 scoring.weights 在本章只作为词向量的输入被提及)→ 见 03
  • 不讲稠密/稀疏分数怎么融合成混合检索结果 → 见 04-search-and-fusion.md
  • 量化/截断只适用稠密;稀疏调用会直接抛错(§6),这是刻意的边界。
  • dot 假设输入已归一化(vectors/base.py:233 注释),若绕过 vectorize 自己塞未归一化的向量,相似度会失真。

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

主题文件路径符号
向量化基类 / 整条管线src/python/txtai/vectors/base.pyVectors
四步后处理入口src/python/txtai/vectors/base.pyvectorize
编码抽象方法(子类实现)src/python/txtai/vectors/base.pyencode
预处理(分词 + 指令前缀)src/python/txtai/vectors/base.pyprepare / tokens
对外转换包装src/python/txtai/vectors/base.pybatchtransform / transform
切批 + 流式落盘src/python/txtai/vectors/base.pyindex / batch
memmap 回读src/python/txtai/vectors/base.pyvectors
L2 归一化src/python/txtai/vectors/base.pynormalize
标量量化(qbits→uint8)src/python/txtai/vectors/base.pyquantize(qbits__init__)
维度截断(MRL)src/python/txtai/vectors/base.pytruncate
checkpoint 身份 / 落盘文件src/python/txtai/vectors/base.pyvectorsid / spool
读写盘src/python/txtai/vectors/base.pyloadembeddings / saveembeddings
断点续跑src/python/txtai/vectors/recovery.pyRecovery
稠密后端选择src/python/txtai/vectors/dense/factory.pyVectorsFactory.create / method
SBERT 稠密src/python/txtai/vectors/dense/sbert.pySTVectors
本地 Transformer 稠密src/python/txtai/vectors/dense/huggingface.pyHFVectors
远程 API 稠密src/python/txtai/vectors/dense/litellm.pyLiteLLM
llama.cpp / GGUFsrc/python/txtai/vectors/dense/llama.pyLlamaCpp
LiteRT / tflitesrc/python/txtai/vectors/dense/litert.pyLiteRT
Model2Vec 静态src/python/txtai/vectors/dense/m2v.pyModel2Vec
词向量 + 权重加权平均src/python/txtai/vectors/dense/words.pyWordVectors
自定义/外部函数src/python/txtai/vectors/dense/external.pyExternal
LSA 降噪(词向量呼应)src/python/txtai/embeddings/index/reducer.pyReducer
稀疏基类src/python/txtai/vectors/sparse/base.pySparseVectors
稀疏 SBERTsrc/python/txtai/vectors/sparse/sbert.pySparseSTVectors
稀疏后端选择src/python/txtai/vectors/sparse/factory.pySparseVectorsFactory.create
上游触发点(dtype 决定)src/python/txtai/embeddings/index/transform.pyvectors(dtype = uint8 if qbits)