返回 文章 build CMS 文章

使用 Sentence Transformers 训练和微调多向量嵌入模型

手把手教你用 Sentence Transformers 微调多向量嵌入模型,在领域数据上超越通用检索器。

多向量嵌入Sentence TransformersColBERT微调
成长分 / 100 77 综合收获、行动、留存与影响

使用 Sentence Transformers 训练和微调多向量嵌入模型
为什么值得读了解多向量(后期交互)模型相比稠密嵌入模型的优势,以及如何通过微调提升特定领域检索性能。

获得完整的训练流程和代码示例,包括模型加载、数据准备、损失函数、训练参数和评估器配置。

关键洞察
  1. 多向量模型为每个 token 保留向量,使用 MaxSim 评分,能捕捉细粒度信号,但索引更大。
  2. 微调多向量模型能显著提升领域检索性能,且对适量领域内数据响应良好。
  3. 从无监督预训练检查点开始微调,比从完全训练完成的检查点开始效果更好。
转成行动

深入阅读

正文与原文对照

原文保真覆盖:全文原文字符:36712

句子相似度 • 0.1B • 已更新 • 165k • 200

MultiVectorEncoder

,用于 ColBERT 风格的后期交互检索,并为其提供了完整的训练方法。在这篇博客文章中,我将向你展示如何使用它来微调一个多向量模型,使其在你的数据上超越通用检索器。这种方法也可以从头训练出强大的全新多向量模型。以下所有内容都可在 pip install -U "sentence-transformers[train]"

上运行。

微调多向量模型涉及多个组件:模型本身、数据集、损失函数、训练参数、评估器和训练器类。我将逐一介绍这些组件,并附上如何将它们用于微调强大多向量模型的实用示例。

最后,在评估部分,我将向你展示,我微调的 multi-vector-encoder/mLateOn-medical 模型——在单张 RTX 3090 上训练了 14.5 小时,与这篇博客文章同步完成——在我的医学检索评估中轻松超越了所有我能找到的通用检索模型:无论是稠密、稀疏、词汇还是多向量模型。

如果你有兴趣微调稠密嵌入模型、稀疏嵌入模型或重排序器,那么可以考虑阅读我之前关于训练和微调嵌入模型训练和微调稀疏嵌入模型以及训练和微调重排序器模型的博客文章。

这篇博客文章是关于

训练多向量模型的。如果你想了解如何

使用它们,从加载和编码到在向量数据库中建立索引,请参阅配套的[使用 Sentence Transformers 的多向量(后期交互)嵌入模型]博客文章。

稠密嵌入模型将整个文本压缩为单个向量,相似度就是两个这样的摘要之间的一个点积。多向量模型(也称为后期交互或 ColBERT 风格模型)跳过了这种压缩。它为每个 token 保留一个小向量,并使用 MaxSim 算子对查询与文档进行评分,其中每个查询 token 找到其最佳匹配的文档 token,然后将分数相加。token 级别的匹配恰好保留了单个向量不得不平均掉的细粒度信号,这通常意味着更强的检索能力,代价是更大的索引。

配套的多向量嵌入模型博客文章详细介绍了架构、编码、评分和索引,因此我将保持本节简短,直接进入训练部分。

微调多向量模型能显著提升其在你的特定领域上的检索性能:网络搜索、法律取证、代码搜索和科学文献综述之间的词汇、查询风格以及相关性概念都各不相同。由于查询和文档是逐 token 匹配的,多向量模型能够捕捉到单向量模型往往会平均掉的细粒度领域信号,而且它们对即使是适量的领域内微调数据也能做出很好的响应。

除此之外,大多数已发布的检索模型都是针对短段落配置的。经典的 ColBERT 检查点将文档截断为 180 或 300 个 token,而许多流行的稠密模型则截断为 256 或 512 个 token,因为它们的 MS MARCO 风格训练数据很少超过这个长度。如果你的文档很长,这些模型在评分之前会静默地丢弃每个文档的大部分内容。在我对平均长度为 941 个 token 的段落进行的医学评估中,我测量到这种截断导致 NDCG@10 最多下降 0.24,远大于任何模型架构之间的差异。当你训练自己的模型时,你可以配置你的数据所需的文档长度。

