跳到正文
北京时间
原文
Hugging Face:Blog(RSS)·· 2026-08-18精选AI 评分75

Sentence Transformers v6.0 新增 MultiVectorEncoder,支持 ColBERT 风格多向量模型

Multi-Vector (Late Interaction) Embedding Models with Sentence Transformers

AI 导读

Sentence Transformers v6.0 新增第四种模型类型 MultiVectorEncoder,可直接加载 PyLate、Stanford-NLP ColBERT 及 colpali-engine 检查点,用于 ColBERT 式晚期交互检索。

推荐理由

与同骨干的稠密模型相比,多向量检索在平均 NDCG 上高出约一个点,代价是索引体积增大数十倍,适合需要保留逐词精确匹配的场景。

正文 · AI 翻译

Sentence Transformers 是一个 Python 库,用于使用和训练嵌入向量与重排序模型,应用场景包括检索增强生成、语义搜索等。在 v6.0 更新中,它新增了第四种模型类型:MultiVectorEncoder,用于 ColBERT 风格的后期交互检索。任何 PyLate 检查点和任何 Stanford-NLP ColBERT 检查点都可以直接加载进来,同时 colpali-engine 的视觉文档检索模型也可以通过你熟悉的同一套 API 使用——这套 API 同样适用于稠密、稀疏和重排序模型。普通嵌入模型会把整段文本压缩成一个向量,而多向量模型则为每个 token 保留一个向量,并使用 MaxSim 算子对查询与文档进行打分。这样就能保留 token 级别的匹配信息,而单一向量只能把这些信息平均化掉,通常意味着更强的检索效果,代价是索引体积更大。它也是视觉文档检索领域的最新技术水平,文本查询直接与页面图像进行匹配,中间无需 OCR 步骤。

在这篇博客文章中,我们将向你展示如何使用这些模型:加载各种检查点格式、编码与打分、将它们接入搜索技术栈、在页面图像上运行,以及控制索引成本。以下所有内容都可以在普通的 pip install -U sentence-transformers 上运行。

什么是多向量模型?

稠密嵌入模型读取一段文本,然后返回一个固定大小的向量。模型注意到的一切都必须压缩进那 384、768 或 1024 个数字里,而相似度就是两个这样的摘要向量之间的一次点积。这种方法效果相当好,但压缩在特定意义上有损:一个稀有实体、一个精确的标识符,或长段落中一个关键从句,都必须在同一个向量里争夺空间。一个同时包含多个条件的查询也会撞上同样的墙。对于“带木腿和圆润靠垫的绿色沙发”,单个向量必须把全部四个条件融合成一个点,于是腿型不对的绿色沙发最终会和你要的那款靠得很近。

多向量模型(也叫晚期交互或 ColBERT 风格模型,得名于 ColBERT 论文)跳过了这种压缩。它运行同样的 Transformer 架构,但不再把 token 嵌入池化成一个向量,而是把每个 token 嵌入投影到较小的维度(经典做法是 128),并全部保留下来。一个 9 个 token 的文档变成 9x128 的矩阵,而不是 1x128 的向量。

查询与文档之间的交互被推迟到打分阶段进行,这正是“晚期交互”名称的由来。交叉编码器是早期交互:两段文本一起送入模型,这很准确,但没有任何可预计算的内容,因为每个文档都必须针对每个新查询重新编码。双编码器——也就是上面提到的稠密嵌入模型——几乎不交互(两个现成摘要向量之间做一次点积),而这恰恰让你可以一次性编码整个文档集并快速查询。晚期交互介于两者之间:文档仍然独立编码,可以离线建索引,但打分时会比较每个查询 token 与每个文档 token,给两者留出了大得多的交互空间。

MaxSim 算子

评分使用 MaxSim:对于每个查询 token,取其与任意文档 token 的最高相似度,然后将这些最大值在查询范围内求和。

$$ \text{MaxSim} \left(\right. Q , D \left.\right) = \underset{Q_{i} \in Q}{\sum} \underset{D_{j} \in D}{max ⁡} Q_{i} \cdot D_{j} $$

MaxSim(Q,D)=Q i​∈Q∑​D j​∈D max​Q i​⋅D j​

由于 token 嵌入向量经过 L2 归一化,这些点积中的每一个都是 [-1, 1] 中的余弦相似度,因此整个求和结果落在 [-num_query_tokens, num_query_tokens] 范围内。

你可以把这个算子理解为一种软对齐:每个查询 token 都指向最能解释它的那个文档 token,而得分则衡量文档整体上对查询的支持程度。

对齐不一定是词面上的,因为 token 的嵌入向量是经过上下文语境化的。用 lightonai/mLateOn 对“企鹅生活在哪里?”和“企鹅栖息于南极洲。”进行编码,查询 token live 会在 inhabit 上找到它最匹配的对象,相似度达到 0.94——而这两者之间没有任何相同的字符!这正是词面检索做不到的事情,BM25 及其同类算法需要词条本身出现,因此同义词和改写说法都会从它们眼皮底下溜走。当然,稠密嵌入向量模型也能弥合这一差距。而晚期交互(late interaction)在此基础上增加的,是它做到这一点时不会牺牲另一个方向:当精确匹配至关重要时(比如产品代码、姓氏、函数名),MaxSim 仍然能让那个 token 独立存在,而单向量模型则不得不把它和其他所有信息一起平均掉。它也不是严格一对一的,因为多个查询 token 通常会落在同一个文档 token 上。

你得到什么,以及它的代价

你获得的是检索质量的提升,尤其是在以下场景中:查询中某一段特定内容才是相关性的关键所在;多条件查询(比如上面那个沙发的例子)中每个条件都能找到自己的证据;以及领域外数据上——稠密模型的压缩是针对不同分布调优的。这种压缩是从训练查询中学习而来的,因此模型学会保留训练查询所需的信息、丢弃其余一切,而丢弃的部分可能恰恰是你的生产环境查询所关心的内容。这种影响会随着文档长度的增加而放大,因为更多的文本必须被塞进同样大小的固定向量里。

代价在于索引大小。每个 token 一个向量,而不是每个文档一个向量,这意味着向量数量大幅增加,而维度变小只能部分抵消这一影响。使用 lightonai/LateOn 对 4,874 个 Natural Questions 段落进行编码,产生了 608,414 个 token 向量,平均每个段落 124.8 个:

