使用 Sentence Transformers 训练与微调多向量 Embedding 模型
Sentence Transformers 是一个 Python 库,用于使用和训练面向各种应用的嵌入与重排序模型,包括检索增强生成、语义搜索、语义文本相似度等场景。其 v6.0 版本新增了第四种模型类型:用于 ColBERT 式晚期交互检索的 MultiVectorEncoder,并配套提供了一整套训练方案。在本文中,我会演示如何利用它微调多向量模型,让它在你的数据上超越通用检索器。该方法也能从零训练出强大的多向量模型。下面所有内容只需运行 pip install -U "sentence-transformers[train]" 即可上手。
微调多向量模型涉及多个组件:模型本身、数据集、损失函数、训练参数、评估器以及训练器类。我会逐一介绍每个组件,并配合实际示例展示如何用它们微调出强大的多向量模型。
最后,在评估章节,我会展示我在撰写本文的同时用单张 RTX 3090 耗时 14.5 小时微调出的 multi-vector-encoder/mLateOn-medical 模型,在我的医学检索评估任务上轻松超越了能找到的所有通用检索模型——无论是稠密的、稀疏的、词法层面的,还是多向量的。
如果你感兴趣的是微调稠密嵌入模型、稀疏嵌入模型或重排序器,可以阅读我之前的几篇博客:《训练与微调嵌入模型》、《训练与微调稀疏嵌入模型》 以及 《训练与微调重排序模型》。
本文讲的是如何训练多向量模型。如果你想了解如何使用它们——从加载、编码到在向量数据库中建索引——可以参考配套博客 Multi-Vector (Late Interaction) Embedding Models with Sentence Transformers。
目录
什么是多向量模型?
稠密嵌入模型会把整段文本压缩成一个向量,两段文本之间的相似度就是这两个向量的点积。多向量模型(也叫晚期交互模型或 ColBERT 风格模型)跳过了这一步压缩:它为每个 token 保留一个小向量,用 MaxSim 算子对查询和文档打分——让每个查询 token 找到与其最匹配的文档 token,再把所有得分加起来。token 级的匹配保留了那些被单向量模型不得不"平均掉"的细粒度信号,通常意味着更强的检索效果,代价则是更大的索引体积。
配套的多向量嵌入模型文章详细介绍了架构、编码、打分和索引,所以这一节我就简短带过,直接进入训练部分。
为什么要微调?
微调多向量模型能显著提升它在特定领域上的检索效果:不同场景下——网页搜索、法律案件检索、代码搜索、学术文献综述——用词、查询风格、相关性的定义都不一样。由于查询和文档是逐 token 比对的,多向量模型能够捕捉到单向量模型容易忽略的细粒度领域信号,因此即便只用少量领域内数据进行微调,效果提升也会非常明显。
除此之外,市面上大多数已发布的检索模型都是为短文本配置的。经典的 ColBERT 权重把文档截断到 180 或 300 token,很多主流稠密模型则截断到 256 或 512 token——因为它们的 MS MARCO 风格训练数据几乎不会超过这个长度。如果你的文档很长,这些模型会在打分之前就把每个文档的大部分内容悄悄丢弃。我在一次医学评估中测试过,文档平均长度为 941 token,这种截断最多会损失 0.24 的 NDCG@10,远超不同模型架构之间的差异。当你训练自己的模型时,就可以根据你的数据配置合适的文档长度。
LightOn 在代码检索场景中也遇到了同样的问题——通用的 LateOn 并不够用,于是他们专门训练了 LateOn-Code。无论你的领域是医学、法律、金融,还是公司内部的文档,都不会有现成的官方模型可以直接用。本文将向你展示如何在几小时内,仅用一块消费级 GPU 自己打造一个合适的模型。
训练组件
训练 MultiVectorEncoder 模型涉及以下组件:
- Model(模型):待微调的模型或要从零搭建的架构。
- Dataset(数据集):用于训练和评估的数据。
- Loss Function(损失函数):衡量模型表现并指导优化过程的函数。
- Training Arguments(训练参数)(可选):影响训练性能、日志记录和调试的参数。
- Evaluator(评估器)(可选):用于在训练前、训练中或训练后评估模型的类。
- Trainer(训练器):将所有训练组件整合在一起。
下面我们来逐一了解每个组件。
Model
多向量训练让你在起点的选择上有充分的余地,而这一点比想象中更重要。
微调已有的多向量模型
如果你想进一步微调一个已有的多向量模型,完全不需要操心架构的问题:
from sentence_transformers import MultiVectorEncoder
# 训练时如果显存允许,建议使用 fp32 加载
model = MultiVectorEncoder(
"lightonai/mLateOn-unsupervised",
model_kwargs={"torch_dtype": "float32"},
processor_kwargs={"model_max_length": 8192}, # tokenizer 层面的 token 上限
)
这个 checkpoint 自带一套训练配置,包括 query 和 document 的标记 token、projection head 以及 scoring skiplist。微调时,通常应保留这些组件,只根据数据需求修改必要的部分。首先要检查长度配置,因为许多已发布的 checkpoint 会将文档长度限制在 180 到 512 tokens(见为何微调?),而我的医学段落长达 1,400 tokens。mLateOn 系列已经支持 backbone 的完整 8,192 tokens 上下文;但如果初始 checkpoint 存在长度上限,就将其解除:
# 让模型读取完整文档,而不是训练时设置的长度上限;
# 例如,GTE-ModernColBERT-v1 默认使用 query_length=48、document_length=300
model[0].query_length = None
model[0].document_length = None
取消针对各任务的限制后,截断处理将回退到 tokenizer 的 model_max_length,因此我会在上面的加载阶段配置这一上限。
我还做了一处调整:新增标点 skiplist,在 document 端的评分和存储中排除标点 token。在四组消融实验(不排除、排除标点、排除停用词、同时排除二者)中,加入标点 skiplist 的模型在质量上略有胜出,并且让这套数据的 document 索引大小免费缩小了 9.6%:
import string
# model[2] 是 MultiVectorMask 模块
model[2].skiplist_words = list(string.punctuation)
model[2].resolve_with_tokenizer(model.tokenizer) # token ids 会被缓存,因此修改后需要重新解析
基于基础 Transformer 构建模型
你也可以将任意基础 Transformer 传给 MultiVectorEncoder,系统会为模型追加一个全新、随机初始化的 token 级 projection:
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 产出带上下文的 token 向量,一个 token 级 Dense 把每个向量投影到 128 维,一个 MultiVectorMask 决定哪些 token 参与打分,最后再做一次 token 级 Normalize。投影层是随机初始化的,所以这个模型必须经过训练才能用。有意思的是,即便用很强的稠密向量 backbone,这套方法同样奏效。我在实验里给 Alibaba-NLP/gte-modernbert-base 重新接了一个投影层,仅凭这层投影加上 25k 训练对,结果就和现有 checkpoint 的起点只差了 0.03。
ColBERT 的那些经典 token 化技巧([MASK] 查询扩展、[Q] / [D] 前缀 token、文档长度上限、标点跳过列表)默认都是关闭的,可以自行配置。完整选项见 Creating Custom Models。就我个人经验来说,我在自己领域的微调里把 [MASK] 查询扩展试了四种配置,没有一种带来可观测的差异,所以不必执着于这套经典配方。
该选哪个起点?
为写这篇博客我专门测了一轮:挑了六个起点,用相同的训练配置各跑一遍,数据是 MIRIAD 里 25k 条医学问答-段落对,然后用 1,000 道留出的问题在 50,000 篇段落构成的语料上评估:
| 起点 | Zero-shot NDCG@10 | 训练 25k 对之后 | Delta |
|---|---|---|---|
| lightonai/mLateOn-unsupervised | 0.9087 | 0.9398 | +0.0311 |
| lightonai/mLateOn | 0.9277 | 0.9319 | +0.0042 |
| lightonai/LateOn-unsupervised | 0.9026 | 0.9206 | +0.0180 |
| lightonai/LateOn | 0.9185 | 0.9105 | -0.0080 |
| 评估器 | 所需数据 |
|---|---|
MultiVectorInformationRetrievalEvaluator |
查询、语料库及相关文档映射 |
MultiVectorNanoBEIREvaluator |
无需数据 |
MultiVectorTripletEvaluator |
(锚例、正例、负例) 三元组 |
MultiVectorRerankingEvaluator |
{'query': '...', 'positive': [...], 'negative': [...]} 字典列表 |
MultiVectorDistillationEvaluator |
带候选文档及教师模型分数的查询 |
对于领域微调来说,由你自己留出的数据构建的 MultiVectorInformationRetrievalEvaluator 才是关键。构建时有个小建议:语料库要有足够的区分度,才能把不同模型的差异拉开。以我的情况为例,MIRIAD 的问题是由其原文段落直接生成的,所以检索起来异常简单。在仅针对那 1 万条标准段落时,几乎所有模型的 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")
# 标准答案:1,000 个评估问题,每个问题对应一个段落,
# 评估集全部约 1 万个唯一段落作为初始语料库
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"]]}
# 干扰项:训练集中的唯一段落,用于让检索场景更真实
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)
Trainer
MultiVectorEncoderTrainer 把前面所有的组件整合在了一起。下面这段完整脚本就是用来训练 multi-vector-encoder/mLateOn-medical(也就是引言中提到的模型)的:
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. 加载起始检查点:已做过对比学习预训练,但尚未进行监督微调
# 如果显存够用,训练时优先以 fp32 加载
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. 解除各任务的长度上限,使训练和推理都能看到完整的医学段落
model[0].query_length = None
model[0].document_length = None
# 3. 评分时跳过标点 token:略微提升质量,索引体积也缩小 9.6%
model[2].skiplist_words = list(string.punctuation)
model[2].resolve_with_tokenizer(model.tokenizer)
# 4. 加载 100 万条医学问答-段落对
train_dataset = load_dataset("tomaarsen/miriad-4.4M-split", split="train").select(range(1_000_000))
# 5. 使用 GradCache 做 in-batch 负采样:有效 batch 大,但显存占用受 mini-batch 控制
loss = CachedMultiVectorMultipleNegativesRankingLoss(model=model, mini_batch_size=16)
# 6. 一个轻量的 dev 评估器,用于在训练过程中观察进度:500 条留出问题的评估,
# 对应 eval 切分中约 1 万条独立段落。完整的 20 万规模评估在训练结束后进行。
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. 训练参数,如上文所述
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, # 如果你的 GPU 支持 FP16,设为 True
bf16=True, # 如果你的 GPU 支持 BF16,设为 True
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. 创建 Trainer 并开始训练
trainer = MultiVectorEncoderTrainer(
model=model,
args=args,
train_dataset=train_dataset,
loss=loss,
evaluator=dev_evaluator,
)
trainer.train()
# 9. 保存训练好的模型
model.save_pretrained(f"models/{run_name}/final")
# 10.(可选)推送到 Hugging Face Hub
try:
model.push_to_hub(run_name)
except Exception:
logging.error(f"上传模型到 Hugging Face Hub 时出错:\n{traceback.format_exc()}")
if __name__ == "__main__":
main()
整个配方就这么简单:一个预监督 checkpoint、一百万领域配对、批内负例、完整文档长度、再加一个偏高的学习率。我在单卡 RTX 3090 上跑了 14.5 小时,峰值显存 17.5 GB;上述每一项选择都是实测对比后的优胜者,而非凭空猜测。
如果预算有限,可以参考我的缩放实验:10 万配对(训练 75 分钟)的 NDCG@10 与完整百万配对相差仅 0.012,大部分收益其实在第一小时内就能拿到。
回调
MultiVectorEncoder 训练器支持多种 transformers.TrainerCallback 子类,包括:
WandbCallback:在安装了wandb的情况下,把训练指标记录到 W&BTensorBoardCallback:在可访问tensorboard的情况下,把训练指标记录到 TensorBoardCodeCarbonCallback:在安装了codecarbon的情况下,跟踪训练过程中的碳排放
通过 report_to 训练参数启用这些回调,例如 report_to=["wandb", "codecarbon"],并提前装好对应依赖即可。该参数默认为 "none",设为 report_to="all" 则会启用所有已安装依赖对应的集成。
更多关于这些回调及自定义方法的信息,请参阅 Transformers 回调文档。
多数据集训练
通常来说,性能顶尖的通用模型都是在多个数据集上同时训练得到的。不过由于各数据集的格式差异,这种做法并不容易实现。好在MultiVectorEncoderTrainer 允许你在多个数据集上训练,并且不要求它们格式统一。此外,它还支持为不同数据集指定不同的损失函数。一次性训练多个数据集的步骤如下:
- 使用一个
datasets.Dataset实例的字典(或一个datasets.DatasetDict)作为train_dataset(可选地也作为eval_dataset)。 - (可选)使用一个损失函数的字典,将数据集名称映射到对应的损失函数。只有当你希望为不同数据集使用不同损失函数时才需要设置。
MultiDatasetBatchSamplers 枚举定义,可以通过 multi_dataset_batch_sampler 参数传递给 MultiVectorEncoderTrainingArguments。可选值包括:
MultiDatasetBatchSamplers.ROUND_ROBIN:对每个数据集进行轮询采样,直到其中一个数据集耗尽。使用该策略时,每个数据集很可能无法用完所有样本,但各数据集的采样次数是均等的。MultiDatasetBatchSamplers.PROPORTIONAL(默认):按照各数据集的大小比例进行采样。使用该策略时,每个数据集的所有样本都会被使用,且较大的数据集被采样得更频繁。
评估
为了确定微调模型的水平,我在 MIRIAD 评估集上对比了四种架构系列、共 50 多种检索模型配置。该评估集与上文 Evaluator 部分的构建方式完全相同:包含 1,000 个留出的医学问题,以及 200,000 个不同的段落(其中包括训练集里的 10,000 个金标准段落,并将其隐藏在 190,000 个去重干扰段落中)。这个语料库是如何选择起始模型?中 50,000 段语料库的 4 倍,因此两个表格中的分数无法直接比较。
主要结果如下,完整表格可在下方展开查看:
| 模型 | 系列 | NDCG@10 |
|---|---|---|
| multi-vector-encoder/mLateOn-medical(我的模型) | 多向量,微调 | 0.9139 |
| lightonai/mLateOn | 多向量,零样本 | 0.8520 |
| lightonai/GTE-ModernColBERT-v1(解除上限) | 多向量,零样本 | 0.8502 |
| Qwen/Qwen3-Embedding-4B | 稠密,零样本 | 0.7817 |
| voyageai/voyage-4-nano | 稠密,零样本 | 0.7563 |
| BM25 | 词法 | 0.7501 |
| naver/splade-v3 | 稀疏,零样本 | 0.6853 |
微调后的模型位居榜首,比最强的零样本模型(无论架构)在 NDCG@10 上高出 +0.062。换句话说,最强的零样本模型能在 75.8% 的查询中把正确段落排在第一位,而微调后的模型把这一比例提升到了 84.9%,将首位错误率削减了超过三分之一。
架构规律同样一目了然:表格前列清一色是 late interaction 模型。在长文档场景下,即便训练数据和骨干网络完全相同,逐 token 的多向量表示也优于逐文档的单向量表示。DenseOn 和 LateOn 除了检索头以外,训练数据和架构完全一致,而 late interaction 版本领先 +0.12;多语言版本(mDenseOn 和 mLateOn)同样复现了这一差距,也是 +0.13。单向量模型靠规模也救不回来。Qwen3-Embedding-4B 是最强的稠密模型,其活跃参数(不含嵌入层)大约是我的模型的 33 倍,但仍然差了 0.13;而 8B 版本的表现反而不如 4B 版本。
BM25 的表现也出人意料地好,它击败了所有稀疏模型、所有因截断而受限的多向量模型,以及除三个稠密模型以外的所有模型:参数达数十亿的 Qwen3-Embedding-4B 和 8B,以及 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 或二值量化加 rescoring,稀疏索引会压缩倒排表,而多向量索引使用 PLAID 风格的残差压缩。读这些点时要把它当作相对存储成本,而不是你需要买的硬盘。
实线代表 token pooling。向量数减半只损失 0.0033 的 NDCG@10,rank-1 准确率不变;只保留四分之一的向量(11.2 GB)也能拿到 0.8991 的分数。曲线还会继续往下走(我测到了十分之一向量数,仍然有 0.8765),但量化方案一旦就位就没必要再往池化这个方向死磕了——这正是下面虚线要讨论的事。
虚线展示的是一个真实的部署方案。我提前把模型和基准交给了 Omar Khattab,他用 fast-plaid 测了下面这些配置:1-bit 残差量化、紧凑的 17-bit 中心点 id 和 18-bit 文档 id(替代通常未压缩的 64-bit 整数),再加上文档侧的剪枝:
| 配置 | 保留向量比例 | 索引大小 | NDCG@10 |
|---|---|---|---|
| 1-bit PLAID,全部向量 | 100% | 3.37 GB | 0.8984 |
| 1-bit PLAID + 剪枝 | 65% | 2.23 GB | 0.8830 |
| 1-bit PLAID + 剪枝 | 42% | 1.45 GB | 0.8642 |
第一行的体积只有原始 embedding 的 1/13,但 NDCG@10 只掉到 0.0155。这比池化曲线上任何一点的取舍都划算得多。量化压缩每个向量的大小,池化和剪枝则削减向量数量,两者可以叠加,而量化应当是首选。继续往下,最后一行压缩到 1.45 GB,比 Qwen3-Embedding-8B 的 fp16 embedding(1.64 GB)还要小,同时得分高出 0.0895。"多向量索引太大"这个反对意见在配置得当的索引面前站不住脚。
这里的剪枝只是粗略实现的,目的是验证在量化之上做 token 削减是可行的,所以请把最后两行当作下限而非前沿。如果你完全不想手动调参,配套文章中索引构建一节涵盖了 fast-plaid、Qdrant、Weaviate 和 Vespa。
多向量检索的成本完全取决于索引。这个语料的原始 embedding 是 45 GB,而配置得当的索引体积至少能缩小 7 倍,精度却几乎不变。索引值得你投入与模型权重同等的关注。
致谢
感谢 Omar Khattab 测量了索引优化一节中量化与剪枝的索引配置,并就 late-interaction 索引的开销问题进行了讨论。
补充资源
训练示例
以下页面提供了带讲解的训练示例和训练脚本链接,可以帮助你熟悉多向量训练流程:
- MIRIAD:面向医疗检索的领域训练,是本文方案的更早期、更简洁版本
- MS MARCO:对比学习与知识蒸馏方案
- 多模态:ColPali 风格的视觉文档检索训练
- PEFT 适配器:基于 LoRA 的参数高效微调