LightOn 在代码检索中遇到了同样的动态,通用的 LateOn 不够用,于是他们训练了 LateOn-Code。你的领域,无论是医学、法律、金融还是公司内部文档,都不会有官方模型。这篇博文将向你展示如何自己构建它,只需几个小时,在单个消费级 GPU 上即可完成。

训练 MultiVectorEncoder 模型涉及以下组件:

让我们仔细看看每个组件。

多向量训练让你真正可以选择起点,而且它的重要性可能超出你的预期。

如果你想进一步微调现有的多向量模型,你完全不必担心架构:

from sentence_transformers import MultiVectorEncoder
# Loading in fp32 is preferred for training if your memory can handle it
model = MultiVectorEncoder(
"lightonai/mLateOn-unsupervised",
model_kwargs={"torch_dtype": "float32"},
processor_kwargs={"model_max_length": 8192}, # the tokenizer-level token limit
)

检查点自带其配方:它的查询和文档标记词元、它的投影头、它的评分跳表。对于微调,你通常希望保留所有这些,只更改你的数据所要求的部分。首先要检查的是长度配置,因为许多已发布的检查点将文档限制在 180 到 512 个词元(参见为什么要微调?),而我的医学段落长达 1,400 个词元。mLateOn 系列已经提供了骨干网络完整的 8192 词元上下文,但如果你的起始检查点带有上限,就解除它们:

# 让模型读取完整文档,而不是它训练时所用的长度上限,
# 例如 GTE-ModernColBERT-v1 自带 query_length=48 和 document_length=300
model[0].query_length = None
model[0].document_length = None

在未设置每任务上限的情况下,截断会回退到分词器的 model_max_length

,这就是为什么我在上面的加载时配置了该限制。

我还做了一处改动,添加了一个标点跳过列表,将标点符号 token 从文档侧评分和存储中排除。在四路消融实验(无、标点、停用词、两者)中,它在质量上略有胜出,并且在此数据上免费将文档索引缩小了 9.6%:

import string
# model[2] is the MultiVectorMask module
model[2].skiplist_words = list(string.punctuation)
model[2].resolve_with_tokenizer(model.tokenizer) # token ids are cached, so re-resolve after changing

你也可以将 MultiVectorEncoder 指向任何基础 transformer,系统会自动为你附加一个全新的、随机初始化的 token 级投影:

from sentence_transformers import MultiVectorEncoder
model = MultiVectorEncoder("answerdotai/ModernBERT-base", model_kwargs={"torch_dtype": "float32"})
# MultiVectorEncoder(
# (0): Transformer({..., 'architecture': 'ModernBertModel'})
# (1): Dense({'in_features': 768, 'out_features': 128, 'bias': False, ...})
# (2): MultiVectorMask({'skiplist_words': [], 'skiplist_tasks': ['document'], ...})
# (3): Normalize({...})
# )

这就是经典的 ColBERT 流程:一个 Transformer

生成上下文化的词元嵌入,一个词元级 Dense

将每个嵌入投影到 128 维,一个 MultiVectorMask

决定评分时哪些词元参与计算,以及一个词元级 Normalize

。投影初始是随机的,因此该模型在训练之前无法使用。有趣的是,这在强大的稠密嵌入骨干模型上同样有效。在我的实验中,在 Alibaba-NLP/gte-modernbert-base 上全新初始化的投影,仅凭投影层和 25k 训练对,就达到了与现有检查点起点相差 0.03 以内的水平。

经典的 ColBERT 分词技巧([MASK]

查询扩展、[Q]

/ [D]

前缀词元、文档长度上限、标点跳过列表)默认全部关闭且可配置。完整列表请参见创建自定义模型。值得一提的是,我在领域微调中测试了四种配置下的 [MASK]

查询扩展,没有一种产生可测量的差异,所以不必觉得必须采用经典方案。

在准备这篇博文时,我直接进行了测量:取六个起点,用完全相同的配方在来自 MIRIAD 的 25k 医学问答对上训练每个模型,然后在 1,000 个留出问题上针对 50,000 篇段落语料库进行评估:

起点 零样本 NDCG@10 25k 对之后 差值

