Hugging Face官方博客详解如何在Sentence Transformers中使用多向量嵌入模型处理晚交互语义匹配任务。
![]()
![]()
![]()
普通的 embedding 模型将整段文本压缩成一个向量,而多向量模型为每个 token 保留一个向量,并用 MaxSim 算子对查询和文档进行评分。这保留了 token 级别的匹配信息,而单向量模型不得不将这些信息平均掉——这通常意味着更强的检索能力,代价是更大的索引。对于视觉文档检索来说,这也是目前最先进的技术:文本查询直接与页面图像匹配,中间不经过 OCR 步骤。
在这篇博客中,我们将展示如何使用这些模型:加载各种 checkpoint 格式、编码和评分、将它们接入搜索技术栈、在页面图像上运行,以及保持索引的可承受性。以下所有内容都可以通过简单的 pip install -U sentence-transformers 运行。
加载模型 检查 Checkpoint 的配置
编码查询和文档
使用 MaxSim 评分 Score Magnitude 和 MeanMaxSim
视觉文档检索
加速推理
从 PyLate 或 colpali-engine 迁移
密集 embedding 模型读取一段文本后返回一个单一固定大小的向量。模型注意到的所有信息都必须塞进这 384、768 或 1024 个数字中,而相似度就是这两个摘要之间的一个点积。这种方法效果非常好,但压缩是有损的,而且是以一种特定方式进行的:一个稀有实体、一个精确标识符,或者长段落中的一个关键条款,都必须在同一个向量中争夺空间。一个同时有多个要求的查询也会遇到同样的问题。对于"绿色沙发,木质腿,圆靠垫",单向量不得不把四个要求融合成一点,所以一张绿色沙发配上错误的腿,就会坐到你实际询问的那张旁边。
多向量模型(也称为晚期交互模型,或以 ColBERT 论文命名的 ColBERT 风格模型)跳过了这种压缩。它运行相同的 transformer,但不将 token embedding 池化成一个向量,而是将每个 token embedding 投影到较小的维度(经典的是 128)并保留所有 token。一个 9 token 的文档变成一个 9×128 的矩阵,而不是 1×128 的向量。
查询和文档之间的交互被推迟到评分时刻,这就是"晚期交互"(late interaction)这个名字的由来。交叉编码器(cross-encoder)进行早期交互:两个文本一起通过模型,这是准确的,但无法预计算,因为每个文档都必须为每个新查询重新编码。双编码器(bi-encoder),即上面的密集 embedding 模型,几乎没有任何交互(一个点积在两个完成的摘要之间),而这正是让你能够一次性编码整个集合并快速查询的原因。晚期交互处于两者之间:文档仍然是独立编码的,可以离线建立索引,但评分会比较每个查询 token 和每个文档 token,这为两者的交互留下了更多空间。

