跳到正文
原文
Hugging Face Blog·· 2026-08-18精选AI 评分71

Sentence Transformers v6.0 新增 MultiVectorEncoder 支持 ColBERT 式晚交互检索

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

AI 导读

Sentence Transformers v6.0 新增第四种模型类型 MultiVectorEncoder,用于 ColBERT 式晚交互检索,PyLate、Stanford-NLP ColBERT 以及 colpali-engine 视觉文档检索检查点均可通过同一套 API 加载。

推荐理由

原文给出多种 ColBERT 类检查点的统一加载方式,以及索引体积与检索收益之间的实测取舍,可据此决定是否替换现有稠密检索。

正文 · AI 翻译

译文尚不完整,完整内容请切换到原文。

Sentence Transformers 是一个 Python 库,用于使用和训练嵌入模型与重排序模型,可应用于检索增强生成、语义搜索等场景。在 v6.0 更新中,它新增了第四种模型类型:MultiVectorEncoder,用于 ColBERT 风格的后期交互检索。任何 PyLate 检查点和任何 Stanford-NLP ColBERT 检查点都可以直接加载到其中,用于视觉文档检索的 colpali-engine 模型也同样可以使用,通过你已经熟悉的同一套 API,即可用于稠密、稀疏和重排序模型。

普通嵌入模型会把整段文本压缩成一个向量,而多向量模型则为 每个 token 保留一个向量,并使用 MaxSim 算子对查询和文档进行打分。这保留了单个向量不得不平均掉的 token 级匹配信息,通常意味着更强的检索能力,代价是更大的索引。它也是视觉文档检索的最先进方案,在此场景中文本查询会直接与页面图像匹配,中间无需 OCR 步骤。

在这篇博文中,我们将向你展示如何使用这些模型:加载各种检查点格式、编码和打分、将它们接入搜索栈、在页面图像上运行它们,以及让索引保持可负担。下面的一切都可以在普通的 pip install -U sentence-transformers 上运行。

本篇博客介绍的是如何使用多向量模型。如果你想了解如何训练它们,请参阅配套博文 Training and Finetuning Multi-Vector Embedding Models with Sentence Transformers。

目录

什么是多向量模型?

稠密嵌入模型读取一段文本并返回单个固定大小的向量。模型注意到的一切都必须装进那 384、768 或 1024 个数字中,相似度就是两个这样的摘要之间的一个点积。这效果出奇地好,但这种压缩在特定方面是有损的:一个稀有实体、一个精确标识符,或长段落中一个关键子句,都必须争抢同一向量中的空间。一个同时带有多个要求的查询也会撞上同一堵墙。对于“带木腿和圆形坐垫的绿色沙发”,单个向量必须把四个要素混合成一个点,因此一张木腿不对的绿色沙发最终会离你真正想要的沙发很近。

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

查询与文档之间的交互被推迟到打分阶段,这正是"后期交互"这一名称的由来。交叉编码器(cross-encoder)进行的是早期交互,两段文本一起通过模型,结果准确,但没有任何东西可以预先计算,因为每来一个新查询,所有文档都得重新编码。双编码器(bi-encoder),也就是上面那个稠密嵌入模型,几乎不发生交互(两个已经完成的摘要之间做一次点积),而正是这一点让你可以一次性编码整个集合、随后快速查询。后期交互介于两者之间。文档仍然被独立编码,可以离线建立索引,但打分时会将每一个查询 token 与每一个文档 token 进行比较,为二者的交互留下了大得多的空间。

Dense embedding versus multi-vector late interaction: a dense model encodes each text into one vector and scores with cosine similarity, while a multi-vector model keeps one vector per token and scores every query token against every document token with MaxSim

MaxSim 算子

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

MaxSim(Q,D)=∑Qi∈Qmax⁡Dj∈DQi⋅Dj\text{MaxSim}(Q, D) = \sum_{Q_i \in Q} \max_{D_j \in D} Q_i \cdot D_j

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

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

这种对齐不必是字面上的,因为 token 嵌入是上下文化的。用 lightonai/mLateOn 把"Where do penguins live?"与"Penguins inhabit Antarctica."编码后,查询 token live 在 inhabit 上找到最佳匹配,相似度为 0.94,而它与这个词没有任何共同字符!这正是字面检索做不到的事,BM25 及其同类方法需要词本身出现,因此同义词和改述会从它们眼皮底下溜过去。稠密嵌入模型当然也能弥合这一差距。后期交互的额外之处在于,它在做到这一点的同时并没有放弃另一个方向。当精确匹配才是关键时(产品编号、姓氏、函数名),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 支持稠密和稀疏模型,但不支持后期交互,因此 LightOn 在其基础上构建了 PyLate 来填补这一空白,增加了这些模型所需的训练、推理和检索组件。下面你将加载的许多模型都是用 PyLate 训练的,LightOn 也围绕它构建了生态系统,包括 fast-plaid,即出现在索引部分的后期交互索引。在 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] 标记填充,以及为文档评分时跳过哪些标记。它们都存在于模块配置中,因此 print(model) 会准确展示你加载了什么。以下是原始的 ColBERTv2 检查点,它将每个查询精确填充到 32 个标记,并将文档截断到 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 将每个嵌入投影到 128 维,一个 MultiVectorMask 决定打分时哪些 token 计入,以及一个 token 级别的 Normalize。其他 checkpoint 则填入不同的值。lightonai/GTE-ModernColBERT-v1 使用相同的四个模块,搭配 [Q] 和 [D] 提示,不做查询扩展,上限分别为 48 和 300。