结果令我惊讶,并且在两个模型家族中得到了复现。*-unsupervised

检查点对新领域的适应能力远好于其已完成的同类,尽管起点更低,却实现了反超。这些检查点位于大规模对比预训练之后、但在通用检索的监督微调之前,因此它们携带了所有后期交互结构,却没有那些领域训练随后必须撤销的通用调优。相比之下,已完成的检查点在我尝试的每个学习率下几乎纹丝不动,甚至出现退化。

因此,如果你喜欢的模型家族发布了监督前的检查点,就从那里开始。如果没有,在强大的检索预训练骨干上全新初始化投影是接近的次优选择。从完全训练完成的检查点继续是领域适应最弱的选择,尽管它感觉上最自然。

MultiVectorEncoderTrainer 使用

datasets.Dataset

datasets.DatasetDict

注意: 许多可直接用于 Sentence Transformers 的公共数据集已在 Hugging Face Hub 上标记为 sentence-transformers

,因此你可以在 https://huggingface.co/datasets?other=sentence-transformers 上轻松找到它们。不妨浏览这些数据集,寻找可能对你的任务、领域或语言有用的现成数据集。

你可以使用 load_dataset 函数从 Hub 上的数据集加载数据:

from datasets import load_dataset
train_dataset = load_dataset("tomaarsen/miriad-4.4M-split", split="train")
print(train_dataset)
"""
Dataset({
features: ['question', 'passage_text'],
num_rows: 4467542
})
"""

这是我在本篇博文中将用于训练的数据集:来自 MIRIAD 的 440 万个医学问题,每个问题都配有一段包含其答案的源段落(平均 941 个 token)。像这样简单的(查询,相关段落)对,是为你自己所在领域收集检索训练数据时最容易获取的,而且正如你将看到的,它们就是你所需要的全部。

你也可以使用 load_dataset 来加载常见文件格式的本地数据:

from datasets import load_dataset
dataset = load_dataset("csv", data_files="my_file.csv")
# or
dataset = load_dataset("json", data_files="my_file.json")

如果你的本地数据需要预处理,你可以使用 datasets.Dataset.from_dict 通过字典列表来初始化你的数据集:

from datasets import Dataset
queries = []
documents = []
# Open a file, perform preprocessing, filtering, cleaning, etc.
# and append to the lists
dataset = Dataset.from_dict({
"query": queries,
"document": documents,
})

重要的是,你的数据集格式要与损失函数匹配(或者你选择一个与数据集格式匹配的损失函数)。验证数据集格式是否适用于损失函数涉及两个步骤:

在此基础上,有两个多向量特定的约定:

router_mapping

训练参数。(query, document_1, ..., document_N, scores)

其中 scores

是每行 N 个教师分数的列表。对于存储查询和文档 resolve_ids 的 KD 数据集

损失函数量化模型在给定一批数据上的表现如何,允许优化器更新模型权重以产生更有利(即更低)的损失值。适合你任务的正确损失函数取决于你拥有的数据以及你想要实现的目标。你可以在损失概述中找到完整的选项列表。

对于问答或问题-段落对的常见情况,主力是使用MultiVectorMultipleNegativesRankingLoss的批内负样本训练,其中批次中的每个其他文档都作为每个查询的负样本。更大的批次意味着更多的负样本和更强的训练,因此实际上你会想要它的 GradCache 变体,CachedMultiVectorMultipleNegativesRankingLoss

from sentence_transformers import MultiVectorEncoder
from sentence_transformers.multi_vector_encoder.losses import CachedMultiVectorMultipleNegativesRankingLoss
model = MultiVectorEncoder("lightonai/mLateOn-unsupervised", model_kwargs={"torch_dtype": "float32"})
loss = CachedMultiVectorMultipleNegativesRankingLoss(
model=model,
mini_batch_size=16, # how many documents to encode per chunk: bounds memory, not quality
)

mini_batch_size

参数通过按此大小分块编码文档来限制内存,而有效的对比批次大小(在我下面的运行中为 128,在我的消融实验中更大的批次没有带来任何额外收益)仍然可以自由选择。GradCache 保证无论分块大小如何,结果都完全相同,因此对于较小的 GPU,可以降低该值,代价仅仅是墙钟时间。当你的文档长度变化很大时,可以考虑它的同类参数 mini_batch_num_tokens