表示方式 向量数 维度 float32 大小
稠密向量,all-MiniLM-L6-v2 4,874 384 7.5 MB
稠密向量,gte-modernbert-base 4,874 768 15.0 MB
多向量,LateOn 608,414 128 311.5 MB

这大约是 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 支持稠密和稀疏模型,但不支持晚期交互(late interaction),因此 LightOn 在其基础上构建了 PyLate 来填补这一空白,加入了这类模型所需的训练、推理和检索组件。你下面加载的大部分内容都是用 PyLate 训练的,LightOn 还围绕它构建了一个生态系统,包括 fast-plaid——也就是 Indexing 部分提到的晚期交互索引。从 v6.0 开始,这些能力已经内置到 Sentence Transformers 本身。

带着这个权衡,我们让一个模型跑起来吧。

安装

多向量模型通过常规安装即可使用:

pip install -U sentence-transformers

对于 ColPali 风格的视觉文档检索,你还需要图像依赖(有关所有附加项,请参阅 安装,有关多模态支持的总体信息,请参阅 多模态嵌入与重排序模型):

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 检查点、Stanford-NLP ColBERT 检查点,还是用于视觉文档检索的 ColPali 系列模型。我们正在整个生态系统中推进,将这一标签添加到所有可用的模型上,因此列表会不断增长。

在底层,MultiVectorEncoder 会读取这些检查点历年来发布的各种格式,因此即使尚未添加标签,PyLate 和 Stanford-NLP 检查点也可以直接加载:

from sentence_transformers import MultiVectorEncoder

# Native Sentence Transformers checkpoints. PyLate builds on the same schema,
# so any PyLate checkpoint loads identically
model = MultiVectorEncoder("lightonai/LateOn")
model = MultiVectorEncoder("mixedbread-ai/mxbai-edge-colbert-v0-17m")
model = MultiVectorEncoder("LiquidAI/LFM2.5-ColBERT-350M", trust_remote_code=True)

# Any Stanford-NLP ColBERT checkpoint, detected via the `HF_ColBERT` architecture
# marker. The inline projection weight and the recipe come from `artifact.metadata`
model = MultiVectorEncoder("colbert-ir/colbertv2.0")
model = MultiVectorEncoder("answerdotai/answerai-colbert-small-v1")

# A bare transformer: a fresh random projection is appended, so training is required
model = MultiVectorEncoder("answerdotai/ModernBERT-base")

视觉文档检索模型是个例外。ColPali 系列检查点以 colpali-engine 自己的格式发布,这种格式不携带 Sentence Transformers 可用的任何信息,因此每个检查点都需要在其仓库中添加一个小配置后才能加载。这项工作大部分已完成,正在等待合并。当前状态以及如何加载这些模型,请参见 支持的模型。

检查检查点配置了什么

多向量模型带有少量因检查点而异的配方参数:查询和文档的前缀标记、长度上限、查询是否用 [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。

你很少需要改动这些配置,因为每个发布的检查点都会自行配置好。只有在从裸骨干网络构建模型时才需要关注,这部分内容在 创建自定义模型 中有详细介绍。

不过,有一个数值值得用你自己的数据来验证。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,
)

使用 MaxSim 进行评分

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])

得分量级与 MeanMaxSim

MaxSim 会对查询中的所有 token 求和,因此其量级会随查询 token 数量的多少而变化,这意味着你无法在不同查询处理方式的模型之间比较得分。LateOn 将上面那句“Red Planet”查询编码为 12 个 token。如果用 ColBERTv2 运行同样的查询和同样的文档——ColBERTv2 会将每个查询补齐或截断到恰好 32 个 token——得分就会落在完全不同的区间:

model = MultiVectorEncoder("colbert-ir/colbertv2.0")
# ... same encode_query / encode_document / similarity calls ...
print(scores)
# tensor([[12.7970, 27.1945, 23.8495, 24.5656]])

在同一个模型内部,排序结果就足够用了;但如果你希望得分落在一个有界的范围内,可以将模型的相似度函数切换为 MeanMaxSim,它会除以查询的 token 数量。回到 LateOn 上:

model = MultiVectorEncoder("lightonai/LateOn", similarity_fn_name="meanmaxsim")
# or on an already-loaded model: 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]")
# Several questions share an answer passage, so drop repeats but keep the order
corpus = list(dict.fromkeys(dataset["answer"]))  # 5,000 rows -> 4,874 passages

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 秒完成编码,而每次搜索端到端大约耗时 120 毫秒,其中大部分时间花在与全部 608,414 个 token 向量进行 MaxSim 评分上。这是精确检索,但其复杂度随语料库总 token 数线性增长,并且需要将所有 token 向量保留在内存中,因此它适用于几千篇文档的场景,而非几百万篇。该脚本的可运行版本是 semantic_search.py。

超过这个规模,你就需要一个真正的后期交互索引,而 Sentence Transformers 并不提供这一功能。它也不需要提供:这些索引存储的是 encode_document 生成的任何内容,因此你可以在这里完成编码,然后将 token 嵌入向量交给为此构建的工具。索引 部分提供了四种可选方案的可用代码片段,而紧随其后的章节则介绍了如何完全跳过索引。

检索与重排

你也可以在不维护后期交互索引的情况下获得后期交互的质量,方法是使用多向量模型作为你的 重排器。一个快速的 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)

# First stage: index the corpus once with a fast bi-encoder
corpus_embeddings = retriever.encode_document(corpus, convert_to_tensor=True, show_progress_bar=True)

# Retrieve the top 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]

# Second stage: rescore just those candidates with 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 向量是临时的。这与交叉编码器在检索-重排架构中扮演的角色相同,但多向量模型对每个候选的计算成本要低得多。你可以一次性批量编码文档,并通过矩阵乘法进行评分,而不是为每个查询-文档对执行一次前向传播。可运行脚本是 retrieve_rerank.py,它会打印两个阶段的耗时。

索引

多个向量数据库原生支持多向量索引与评分:Qdrant 自 v1.10 起、Weaviate 自 v1.29 起、Vespa 已支持多年、LanceDB 自 v0.15.0 起,以及 VectorChord,它为 Postgres 增加了 MaxSim 算子,这是普通 pgvector 所不具备的。Milvus 在 v2.6.4 中加入这一行列,采用的是结构体数组形式,而非其所谓的多向量搜索这一不相关特性。如果你完全不想运行服务器,LightOn 的 fast-plaid 只需 pip install 即可安装,直接实现 PLAID 算法,而 PyLate 则将其封装进更完整的检索栈中。