评分使用 MaxSim:对于每个查询 token,取其与任何文档 token 的最高相似度,然后将这些最大值在查询上求和。
$$\text{MaxSim}(Q, D) = \sum_{Q_i \in Q} \max_{D_j \in D} Q_i \cdot D_j$$
因为 token embedding 是 L2 标准化的,每个点积都是 [-1, 1] 范围内的余弦相似度,所以整个和落在 [-num_query_tokens, num_query_tokens] 范围内。
你可以把这个算子理解为一个软对齐:每个查询 token 指向那个最能解释它的文档 token,评分就是文档整体上对查询的支持程度。
这种对齐不一定是词法层面的,因为 token embedding 是上下文相关的。用 lightonai/mLateOn 对"Where do penguins live?"和"Penguins inhabit Antarctica."进行编码,查询 token live 会在 inhabit 上找到最佳匹配,得分 0.94——一个与它没有任何共同字符的词!这就是词法检索做不到的事情,BM25 及其同类需要 term 本身,所以同义词和改写会从它们眼皮底下溜走。密集 embedding 模型当然也弥合了这一差距。不过晚期交互增加的是,它在做到这一点的同时没有放弃另一个方向:当精确匹配才是关键时(产品代码、姓氏、函数名),MaxSim 仍然让那个 token 独自站在那里,而单向量模型不得不把它和所有其他信息平均在一起。它也不是一对一的关系,因为多个查询 token 通常会落到同一个文档 token 上。
你获得的是检索质量的提升,特别是在查询中文档的某个特定部分才是相关所在的情况、在像上述沙发那样每个要求都能找到各自证据的多要求查询上,以及在分布外数据上——因为密集模型的压缩是为不同分布调优的。这种压缩是从训练查询中学习的,所以模型学会了保留它们需要的东西,丢弃其他一切,而这可能恰恰包含你的生产查询所询问的内容。这种效果随着文档长度的增加而增大,因为更多的文本必须塞进同一个固定向量中。
代价是索引大小。每个 token 一个向量而不是每个文档一个向量会产生多得多 的向量,只是被较小的维度部分抵消了。用 lightonai/LateOn 编码 4,874 个 Natural Questions 段落产生了 608,414 个 token 向量,平均每个段落 124.8 个:
这大约是 MiniLM 索引存储量的 42 倍,或者说每个段落 62 KiB。然而索引通常会被压缩,例如同样的 608,414 个向量在 fast-plaid 索引中只占 92 MB,因为 PLAID 存储的是每个向量的质心 id 加上量化残差,而不是向量本身。从规模上看,像 Qwen3-Embedding-8B 这样的 4096 维密集模型为这同样的 4,874 个段落大约需要 80 MB,所以压缩后的多向量索引与人们已经在运行的其他密集索引处于同一量级。Token Pooling 在此之前就削减了向量数量,而 Retrieve and Rerank 则完全避免了构建索引。
PyLate 在这篇文章中反复出现,所以简要说明一下:Sentence Transformers 处理了密集模型和稀疏模型,但没有处理晚期交互,所以 LightOn 在其基础上构建了 PyLate 来填补这一空白,添加了这些模型所需的训练、推理和检索组件。你下面加载的大部分内容都是用它训练的,LightOn 还围绕它构建了一个生态系统,包括 fast-plaid——这个在索引中出现的晚期交互索引。在 v6.0 中,这些能力已内置于 Sentence Transformers 本身。
考虑到这种权衡,让我们来运行一个模型。
多向量模型可以通过简单的安装使用:
pip install -U sentence-transformers
对于 ColPali 风格的视觉文档检索,你还需要图像依赖(所有额外依赖见安装部分,多模态支持总体见多模态 Embedding 和 Reranker 模型):
pip install -U "sentence-transformers[image]"
Sentence Transformers v6.0 需要 transformers v5.x、torch 2.2+ 和 huggingface-hub v1.x。如果你将其中任何一个锁定在较低版本,先计划升级。见迁移指南了解完整的破坏性变更列表。
加载多向量模型看起来与加载任何其他 Sentence Transformers 模型完全一样:
from sentence_transformers import MultiVectorEncoder
model = MultiVectorEncoder("lightonai/LateOn")
要找到可用的模型,请在 Hub 上寻找 multi-vector 和 sentence-transformers 标签。任何带有这些标签的模型都可以用上面的代码行加载,无论它最初是 PyLate checkpoint、Stanford-NLP ColBERT checkpoint,还是用于视觉文档检索的 ColPali 系列模型。我们正在努力让每个可用的模型都带上这些标签,所以列表还在不断增长。
在底层,MultiVectorEncoder 读取这些 checkpoint 多年来发布过的各种格式,所以 PyLate 和 Stanford-NLP 的 checkpoint 可以直接加载,即使标签尚未添加:
from sentence_transformers import MultiVectorEncoder
model = MultiVectorEncoder("lightonai/LateOn")
model = MultiVectorEncoder("mixedbread-ai/mxbai-edge-colbert-v0-17m")
model = MultiVectorEncoder("LiquidAI/LFM2.5-ColBERT-350M", trust_remote_code=True)
# 任意 Stanford-NLP ColBERT 权重,通过 `HF_ColBERT` 架构标记识别。
# 内联投影权重和配方来自 `artifact.metadata`
model = MultiVectorEncoder("colbert-ir/colbertv2.0")
model = MultiVectorEncoder("answerdotai/answerai-colbert-small-v1")
# 纯 transformer:追加一个随机投影,因此需要训练
model = MultiVectorEncoder("answerdotai/ModernBERT-base")
视觉文档检索权重是例外。ColPali 系列权重的格式为 colpali-engine 自有格式,不包含 Sentence Transformers 可用的信息,因此每个权重在加载前需要向其仓库添加一个小配置。这部分工作大部分已完成,正在等待合并。参见 Supported Models 了解当前状态及当前如何加载。
多向量模型携带少量配方旋钮,每个权重各不相同:查询和文档的标记前缀、长度上限、查询是否用 [MASK] token 填充、以及计分时跳过哪些 token。它们都位于模块配置中,因此 print(model) 会显示你实际加载的内容。以下是原始 ColBERTv2 权重,它将每个查询填充至恰好 32 个 token,并将文档截断至 180:
from sentence_transformers import MultiVectorEncoder
model = MultiVectorEncoder("colbert-ir/colbertv2.0")
print(model)
"""
MultiVectorEncoder(
(0): Transformer({..., 'document_length': 180,
'query_expansion': {'strategy': 'fixed', 'attend': False, 'token': None, 'length': 32}})
(1): Dense({'in_features': 768, 'out_features': 128, 'bias': False, ...})
(2): MultiVectorMask({'skiplist_words': ['!', '"', '#', ...], 'skiplist_tasks': ['document'], ...})
(3): Normalize({...})
)
"""
print(model.prompts)
# {'query': '[unused0] ', 'document': '[unused1] '}
这是经典的 ColBERT 流程:一个 Transformer 生成上下文 token 嵌入、一个 token 级别的 Dense 将每个 token 投影至 128 维、一个 MultiVectorMask 决定计分时哪些 token 算数、以及一个 token 级别的 Normalize。其他权重填入不同值。lightonai/GTE-ModernColBERT-v1 使用相同的四个模块,带有 [Q] 和 [D] 提示,无查询扩展,上限分别为 48 和 300。
你很少需要碰这些,因为每个发布的权重都已自行配置。当你从空白 backbone 构建模型时才会用到,这部分在"创建自定义模型"中介绍。
有一个值值得根据你自己的数据检查。document_length 负责截断,因此超过该值的任何内容都不会进入索引。例如,一段 662 token 的文字通过 LateOn 的 300 上限后返回 273 个向量,剩余部分直接丢失。大多数这些权重都在短文本上训练,因此如果你的块比上限更长,可以通过 encode_document(..., processing_kwargs={"text": {"max_length": 512}}) 为单次调用提升上限,同时注意这将让模型运行在其训练长度之外,且索引大小大致按比例增长。多向量模型对这种情况容忍度较高。在 MLDR(一个长文档检索基准)上,上述两个权重的多语言版本差距明显:mLateOn 得 77.92,mDenseOn 得 51.59。
多向量模型是非对称的:查询和文档经过不同的前缀、不同的长度上限和不同的计分掩码。与许多密集模型不同(两者可互换),encode_query() 和 encode_document() 是获得正确嵌入所必需的:
from sentence_transformers import MultiVectorEncoder
model = MultiVectorEncoder("lightonai/mLateOn")
queries = ["What is the capital of France?"]
documents = [
"Paris is the capital of France.",
"Berlin is the capital and largest city of Germany, by both area and population.",
]
query_embeddings = model.encode_query(queries)
document_embeddings = model.encode_document(documents)
print(query_embeddings[0].shape)
# (10, 128)
print(document_embeddings[0].shape, document_embeddings[1].shape)
# (10, 128) (19, 128)
注意你得到的内容:一个 2D 张量列表,每个输入一个,形状为 (num_tokens, embedding_dim)。与密集嵌入不同,你无法将它们堆叠成一个矩形张量,因为每个输入的 token 数量各不相同。第二个文档比第一个长,因此返回的是一个更高的矩阵。
每次调用都会为你应用模型的配方。encode_query() 预置查询标记、按需将查询扩展至固定长度、并截断至查询长度上限。encode_document() 预置文档标记、截断至文档长度上限、并从计分掩码中丢弃任何在跳词表中的 token(对大多数权重来说是标点符号)。
通常的 encode() 参数仍然适用,因此 batch_size、show_progress_bar、convert_to_tensor、device 和多进程池都按预期工作:
document_embeddings = model.encode_document(
documents,
batch_size=64,
convert_to_tensor=True,
show_progress_bar=True,
)
model.similarity() 计算完整的全对 MaxSim 矩阵:
from sentence_transformers import MultiVectorEncoder
model = MultiVectorEncoder("lightonai/LateOn")
query_embeddings = model.encode_query(["Which planet is known as the Red Planet?"])
document_embeddings = model.encode_document([
"Venus is often called Earth's twin because of its similar size and proximity.",
"Mars, known for its reddish appearance, is often referred to as the Red Planet.",
"Jupiter, the largest planet in our solar system, has a prominent red spot.",
"Saturn, famous for its rings, is sometimes mistaken for the Red Planet.",
])
scores = model.similarity(query_embeddings, document_embeddings)
print(scores)
# tensor([[10.7942, 11.1104, 10.9743, 11.0811]])
Mars 赢了,理应如此。注意亚军差距有多小:Saturn 也包含字面短语"the Red Planet",Jupiter 是一颗有红斑的行星,因此 token 级别的操作符在三者中都有大量可抓取的内容。排序才是关键。
分数经常如此接近,GLInt 通过测量整个候选池中的分散程度来证明这一点。MaxSim 对每个查询 token 取最大值,因此文档通常会给每个查询 token 一些不错的最佳匹配,分数从底价开始。上下文 token 嵌入也是各向异性的,聚集在一个狭窄的锥形区域而非分散开来,因此即使是任意的 token 对也倾向于获得高分。
还有 model.similarity_pairwise(),用于当你已有配对结果、只想获得配对分数而非完整相似度矩阵时:
scores = model.similarity_pairwise(query_embeddings, document_embeddings[:1])
print(scores)
# tensor([10.7942])
MaxSim 对查询 token 求和,因此其量级随查询 token 数量缩放,这意味着你不能跨模型比较分数——不同模型的查询配方不同。LateOn 将上面的 Red Planet 查询编码为 12 个 token。用同一个查询和同样的文档在 ColBERTv2(将每个查询填充并截断至恰好 32 个 token)上运行,分数会落在完全不同的范围:
model = MultiVectorEncoder("colbert-ir/colbertv2.0")
# ... 相同的 encode_query / encode_document / similarity 调用 ...
print(scores)
# tensor([[12.7970, 27.1945, 23.8495, 24.5656]])
在单一模型内排序是你所需的全部,但如果你想要有界尺度的分数,请将模型的相似度函数切换为 MeanMaxSim,它除以查询 token 数量。回到 LateOn:
model = MultiVectorEncoder("lightonai/LateOn", similarity_fn_name="meanmaxsim")
# 或在已加载模型上:model.similarity_fn_name = "meanmaxsim"
print(model.similarity(query_embeddings, document_embeddings))
# tensor([[0.8995, 0.9259, 0.9145, 0.9234]])
现在每个分数都是 [-1, 1] 范围内的平均余弦相似度,不过实践中你只会看到 [0, 1]。
如果你的语料库较小,对全部内容穷举 MaxSim 是最简单有效的方案。编码语料库一次,然后对每个查询与所有内容计分:
import time
from datasets import load_dataset
from sentence_transformers import MultiVectorEncoder
dataset = load_dataset("sentence-transformers/natural-questions", split="train[:5000]")
# 多个问题共享同一答案段落,因此删除重复但保持顺序
corpus = list(dict.fromkeys(dataset["answer"])) # 5,000 行 -> 4,874 段
```python
model = MultiVectorEncoder("lightonai/LateOn")
corpus_embeddings = model.encode_document(corpus, convert_to_tensor=True, show_progress_bar=True)
query = "when did richmond last play in a preliminary final"
start = time.perf_counter()
query_embeddings = model.encode_query([query], convert_to_tensor=True)
scores = model.similarity(query_embeddings, corpus_embeddings)[0] # 98ms
top_scores, top_indices = scores.topk(3)
print(f"Search took {(time.perf_counter() - start) * 1000:.1f}ms")
for score, index in zip(top_scores.tolist(), top_indices.tolist()):
print(f"{score:.4f} {corpus[index][:100]}")
"""
Search took 122.7ms
11.9192 Richmond Football Club Richmond began 2017 with 5 straight wins, a feat it had not achieved
11.7591 2017 AFL Grand Final The 2017 AFL Grand Final was an Australian rules football game contest
11.6710 Battle of Appomattox Court House The Battle of Appomattox Court House (Virginia, U.S.), fou
"""
这 4,874 段文本在一块 RTX 3090 上编码耗时 20 秒,每次搜索端到端约 120ms,其中大部分时间花在针对全部 608,414 个 token 向量执行 MaxSim 评分。这是精确搜索,但计算量随语料库总 token 数线性增长,且需要将所有 token 向量保留在内存中,因此适用于几千篇文档而非几百万篇。该脚本的可运行版本为 semantic_search.py。
超过这个规模你就需要一个真正的 late-interaction 索引,Sentence Transformers 并没有自带。但这也不需要:这些索引存储的是 encode_document 输出的内容,所以你可以在这里编码,然后把 token embedding 交给专门为它们构建的工具。索引部分有四个选项的工作代码片段,下面的章节会介绍如何完全跳过索引。
你也可以不维护 late-interaction 索引而获得 late-interaction 质量,方法是将多向量模型作为你的重排序器。快速的 bi-encoder 先将大规模语料库缩小到少量候选文档,然后多向量模型只对这些候选进行重新评分:
from datasets import load_dataset
from sentence_transformers import MultiVectorEncoder, SentenceTransformer
from sentence_transformers.util import semantic_search
dataset = load_dataset("sentence-transformers/natural-questions", split="train[:50000]")
corpus = list(dict.fromkeys(dataset["answer"]))
retriever = SentenceTransformer("jinaai/jina-embeddings-v5-text-nano-retrieval")
reranker = MultiVectorEncoder("perplexity-ai/pplx-embed-v1-late-0.6b", trust_remote_code=True)
# 第一阶段:用快速 bi-encoder 对语料库建立一次索引
corpus_embeddings = retriever.encode_document(corpus, convert_to_tensor=True, show_progress_bar=True)
# 检索前 50 条
query = "when did richmond last play in a preliminary final"
hits = semantic_search(retriever.encode_query([query], convert_to_tensor=True), corpus_embeddings, top_k=50)[0]
candidates = [corpus[hit["corpus_id"]] for hit in hits]
# 第二阶段:用 MaxSim 只对候选文档重新评分
query_embeddings = reranker.encode_query([query])
document_embeddings = reranker.encode_document(candidates)
scores = reranker.similarity(query_embeddings, document_embeddings)[0]
for index in scores.argsort(descending=True)[:3].tolist():
print(f"{scores[index].item():.4f} {candidates[index][:100]}")
只有这 50 个候选文档会被编码为多向量,因此你的索引始终是普通的密集索引,token 向量是临时的。这与 cross-encoder 在 retrieve-and-rerank 架构中扮演的角色相同,但多向量模型每个候选的代价要低得多。你可以在一个批次中对文档进行编码,然后用一次矩阵乘法完成评分,而不是每个"查询-文档"对都做一次前向传播。可运行脚本是 retrieve_rerank.py,它会打印两个阶段的耗时。
多个向量数据库原生支持多向量索引和评分:Qdrant(自 v1.10)、Weaviate(自 v1.29)、Vespa(已有多时)、LanceDB(自 v0.15.0),以及 VectorChord(它为普通 pgvector 所没有的 Postgres 添加了 MaxSim 算子)。Milvus 于 v2.6.4 加入,采用 array-of-structs 形式而非它所称的多向量搜索(与之无关的功能)。如果你完全不想运行服务器,LightOn 的 fast-plaid 只需 pip install 即可使用,并直接实现了 PLAID,PyLate 则在其基础上封装了更完整的检索栈。
其他一些方案能实现部分功能。OpenSearch 和 Elasticsearch 可以用 MaxSim 对候选进行重新评分,但不能用它进行检索,而且 Elasticsearch 字段尚处于技术预览阶段且属于 Enterprise 层级。turbopuffer 的 late-interaction 索引正在私人 beta 中。
以下代码片段是对文本建立索引,但其中没有任何内容是文本特有的。encode_document 返回的 token 向量矩阵列表与文档是段落、页面图片、音频片段还是视频无关,因此 Visual Document Retrieval 中的 ColPali 风格模型可以不加修改地接入任何一个这些方案。只是每个文档的向量更多了,这也正是为什么在这种场景下更值得尽早采用 Token Pooling。
fast-plaid、Qdrant、Weaviate 和 Vespa 都直接接收 encode_document 返回的内容,所以除了客户端库不同之外代码是一样的。以下是每个数据库的工作代码片段,针对的是 Semantic Search 示例中的 4,874 段文本和 608,414 个 token 向量。每个人给出的都是在同一台机器上(RTX 3090、i7-13700K)产生的摄取和查询时间,除了代码所示的调优之外没有额外优化,以给出一个工作负载形状的感觉。这四个方案回答查询的速度都比该章节中 model.similarity 的 98ms 更快,其中三个在 CPU 上完成,因为 fast-plaid 是这里唯一使用 GPU 的。
这四个方案返回的三个段落与本文前文穷举式 PyTorch MaxSim 的结果完全一致且顺序相同,三个数据库的评分也精确到四位小数!这是因为它们的代码片段对每个文档都进行了评分,在这个规模下这样做是可行的,而且消除了近似作为变量带来的影响。fast-plaid 按设计是近似算法,因此其评分略有不同。每个方案下的说明指出了当你切换到近似索引时会发生什么变化——正是在那里排名开始出现偏差。
fast-plaid 是 LightOn 的 Rust 实现。