,它按总 token 预算而非文档数量来打包每个分块,因此一个包含异常长文档的分块永远不会让你的内存飙升(我的 mini_batch_size=16

在每篇文档约 940 个 token 时对应于 mini_batch_num_tokens=15_000

)。

一个多向量特有的陷阱是,对比损失默认使用 scale=1.0

,而稠密嵌入的对应损失默认使用 scale=20.0

。20.0 的存在是因为余弦相似度是 [-1, 1] 范围内的单个值,对于尖锐的 softmax 来说范围太窄。而 MaxSim 分数则是对每个查询 token 的最佳匹配相似度求和,因此它已经大致跨越 [0, query_length]:一个 32 个 token 的查询最高可以得 32 分。所以不要从稠密训练脚本中照搬 scale=20.0

,因为它会使 softmax 饱和并杀死你的梯度。

关于从更强的教师模型进行蒸馏(这是训练最强的通用晚期交互模型的方式),请参阅 MultiVectorDistillKLDivLoss 以及 Knowledge Distillation 选项卡。

你可以使用 MultiVectorEncoderTrainingArguments 类来自定义训练过程。该类允许你调整可能影响训练速度的参数,并帮助你理解训练过程中发生的事情。

有关最有用的训练参数的更多信息,请查看 Multi-Vector Encoder > Training Overview > Training Arguments。值得一读,以充分利用你的训练。

下面是一个示例,使用了我的实际训练运行中的值:

from sentence_transformers import MultiVectorEncoderTrainingArguments
from sentence_transformers.base.sampler import BatchSamplers
args = MultiVectorEncoderTrainingArguments(
# Required parameter:
output_dir="models/mLateOn-medical",
# Optional training parameters:
num_train_epochs=1,
per_device_train_batch_size=128, # the effective contrastive batch, thanks to GradCache
per_device_eval_batch_size=16,
learning_rate=1e-4,
warmup_steps=0.05,
prompts={"question": "[Q] ", "passage_text": "[D] "}, # the checkpoint's markers, keyed by training column
fp16=False, # Set to True if you have a GPU that supports FP16
bf16=True, # Set to True if you have a GPU that supports BF16
batch_sampler=BatchSamplers.NO_DUPLICATES, # in-batch negatives benefit from no duplicates
# Optional tracking/debugging parameters:
eval_strategy="steps",
eval_steps=0.1,
save_strategy="steps",
save_steps=0.05,
logging_steps=0.01,
run_name="mLateOn-medical", # Will be used in e.g. Trackio, W&B, etc.
)

其中一些值得评论:

prompts

:训练不会自动应用存储在模型中的提示,因此需要将它们显式映射到你的训练列上。这里,检查点的 [Q]

标记用于问题列,[D]

用于段落列,以保持训练与推理一致。max_length

(故意未设置):此参数在 learning_rate=1e-4 期间限制分词

:在从 5e-6 到 2e-4 的扫描后,我发现这个高于通常的学习率效果最好。要在训练期间跟踪模型性能,你可以向训练器传递一个 eval_dataset

以获取评估损失,但具体的检索指标信息量要大得多。Sentence Transformers 为多向量模型包含以下内置评估器:

评估器 所需数据
MultiVectorInformationRetrievalEvaluator

MultiVectorNanoBEIREvaluator

MultiVectorTripletEvaluator

MultiVectorRerankingEvaluator

{'query': '...', 'positive': [...], 'negative': [...]}

字典MultiVectorDistillationEvaluator

对于领域微调,基于你自己的留出数据构建的 MultiVectorInformationRetrievalEvaluator 才是关键。构建它的一个提示是,语料库应该足够难,以便区分不同模型。在我的案例中,MIRIAD 问题是从它们自己的源段落生成的,这使得检索异常容易。仅针对 10k 黄金段落,几乎每个模型的 NDCG@10 得分都超过 0.97。如果你的评估像这样饱和,请添加

