跳到正文
原文
Hugging Face:Blog(RSS)·· 2026-05-19精选AI 评分62

Hugging Face 发布 Ettin Reranker 系列六个 CrossEncoder 重排模型

Introducing the Ettin Reranker Family

AI 导读

作者基于 Ettin ModernBERT 编码器发布六个 Sentence Transformers CrossEncoder 重排模型(17M 到 1B,Apache 2.0),在各自规模上达到 SOTA,并同步放出约143M条训练数据和完整训练脚本。

推荐理由

作者同时放出六个模型、约143M条训练数据和完整训练脚本,读者可以按参数量直接选型或复现蒸馏配方。

正文 · AI 翻译

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

TL;DR

今天我发布六个新的 Sentence Transformers CrossEncoder 重排序器,在各自规模上均达到最先进水平,构建于 Ettin ModernBERT 编码器之上,并附带产生它们的数据和完整训练配方:

The models were trained with a distillation recipe: pointwise MSE on mixedbread-ai/mxbai-rerank-large-v2 scores over cross-encoder/ettin-reranker-v1-data, which is a subset of lightonai/embeddings-pre-training mixed with a reranked subset of lightonai/embeddings-fine-tuning.

Our six rerankers paired with embeddinggemma-300m on MTEB(eng, v2) Retrieval

我们的六个重排序器与 google/embeddinggemma-300m 在 MTEB(eng, v2) Retrieval 上的配对。更多嵌入器配对请参见 Results。

如果你对重排序器还不熟悉,想先了解“为什么”,请跳到 What is a reranker, and why pair one with an embedder?。如果你只想直接使用模型,请跳到 Usage。如果你想训练自己的模型,请跳到 Training。

我使用 Sentence Transformers v5.5.0 中发布的新 train-sentence-transformers Agent Skill 引导了下面的训练配方。用 hf skills add train-sentence-transformers [--global] [--claude] 安装它,然后让你的 AI 编程代理(Claude Code、Codex、Cursor、Gemini CLI 等)在你的数据上微调 SentenceTransformer、CrossEncoder 或 SparseEncoder 模型。

Table of contents

What is a reranker, and why pair one with an embedder?

重排序器(又称逐点交叉编码器)是一种神经模型,接收一个 (query, document) 对并输出单个相关性分数。与嵌入模型分别编码查询和文档、并根据两个嵌入向量计算相似度不同,重排序器让两段文本在每一层 transformer 中相互注意力。这种联合编码更准确,但也更昂贵:模型必须对每个 (query, document) 对运行一次,而不是对每段文本运行一次。

由于交叉编码器在全量语料上运行过于昂贵,常见的生产模式是先检索后重排序:快速的嵌入模型检索出前 K 个候选(成本低),然后交叉编码器仅对这 K 个候选进行高精度重排序。总成本保持有界,而最终排名更接近穷举交叉编码器遍历所能产生的结果。

Embedding vs Reranker Models

在本博客文章中,我将“重排序器”和“交叉编码器”互换使用。

Usage

发布的模型是普通的 Sentence Transformers CrossEncoder 模型,因此只需 3 行代码即可使用:

from sentence_transformers import CrossEncoder

model = CrossEncoder("cross-encoder/ettin-reranker-32m-v1")
scores = model.predict([
    ("Where was Apple founded?", "Apple Inc. was founded in Cupertino, California in 1976 by Steve Jobs, Steve Wozniak, and Ronald Wayne."),
    ("Where was Apple founded?", "The Fuji apple is an apple cultivar developed in the late 1930s and brought to market in 1962."),
])
print(scores)
# [11.393298  2.968891]   <- larger means more relevant

对于查询和候选列表,你也可以使用 rank 来获取排序后的索引和分数:

ranked = model.rank(
    query="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.",
        "Saturn, famous for its rings, is sometimes mistaken for the Red Planet.",
    ],
    top_k=4,
    return_documents=True,
)
for r in ranked:
    print(f"({r['score']:.2f}): {r['text']}")
# (10.82): Mars, known for its reddish appearance, is often referred to as the Red Planet.
# (9.86): Saturn, famous for its rings, is sometimes mistaken for the Red Planet.
# (8.55): Jupiter, the largest planet in our solar system, has a prominent red spot.
# (6.21): Venus is often called Earth's twin because of its similar size and proximity.

你可以将 cross-encoder/ettin-reranker-32m-v1 替换为任何其他规模,以质量换取速度。得益于 ModernBERT 的长上下文预训练,所有六个模型都接受最多 8K token 的上下文(适用于长文档重排序)。

建议安装 kernels 并设置 model_kwargs={"dtype": "bfloat16", "attn_implementation": "flash_attention_2"} 以获得最高吞吐量。更多详情请参阅下方的 Speed 部分,但总体而言,根据模型大小和序列长度,相比默认加载方式,你可以预期获得 1.7 倍到 8.3 倍的加速。

from sentence_transformers import CrossEncoder

model = CrossEncoder(
    "cross-encoder/ettin-reranker-32m-v1",
    model_kwargs={"dtype": "bfloat16", "attn_implementation": "flash_attention_2"},
)

端到端检索-重排序流水线

一个完整的示例,使用快速嵌入模型进行检索,并使用重排序器进行最终排序:

from sentence_transformers import SentenceTransformer, CrossEncoder

# Fast retrieval with a static embedder (sub-millisecond on CPU per query)
embedder = SentenceTransformer("sentence-transformers/static-retrieval-mrl-en-v1")
reranker = CrossEncoder("cross-encoder/ettin-reranker-68m-v1")

corpus = [
    "Apple Inc. was founded in Cupertino, California in 1976 by Steve Jobs, Steve Wozniak, and Ronald Wayne.",
    "The Fuji apple is an apple cultivar developed in the late 1930s.",
    "Steve Jobs introduced the iPhone in 2007 at Macworld.",
    "Macintosh computers were sold by Apple from 1984 onward.",
    # ... thousands or millions more in production
]
query = "Where was Apple founded?"