还有几个方案只能帮你走一半路。OpenSearch 和 Elasticsearch 可以用 MaxSim 对候选结果进行重排序,但不能基于它进行检索,而且 Elasticsearch 的该功能目前仍处于技术预览阶段,且仅限企业版。turbopuffer 的后期交互索引正处于私有测试阶段。

下面的代码片段是对文本进行索引,但其中没有任何内容是文本专属的。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 fast-plaid 是 LightOn 对 PLAID 的 Rust 实现,而 PLAID 正是 ColBERT 最初所基于的索引。它无需启动服务器,并且可以直接读取 encode_document 返回的张量,无需任何转换。

# pip install sentence-transformers datasets fast-plaid
from datasets import load_dataset
from fast_plaid import search
from sentence_transformers import MultiVectorEncoder

dataset = load_dataset("sentence-transformers/natural-questions", split="train[:5000]")
corpus = list(dict.fromkeys(dataset["answer"]))
model = MultiVectorEncoder("lightonai/LateOn")
query = "when did richmond last play in a preliminary final"

document_embeddings = model.encode_document(corpus, batch_size=32, convert_to_tensor=True)
query_embedding = model.encode_query(query, convert_to_tensor=True)

fast_plaid = search.FastPlaid(index="natural-questions", device="cuda")

# 4,874 documents (608,414 token vectors) indexed in 5s
fast_plaid.create(documents_embeddings=document_embeddings)

results = fast_plaid.search(queries_embeddings=query_embedding.unsqueeze(0), top_k=3)  # 11ms

for index, score in results[0]:
    print(f"{score:.4f}  {corpus[index][:90]}")
"""
11.8828  Richmond Football Club Richmond began 2017 with 5 straight wins, a feat it had not achieve
11.7676  2017 AFL Grand Final The 2017 AFL Grand Final was an Australian rules football game contes
11.6758  Battle of Appomattox Court House The Battle of Appomattox Court House (Virginia, U.S.), fo
"""

index 参数是一个目录,而不仅仅是一个标签,因此索引在构建过程中会直接写入磁盘。将新的 FastPlaid 指向同一路径即可重新打开该索引用于搜索或添加更多文档,而无需每次都从嵌入向量重新构建。在这个语料库上,该索引占用 92 MB,而原始 float32 向量则占用 311.5 MB。

这是四个方案中唯一一个近似计算,也是本节中分数与穷举式 MaxSim 不一致的唯一一处。PLAID 使用质心进行剪枝,并存储量化后的残差,因此这三个分数与之前计算出的 11.9192 / 11.7591 / 11.6710 相比,会在两个方向上产生百分之几的偏差。这里的排序并未受到影响,而这正是 PLAID 所做的取舍:它的设计目标是处理远比当前语料库更大的语料,在那种场景下,扫描全部内容是不可行的。

Qdrant Qdrant 需要服务器:docker run -p 6333:6333 qdrant/qdrant。客户端也有本地模式(QdrantClient(":memory:")),该模式无需服务器,但它是纯 Python 重新实现,因此适合用来试用功能,而不适合用来做计时测试。

# pip install sentence-transformers datasets qdrant-client
from datasets import load_dataset
from qdrant_client import QdrantClient, models
from sentence_transformers import MultiVectorEncoder

dataset = load_dataset("sentence-transformers/natural-questions", split="train[:5000]")
corpus = list(dict.fromkeys(dataset["answer"]))
model = MultiVectorEncoder("lightonai/LateOn")
query = "when did richmond last play in a preliminary final"

document_embeddings = model.encode_document(corpus, batch_size=32)
query_embedding = model.encode_query(query)

client = QdrantClient("http://localhost:6333")
client.create_collection(
    collection_name="natural-questions",
    vectors_config=models.VectorParams(
        size=model.get_embedding_dimension(),
        distance=models.Distance.COSINE,
        multivector_config=models.MultiVectorConfig(
            comparator=models.MultiVectorComparator.MAX_SIM
        ),
        # MaxSim never walks the HNSW graph, so skip building one
        hnsw_config=models.HnswConfigDiff(m=0),
    ),
)

# 4,874 documents (608,414 token vectors) ingested in 26.3s
client.upload_points(
    collection_name="natural-questions",
    points=[
        models.PointStruct(id=idx, vector=embedding, payload={"text": text})
        for idx, (embedding, text) in enumerate(zip(document_embeddings, corpus))
    ],
    batch_size=64,
)

results = client.query_points(
    collection_name="natural-questions",
    query=query_embedding,
    limit=3,
    with_payload=True,
).points  # 18ms

for result in results:
    print(f"{result.score:.4f}  {result.payload['text'][:90]}")
"""
11.9192  Richmond Football Club Richmond began 2017 with 5 straight wins, a feat it had not achieve
11.7591  2017 AFL Grand Final The 2017 AFL Grand Final was an Australian rules football game contes
11.6710  Battle of Appomattox Court House The Battle of Appomattox Court House (Virginia, U.S.), fo
"""

MAX_SIM 是 Qdrant 提供的唯一比较器,而 hnsw_config=HnswConfigDiff(m=0) 是他们针对后期交互字段的推荐方案,因为这类向量用于重新打分而非图遍历。请注意,Qdrant 官方建议将后期交互仅用于对几百个候选结果进行重排,而不是扫描整个集合,这正是 检索与重排(Retrieve and Rerank) 模式。在 4,874 篇文档的规模下,全量扫描耗时 18ms 且结果精确,但这种表现无法外推到更大规模。

Weaviate Weaviate 同样需要服务器:docker run -p 8080:8080 -p 50051:50051 cr.weaviate.io/semitechnologies/weaviate:1.34.0。多向量支持需要 1.29 或更高版本,且嵌入式模式在 Windows 上不可用。

# pip install sentence-transformers datasets weaviate-client
import weaviate
from datasets import load_dataset
from sentence_transformers import MultiVectorEncoder
from weaviate.classes.config import Configure, DataType, Property
from weaviate.classes.query import MetadataQuery

dataset = load_dataset("sentence-transformers/natural-questions", split="train[:5000]")
corpus = list(dict.fromkeys(dataset["answer"]))
model = MultiVectorEncoder("lightonai/LateOn")
query = "when did richmond last play in a preliminary final"