from datasets import load_dataset
from sentence_transformers.multi_vector_encoder.evaluation import MultiVectorInformationRetrievalEvaluator
dataset = load_dataset("tomaarsen/miriad-4.4M-split")
# Gold: 1,000 evaluation questions, each mapping to its own passage, with the
# eval split's full ~10k unique passages as the initial corpus
corpus = {}
queries = {}
relevant_docs = {}
passage_to_id = {}
for idx, row in enumerate(dataset["eval"]):
if row["passage_text"] not in passage_to_id:
passage_to_id[row["passage_text"]] = f"p{len(passage_to_id)}"
corpus[passage_to_id[row["passage_text"]]] = row["passage_text"]
if idx < 1_000:
queries[f"q{idx}"] = row["question"]
relevant_docs[f"q{idx}"] = {passage_to_id[row["passage_text"]]}
# Distractors: unique train passages that make the haystack realistic
seen = set(passage_to_id)
for row in dataset["train"]:
if len(corpus) >= 200_000:
break
if row["passage_text"] not in seen:
seen.add(row["passage_text"])
corpus[f"d{len(corpus)}"] = row["passage_text"]
evaluator = MultiVectorInformationRetrievalEvaluator(
queries=queries,
corpus=corpus,
relevant_docs=relevant_docs,
name="miriad-dev",
batch_size=16,
)
# results = evaluator(model)

MultiVectorEncoderTrainer 是将之前所有组件整合在一起的地方。以下是训练所用的完整脚本

import logging
import string
import traceback
from datasets import load_dataset
from sentence_transformers import (
MultiVectorEncoder,
MultiVectorEncoderModelCardData,
MultiVectorEncoderTrainer,
MultiVectorEncoderTrainingArguments,
)
from sentence_transformers.base.sampler import BatchSamplers
from sentence_transformers.multi_vector_encoder.evaluation import MultiVectorInformationRetrievalEvaluator
from sentence_transformers.multi_vector_encoder.losses import CachedMultiVectorMultipleNegativesRankingLoss
logging.basicConfig(format="%(asctime)s - %(message)s", datefmt="%Y-%m-%d %H:%M:%S", level=logging.INFO)
def main():
# 1. Load the starting checkpoint: contrastively pretrained, not yet supervised
# Loading in fp32 is preferred for training if your memory can handle it
model = MultiVectorEncoder(
"lightonai/mLateOn-unsupervised",
model_kwargs={"torch_dtype": "float32"},
processor_kwargs={"model_max_length": 8192},
model_card_data=MultiVectorEncoderModelCardData(
language="en",
license="apache-2.0",
model_name="mLateOn finetuned on MIRIAD medical retrieval",
),
)
# 2. Lift the per-task length caps so training and inference see full medical passages
model[0].query_length = None
model[0].document_length = None
# 3. Skip punctuation tokens during scoring: a small quality win and a 9.6% smaller index
model[2].skiplist_words = list(string.punctuation)
model[2].resolve_with_tokenizer(model.tokenizer)
# 4. Load 1 million medical question-passage pairs
train_dataset = load_dataset("tomaarsen/miriad-4.4M-split", split="train").select(range(1_000_000))
# 5. In-batch negatives with GradCache: large effective batch, memory-bounded chunks
loss = CachedMultiVectorMultipleNegativesRankingLoss(model=model, mini_batch_size=16)
# 6. A light dev evaluator to watch progress during training: 500 held-out questions
# against the eval split's ~10k unique passages. The full 200k protocol runs afterwards.
eval_split = load_dataset("tomaarsen/miriad-4.4M-split", split="eval")
corpus, queries, relevant_docs, passage_to_id = {}, {}, {}, {}
for idx, row in enumerate(eval_split):
if row["passage_text"] not in passage_to_id:
passage_to_id[row["passage_text"]] = f"p{len(passage_to_id)}"
corpus[passage_to_id[row["passage_text"]]] = row["passage_text"]
if idx < 500:
queries[f"q{idx}"] = row["question"]
relevant_docs[f"q{idx}"] = {passage_to_id[row["passage_text"]]}
dev_evaluator = MultiVectorInformationRetrievalEvaluator(
queries=queries, corpus=corpus, relevant_docs=relevant_docs, name="miriad-dev", batch_size=16
)
# 7. Training arguments, as discussed above
run_name = "mLateOn-medical"
args = MultiVectorEncoderTrainingArguments(
output_dir=f"models/{run_name}",
num_train_epochs=1,
per_device_train_batch_size=128,
per_device_eval_batch_size=16,
learning_rate=1e-4,
warmup_steps=0.05,
prompts={"question": "[Q] ", "passage_text": "[D] "},
fp16=False, # Set to True if you have a GPU that supports FP16
bf16=True, # Set to True if you have a GPU that supports BF16
batch_sampler=BatchSamplers.NO_DUPLICATES,
eval_strategy="steps",
eval_steps=0.1,
save_strategy="steps",
save_steps=0.05,
logging_steps=0.01,
run_name=run_name,
)
# 8. Create a trainer & train
trainer = MultiVectorEncoderTrainer(
model=model,
args=args,
train_dataset=train_dataset,
loss=loss,
evaluator=dev_evaluator,
)
trainer.train()
# 9. Save the trained model
model.save_pretrained(f"models/{run_name}/final")
# 10. (Optional) Push it to the Hugging Face Hub
try:
model.push_to_hub(run_name)
except Exception:
logging.error(f"Error uploading model to the Hugging Face Hub:\n{traceback.format_exc()}")
if __name__ == "__main__":
main()