# Step 1: encode + retrieve top-100
query_emb = embedder.encode_query(query, convert_to_tensor=True)
corpus_emb = embedder.encode_document(corpus, convert_to_tensor=True)
scores = embedder.similarity(query_emb, corpus_emb)[0]
top_k_idx = scores.topk(min(100, len(corpus))).indices.tolist()

# Step 2: rerank
top_k_docs = [corpus[i] for i in top_k_idx]
ranked = reranker.rank(query, top_k_docs, top_k=5, return_documents=True)
for r in ranked:
    print(f"({r['score']:.2f}): {r['text']}")
# (11.63): Apple Inc. was founded in Cupertino, California in 1976 by Steve Jobs, Steve Wozniak, and Ronald Wayne.
# (4.71): Steve Jobs introduced the iPhone in 2007 at Macworld.
# (1.96): The Fuji apple is an apple cultivar developed in the late 1930s.
# (1.49): Macintosh computers were sold by Apple from 1984 onward.

这与大多数现代搜索系统使用的形态相同。检索器决定什么进入漏斗,重排序器决定什么胜出。

架构细节

所有六个重排序器共享相同的架构,仅在主干规模上有所不同。主干是来自约翰斯·霍普金斯大学 Ettin 套件中六个 Ettin 编码器之一。这些是 ModernBERT 风格的模型,具有无填充注意力、RoPE 位置编码、GeGLU,以及 2T 开放许可预训练 token,支持最多 8192 个 token 的上下文。

在每个编码器之上,重排序器使用一个 4 模块分类头,其结构镜像了 ModernBertForSequenceClassification,但由 Sentence Transformers 的模块化组件构建。底层的 Transformer 是普通的 AutoModel 而非 AutoModelForSequenceClassification,这使我们能够对可变长度输入使用序列去填充以支持 Flash Attention 2。在中等文档序列长度下,根据模型大小,相比 fp32+SDPA 可获得 1.7 倍到 8.3 倍的加速(完整基准测试见 Speed):

1. Transformer(FA2)
2. Pooling(cls)
3. Dense(H, H, bias=False, GELU)
4. LayerNorm(H)
5. Dense(H, 1, scores)

在我的消融实验中,CLS 池化优于平均池化。这有点令人意外。ModernBERT 仅每三层使用一次全局注意力,其余三分之二使用局部窗口注意力,无法从远处位置到达 CLS。经验上,那几个全局层携带了足够的信号,使 CLS 成为更好的池化选择。

模型 主干 隐藏维度 层数 参数量(含分类头)
cross-encoder/ettin-reranker-17m-v1 jhu-clsp/ettin-encoder-17m 256 7 17.6M
cross-encoder/ettin-reranker-32m-v1 jhu-clsp/ettin-encoder-32m 384 10 32.8M
cross-encoder/ettin-reranker-68m-v1 jhu-clsp/ettin-encoder-68m 512 19 68.6M
cross-encoder/ettin-reranker-150m-v1 jhu-clsp/ettin-encoder-150m 768 22 150.9M
cross-encoder/ettin-reranker-400m-v1 jhu-clsp/ettin-encoder-400m 1024 28 401.6M
cross-encoder/ettin-reranker-1b-v1 jhu-clsp/ettin-encoder-1b 1792 28 1.00B

所有六个模型均在 Apache 2.0 许可下发布,与 Ettin 编码器一致。

结果

MTEB(eng, v2) 检索

我使用 MTEB 的 两阶段重排序流程,将每个已发布模型跑完整的 MTEB(eng, v2) 检索基准(10 个任务,top-100 重排序),并将每个重排序器与六个覆盖速度/质量谱系的嵌入模型配对:

嵌入模型 激活参数量 仅检索器 NDCG@10
sentence-transformers/static-retrieval-mrl-en-v1 0M 0.3495
sentence-transformers/all-MiniLM-L6-v2 23M 0.4292
BAAI/bge-small-en-v1.5 33M 0.5149
nomic-ai/nomic-embed-text-v1.5 137M 0.5226
google/embeddinggemma-300m 308M 0.5463
jinaai/jina-embeddings-v5-text-small-retrieval 596M 0.5980

下方每张图表中的 虚线仅检索器基线 是需要超越的头条数字。低于它意味着重排序器平均而言实际上损害了流水线:

MTEB(eng, v2) Retrieval with static-retrieval-mrl-en-v1 + reranker MTEB(eng, v2) Retrieval with all-MiniLM-L6-v2 + reranker
MTEB(eng, v2) Retrieval with bge-small-en-v1.5 + reranker MTEB(eng, v2) Retrieval with nomic-embed-text-v1.5 + reranker
MTEB(eng, v2) Retrieval with embeddinggemma-300m + reranker MTEB(eng, v2) Retrieval with jina-embeddings-v5-text-small-retrieval + reranker

完整结果表(点击展开)

6 个嵌入模型配对上的平均 NDCG@10,按降序排列。我们的六个模型以 粗体 显示,教师模型 mixedbread-ai/mxbai-rerank-large-v2 以下划线标出。