你几乎不需要改动这些,因为每个已发布的 checkpoint 都会配置好自己的一套。只有当你从裸骨干网络构建模型时才需要关注,这部分的说明见 Creating Custom Models。

不过有一个值值得对照你自己的数据检查一下。document_length 会截断,所以超出它的内容永远不会进入索引。例如,一段 662 个 token 的段落经过 LateOn 的 300 上限后会返回 273 个向量,段落其余部分则直接消失。这些 checkpoint 大多是在短段落上训练的,所以如果你的分块比上限长,可以在单次调用中用 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 会前置查询标记,如果 checkpoint 要求,就把查询扩展到固定长度,并将其限制在查询长度内。encode_document 会前置文档标记,限制在文档长度内,并从打分掩码中丢弃任何 skiplist 中的 token(对大多数 checkpoint 来说是标点符号)。

通常的 encode() 参数依然全部适用,所以 batch_size、show_progress_bar、convert_to_numpy、device 以及多进程池都按你预期的方式工作:

document_embeddings = model.encode_document(
    documents,
    batch_size=64,
    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 运行——它会把每个查询填充或截断到恰好 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, show_progress_bar=True)

query = "when did richmond last play in a preliminary final"
start = time.perf_counter()
query_embeddings = model.encode_query([query])
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 嵌入交给专为此构建的系统。Indexing 中有四个选项的可运行代码片段,紧接着下面的一节则介绍如何完全跳过索引。

检索与重排

你还可以在不维护后交互索引的情况下获得后交互质量,方法是将多向量模型用作重排器。一个快速的双编码器把大语料缩小到少量候选,然后多向量模型只对这些候选重新打分:

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 添加了普通 pgvector 所没有的 MaxSim 算子。Milvus 在 v2.6.4 中加入了它们,归类在 array-of-structs 之下,而不是它那个名为 multi-vector search 的无关功能。如果你根本不想运行服务器,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)
query_embedding = model.encode_query(query)

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://hf-mirror.com/datasets/sentence-transformers/example-documents/resolve/main/doc1.jpg",
    "https://hf-mirror.com/datasets/sentence-transformers/example-documents/resolve/main/doc2.jpg",
    "https://hf-mirror.com/datasets/sentence-transformers/example-documents/resolve/main/doc3.jpg",
    "https://hf-mirror.com/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 池化。

这些是 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://hf-mirror.com/datasets/sentence-transformers/example-documents/resolve/main/mapo_tofu.mp4",
    "https://hf-mirror.com/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 的去向:

MaxSim heatmap of the query token "water" overlaid on a 1971 US budget outlays page, with the brightest patch on the "Water Resources & Power" bar of the lower chart

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

文本文档没有可叠加的 patch 网格,但同样的分解同样适用。text_similarity_map.py 会对语料库进行排名,然后逐 token 归因最高命中项的得分,这里是之前使用 3200 万参数的 mxbai-edge-colbert-v0-32m 在 Natural Questions 语料库上的结果:

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 linkage 对每个文档的 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 上,使用 Flash Attention 的 fp16 是我们测得的最佳配置,吞吐量是 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"},
)

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

在 CPU 上,如果架构受支持,OpenVINO 是更好的选择,而 int8 量化可以进一步提速,代价是约 0.4% 的准确率损失。有关完整基准测试详情、导出和量化辅助工具,以及选择后端的流程图,请参阅加速推理。

评估模型

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

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 骨干和相同的 149M 参数,唯一区别在于它们是每个 token 保留一个向量,还是池化为每个文档一个向量。在全部 13 个 NanoBEIR 数据集上运行两者,可以分离出这一选择带来的效果:

NanoBEIR 数据集 LateOn(多向量,128d) DenseOn(稠密,768d)
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

Late interaction 在 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 或参见 索引
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") 构建的是普通堆栈,将前缀、查询扩展和 skiplist 留作显式选择。训练损失与评估器的对应项,以及数据处理差异,见 迁移指南。