这就是完整的配方:一个预监督检查点、一百万对领域数据、批内负样本、完整文档长度,以及高于常规的学习率。这次运行在我的单张 RTX 3090 上耗时 14.5 小时,峰值显存占用 17.5 GB,而其中每一个选择都是经过实测比较后的胜出者,而非猜测。

对于预算较小的读者,我的扩展实验表明,10 万对数据(训练 75 分钟)与完整的百万对运行相比,NDCG@10 差距在 0.012 以内。大部分收益都来自第一个小时。

MultiVectorEncoder 训练器支持多种 transformers.TrainerCallback 子类,包括:

WandbCallback

wandb

已安装TensorBoardCallback

tensorboard

可访问CodeCarbonCallback

codecarbon

已安装通过 report_to

训练参数启用这些回调,例如 report_to=["wandb", "codecarbon"]

,并安装所需的依赖项。它默认为 "none"

,而 report_to="all"

会激活所有依赖项已安装的集成。

有关这些回调以及如何创建自己的回调的更多信息,请参阅 Transformers 回调文档

通常,表现最佳的通用模型会同时在多个数据集上进行训练。然而,由于每个数据集的格式各不相同,这种方法可能颇具挑战性。幸运的是, MultiVectorEncoderTrainer 允许你在多个数据集上训练,而无需统一的格式。此外,它还提供了为每个数据集应用不同损失函数的灵活性。以下是同时使用多个数据集进行训练的步骤:

datasets.Dataset

datasets.DatasetDict

train_dataset

(以及可选的 eval_dataset

)。每个训练/评估批次将只包含来自其中一个数据集的样本。从多个数据集中采样批次的顺序由 MultiDatasetBatchSamplers 枚举定义,该枚举可以传递给

MultiVectorEncoderTrainingArguments

multi_dataset_batch_sampler

。有效选项包括:MultiDatasetBatchSamplers.ROUND_ROBIN

:从每个数据集中轮流采样,直到其中一个耗尽。使用此策略,可能不会使用每个数据集中的所有样本,但每个数据集被采样的机会均等。MultiDatasetBatchSamplers.PROPORTIONAL

(默认):按每个数据集的大小比例进行采样。使用此策略,每个数据集中的所有样本都会被使用,且较大的数据集被采样的频率更高。为了了解微调模型的表现,我在 MIRIAD 评估集上,针对四个架构系列中的 50 多种检索模型配置进行了评估,评估集的构建方式与上文 Evaluator 部分完全一致,使用 1,000 个留出的医学问题搜索 200,000 个独特段落(10k 个黄金段落隐藏在来自训练分割的 190k 个去重干扰项中)。该语料库的大小是 你应该选择哪个起点? 中 50,000 段落语料库的四倍,因此两个表格之间的分数不可比较。

主要结果如下,完整表格见下方可折叠部分:

模型 系列 NDCG@10
multi-vector-encoder/mLateOn-medical(我的)