重排序器 参数量 MTEB(eng, v2) 检索 NDCG@10
Qwen/Qwen3-Reranker-4B† 4.02B 0.6367
mixedbread-ai/mxbai-rerank-large-v2 1.54B 0.6115
cross-encoder/ettin-reranker-1b-v1 1.00B 0.6114
cross-encoder/ettin-reranker-400m-v1 401M 0.6091
cross-encoder/ettin-reranker-150m-v1 151M 0.5994
Qwen/Qwen3-Reranker-0.6B 596M 0.5940
mixedbread-ai/mxbai-rerank-base-v2 494M 0.5920
cross-encoder/ettin-reranker-68m-v1 68.6M 0.5915
jinaai/jina-reranker-m0 2.44B 0.5856
Alibaba-NLP/gte-reranker-modernbert-base 150M 0.5843
cross-encoder/ettin-reranker-32m-v1 32.8M 0.5779
ibm-granite/granite-embedding-reranker-english-r2 150M 0.5656
cross-encoder/ettin-reranker-17m-v1 17.6M 0.5576
BAAI/bge-reranker-v2-m3 568M 0.5526
zeroentropy/zerank-2-reranker† 4.02B 0.5300
BAAI/bge-reranker-large 560M 0.5098
cross-encoder/ms-marco-MiniLM-L6-v2 22.7M 0.5082
cross-encoder/ms-marco-MiniLM-L12-v2 33.4M 0.5066
mixedbread-ai/mxbai-rerank-large-v1 435M 0.5063
cross-encoder/ms-marco-MiniLM-L4-v2 19.2M 0.4979
mixedbread-ai/mxbai-rerank-xsmall-v1 70.8M 0.4968
BAAI/bge-reranker-base 278M 0.4890
mixedbread-ai/mxbai-rerank-base-v1 184M 0.4865

† 上限设为 max_seq_length=8192(4B 的 Qwen3 重排序器在原生上下文下无法放入单张 H100 80GB)。原生上下文评估的结果可能更高。

NanoBEIR 结果完整表格(点击展开)

NanoBEIR 是 BEIR 的一个快速 13 数据集子集,每个数据集使用 50 条查询,对应最多 5000 篇文档。NanoBEIR 正是训练期间 metric_for_best_model 所设定的目标(见 评估),也是我用来指导实验的基准。

重排序器 参数量 NanoBEIR 平均 NDCG@10
mixedbread-ai/mxbai-rerank-large-v2 1.54B 0.7318
cross-encoder/ettin-reranker-1b-v1 1.00B 0.7237
jinaai/jina-reranker-m0 2.44B 0.7197
cross-encoder/ettin-reranker-400m-v1 401M 0.7193
mixedbread-ai/mxbai-rerank-base-v2 494M 0.7162
cross-encoder/ettin-reranker-150m-v1 151M 0.7086
Alibaba-NLP/gte-reranker-modernbert-base 150M 0.7017
BAAI/bge-reranker-v2-m3 568M 0.6971
cross-encoder/ettin-reranker-68m-v1 68.6M 0.6915
ibm-granite/granite-embedding-reranker-english-r2 150M 0.6909
cross-encoder/ettin-reranker-32m-v1 32.8M 0.6825
cross-encoder/ettin-reranker-17m-v1 17.6M 0.6746
mixedbread-ai/mxbai-rerank-large-v1 435M 0.6488
BAAI/bge-reranker-large 560M 0.6379
cross-encoder/ms-marco-MiniLM-L12-v2 33.4M 0.6369
cross-encoder/ms-marco-MiniLM-L6-v2 22.7M 0.6312
cross-encoder/ms-marco-MiniLM-L4-v2 19.2M 0.6298
mixedbread-ai/mxbai-rerank-base-v1 184M 0.6231
mixedbread-ai/mxbai-rerank-xsmall-v1 70.8M 0.6136
BAAI/bge-reranker-base 278M 0.6027

我发布的最小模型,即我们的 17M,在参数量大约只有一半的情况下,在 MTEB 上以 +0.051 NDCG@10(0.5576 对 0.5066)击败了 33M 的 ms-marco-MiniLM-L12-v2,在 NanoBEIR 上以 +0.038(0.6746 对 0.6369)胜出。32M 在 MTEB 上以 +0.025(0.5779 对 0.5526)击败 568M 的 BAAI/bge-reranker-v2-m3,参数量相差 17 倍。如果你一直把某个旧版 MiniLM 重排序器作为检索后重排序流程中的默认选项,换成我们的 17M(或 32M)是低风险的直接替换,在两个基准上都有明显的质量提升。

往上看,我们的 150M 是我在 MTEB 上测试过的 600M 以下范围内最强的重排序器,以 +0.005(0.5994 对 0.5940)略胜最近的 Qwen/Qwen3-Reranker-0.6B(596M),并以 0.03 到 0.05 的优势击败所有 BAAI bge-reranker 变体。68M 也值得一提:它以 0.5915 几乎正好落在 Qwen3-Reranker-0.6B(0.5940)上,而参数量只有其九分之一。

在已发布范围的顶端,我们的 1B 模型紧追其教师模型。它在 MTEB 上与 1.54B 的 mxbai-rerank-large-v2 相差仅 0.0001(0.6114 对 0.6115),在 NanoBEIR 上相差 0.008,尽管蒸馏自一个比自身大 54% 的模型。蒸馏有效地弥合了与教师模型的差距,这正是我在这次发布前希望看到的。

对比中整体最强的重排序器是 Qwen/Qwen3-Reranker-4B,MTEB 为 0.6367,比我们的 1B 模型高 +0.025。以当前方案弥合这一差距,可能需要从更强的教师模型蒸馏(我们的教师本身也低于 Qwen3-Reranker-4B)。对于大多数检索后重排序工作负载,我们的 1B 以四分之一的参数量(见 速度)是更实用的选择。

速度

质量数字只是重排序器的一半关键。另一半是其延迟是否能放进你在检索与向用户展示结果之间的预算内。让我逐一说明我测量到的内容。

我在单块 NVIDIA H100 80GB 上,将全部六个已发布模型与十三个公开重排序器(强基线,参数量最高约 1B)进行了基准测试。查询和文档来自 sentence-transformers/natural-questions,采用其自然的文档长度分布:大多数 NQ 答案较短,部分较长。文档在 max_length=512 处截断,以避免给较旧的模型带来不公平的优势。每个模型都使用其支持的最佳注意力实现:在架构支持的地方使用 Flash Attention 2(BERT、XLM-RoBERTa、ModernBERT、Qwen2),不支持的地方使用 SDPA,DeBERTa-v2 则使用 eager(目前在 transformers 中既不支持 FA2 也不支持 SDPA)。

