多向量嵌入模型的 Token 池化感知训练
多向量嵌入模型让每个查询 Token 都有机会在文档中找到匹配。这让它们在搜索方面极具表现力,但这些文档向量会迅速累积。一个包含数百万文档的语料库可能包含数亿个向量!
Token 池化通过合并相似的 Token 向量来缩小这些表示。然而,我越激进地压缩它们,就越容易丢失有用的信息。池化感知训练教会编码器在其向量被合并时保留重要的信息,帮助你在相同存储预算下获得更好的检索效果。
在本文中,我将解释池化如何工作,使用 Sentence Transformers 训练一个用于压缩检索的模型,并探索由此产生的质量与存储权衡。在 pool_factor=4 时,我按常规训练的模型保留了其未池化 NanoBEIR nDCG@10 的 94.6%,同时文档向量减少了 4 倍。Token 池化感知训练在相同存储预算下将这一保留率提升到 97.8%!让我们看看如何做到。
本文建立在 Stefan Josef 的 Learn to Pool 以及 LightOn 的池化正则化研究中探讨的池化感知训练之上,该研究还探索了定向训练和自适应预算。在这里,我将专注于理解该方法,并用一个简短、可复现的 Sentence Transformers 示例将其付诸实践。

池化感知训练在相同向量预算下提升了检索质量。详情见下文。
目录
- 为什么多向量检索需要这么多向量
- Token 池化 选择池化预算 校准自适应池化
- 选择池化预算
- 校准自适应池化
- Token 池化感知训练 Sentence Transformers 中的损失包装器 可选的内核加速
- Sentence Transformers 中的损失包装器
- 可选的内核加速
- 使用训练好的模型
- 评估结果
- 结合压缩技术
- 代码与模型
为什么多向量检索需要这么多向量
单向量嵌入模型将整个文档压缩成一个向量。像 ColBERT 这样的多向量模型则保留一组上下文 Token 向量,允许查询的不同部分匹配文档的不同部分。
例如,一个关于电池续航长的笔记本电脑的查询,可以在文档的一部分找到关于笔记本电脑的证据,在另一部分找到电池续航的证据。使用 MaxSim 评分时,每个查询向量选择其最佳匹配的文档向量,并将这些分数相加:
MaxSim(Q,D)=∑q∈Qmaxd∈Dq⊤d \operatorname{MaxSim}(Q, D) = \sum_{q \in Q} \max_{d \in D} q^\top d MaxSim(Q,D)=q∈Q∑d∈Dmaxq⊤d
即使使用 Fast-PLAID 支持的 2-bit 或 4-bit 残差量化,每个文档存储数百个向量也会累积起来。按每个残差坐标 4 bits 计算,200 个 128 维向量每个文档需要 12.8 KB 的量化残差,或在 100 万个文档中需要 12.8 GB。在相同维度和精度下,将每个文档池化到 50 个向量可将其减少到 3.2 GB。
有用的观察是,许多文档向量可能相似。多个 Token 可能描述密切相关的概念,从而创造了将它们一起表示的机会。
Token 池化
HierarchicalTokenPooling 使用 Ward 层次聚类对相似的 Token 嵌入进行分组。它从每个 Token 向量一个组开始,反复合并使组内向量分散程度增加最小的两个组。这种分散程度通过每个向量到其组均值的平方距离之和来衡量,因此 Ward 方法倾向于形成由相似向量组成的紧凑分组。
这些合并会构建出一个层级结构,其中分组逐步变大,让我们能够选择适合自身预算的压缩级别。随后,我们用每个组的均值来表示该组,并再次对得到的向量进行归一化,使其准备好用于 MaxSim 评分。
你可以让压缩程度与文档长度成比例:
from sentence_transformers.multi_vector_encoder.modules import HierarchicalTokenPooling
pooling = HierarchicalTokenPooling(pool_factor=4)
当 pool_factor=4 时,文档大约保留其原始向量的四分之一,因此较长的文档可以按比例保留更多容量。默认情况下,第一个 token 会受到保护,不会被合并;并且池化只应用于文档嵌入,查询嵌入保持不变。
选择池化预算
HierarchicalTokenPooling 提供三种可选的控制方式:
| 设置 | 它所表达的含义 |
|---|---|
| max_tokens=16 | 每篇文档最多保留 16 个向量,包括受保护的 token。 |
| pool_factor=4 | 大约为每篇文档原始向量的四分之一。 |
| distance_threshold=... | 一个共享的 Ward 合并距离阈值,使向量数量可以随嵌入的几何结构而变化。 |
当文档长度各不相同时,这种区别很重要。使用 pool_factor=4 时,较长的文档可以保留大约四分之一的向量,使其表示能够随内容增长。使用 distance_threshold 时,当需要保留更多不同的嵌入时,向量数量可以增加。
校准自适应池化
自适应池化使用一个共享的距离阈值,更积极地合并相似的嵌入,同时为内容独特的文档保留更多向量。它可以与任何多向量模型一起使用,包括未使用池化感知目标训练的模型。
除了手动选择阈值,你还可以针对目标平均向量数量来校准它。给定 calibration_documents 中具有代表性的语料样本:
from sentence_transformers import MultiVectorEncoder
from sentence_transformers.multi_vector_encoder.modules import HierarchicalTokenPooling
model = MultiVectorEncoder("lightonai/LateOn")
calibration_embeddings = model.encode_document(calibration_documents)
threshold = HierarchicalTokenPooling.calibrate_distance_threshold(calibration_embeddings, target_tokens=16)
pooling = HierarchicalTokenPooling(distance_threshold=threshold)
# Apply pooling, targeting an average of 16 vectors per document
document_embeddings = model.encode_document(documents_to_index, token_pooling=pooling)
校准只需要文档嵌入,并返回一个可在索引时复用的阈值。目标是一个平均值,因此单个文档可能获得多于或少于 16 个向量。实际达到的平均值在另一个语料上也可能会变化。
Token 池化感知训练
池化在普通的多向量模型上已经可能有用。困难出现在文档包含对检索很重要的区分,但合并其向量会使这种区分更难恢复时。原始训练目标从未要求模型使用这些压缩表示。但是,你可以教它专门做到这一点。
如果你使用过 Matryoshka 嵌入,训练原理会让人感到熟悉。Matryoshka 在多个嵌入宽度上应用检索目标,教会较小的前缀保持有用。池化感知训练则对具有更少 token 向量的表示应用该目标。
在我的示例中,每个训练批次贡献两个损失:
- 使用原始文档嵌入进行检索。
- 将文档压缩到大约其原始向量的四分之一后进行检索。
我对它们取平均:
L=12Lunpooled+12Lpooled \mathcal{L} = \tfrac{1}{2}\mathcal{L}_{\mathrm{unpooled}} + \tfrac{1}{2}\mathcal{L}_{\mathrm{pooled}} L=21Lunpooled+21Lpooled
面向多向量嵌入模型的 Token Pooling 感知训练
第一项教导模型使用其完整表示进行检索。第二项教导模型安排其 token 嵌入,使得有用的匹配在池化后仍能保留。两个目标更新同一个编码器,为其提供压缩前后检索的反馈。
Ward 聚类对哪些 token 属于同一组做出离散决策。在训练期间,HierarchicalTokenPooling 使用直通估计器将梯度从池化向量传回其成员 token,而不对聚类分配进行微分。编码器随后可以从其池化表示产生的检索错误中学习。
Sentence Transformers 中的损失包装器
MultiVectorTokenPoolingLoss 包装现有的多向量检索损失,并使用你选择的池化器应用它:
from sentence_transformers import MultiVectorEncoder
from sentence_transformers.multi_vector_encoder.losses import (
CachedMultiVectorMultipleNegativesRankingLoss,
MultiVectorTokenPoolingLoss,
)
from sentence_transformers.multi_vector_encoder.modules import HierarchicalTokenPooling
model = MultiVectorEncoder("answerdotai/ModernBERT-base", similarity_fn_name="maxsim")
pooling = HierarchicalTokenPooling(pool_factor=4)
base_loss = CachedMultiVectorMultipleNegativesRankingLoss(model, mini_batch_size=64)
loss = MultiVectorTokenPoolingLoss(
model,
base_loss,
token_poolings=[None, pooling],
weights=[0.5, 0.5],
)
CachedMultiVectorMultipleNegativesRankingLoss 提供检索目标。它教导每个查询将其相关文档的得分排在批次中的负文档之上。默认情况下,它使用 model.similarity,采用模型的相似度设置(此示例中为 MaxSim)和内核设置。配置了 similarity_fn_name="meanmaxsim" 的模型在训练期间也会使用 MeanMaxSim。
梯度缓存允许编码器处理小批次,同时保留更大集合的批内负样本。我的训练脚本使用 64 的小批次,训练批次大小为 256。
MultiVectorTokenPoolingLoss 增加了压缩目标。这里,None 表示一个变体使用原始嵌入,而 pooling 提供因子为 4 的变体。两个权重对其损失取平均。池化变体复用计算出的 token 嵌入,因此每个额外的池化器都会增加聚类和评分工作,而无需为该变体单独进行一次编码器前向传播。
池化器也可以是自定义的 BaseTokenPooling 实现,这些实现通过其输入嵌入保留梯度。你可以调整训练目标以适应你想要部署的表示。
将因子为 4 的池化器替换为你选择的部署配置,或者包含多个池化器并为其损失选择权重。
完整的训练脚本包括数据集、训练器、验证损失以及训练期间的小型检索评估。从仓库的检出中,使用 uv 在本地运行两个变体:
uv run examples/multi_vector_encoder/training/token_pooling/training_cached_contrastive.py --baseline
uv run examples/multi_vector_encoder/training/token_p
这会在 A10G GPU 上运行 pooling-aware 变体,时限为四小时。将 HF_TOKEN 作为 secret 传入,脚本就能在训练完成后把训练好的模型上传到你的 Hub 账户。若要同时运行普通损失 baseline,请在脚本 URL 后面加上 --baseline 重复该命令。
## 可选的 Kernel 加速
你可以用 kernels 加速 MaxSim 打分、Ward 聚类以及受支持的 backbone 操作。安装训练和 kernel 依赖:
pip install -U "sentence-transformers[train,kernels]"
在上面的训练示例中,在构建 loss 之前替换模型和 pooler 的初始化:
model = MultiVectorEncoder( "answerdotai/ModernBERT-base", device="cuda", similarity_fn_name="maxsim", use_kernels=True, ) pooling = HierarchicalTokenPooling(pool_factor=4)
分层 pooler 默认继承模型的 kernel 设置,因此你只需在模型上启用 kernels。
Sentence Transformers 会为你的环境选择兼容的 kernels,并在没有可用替代实现的地方使用现有实现。例如,默认的 MaxSim kernel 会在不于内存中构建完整 token 相似度矩阵的情况下对查询与文档打分,但它确实需要 CUDA 和 Triton。
默认的 Ward 聚类 kernel 通过在 CUDA 而非 CPU 上运行,显著加速分层聚类。根据设置不同,比 SciPy 快 30-150 倍。
## 使用训练好的模型
让我们加载 pooling-aware 模型,并使用压缩后的文档嵌入进行检索。Kernel 设置不会随模型保存,因此如果你也希望在推理时使用它们,请在加载时加上 use_kernels=True:
from sentence_transformers import MultiVectorEncoder from sentence_transformers.multi_vector_encoder.modules import HierarchicalTokenPooling
model = MultiVectorEncoder("tomaarsen/mve-ModernBERT-base-token-pooling") pooling = HierarchicalTokenPooling(pool_factor=4)
queries = ["Which planet is known as the Red Planet?"] documents = [ "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.", ]
query_embeddings = model.encode_query(queries) document_embeddings = model.encode_document(documents, token_pooling=pooling)
print([len(embedding) for embedding in document_embeddings])
[5, 6, 5]
print(model.similarity(query_embeddings, document_embeddings))
tensor([[16.0037, 25.9879, 22.8159]], device='cuda:0')
For comparison, the same model without token pooling:
[18, 21, 19]
tensor([[16.6115, 27.6060, 24.1766]], device='cuda:0')
Pooler 会在编码器的前向流水线之后压缩文档嵌入,将总数从 58 个向量减少到 16 个,同时保持查询嵌入不变。火星仍然获得最高相似度分数,其次是木星和金星,因此尽管存储的文档向量少得多,排序仍得以保留。
你可以在推理时更改 pool_factor,以用同一个训练好的模型探索不同的存储预算。若需重复使用,也可以在初始化 MultiVectorEncoder 时传入 token_pooling=pooling,使其成为运行时默认值。Pooler 的任务仍然控制哪些输入会被压缩(例如仅文档)。
评估结果
为了探究,我从 answerdotai/ModernBERT-base 训练了两个模型,每个模型都带有一个全新的 128 维 token 投影:
- tomaarsen/mve-ModernBERT-base-token-pooling-baseline,仅使用缓存的对比损失。
- tomaarsen/mve-ModernBERT-base-token-pooling,使用缓存的对比损失,并用上述未池化目标和 factor-4 目标进行包装。
两个运行都在相同的 120,000 个 MS MARCO 三元组上训练一个 epoch,随机种子为 42,并使用匹配的初始化和优化设置。训练 token 上限为 300,短查询会扩展到至少 32 个 token 位置,以提供额外的上下文查询向量。两个实验之间只有损失函数不同,我没有进行超参数搜索。
我在 NanoBEIR@13 上评估了两个模型,报告平均 nDCG@10(%)。该图比较了普通训练和池化感知训练在池化因子为 2、3、4 和 6 时的情况,以及未池化检索。表中还包括固定上限和自适应阈值,展示训练带来的收益如何迁移到其他池化设置。
| 推理时池化 | 平均向量数 | 普通模型 | 池化感知模型 | 增益(点) |
|---|---|---|---|---|
| 未池化 | 193.22 | 51.84 | 52.39 | +0.55 |
| pool_factor=2 | 96.86 | 50.10 | 51.74 | +1.64 |
| pool_factor=3 | 64.74 | 49.70 | 51.45 | +1.76 |
| pool_factor=4 | 48.68 | 49.03 | 51.23 | +2.20 |
| pool_factor=5 | 39.04 | 48.13 | 50.28 | +2.15 |
| pool_factor=6 | 32.62 | 47.07 | 49.85 | +2.78 |
| max_tokens=64 | 54.76 | 49.27 | 50.89 | +1.63 |
| max_tokens=32 | 29.58 | 48.25 | 50.12 | +1.87 |
| max_tokens=16 | 15.60 | 46.61 | 49.26 | +2.65 |
| max_tokens=8 | 7.99 | 43.12 | 46.61 | +3.48 |
| max_tokens=4 | 4.00 | 37.22 | 43.00 | +5.78 |
| distance_threshold(已校准) | 54.76 | 50.10 | 51.24 | +1.14 |
| distance_threshold(已校准) | 29.58 | 48.38 | 50.96 | +2.58 |
| distance_threshold(已校准) | 15.60 | 47.23 | 49.63 | +2.41 |
对于自适应池化,我为每个编码器和 NanoBEIR 语料库分别校准了一个阈值,使用其未池化文档嵌入来匹配上限为 64、32 和 16 时的平均向量数。短文档会使这些平均值低于各自对应的上限。
在训练后的 pool_factor=4 时,nDCG@10 从 49.03 提升到 51.23,同时比未池化检索少存储 74.8% 的文档向量。这将分数保留率从 94.6% 提高到 97.8%,其中保留率是池化后的平均 nDCG@10 除以每个模型自身未池化的平均值。未池化检索也有所提升,从 51.84 提高到 52.39。
这些增益也扩展到固定上限和自适应池化,尽管训练只使用了完整表示和比例池化。在每文档平均 29.58 个向量时,自适应池化保留了池化感知模型未池化分数的 97.3%,同时少存储 84.7% 的文档向量。在相同预算下,常规训练的模型保留了 93.3%。
组合压缩技术
如果索引大小是你主要关心的问题,我会按以下顺序考虑这些选项:
- 将嵌入量化到 4、2 或 1 比特,例如通过 Fast-PLAID,以减少每个坐标的存储。
- 使用 token 池化减少文档向量数量,可选地采用本博客展示的池化感知微调。
- 使用 Matryoshka token 嵌入降低每个 token 向量的维度,可选地进行微调,以在较小宽度下保持检索质量。
如果模型的默认维度大于 128,我也会更早考虑 Matryoshka 截断和微调。这些方法可以组合使用,因此请用你的搜索引擎和语料库评估最终索引大小和检索质量。
MultiVectorMatryoshkaLoss 训练 token 嵌入在截断后仍然有用。你可以用 MultiVectorTokenPoolingLoss 包装它,以同时针对更小维度和更少文档向量进行训练,并在不同压缩设置间复用编码器的嵌入。
尝试完整训练示例,选择对应用重要的预算,并将其与匹配的普通损失运行进行比较。随附的评估脚本会测量未池化检索、64、32、16、8 和 4 个向量的上限,以及 2 到 6 的池化因子,这样你就可以为自己的模型绘制质量与存储的权衡图。
只需在训练目标里多写几行,更小的文档表示就会成为模型主动学习使用的东西。这为让多向量检索变得更实用打开了令人兴奋的空间!
参考文献
- Stefan Josef. Learn to Pool: Lightweight Fine-Tuning for Flexible Multi-Vector Compression.
- Benjamin Clavié、Antoine Chaffin 和 Griffin Adams. Reducing the Footprint of Multi-Vector Retrieval with Minimal Performance Impact via Token Pooling.
- Antoine Chaffin. After the party comes the free lunch: regularizing ColBERT models to enhance pooling capabilities and reduce index footprint.
- Luyu Gao、Yunyi Zhang、Jiawei Han 和 Jamie Callan. Scaling Deep Contrastive Learning Batch Size under Memory Limited Setup.
- Matryoshka 嵌入模型简介.
代码与模型
- Token pooling 感知训练示例与 README
- 完整训练脚本
- NanoBEIR 评测脚本
- 示例 checkpoint:tomaarsen/mve-ModernBERT-base-token-pooling tomaarsen/mve-ModernBERT-base-token-pooling-baseline
- tomaarsen/mve-ModernBERT-base-token-pooling
- tomaarsen/mve-ModernBERT-base-token-pooling-baseline
- 兼容 Sentence Transformers 的预训练多向量模型
- 多向量模型的 Matryoshka 感知训练
像 {{NAME_ab12cd34}} 这样写出的值是真实值的占位符。请完全按照其写法使用它们,在回复和工具调用中都是如此:它们会在任何内容运行之前被替换为真实值。不要请求验证它们,也不要用示例替换它们。