微调后的模型位居榜首,比任何架构中最强的零样本模型高出 +0.062 NDCG@10。换句话说,最强的零样本模型在 75.8% 的查询中将正确段落作为第一个命中返回,而微调模型在 84.9% 的查询中做到这一点,将排名第一的错误减少了三分之一以上。

架构模式同样清晰,表格顶部清一色是后期交互(late interaction)。在长文档上,每个 token 一个向量胜过每个文档一个向量,即使在训练数据和骨干网络都匹配的情况下也是如此。DenseOn 和 LateOn 除头部外共享训练数据和架构,而后期交互的兄弟模型以 +0.12 胜出,多语言配对(mDenseOn 和 mLateOn)以 +0.13 复现了这一结果。规模也无法拯救单向量模型。Qwen3-Embedding-4B 是最强的稠密模型,其活跃(非嵌入)参数量约为我的模型的 33 倍,但仍差了 0.13,而 8B 版本的得分低于 4B。

BM25 的表现也出人意料地好,击败了所有稀疏模型、所有受截断限制的多向量模型,以及除三个稠密模型之外的所有稠密模型:数十亿参数的 Qwen3-Embedding-4B8B,以及 voyage-4-nano,后者读取其完整的 32k token 上下文,仅以 0.006 的优势险胜。不过别指望这能迁移到你自己的数据上。MIRIAD 的问题是从段落生成的,因此查询与其黄金段落之间的词汇重叠远大于典型检索场景,而 BM25 不受限的上下文长度让它能利用每一个重叠词,而大多数神经检查点则会截断。BM25 基线成本低廉,始终值得运行,只是别指望这个差距。

完整阵容一览,按分数排序,按架构系列着色。

标记为 @N 的模型

在评估时将其文档长度上限提升至 N 个 token,因为它们的原生上限(180 到 512 个 token)否则会截断平均 941 个 token 的段落。对于每个多向量模型,这一提升相比按原样提供的行带来了 +0.08 到 +0.24 NDCG@10 的增益,甚至稠密的 DenseOn 也从同样的处理中获得了 +0.03。

请注意,这并不意味着 multi-vector-encoder/mLateOn-medical所有领域上最强的模型。它只是在我的领域上最强。这完全没问题,因为我只需要这个模型在我的数据上表现良好。

不要低估在你的领域上微调多向量模型的力量。在单块消费级 GPU 上花费十四个半小时,就产出了一个在这个数据上没有任何通用检索器能接近的模型,而且配方只是一个脚本,没有教师模型,也没有挖掘的负样本!

对多向量检索的合理反对意见在于索引大小,而这一领域几乎处于其最坏情况。每个 token 存储一个向量时,我的模型每段大约需要 878 个向量,因此 20 万段的语料库在 fp16 下大约占用 45 GB,而稠密模型所需远低于 1 GB。文档长度正是造成这一巨大差距的原因。配套文章中的 Natural Questions 段落平均每段约 125 个 token 向量,少了七倍,因此短段落语料库的起始索引远小于此。

HierarchicalTokenPooling 模块正是通过聚类每个文档的 token 嵌入并存储聚类均值来压缩这一点,大致保留

1 / pool_factor