document_embeddings = model.encode_document(corpus, batch_size=32)
query_embedding = model.encode_query(query)

client = weaviate.connect_to_local()
collection = client.collections.create(
    "Documents",
    # self_provided turns on MaxSim late interaction
    vector_config=[Configure.MultiVectors.self_provided(name="colbert")],
    properties=[Property(name="text", data_type=DataType.TEXT)],
)

# 4,874 documents (608,414 token vectors) ingested in 41s
with collection.batch.fixed_size(batch_size=64) as batch:
    for text, embedding in zip(corpus, document_embeddings):
        batch.add_object(properties={"text": text}, vector={"colbert": embedding.tolist()})

results = collection.query.near_vector(
    near_vector=query_embedding.tolist(),
    target_vector="colbert",
    limit=3,
    return_metadata=MetadataQuery(distance=True),
)  # 17ms

for result in results.objects:
    # Weaviate reports the MaxSim score as a negated distance
    print(f"{-result.metadata.distance:.4f}  {result.properties['text'][:90]}")
"""
11.9192  Richmond Football Club Richmond began 2017 with 5 straight wins, a feat it had not achieve
11.7591  2017 AFL Grand Final The 2017 AFL Grand Final was an Australian rules football game contes
11.6710  Battle of Appomattox Court House The Battle of Appomattox Court House (Virginia, U.S.), fo
"""

client.close()

这里默认配置就足够了:Weaviate 的动态 ef 在 top-3 查询中解析为 100,而从大约 32 开始,这个排序就已经是精确的了。这个余量是嵌入向量本身的属性,而不是 Weaviate 的特性,所以值得在你自己的模型上确认一下,而不是假设默认配置一定成立。

Weaviate 还支持 MUVERA 编码,在我们的测试中,这使得数据摄入速度快了 3 倍,查询速度快了 1.8 倍。但在这种规模下,它损失的精度远超这个速度提升的价值:正确的第三段内容甚至没有出现在它的前 50 名中。

Vespa Vespa 也运行在容器中,但 pyvespa 会为你启动它,所以不需要单独的 docker run。

# pip install sentence-transformers datasets pyvespa
from datasets import load_dataset
from sentence_transformers import MultiVectorEncoder
from vespa.deployment import VespaDocker
from vespa.package import (
    ApplicationPackage, Document, Field, FirstPhaseRanking, Function, RankProfile, Schema,
)

dataset = load_dataset("sentence-transformers/natural-questions", split="train[:5000]")
corpus = list(dict.fromkeys(dataset["answer"]))
model = MultiVectorEncoder("lightonai/LateOn")
query = "when did richmond last play in a preliminary final"

document_embeddings = model.encode_document(corpus, batch_size=32)
query_embedding = model.encode_query(query)

# "dt" is a mapped dimension over the variable token count, "x" the dense 128-dim vector
package = ApplicationPackage(
    name="colbert",
    schema=[
        Schema(
            name="doc",
            document=Document(fields=[
                Field(name="text", type="string", indexing=["summary"]),
                Field(name="colbert", type="tensor<float>(dt{}, x[128])", indexing=["attribute"]),
            ]),
            rank_profiles=[
                RankProfile(
                    name="colbert",
                    inputs=[("query(qt)", "tensor<float>(qt{}, x[128])")],
                    functions=[Function(
                        name="max_sim",  # per query token take the best document token, then sum
                        expression="sum(reduce(sum(query(qt) * attribute(colbert), x), max, dt), qt)",
                    )],
                    first_phase=FirstPhaseRanking(expression="max_sim"),
                )
            ],
        )
    ],
)
app = VespaDocker(port=8080).deploy(application_package=package)  # ~40s to boot

# Vespa reads a mixed tensor as {token index: vector}, for documents and queries alike
def to_tensor(embedding):
    return {str(token): vector for token, vector in enumerate(embedding.tolist())}

# 4,874 documents (608,414 token vectors) ingested in ~80s
app.feed_iterable(
    ({"id": str(idx), "fields": {"text": text, "colbert": to_tensor(embedding)}}
     for idx, (text, embedding) in enumerate(zip(corpus, document_embeddings))),
    schema="doc",
)

response = app.query(body={
    "yql": "select text from doc where true",
    "ranking.profile": "colbert",
    "hits": 3,
    "input.query(qt)": to_tensor(query_embedding),
})  # ~75ms warm, ~115ms on the first call

for hit in response.hits:
    print(f"{hit['relevance']:.4f}  {hit['fields']['text'][:90]}")
"""
11.9192  Richmond Football Club Richmond began 2017 with 5 straight wins, a feat it had not achieve
11.7591  2017 AFL Grand Final The 2017 AFL Grand Final was an Australian rules football game contes
11.6710  Battle of Appomattox Court House The Battle of Appomattox Court House (Virginia, U.S.), fo
"""

在这四个方案中,Vespa 要求的前期结构是最多的,因为你声明的是一个排序流水线,而不仅仅是一个索引。作为回报,你可以将 MaxSim 写成张量表达式,并确切地看到它计算的是什么。这个版本将 MaxSim 放在 first-phase 中,作用于 where true,它对全部 4,874 篇文档进行评分,这也是其输出与穷举式 MaxSim 完全一致的原因。这刻意不是 Vespa 在大规模场景下推荐的做法:他们的 ColBERT 示例应用 存储 int8 二值化向量,并将 MaxSim 移入 second-phase,以便对成本更低的初选阶段进行重排。

转向这种分阶段设置需要谨慎:second-phase 默认只对最佳的 100 个候选进行重新评分,而在这里,这个窗口导致三个正确段落中有两个完全未被评分。提高 rerank-count 以覆盖你的候选集可以解决这个问题,不过在这种规模下,分阶段版本仍然比简单地扫描所有内容要慢。

视觉文档检索

晚期交互是视觉文档检索领域的最先进技术:将文本查询与页面图像进行匹配,图表、表格和版面均保持原样,且无需 OCR 步骤。这正是 ColPali 系列模型所做的,这些检查点可通过同一 API 加载和运行,revision 固定了添加该模型 Sentence Transformers 配置的开放拉取请求(支持的模型 中有完整列表)。图像文档以 URL、本地路径或 PIL 图像的形式传入:

from sentence_transformers import MultiVectorEncoder

model = MultiVectorEncoder("vidore/colqwen2.5-v0.2")