对于每个模型,自动批大小搜索从批大小 8 开始,并不断翻倍,直到 GPU 显存耗尽。在每个批大小下,我运行三次计时遍历并取吞吐量的中位数,这样单次运气不佳的运行就不会影响数值。报告的吞吐量取自胜出的那个批大小。

表 1. 吞吐量,单位为每秒对数,均以 bfloat16 计。我们的六个重排序器以粗体显示。

模型 参数量 注意力 每秒对数
cross-encoder/ettin-reranker-17m-v1 17M FA2 7517
cross-encoder/ettin-reranker-32m-v1 32M FA2 6602
cross-encoder/ettin-reranker-68m-v1 68M FA2 4913
cross-encoder/ms-marco-MiniLM-L4-v2 19M FA2 4029
cross-encoder/ms-marco-MiniLM-L6-v2 22M FA2 3817
cross-encoder/ms-marco-MiniLM-L12-v2 33M FA2 3311
cross-encoder/ettin-reranker-150m-v1 150M FA2 3237
BAAI/bge-reranker-base 278M FA2 2858
mixedbread-ai/mxbai-rerank-xsmall-v1 70M eager 2636
mixedbread-ai/mxbai-rerank-base-v1 184M eager 1953
cross-encoder/ettin-reranker-400m-v1 400M FA2 1738
BAAI/bge-reranker-large 560M FA2 1659
BAAI/bge-reranker-v2-m3 568M FA2 1569
Alibaba-NLP/gte-reranker-modernbert-base 150M FA2 1418
ibm-granite/granite-embedding-reranker-english-r2 150M FA2 1404
cross-encoder/ettin-reranker-1b-v1 1B FA2 928
mixedbread-ai/mxbai-rerank-large-v1 435M eager 867
mixedbread-ai/mxbai-rerank-base-v2 494M FA2 809
mixedbread-ai/mxbai-rerank-large-v2 1.5B FA2 387

我们的 17M 是整个对比中最快的重排序器,达到每秒 7517 对。这几乎是 ms-marco-MiniLM-L6-v2(3817)吞吐量的两倍,甚至比更小的 ms-marco-MiniLM-L4-v2(4029)还要快。而且正如你在前面的 MTEB 表格中看到的,我们的 17M 也比每一个 MiniLM 变体都更准确。如果你目前正在运行 MiniLM 交叉编码器,切换到我们的 17M 只需改一行代码,就能同时改善延迟和搜索质量。

我们的 150M 是一个更有趣的对比,因为在恰好 150M 参数量上有两个直接的架构同类:Alibaba-NLP/gte-reranker-modernbert-base 和 ibm-granite/granite-embedding-reranker-english-r2。两者都构建在相同的 ModernBERT-base 主干上。我们的 150M 运行速度为每秒 3237 对,而这两个同类分别为 1418 和 1404,速度差距达 2.3 倍。

三个 150M 模型都使用 Flash Attention 2,但这两个同类通过 AutoModelForSequenceClassification 加载,这会保持输入填充。因此注意力本身运行 FA2 内核,但模型的其余部分仍然在对没有任何贡献的填充 token 进行密集计算。我们的模块化 Transformer 模块(见上方的架构细节)将未填充的输入一路传播通过整个模型,因此每一层只对真实 token 花费计算。这就是获得 FA2 部分收益与获得其全部收益之间的区别。

在表格底部,我们的 1B 模型达到每秒 928 对,比 1.54B 教师模型 mxbai-rerank-large-v2(每秒 387 对)快 2.4 倍,同时其 MTEB 分数与教师模型的差距在 0.0001 以内。教师模型基于 Qwen2,每对都有提示模板开销,因此蒸馏出的学生模型继承了教师的校准和判断力,但跳过了所有运行时包袱。老实说,这是整个发布中对我来说最令人满意的单个数字。

一个不幸的说明:基于 DeBERTa-v2 的 mxbai-rerank-{xsmall,base,large}-v1 系列最终比表中其他模型慢得多,因为 DeBERTa-v2 目前在 transformers 中既不支持 Flash Attention 2 也不支持 SDPA。70M 的 mxbai-rerank-xsmall-v1 运行速度为每秒 2636 对,在参数量几乎相同的情况下,吞吐量大约是我们 68M 模型的一半。这些模型本身完全没问题,只是无法使用现代注意力内核。

在消费级 GPU(RTX 3090,24 GB)上的相同基准测试

如果你是在消费级显卡而非数据中心 GPU 上自托管,这里是在 RTX 3090 上进行的相同吞吐量扫描。基准测试设置与表 1 相同:bfloat16,每个模型使用最佳支持的注意力机制,在能容纳的最大批次下取三次试验的中位吞吐量。

模型 参数量 最佳注意力 对 / 秒
cross-encoder/ettin-reranker-17m-v1 17M FA2 9008
cross-encoder/ms-marco-MiniLM-L4-v2 19M FA2 5071
cross-encoder/ettin-reranker-32m-v1 32M FA2 4497
cross-encoder/ms-marco-MiniLM-L6-v2 22M FA2 4234
cross-encoder/ms-marco-MiniLM-L12-v2 33M FA2 2847
cross-encoder/ettin-reranker-68m-v1 68M FA2 1916
mixedbread-ai/mxbai-rerank-xsmall-v1 70M eager 1677
BAAI/bge-reranker-base 278M FA2 1329
cross-encoder/ettin-reranker-150m-v1 150M FA2 982
mixedbread-ai/mxbai-rerank-base-v1 184M eager 772
ibm-granite/granite-embedding-reranker-english-r2 150M FA2 598
Alibaba-NLP/gte-reranker-modernbert-base 150M FA2 586
BAAI/bge-reranker-large 560M FA2 448
BAAI/bge-reranker-v2-m3 568M FA2 436
cross-encoder/ettin-reranker-400m-v1 400M FA2 429
mixedbread-ai/mxbai-rerank-large-v1 435M eager 266
mixedbread-ai/mxbai-rerank-base-v2 494M FA2 221
cross-encoder/ettin-reranker-1b-v1 1B FA2 189
mixedbread-ai/mxbai-rerank-large-v2 1.5B FA2 69