的向量:```

from sentence_transformers.multi_vector_encoder.modules import HierarchicalTokenPooling

pooling = HierarchicalTokenPooling(pool_factor=4)

document_embeddings = model.encode_document(passages, token_pooling=pooling)


我在完成后的模型上事后测量了这一点,没有进行池化感知训练,而在长文档上,它的成本低得惊人。

实心点表示未压缩的嵌入,因此每个系列都以相同方式计数,并使用精确搜索进行评分。不过,你不会以那种方式部署它们中的任何一个。密集索引通常使用 int8 或二值量化并重新评分,稀疏索引压缩其倒排列表,多向量索引使用 PLAID 风格的残差压缩。不要把这些点看作你需要购买的磁盘,而应看作相对存储成本。

令牌池化是实线。将向量数量减半会损失 0.0033 NDCG@10,并且不影响排名第一的准确率,而只保留四分之一,即 11.2 GB,仍然得分 0.8991。曲线还在继续(我测量到向量的十分之一,仍然为 0.8765),但一旦量化被提上日程,就没有什么理由把池化推到那么远,这就是下面虚线的内容。

虚线是真实部署可能的样子。我让 Omar Khattab 提前访问了模型和基准测试,他用 [fast-plaid](https://github.com/lightonai/fast-plaid) 在 1 位残差量化下测量了这些配置,使用紧凑的 17 位质心 ID 和 18 位文档 ID,而不是其通常的未打包 64 位整数,再加上文档侧剪枝:

配置 | 保留向量 | 索引 | NDCG@10 |
---|---|---|---|

1 位 PLAID,所有向量 | 100% | 3.37 GB | 0.8984 |

1 位 PLAID + 剪枝 | 65% | 2.23 GB | 0.8830 |

1 位 PLAID + 剪枝 | 42% | 1.45 GB | 0.8642 |

第一行比原始嵌入小 13 倍,NDCG@10 为 0.0155。这比池化曲线上的任何位置都要好得多。量化缩小每个向量,而池化和剪枝减少你保留的数量,因此它们可以组合,而量化是首先应该采用的。进一步推进,最后一行达到 1.45 GB,*比* [Qwen3-Embedding-8B](https://huggingface.co/Qwen/Qwen3-Embedding-8B) 的 fp16 嵌入(1.64 GB)*更小*,同时得分高出 0.0895。多向量索引太大的反对意见在正确配置的索引面前站不住脚。

这里的剪枝是朴素的,仅用于确立令牌减少在量化之上有效,因此将底部两行视为下限而非前沿。如果你根本不想手动调整量化,配套文章的 [Indexing](https://huggingface.co/blog/multi-vector-encoder#indexing) 部分涵盖了 fast-plaid、Qdrant、Weaviate 和 Vespa。

多向量检索的成本只取决于其索引。此语料库的原始嵌入为 45 GB,而正确配置的索引至少小 7 倍,准确率几乎相同。索引值得你像检查点一样关注。

感谢 [Omar Khattab](https://github.com/okhat) 在 [Optimizing the index](https://huggingface.co#optimizing-the-index) 中测量量化和剪枝的索引配置,以及围绕后期交互索引成本的讨论。

这些页面有带解释的训练示例以及训练脚本的链接。你可以用它们来熟悉多向量训练循环:

如需进一步学习,你可能还想探索以下关于 Sentence Transformers 的资源:

这里有一个可能让你感兴趣的高级页面:

以及配套博客文章,涵盖关于*使用*这些模型的一切:

微调多向量模型涉及几个组件:模型本身、数据集、损失函数、训练参数、评估器和训练器类。我将逐一查看

[playfy]这些组件,并附上实际示例,说明如何用它们微调强大的多向量模型。

这是一次非常有趣的多向量嵌入模型深度探索... 🔥 `MultiVectorEncoder` 的引入

在 Sentence Transformers v6.0 中看起来特别适用于 ColBERT 风格的后期交互检索,其中保留多个表示可以比单个密集嵌入捕获更细粒度的匹配。同一框架支持 RAG、语义搜索和重排序,这使得它成为现代检索系统非常实用的方向。🚀

我也喜欢这篇文章如何将微调过程分解为各个部分... 数据集、损失函数、训练参数、评估器和训练器类。🧠 围绕每个组件提供实际示例,使该方法更容易理解和复现,特别是对于那些想要超越使用通用检索器并真正为自己的领域优化模型的人。

医学检索结果可能是最令人印象深刻的部分... 💡 在单个 RTX 3090 上仅用 14.5 小时训练 mLateOn-medical 多向量编码器,并获得比密集、稀疏、词汇和其他多向量方法更好的结果,这表明领域特定微调的价值。这是一个很好的提醒:检索中最大的收益通常来自将模型和训练数据与实际搜索任务紧密匹配,而不是简单地选择最新的通用模型。👏📚

总的来说,对于任何尝试高级检索管道的人来说,这是一个很棒的实用资源... ⚡ 能够从头训练强大的多向量模型以及微调现有模型,为专业搜索和 RAG 应用开辟了许多可能性。我特别欣赏整个工作流可以用 `sentence-transformers[train]` 设置

... 它让原本相当复杂的检索训练过程感觉更容易上手。👍🤖