queries = [
    "What is the variable represented on the y-axis of the graph?",
    "Total outlay is maximum in which year?",
]
images = [
    "https://huggingface.co/datasets/sentence-transformers/example-documents/resolve/main/doc1.jpg",
    "https://huggingface.co/datasets/sentence-transformers/example-documents/resolve/main/doc2.jpg",
    "https://huggingface.co/datasets/sentence-transformers/example-documents/resolve/main/doc3.jpg",
    "https://huggingface.co/datasets/sentence-transformers/example-documents/resolve/main/doc4.jpg",
]

query_embeddings = model.encode_query(queries)
document_embeddings = model.encode_document(images)
print(query_embeddings[0].shape, document_embeddings[0].shape)
# (25, 128) (755, 128)

scores = model.similarity(query_embeddings, document_embeddings)
print(scores)
# tensor([[13.8672, 12.3115, 12.1670, 11.0293],
#         [ 7.2012, 14.7207,  6.9414,  6.9746]])

每个查询检索各自的页面(对角线上的结果),第二个查询的区分度明显比第一个更清晰,因为四个页面中只有一页是关于随时间变化的支出。

代码保持不变。在底层,处理器负责处理视觉提示词和图像块,MaxSim 对查询文本 token 与文档图像块进行评分。一个页面包含许多独立的区域,这正是晚期交互在此场景下如此契合的原因——因为单个向量需要将图表、表格和三个段落平均压缩成一个摘要。不过,这种保真度会消耗索引空间。上述形状中,一个页面对应 755 个 token 向量,而查询只有 25 个,相比之下,之前 Natural Questions 段落平均约 125 个 token,因此在视觉场景中,token 池化比在文本场景中更值得尽早采用。

这些是 VLM,因此需要为其所需的内存做好规划。支持的模型中的表格列出了从 252M 到 8.8B 参数的模型,其中小参数端在 CPU 上仍可实用运行,而数十亿参数的大模型则不行。

页面图像是常见情况,但并非唯一的非文本模态。Sentence Transformers 接受文本、图像、音频和视频,而某个检查点支持其处理器所支持的全部模态,model.modalities对此有说明。单个文档也可以组合多种模态,方法是传入一个字典(如 {"text": ..., "image": ...})来代替单独的值。多模态嵌入与重排序模型更广泛地介绍了 Sentence Transformers 中的多模态模型,而使用文档则列出了每种模态具体接受哪些输入格式。

音频检索

vidore/colqwen-omni-v0.1基于 Qwen2.5-Omni 构建,支持全部四种模态。用它检索一段录制的对话,与检索页面一样只需两次调用:

# pip install -U "sentence-transformers[audio,video]"
import torch
from datasets import Audio, load_dataset

from sentence_transformers import MultiVectorEncoder

model = MultiVectorEncoder(
    "vidore/colqwen-omni-v0.1",
    model_kwargs={"dtype": torch.bfloat16},
)
print(model.modalities)
# ['text', 'image', 'audio', 'video', 'message']

# 20 recorded conversations, averaging 28 seconds each
dataset = load_dataset("eustlb/dailytalk-conversations-grouped", split="train[:20]")
dataset = dataset.cast_column("audio", Audio(sampling_rate=16_000))
audio = [row["array"] for row in dataset["audio"]]  # raw mono waveforms, float32 at 16 kHz

query_embeddings = model.encode_query(["medicine for car nausea"])
document_embeddings = model.encode_document(audio, batch_size=2)
scores = model.similarity(query_embeddings, document_embeddings)[0]

top_scores, top_indices = scores.topk(3)
for score, index in zip(top_scores.tolist(), top_indices.tolist()):
    print(f"{score:.4f}  {' / '.join(dataset[index]['texts'][:2])}")
"""
50.8902  Excuse me? Do you have anything for a carsickness? / Yes, but you look fine.
46.1028  Excuse me, could you tell me where you have got that music book? / Certainly. Let me see. Oh, it's on that shelf.
46.0514  Jeff, I'm going to the supermarket. Do you want to come with me? / I think the supermarket is closed now.
"""

ColQwen-Omni 仅使用图像-文本对进行训练,因此其音频检索是零样本的:它从未听过任何训练样本,整个流程中也没有任何转写步骤。查询语句说的是 nausea,而录音中说的是 carsickness,它仍然能以极大优势从二十段录音中选出药房对话。

视频检索

视频的工作方式相同,但需要对帧进行采样,否则会耗尽你的显存。其发布博客文章对此直言不讳,称视频“非常消耗内存,因此最适合短视频片段”:

import torch

from sentence_transformers import MultiVectorEncoder

model = MultiVectorEncoder(
    "vidore/colqwen-omni-v0.1",
    model_kwargs={"dtype": torch.bfloat16},
)

# Sparse, low-resolution frames: 0.5 fps rather than the full frame rate
model[0].processing_kwargs.update(
    {"video": {"max_pixels": 32 * 28 * 28, "do_sample_frames": True, "fps": 0.5}}
)

query_embeddings = model.encode_query(["How to cook Mapo Tofu?"])
document_embeddings = model.encode_document([
    "https://huggingface.co/datasets/sentence-transformers/example-documents/resolve/main/mapo_tofu.mp4",
    "https://huggingface.co/datasets/sentence-transformers/example-documents/resolve/main/zhajiang_noodle.mp4",
], batch_size=1)
print(model.similarity(query_embeddings, document_embeddings))
# tensor([[53.3100, 51.0561]])

在 1 fps 和全分辨率下,同一对视频分别产生 8,426 和 5,137 个 token 向量,峰值显存占用 20.8 GB;而这里分别是 4,240 和 2,446 个向量、12.5 GB 显存,且该模型自身占用 9.0 GB。无论哪种方式,排序结果都完全一致。长音频也需要同样的处理方式,发布博客文章建议使用 30 秒的片段,每个片段大约对应 800 个 token。

可解释性

由于 MaxSim 是每个查询 token 最大值的总和,排序可以被精确分解:文档得分的每一个点都归属于一个查询 token 和一个文档 token。这让你能够精确回答“为什么这个结果排在这里?”,而不是靠肉眼判断。

对于图像文档,sentence_transformers.multi_vector_encoder.interpretability 会将这种分解以标准 ColPali 热力图的形式叠加到页面上,既可以按整个查询聚合,也可以为每个查询 token 生成一张热力图。针对上文中的支出页面提出“水资源和电力方面支出了多少?”这个问题时,water token 的分布如下所示:

heatmap.py 是可运行的版本,其中包含将文档嵌入向量与 patch 网格对齐的掩码步骤。

文本文档没有可供叠加的 patch 网格,但同样的分解方法同样适用。text_similarity_map.py 会对语料库进行排序,然后将最高命中结果的得分逐 token 进行归因,这里使用的是前文提到的 Natural Questions 语料库和 32M 参数的 mxbai-edge-colbert-v0-32m 模型:

Query: when did richmond last play in a preliminary final
Top 3 of 4874 documents by exhaustive MaxSim (191.0ms):
  12.3489  Richmond Football Club Richmond began 2017 with 5 straight wins, a feat it had not achieved since 19
  12.1771  2017 AFL Grand Final The 2017 AFL Grand Final was an Australian rules football game contested betwee
  12.0591  2018 UEFA Champions League Final The 2018 UEFA Champions League Final was the final match of the 201

  query token       best document token      sim   share
  when              since                 0.9154    7.4%
  did               had                   0.9675    7.8%
  rich              rich                  0.9764    7.9%
  mond              mond                  0.9856    8.0%
  last              to                    0.9249    7.5%
  play              game                  0.9384    7.6%
  in                the                   0.9732    7.9%
  a                 a                     0.9587    7.8%
  preliminary       preliminary           0.9394    7.6%
  final             final                 0.9654    7.8%
  --------------------------------------------------------
  3 special tokens                        2.8038   22.7%
  MaxSim score                           12.3489  100.0%

rich、mond、preliminary 和 final 与自身匹配,而 when 最终匹配到 since,play 匹配到 game。特殊 token 也值得注意:其中三个贡献了 22.7% 的得分,却不携带查询的任何内容。在这张表下方,脚本会打印出文本本身,并在原位高亮显示胜出的 token。

Token 池化

如果索引占用空间让你担心,最有效的调节手段是存储更少的 token 向量。HierarchicalTokenPooling 实现了 Clavié、Chaffin 和 Adams 提出的 token 池化 技术:它使用余弦距离上的 Ward 连接法对每个文档的 token 向量进行聚类,并用每个聚类的均值替代该聚类,从而大致保留 1 / pool_factor 的 token。在单个文档内部,大量 token 向量最终彼此非常接近,因此你丢弃的大部分是冗余信息而非有效信号:

from datasets import load_dataset

from sentence_transformers import MultiVectorEncoder
from sentence_transformers.multi_vector_encoder.modules import HierarchicalTokenPooling

dataset = load_dataset("sentence-transformers/natural-questions", split="train[:5000]")
documents = list(dict.fromkeys(dataset["answer"]))

model = MultiVectorEncoder("lightonai/LateOn")

pooling = HierarchicalTokenPooling(pool_factor=2)
document_embeddings = model.encode_document(documents, token_pooling=pooling)

根据你希望何时承担这一开销,有三个地方可以应用它:

# 1. Per encode call, as above
document_embeddings = model.encode_document(documents, token_pooling=pooling)

# 2. Standalone, on embeddings you already have saved (e.g. list of [num_tokens, num_dims] tensors)
pooled = pooling.pool(document_embeddings)

# 3. Baked into the model, so every consumer of the checkpoint gets pooled documents
model.append(HierarchicalTokenPooling(pool_factor=2))
model.save_pretrained("my-pooled-colbert")

默认情况下,池化仅应用于文档,因为查询很短,而且是你无法承受失真的那一侧。在前面提到的 Natural Questions 语料上,缩减效果与 pool_factor 高度吻合,池化全部 608k 个 token 向量大约耗时 6 秒:

pool_factor Token 向量 缩减比例 float32 索引
1(关闭) 608,414 1.00x 311.5 MB
2 305,438 1.99x 156.4 MB
3 204,407 2.98x 104.7 MB
4 153,936 3.95x 78.8 MB

聚类均值与查询 token 的匹配度,不如其成员中最佳匹配者;聚类越粗糙,这种差距就越明显。原始实验在 BEIR 上测算了这一代价,发现损失非常小:在 pool_factor=2 下平均保留了未池化检索性能的 100.6%,在 pool_factor=3 下为 99.0%。将索引减半且几乎不损失性能,这笔买卖很划算,因此 2 是一个合理的起点。不过,在你的数据上代价有多大取决于语料库本身,所以在确定压缩倍数之前,请用 评估器进行测量。可运行的对比脚本是 token_pooling.py。

pool_factor 能压缩到什么程度,也部分取决于模型本身。LightOn 的 层次化池化正则化正是为此而训练,它重塑嵌入空间,使池化代价更低,并报告在 5 倍压缩下保留了 99.4% 的性能。使用该正则化器进行训练目前还未集成到 Sentence Transformers 中,但由此产生的检查点就是普通的 PyLate 模型,因此 lightonai/LateOn-hpool-regularized 可以像其他任何模型一样加载和池化。

加速推理

多向量模型与 Sentence Transformers 的其他部分运行在相同的后端机制上,因此你可以使用 torch(默认)、onnx 和 openvino,同时支持半精度、Flash Attention 和 torch.compile。

在 GPU 上,fp16 配合 Flash Attention 是我们测得的最高性能配置,吞吐量达到 fp32 的 2.44 倍,且检索质量无可测损失。Flash Attention 对多向量模型的帮助比大多数模型更大,因为文档只会被截断而不会被填充到统一长度,因此你的批次中序列长度差异很大,而取消填充(unpadding)正好可以利用这一点:

from sentence_transformers import MultiVectorEncoder

model = MultiVectorEncoder(
    "lightonai/GTE-ModernColBERT-v1",
    model_kwargs={"attn_implementation": "flash_attention_2", "dtype": "float16"},
)

GPU

CPU

采用非注意力查询扩展(attend=False,涵盖 Stanford-NLP 的检查点,如 colbert-ir/colbertv2.0 和 answerdotai/answerai-colbert-small-v1)的模型在加载时会拒绝 Flash Attention。Flash Attention 会剥离 attention_mask=0 位置,因此 MaxSim 评分所需的 [MASK] 扩展 token 永远不会收到注意力更新。对这些模型请使用 "sdpa"。