我们的 17M 仍然是表中最快的模型,达到每秒 9008 对,实际上比它在 H100 上的数字还高,这表明在极小规模下原始算力并非瓶颈,H100 的额外性能并未转化为优势。表中部略有洗牌,MiniLM 重排序器超过了我们的 32M 和 68M,而 1B 则落后于 mxbai-rerank-base-v2(189 对 221 对/秒)。我们的 150M 模型仍然对两个基于 ModernBERT 的 150M 同类模型保持稳固领先,教师替换的故事依然成立,我们的 1B 吞吐量是 1.5B mxbai-rerank-large-v2 的 2.7 倍(189 对 69 对/秒)。

在 CPU(Intel Core i7-13700K)上的相同基准测试

模型 参数量 最佳注意力 对 / 秒
cross-encoder/ettin-reranker-17m-v1 17M SDPA 267.4
cross-encoder/ms-marco-MiniLM-L4-v2 19M SDPA 206.2
cross-encoder/ms-marco-MiniLM-L6-v2 22M SDPA 143.9
cross-encoder/ettin-reranker-32m-v1 32M SDPA 92.5
cross-encoder/ms-marco-MiniLM-L12-v2 33M SDPA 75.9
mixedbread-ai/mxbai-rerank-xsmall-v1 70M eager 38.9
cross-encoder/ettin-reranker-68m-v1 68M SDPA 31.2
BAAI/bge-reranker-base 278M SDPA 19.2
Alibaba-NLP/gte-reranker-modernbert-base 150M SDPA 14.7
ibm-granite/granite-embedding-reranker-english-r2 150M SDPA 14.5
cross-encoder/ettin-reranker-150m-v1 150M SDPA 14.0
mixedbread-ai/mxbai-rerank-base-v1 184M eager 13.4
BAAI/bge-reranker-large 560M SDPA 6.2
BAAI/bge-reranker-v2-m3 568M SDPA 6.0
cross-encoder/ettin-reranker-400m-v1 400M SDPA 5.2
mixedbread-ai/mxbai-rerank-large-v1 435M eager 4.3
mixedbread-ai/mxbai-rerank-base-v2 494M SDPA 3.5
cross-encoder/ettin-reranker-1b-v1 1B SDPA 2.1

在 CPU 上,我们无法利用 bf16、Flash Attention 2 或 unpadding,因此延迟情况更简单:参数量越高,模型越慢。17M 模型比 ms-marco-MiniLM-L6-v2 快得多(267.4 对 143.9 对/秒),甚至比更小的 ms-marco-MiniLM-L4-v2(206.2)还快。正如预期,由于 unpadding 不再适用,我们的 150M 模型与两个 150M 同类模型持平(14.0 对 14.5 和 14.7 对/秒)。如果你受限于 CPU,我们的 17M 和 32M 是实用之选。

为了解释速度提升的来源,下表使用相同的基准配置,对我们的六个模型扫描了 fp32+SDPA、bf16+SDPA 和 bf16+FA2。FA2 列被分为两部分:一部分是输入仍然填充的情况(即包装后的模型会看到的情况),另一部分是输入未填充的情况(即我们的模块化 Transformer 实际执行的情况)。最右侧一列是我们的模型在启用 FA2 时默认使用的配置。

表 2. 在自然 NQ 文档上,对六个已发布规模在 max_length=512 下的精度与注意力消融实验。每个单元格显示每秒对数,括号内为相对于 fp32+SDPA 的倍数,第二行显示峰值 GPU 内存。最右侧一列(加粗)是我们的模型在启用 FA2 时默认使用的配置。

模型 参数量 fp32+SDPA bf16+SDPA bf16+FA2 带填充 bf16+FA2 不带填充
ettin-reranker-17m-v1 17M 4402 (1.00x)
0.8 GB
4523 (1.03x)
2.2 GB
3744 (0.85x)
1.9 GB
7517 (1.71x)
1.4 GB
ettin-reranker-32m-v1 32M 3307 (1.00x)
1.2 GB
4357 (1.32x)
1.6 GB
3040 (0.92x)
2.9 GB
6602 (2.00x)
1.1 GB
ettin-reranker-68m-v1 68M 1364 (1.00x)
1.0 GB
2861 (2.10x)
2.2 GB
2003 (1.47x)
2.0 GB
4913 (3.60x)
1.5 GB
ettin-reranker-150m-v1 150M 671 (1.00x)
1.6 GB
1942 (2.90x)
1.8 GB
1396 (2.08x)
3.1 GB
3237 (4.83x)
1.4 GB
ettin-reranker-400m-v1 400M 266 (1.00x)
2.5 GB
1113 (4.18x)
1.8 GB
864 (3.25x)
2.7 GB
1738 (6.53x)
2.2 GB
ettin-reranker-1b-v1 1B 112 (1.00x)
4.6 GB
630 (5.60x)
2.8 GB
522 (4.64x)
3.6 GB
928 (8.26x)
4.5 GB

相对于 fp32+SDPA 基线,bf16+FA2 w.o. padding 带来的总加速随模型规模急剧增长,从 17M 上的 1.71x 到 1B 上的 8.26x。这一增长大部分仅来自 bf16:从 fp32+SDPA 到 bf16+SDPA 这一步给 17M 只带来 1.03x 的加速,却给 1B 带来整整 5.60x 的加速,这也是由于内存开销降低从而允许更大的批大小。简而言之,bfloat16 是整体加速的最大单一贡献者。