在所有情况下,保存兼容性都是单向的。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、查询扩展和标点 skiplist。

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 -
VAGOsolutions/SauerkrautLM-Multi-ModernColBERT 149M 128 0.6741 -
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
VAGOsolutions/SauerkrautLM-Multi-Reason-ModernColBERT 149M 128 0.6616 -
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 -
jinaai/jina-colbert-v2 559M 128 0.6517 需要 trust_remote_code=True
DataScience-UIBK/Reason-mxbai-colbert-v0.1-32m 32M 128 0.6486 -
LiquidAI/LFM2-ColBERT-350M 353M 128 0.6441 -
lightonai/ColBERT-Zero-unsupervised-noprompts 149M 128 0.6430 -
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-Reason-EuroColBERT 212M 128 0.6039 -
VAGOsolutions/SauerkrautLM-EuroColBERT 212M 128 0.5965 -
antoinelouis/colbert-xm 853M 128 0.5915 -
mixedbread-ai/mxbai-colbert-large-v1 335M 128 0.5733 -
lightonai/LateOn-Code-edge 17M 48 0.5274 -
NeuML/biomedbert-base-colbert 110M 128 0.4320 -
NeuML/colbert-bert-tiny 4M 128 0.4035 -
yjoonjang/colbert-ko-v1 149M 128 - -
ytu-ce-cosmos/turkish-colbert 111M 256 - -
samheym/GerColBERT 110M 128 - -
bclavie/JaColBERT 111M 128 - -
bclavie/JaColBERTv2 111M 128 - -
answerdotai/JaColBERTv2.4 111M 128 - revision="refs/pr/2"
answerdotai/JaColBERTv2.5 111M 128 - revision="refs/pr/5"

视觉文档检索模型

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

NanoViDoRe 列报告 NanoViDoRe v3 上的平均 NDCG@10(越高越好),这是一个紧凑的视觉文档检索基准,涵盖 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
vultr/VultronRetrieverPrime-Qwen3.5-8B 8.39B 320 0.6423 revision="refs/pr/2"
vultr/VultronRetrieverCore-Qwen3.5-4.5B 4.54B 320 0.6410 revision="refs/pr/1"
tencent/EVIE-Preview-4.5B 4.54B 128 0.6405 -
nvidia/nemotron-colembed-vl-8b-v2 8.77B 4096 0.6374 revision="refs/pr/4",需要 trust_remote_code=True
athrael-soju/colqwen3.5-4.5B-v3 4.54B 320 0.6358 -
TomoroAI/tomoro-colqwen3-embed-8b 8.8B 320 0.6206 需要 trust_remote_code=True
nvidia/nemotron-colembed-vl-4b-v2 4.83B 2560 0.6200 revision="refs/pr/7",需要 trust_remote_code=True
OpenSearch-AI/Ops-Colqwen3-4B 4.44B 2560 0.6150 revision="refs/pr/5",需要 trust_remote_code=True
nvidia/llama-nemotron-colembed-vl-3b-v2 4.41B 3072 0.6112 revision="refs/pr/3",需要 trust_remote_code=True
tsystems/colqwen2.5-3b-multilingual-v1.0 3.75B 128 0.6039 revision="refs/pr/3"
tsystems/colqwen2.5-3b-multilingual-v1.0-merged 3.75B 128 0.6027 revision="refs/pr/1"
TomoroAI/tomoro-colqwen3-embed-4b 4.4B 320 0.6019 需要 trust_remote_code=True
nomic-ai/colnomic-embed-multimodal-7b 8.29B 128 0.5942 revision="refs/pr/3"
nomic-ai/colnomic-embed-multimodal-3b 3.75B 128 0.5929 revision="refs/pr/6"
VAGOsolutions/SauerkrautLM-ColQwen3-8b-v0.1 8.15B 128 0.5819 revision="refs/pr/1"
Metric-AI/ColQwen2.5-3b-multilingual-v1.0 3.75B 128 0.5763 revision="refs/pr/2"
vultr/VultronRetrieverFlash-Qwen3.5-0.8B 853M 320 0.5693 revision="refs/pr/2"
VAGOsolutions/SauerkrautLM-ColQwen3-4b-v0.1 4.44B 128 0.5656 revision="refs/pr/1"
VAGOsolutions/SauerkrautLM-ColQwen3-2b-v0.1 2.13B 128 0.5530 revision="refs/pr/1"
Verm1ion/ColTurk-VDR-Qwen3VL-4B-v1.0 4.44B 320 0.5434 revision="refs/pr/1"
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 -
VAGOsolutions/SauerkrautLM-ColQwen3-1.7b-Turbo-v0.1 1.76B 128 0.5035 revision="refs/pr/1"
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 -
VAGOsolutions/SauerkrautLM-ColLFM2-450M-v0.1 451M 128 0.4249 revision="refs/pr/1"
vidore/colsmolvlm-v0.1 2.1B 128 0.4054 -
VAGOsolutions/SauerkrautLM-ColMinistral3-3b-v0.1 4.25B 128 0.3978 revision="refs/pr/1"
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 中检查点的人。没有他们,这篇文章就没什么可衡量的了。

其他资源

文档

示例脚本

训练

如需了解如何在自己的数据上训练或微调这些模型:

请参阅配套博客文章:使用 Sentence Transformers 训练和微调多向量嵌入模型。

HF Mirror Hub

配套博客文章

来源:Hugging Face Blog · hf-mirror.com