在 CPU 上,只要架构受支持,OpenVINO 是更优选择,而 int8 量化可带来进一步加速,代价是约 0.4% 的精度损失。完整的基准测试细节、导出与量化辅助工具,以及选择后端的流程图,请参阅 加速推理。

评估模型

MultiVectorNanoBEIREvaluator 使用 MaxSim 评分运行 NanoBEIR 套件中的 13 个小型 BEIR 子集,你无需做任何数据准备:

from sentence_transformers import MultiVectorEncoder
from sentence_transformers.multi_vector_encoder.evaluation import MultiVectorNanoBEIREvaluator

model = MultiVectorEncoder("lightonai/GTE-ModernColBERT-v1")
evaluator = MultiVectorNanoBEIREvaluator(batch_size=16)
results = evaluator(model)
print(f"{evaluator.primary_metric}: {results[evaluator.primary_metric]:.4f}")

这也让我们很容易验证本文开头的说法。lightonai/LateOn和lightonai/DenseOn均由 LightOn 在相同数据上训练,使用相同的 ModernBERT 主干网络和相同的 1.49 亿参数,唯一区别在于它们是每个 token 保留一个向量,还是池化到每个文档一个向量。将两者在全部 13 个 NanoBEIR 数据集上运行,就能分离出这一选择带来的差异:

NanoBEIR 数据集 LateOn(多向量,128 维) DenseOn(稠密,768 维)
MSMARCO 0.7194 0.6517
NQ 0.7810 0.7511
HotpotQA 0.9295 0.8802
FEVER 0.9702 0.9612
ClimateFEVER 0.4887 0.4846
DBPedia 0.6836 0.6748
QuoraRetrieval 0.9795 0.9687
Touche2020 0.5938 0.5673
ArguAna 0.5562 0.5660
NFCorpus 0.3949 0.3851
SciFact 0.7978 0.8057
SCIDOCS 0.4469 0.4484
FiQA2018 0.5871 0.6491
均值 0.6868 0.6764

晚期交互在 13 个数据集中的 9 个以及均值上胜出,领先约一个 NDCG 点。它落败的四个数据集(ArguAna、FiQA2018、SCIDOCS 和 SciFact)恰好勾勒出你应当预期的权衡形态:在相同模型规模下换来检索质量的实际提升,代价是索引体积的增加,而非在每个数据集上都取得全面胜利。同一组对比在完整 15 个数据集的 BEIR 上得分为 57.22 对 56.20,差距相当,可见这一优势并非小型基准测试造成的偶然现象。

与 NanoBEIR 一起,MultiVectorInformationRetrievalEvaluator、MultiVectorRerankingEvaluator、MultiVectorTripletEvaluator 和 MultiVectorDistillationEvaluator 覆盖了在你自己的数据上进行常规评测的各种设置。这些内容在 评测 API 参考 中有文档说明。

从 PyLate 或 colpali-engine 迁移而来

MultiVectorEncoder 整合了这两个库的建模、推理、训练和评测功能。每个 PyLate 检查点都可以直接加载,支持的模型 页面列出了 colpali-engine 的检查点,以及仍需要传入的 revision。如果你正在迁移,以下是会发生变化的关键调用:

PyLate Sentence Transformers
pylate.models.ColBERT(model_name_or_path=...) MultiVectorEncoder(...)
model.encode(..., is_query=True) model.encode_query(...)
model.encode(..., is_query=False) model.encode_document(...)
pylate.scores.colbert_scores model.similarity
pylate.indexes.PLAID / pylate.retrieve.ColBERT 没有对应方案,保留 PyLate 的 PLAID,或参见 Indexing
colpali-engine Sentence Transformers
ColQwen2.from_pretrained(...) + ColQwen2Processor MultiVectorEncoder(...)
processor.process_queries(...) + model(**batch) model.encode_query(queries)
processor.process_images(...) + model(**batch) model.encode_document(images)
processor.score_multi_vector(qs, ds) model.similarity(query_embeddings, document_embeddings)
mask_non_image_embeddings=True MultiVectorMask(keep_only_token_ids=[...])
HierarchicalTokenPooler HierarchicalTokenPooling
colpali_engine.interpretability sentence_transformers.multi_vector_encoder.interpretability

一个值得指出的区别是:在裸(非 ColBERT)检查点上,PyLate 的 ColBERT("bert-base-uncased") 默认应用经典方案,而 MultiVectorEncoder("bert-base-uncased") 则构建一个普通堆栈,将前缀、查询扩展和跳表留作显式选择。训练损失和评估器是等价的,数据处理上的差异详见迁移指南。

请注意,保存兼容性在所有情况下都是单向的:PyLate、Stanford-NLP ColBERT 和 colpali-engine 的检查点都能加载到 MultiVectorEncoder 中,但 MultiVectorEncoder.save_pretrained 的输出无法被它们中的任何一个加载。

支持的模型

在 Hub 上带有 multi-vector 和 sentence-transformers 标签的模型是保持最新的列表,我们正在努力为所有可用的模型打上这些标签。下面的表格是我们直接测试的对象,因此请将它们视为起点而非完整集合。特别是对于文本检索,任何 PyLate 或 Stanford-NLP ColBERT 检查点都可以加载,无论它是否已带有该标签。

有些条目需要先在仓库中添加一个小的 Sentence Transformers 配置,其中几个在撰写本文时仍是待合并的拉取请求。当下面列出 revision 时,请使用它,直到该拉取请求被合并,之后仅使用普通模型名称即可:

model = MultiVectorEncoder("vidore/colqwen-omni-v0.1", revision="refs/pr/N")

文本检索模型

这些模型会加载其训练好的前缀 token、查询扩展以及从保存配置中恢复的标点跳过列表。

NanoBEIR 列报告的是 13 个 NanoBEIR 数据集上的平均 NDCG@10(越高越好),每个数据集是 BEIR 数据集的 50 条查询子样本,作为英文文本检索质量的快速代理指标。我们使用 MultiVectorNanoBEIREvaluator 来计算以英文为主的模型的得分。- 表示该模型未在该项上评估。请注意,NanoBEIR 是一个小型基准,其得分不能替代在你自己的数据上进行评估——在自己的数据上评估始终是选择模型的正确方式。