出乎意料的是,在输入仍然填充的情况下开启 FA2,实际上在发布的所有规模上都比 bf16+SDPA 更慢。FA2 内核偏好未填充格式,当你向它提供填充输入时,你既要为格式转换付出记账开销,又要继续在填充 token 本身上花费计算。因此,bf16+FA2 w. padding 列大致就是你在 model_kwargs 中把 sdpa 换成 flash_attention_2、而不改变模型加载器其他任何部分时会测到的结果。这正是表 1 中 gte-reranker-modernbert-base 和 granite-embedding-reranker-english-r2 所处的情况。

最后,从 bf16+FA2 w. padding 到 bf16+FA2 w.o. padding 可带来 1.78x(1B)到 2.45x(68M)的额外吞吐量,并且还大幅降低峰值内存,从而允许更高的批大小。

所以我的建议很简单:同时启用 bf16 和 FA2。六个 Ettin 重排序器将默认使用未填充输入,因为 架构细节 部分中的模块化 Transformer 模块正是为此设置的。完整代码片段与上文 用法 部分相同:

from sentence_transformers import CrossEncoder

model = CrossEncoder(
    "cross-encoder/ettin-reranker-150m-v1",
    model_kwargs={
        "dtype": "bfloat16",
        "attn_implementation": "flash_attention_2",  # See tip below
    },
)

使用 pip install kernels 安装 FA2。它为广泛的 GPU 架构、CUDA 版本和操作系统提供了预构建内核。

对其他 CrossEncoder 的一个注意事项:完整加速仅适用于像 Ettin 重排序器这样使用模块化 Transformer 构建的模型。将同样的两个标志应用于通过 AutoModelForSequenceClassification 加载的 CrossEncoder,反而会让你落入表 2 中较慢的 bf16+FA2 w. padding 列。

训练

下面的训练脚本最初是新版 train-sentence-transformers Agent Skill 的输出,该技能随 Sentence Transformers v5.5.0 一同发布。如果你使用 AI 编程智能体(Claude Code、Codex、Cursor、Gemini CLI 等),可以安装该技能,让它基于你的数据微调 SentenceTransformer、CrossEncoder 或 SparseEncoder 模型。该技能包含针对基础模型选择、损失函数与评估器选择、难负样本挖掘、蒸馏、LoRA、Matryoshka、多语言训练和静态嵌入的版本感知指导,以及每种模型类型的模板脚本。

hf skills add train-sentence-transformers --claude   # symlinks into .claude/skills/
hf skills add train-sentence-transformers --global   # under ~/.agents/skills/

像 “基于我数据集中的 (query, document) 对微调一个 cross-encoder 重排序器,挖掘难负样本,并推送到我的 Hub 仓库” 这样的提示词会生成一个可运行的脚本,你可以在此基础上继续迭代。我下面这个配方就是这么开始的。

全部六个重排序器都用同一个单阶段配方训练。只有学习率和每设备批大小随模型规模不同而变化。完整训练脚本约 150 行,使用一个已发布的数据集。

该配方在跨模型规模的一次扫描后就收敛了。每个规模的学习率通过在最终训练数据约 15% 的子集上进行小规模网格搜索调出,所得学习率无需重新调优即可直接迁移到全量数据训练。除学习率外,无需按规模做其他调优。

蒸馏配方

大多数已发布的重排序器配方都在人工标注的相关性三元组(一个查询、一个正样本文档,以及可选的难负样本)上训练,使用对比损失、逐点损失、成对损失或列表损失,分别如 MultipleNegativesRankingLoss、BinaryCrossEntropyLoss、RankNetLoss 或 LambdaLoss。例如,参见我之前那篇 Training and Finetuning Reranker Models with Sentence Transformers 博客文章。

但这种方法有一些实际和理论上的缺点。首先,正样本需要人工标注,成本高,且难以跨多个领域扩展。其次,模型只能看到有人过目的那一小部分 (query, document) 对的标签。尤其是在难负样本挖掘之后,你会得到大量假负样本,例如 Hard Negatives, Hard Lessons 中所示。第三,这种标注的二值性质与现实不符,现实中有些文档就是比其他文档更相关。

我这里走了一条不同的路线:从已有的强教师重排序器进行逐点 MSE 蒸馏。这个设置简单到三行就能说清:

  • 教师:mixedbread-ai/mxbai-rerank-large-v2(1.54B 参数)。
  • 损失:在教师原始 logits(范围约 [−12, 22])上使用 MSELoss,即不做重新缩放。
  • 训练数据:约 143M 个 (query, document, teacher_score) 三元组。

数据集

我已将训练数据发布为单个 Hugging Face 数据集 cross-encoder/ettin-reranker-v1-data,由两个来源汇集而成。每个来源保留为自己的 split,以便来源透明可查:

  1. LightOn 预训练数据(lightonai/embeddings-pre-training,未经筛选):32 个 split,覆盖广域文本相似度信号(MTP、FW-EDU、Reddit、PAQ、S2ORC、Amazon、Wikipedia、MS MARCO 等)。我对部分 split 的样本数量做了限制,最终总计约 110M 个 (query, document, similarity) 三元组。
  2. 从 lightonai/embeddings-fine-tuning 重新评分的检索数据:7 个划分(msmarco、hotpotqa、trivia、nq、squadv2、fiqa、fever)。源数据集每个查询最多有 2048 个候选文档(最初用 Alibaba-NLP/gte-modernbert-base 评分),我用 mixedbread-ai/mxbai-rerank-large-v2 重新评分并上传为 cross-encoder/lightonai-embeddings-fine-tuning-reranked-v1。该数据集使用 Jang et al. 的分位数锚点配方(所有正例 + 前 16 个困难负例 + 约 239 个分位数锚点分层样本),将每个查询的 2048 个候选下采样至 256 个。训练时,我从每个查询的这 256 个中选取 64 个:32 个来自按分数排序的头部(正例加上最困难的负例),以及 32 个从教师排名更靠下的区间中采样的中等难度负例。确切的排名位置请参见数据集卡片。

总计:约 143M 个 (query, document, score) 三元组,外加一个留出的 5K 行评估划分(quora 的尾部),用于驱动训练中的评估损失。

训练参数

大多数超参数在不同模型规模下保持不变:

CrossEncoderTrainingArguments(
    num_train_epochs=1,                    # I chose more data over more epochs
    per_device_train_batch_size=...,       # global_batch_size // world_size (see table below)
    gradient_accumulation_steps=1,
    learning_rate=...,                     # per-size, see table
    warmup_ratio=0.03,                     # ~3% linear warmup, then linear decay (default)
    bf16=True,                             # FA2 + bf16 throughout
    eval_strategy="steps",
    eval_steps=0.05,                       # NanoBEIR every 5% of training
    save_strategy="steps",
    save_steps=0.05,
    save_total_limit=5,
    load_best_model_at_end=True,
    metric_for_best_model="eval_NanoBEIR_R100_mean_ndcg@10",
    seed=12,
)

只有学习率和全局批量大小随模型规模变化。

规模 学习率 全局批量大小
17m 2.4e-4 1024
32m 1.2e-4 512
68m 3e-5 256
150m 1.5e-5 192
400m 7e-6 256
1b 3e-6 512

global_batch_size 是 per_device_batch_size x world_size x gradient_accumulation_steps。在单个 8-GPU 节点上,17m 的 1024 全局批量意味着 per_device=128。在 8 个节点上,它意味着 per_device=8。训练脚本从 global_batch_size // world_size 计算 per_device_batch_size,因此同一脚本可在任意节点数下工作。全局批量大小本可以做得更一致,但我发现上述值效果很好,不想仅仅为了一致性而重新调参。

评估

我在训练期间监控 NanoBEIR 平均 NDCG@10(每 5% 的步数评估一次),并将其用作 load_best_model_at_end 的 metric_for_best_model。NanoBEIR 速度很快,因此我可以在每次训练运行中负担 20 次评估。训练后,我在完整的 MTEB(eng, v2) Retrieval 基准上评估了最佳检查点(根据 NanoBEIR)和最后一个检查点。最终发布的检查点是在 MTEB 上表现最好的那个。除 68m 外,NanoBEIR 偏好的检查点在所有规模上都胜出,而 68m 的最后一个检查点略强。

整体训练脚本

完整脚本(每个发布模型训练所用的脚本)是一个单文件。每次运行只有 ENCODER_SIZE 会变化,其余一切都是自动的:

from __future__ import annotations

import logging
import os
from pathlib import Path

import torch
import torch.nn as nn
from datasets import concatenate_datasets, get_dataset_config_names, load_dataset

from sentence_transformers import CrossEncoder
from sentence_transformers.base.modules import Dense
from sentence_transformers.cross_encoder import (
    CrossEncoderModelCardData,
    CrossEncoderTrainer,
    CrossEncoderTrainingArguments,
)
from sentence_transformers.cross_encoder.evaluation import CrossEncoderNanoBEIREvaluator
from sentence_transformers.cross_encoder.losses import MSELoss
from sentence_transformers.sentence_transformer.modules import LayerNorm, Pooling, Transformer

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s", datefmt="%H:%M:%S")
logging.getLogger("httpx").setLevel(logging.WARNING)

# Per-size config. I swept the learning rates with these global (effective) batch sizes,
# also by incorporating accum_steps
CONFIGS: dict[str, dict] = {
    "17m":  {"base_model_name": "jhu-clsp/ettin-encoder-17m",  "learning_rate": 2.4e-4, "global_batch_size": 1024},
    "32m":  {"base_model_name": "jhu-clsp/ettin-encoder-32m",  "learning_rate": 1.2e-4, "global_batch_size": 512},
    "68m":  {"base_model_name": "jhu-clsp/ettin-encoder-68m",  "learning_rate": 3e-5,   "global_batch_size": 256},
    "150m": {"base_model_name": "jhu-clsp/ettin-encoder-150m", "learning_rate": 1.5e-5, "global_batch_size": 192},
    "400m": {"base_model_name": "jhu-clsp/ettin-encoder-400m", "learning_rate": 7e-6,   "global_batch_size": 256},
    "1b":   {"base_model_name": "jhu-clsp/ettin-encoder-1b",   "learning_rate": 3e-6,   "global_batch_size": 512},
}
ENCODER_SIZE = "17m"