模型 参数量 维度 NanoBEIR 备注
lightonai/LateOn-regularized 149M 128 0.6897 -
lightonai/LateOn-hpool-regularized 149M 128 0.6876 -
lightonai/LateOn 149M 128 0.6868 -
LiquidAI/LFM2.5-ColBERT-350M 353M 128 0.6864 需要 trust_remote_code=True
lightonai/mLateOn 307M 128 0.6851 -
lightonai/GTE-ModernColBERT-v1 149M 128 0.6720 -
topk-io/Iso-ModernColBERT 149M 128 0.6687 -
perplexity-ai/pplx-embed-v1-late-0.6b 596M 128 0.6662 需要 trust_remote_code=True
lightonai/ColBERT-Zero 149M 128 0.6569 -
answerdotai/answerai-colbert-small-v1 33M 96 0.6550 -
mixedbread-ai/mxbai-edge-colbert-v0-32m 32M 64 0.6524 -
LiquidAI/LFM2-ColBERT-350M 353M 128 0.6441 -
mixedbread-ai/mxbai-edge-colbert-v0-17m 17M 48 0.6407 -
lightonai/colbertv2.0 110M 128 0.6201 -
lightonai/LateOn-Code 149M 128 0.6169 -
lightonai/Agent-ModernColBERT 149M 128 0.6164 -
lightonai/Reason-ModernColBERT 149M 128 0.6078 -
colbert-ir/colbertv2.0 110M 128 0.6053 -
VAGOsolutions/SauerkrautLM-EuroColBERT 212M 128 0.5982 -
antoinelouis/colbert-xm 853M 128 0.5915 -
VAGOsolutions/SauerkrautLM-Multi-ModernColBERT 1.49 亿 128 0.5886 -
mixedbread-ai/mxbai-colbert-large-v1 3.35 亿 128 0.5733 revision="refs/pr/4"
lightonai/LateOn-Code-edge 1700 万 48 0.5274 -
VAGOsolutions/SauerkrautLM-Multi-Reason-ModernColBERT 1.49 亿 128 0.5267 -
VAGOsolutions/SauerkrautLM-Reason-EuroColBERT 212M 128 0.4479 -
NeuML/biomedbert-base-colbert 110M 128 0.4320 -
yjoonjang/colbert-ko-v1 149M 128 - -
ytu-ce-cosmos/turkish-colbert 111M 256 - -
samheym/GerColBERT 110M 128 - -

视觉文档检索模型

ColPali 风格的模型将页面图像作为文档嵌入,将文本作为查询嵌入。

NanoViDoRe 列报告的是跨 NanoViDoRe v3 的平均 NDCG@10(越高越好)。NanoViDoRe v3 是一个紧凑的视觉文档检索基准,涵盖 8 个子集(计算机科学、能源、金融(英语和法语)、人力资源、工业、制药和物理)。与 NanoBEIR 一样,NanoViDoRe 是一个小型基准,不应取代在您自己数据上的评估。

模型 参数量 向量维度 NanoViDoRe 备注
webAI-Official/webAI-ColVec1.1-8b 8.4B 640 0.6580 需要 trust_remote_code=True
webAI-Official/webAI-ColVec1.1-4b 4.5B 640 0.6520 需要 trust_remote_code=True
tencent/EVIE-Preview-4.5B 4.54B 128 0.6405 -
TomoroAI/tomoro-colqwen3-embed-8b 8.8B 320 0.6206 需要 trust_remote_code=True
TomoroAI/tomoro-colqwen3-embed-4b 4.4B 320 0.6019 需要 trust_remote_code=True
vidore/colqwen2.5-v0.2 3.8B 128 0.5402 -
vidore/colqwen2.5-v0.1 3.8B 128 0.5395 -
vidore/colqwen-omni-v0.1 4.4B 128 0.5309 -
vidore/colpali-v1.3 2.9B 128 0.4802 -
vidore/colpali-v1.3-hf 2.9B 128 0.4793 -
vidore/colpali-v1.2 2.9B 128 0.4691 -
vidore/colqwen2-v1.0 2.2B 128 0.4685 -
vidore/colqwen2-v0.1 2.2B 128 0.4526 -
vidore/colpali 2.9B 128 0.4516 -
vidore/colpali-v1.1 2.9B 128 0.4314 -
vidore/colsmolvlm-v0.1 2.1B 128 0.4054 -
vidore/colpali-hard-v1.1 2.9B 128 0.3949 -
vidore/colSmol-500M 507M 128 0.3459 -
vidore/colSmol-256M 256M 128 0.2673 -
ModernVBERT/colmodernvbert 252M 128 0.2632 -
vidore/colpali-v1.2-hf 2.9B 128 - -
vidore/colqwen2-v1.0-hf 2.2B 128 - -

其中大多数是 LoRA 适配器仓库,适配器在加载时直接应用到其基础模型上。有些在 Hub 上还有 -merged 的姊妹版本(例如 vidore/colpali-v1.3-merged),其中适配器已直接融合进权重中。

这三个 -hf 条目是 transformers 原生 *ForRetrieval 移植版本。它们无需任何配置即可加载,但更多使用了 transformers 中的建模,而较少依赖 sentence_transformers。一般来说,更推荐使用原始模型,因为这些移植版本的得分大致相同。

致谢

Sentence Transformers 中的晚期交互建立在大量前期工作之上。感谢 Omar Khattab 和 Matei Zaharia 提出 ColBERT,本文所述的一切皆源于此;也感谢 LightOn 团队(Antoine Chaffin、Raphael Sourty、Paulo Moura 和 Amélie Chatelain)开发了 PyLate 和 fast-plaid,它们多年来支撑着晚期交互的发展,并塑造了上述 API 的很大一部分设计。

感谢 ColPali 团队(Manuel Faysse、Hugues Sibille、Tony Wu、Bilel Omrani、Gautier Viaud、Céline Hudelot 和 Pierre Colombo)提出 ColPali 并开发了 colpali-engine,将晚期交互引入页面图像领域;也感谢 Benjamin Clavié、Antoine Chaffin 和 Griffin Adams 对 token pooling 的贡献。

同样感谢核心 MTEB 团队,包括 Kenneth Enevoldsen 和 Roman Solomatin 等众多成员,感谢他们维护 MTEB,并默默做着支撑信息检索研究运转的工作。

还要感谢所有训练并发布 Supported Models 中所列检查点的人。没有他们,这篇文章将无以为测。

其他资源

文档

示例脚本

训练

要了解如何使用您自己的数据训练或微调这些模型:

Hugging Face Hub

配套博客文章

来源:Hugging Face:Blog(RSS) · huggingface.co