def main() -> None:
    config = CONFIGS[ENCODER_SIZE]
    encoder_id = config["base_model_name"]
    learning_rate = config["learning_rate"]
    global_batch_size = config["global_batch_size"]

    world_size = int(os.environ.get("WORLD_SIZE", 1))
    per_device_batch_size = global_batch_size // world_size
    dataloader_workers = 0 if world_size > 8 else 4
    run_name = f"ettin-reranker-{ENCODER_SIZE}-lr{learning_rate:.0e}"

    # 1. Load a model to finetune with model card data
    # The model mirrors ModernBertForSequenceClassification, but with a 'headless' Transformer that just loads
    # AutoModel. This allows for unpadding with FA2, which isn't possible with AutoModelForSequenceClassification.
    # This speeds up training considerably, while heavily reducing memory usage.
    torch.manual_seed(12)
    transformer = Transformer(encoder_id, model_kwargs={"attn_implementation": "flash_attention_2"})
    transformer.model.config.num_labels = 1
    embedding_dimension = transformer.get_embedding_dimension()
    pooling = Pooling(embedding_dimension=embedding_dimension, pooling_mode="cls")
    dense_inner = Dense(
        in_features=embedding_dimension, out_features=embedding_dimension, bias=False,
        activation_function=nn.GELU(),
        module_input_name="sentence_embedding", module_output_name="sentence_embedding",
    )
    norm = LayerNorm(dimension=embedding_dimension)
    dense_score = Dense(
        in_features=embedding_dimension, out_features=1, bias=True,
        activation_function=nn.Identity(),
        module_input_name="sentence_embedding", module_output_name="scores",
    )
    model = CrossEncoder(
        modules=[transformer, pooling, dense_inner, norm, dense_score],
        num_labels=1,
        activation_fn=nn.Identity(),
        model_card_data=CrossEncoderModelCardData(
            model_name=f"Ettin Reranker {ENCODER_SIZE} distilled from mxbai-rerank-large-v2",
            language="en",
            license="apache-2.0",
        ),
    )
    actual_attn = getattr(model[0].model.config, "_attn_implementation", None)
    if not (actual_attn and "flash" in actual_attn.lower()):
        logging.warning(f"FA2 may not be active (attn_impl={actual_attn!r}); training will be slower.")

    # 2. Load the dataset. Each config is one source subset (32 lighton + 7 rerank retrieval
    # domains). The held-out eval rows live as the 'validation' split of the 'quora' config.
    dataset_repo = "cross-encoder/ettin-reranker-v1-data"
    train_pieces = []
    eval_dataset = None
    for config_name in get_dataset_config_names(dataset_repo):
        dataset = load_dataset(dataset_repo, config_name)
        train_pieces.append(dataset["train"])
        if "validation" in dataset:
            eval_dataset = dataset["validation"]
    train_dataset = concatenate_datasets(train_pieces)
    print(train_dataset)

    # 3. Define a loss function
    loss = MSELoss(model)

    # 4. Specify training arguments
    args = CrossEncoderTrainingArguments(
        output_dir=f"models/{run_name}",
        num_train_epochs=1,
        per_device_train_batch_size=per_device_batch_size,
        per_device_eval_batch_size=per_device_batch_size,
        gradient_accumulation_steps=1,
        learning_rate=learning_rate,
        warmup_ratio=0.03,
        bf16=True,
        eval_strategy="steps",
        eval_steps=0.05,
        save_strategy="steps",
        save_steps=0.05,
        save_total_limit=5,
        logging_steps=0.025,
        logging_first_step=True,
        load_best_model_at_end=True,
        metric_for_best_model="eval_NanoBEIR_R100_mean_ndcg@10",
        dataloader_num_workers=dataloader_workers,
        run_name=run_name,
        seed=12,
    )

    # 5. Create an evaluator
    evaluator = CrossEncoderNanoBEIREvaluator(
        dataset_names=["msmarco", "nfcorpus", "nq", "fiqa2018", "touche2020", "scifact",
                       "hotpotqa", "arguana", "fever", "dbpedia", "climatefever", "scidocs",
                       "quoraretrieval"],
        batch_size=per_device_batch_size,
        always_rerank_positives=False,
        show_progress_bar=False,
    )

    # 6. Create a trainer
    trainer = CrossEncoderTrainer(
        model=model,
        args=args,
        train_dataset=train_dataset,
        eval_dataset=eval_dataset,
        loss=loss,
        evaluator=evaluator,
    )

    # 7. Evaluate before training
    if trainer.is_world_process_zero():
        with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
            evaluator(model)

    # 8. Train
    trainer.train()

    # 9. Evaluate the final model
    if trainer.is_world_process_zero():
        with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
            evaluator(model)

    # 10. Save the final model
    final_dir = f"models/{run_name}/final"
    model.save_pretrained(final_dir)


if __name__ == "__main__":
    main()

对于多节点训练(任何超过 17m/32m 的情况),使用 torchrun 启动同一脚本:

# Single-node (17m, 32m): defaults work
python train.py

# Multi-node 4n setup for 150m, preserves global_batch_size=192:
torchrun --nproc_per_node=8 --nnodes=4 ... train.py

结论

ettin-reranker-v1 系列使用单一简单配方训练,在直至 1B 参数的每个发布规模上都达到了最先进水平。从强教师到广域和检索专用混合数据的逐点 MSE 蒸馏,从 17M 到 1B 参数都能干净地扩展,不同规模之间只有学习率和每设备批量大小发生变化。

每个 ettin-reranker-v1 模型在 MTEB 和 NanoBEIR 上都以舒适的优势击败 ms-marco-MiniLM-L*-v2 系列。cross-encoder/ettin-reranker-150m-v1 是我在 600M 以下范围内测试过的最强中端重排序器,cross-encoder/ettin-reranker-400m-v1 与 1.54B 教师的 MTEB 分数相差在 0.0024 以内,而 cross-encoder/ettin-reranker-1b-v1 与该教师的差距在 0.0001 以内。

一切都在一处:

如果你在这些基础上构建了什么,请告诉我!我真的很想看看人们会用它们做什么,如果你能用发布的数据训练出更好的重排序器,那就更好了。这个配方刻意保持简单,部分原因是为了给其他人留下充足的改进空间。训练一个更强的教师模型,同一个脚本就能持续产出更好的学生模型。

致谢

我要感谢 Ettin 团队(Orion Weller、Kathryn Ricci、Marc Marone、Antoine Chaffin、Dawn Lawrie 和 Benjamin Van Durme)构建了基础编码器,这些重排序器正是基于它们构建的;感谢 LightOn 团队(Antoine Chaffin、Raphael Sourty、Paulo Moura 和 Amélie Chatelain)在训练数据收集方面的工作;以及感谢 Mixedbread AI 团队(Xianming Li、Aamir Shakir、Rui Huang、Tsz-fung Andrew Lee、Julius Lipp、Benjamin Clavié 和 Jing Li)在教师模型方面的工作。

引用

如果你使用了 ettin-reranker-v1 系列或任何已发布的产物,请引用这篇博文:

@misc{aarsen2026ettin-reranker,
    title = "Introducing the Ettin Reranker Family",
    author = "Aarsen, Tom",
    year = "2026",
    publisher = "Hugging Face",
    url = "https://huggingface.co/blog/ettin-reranker",
}

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