引入 Ettin Reranker 系列
从17M到1B的全尺寸reranker家族,每个量级都是SOTA,而且训练数据和代码全开放,做搜索和RAG的开发者可以无痛替换旧模型。
Hugging Face 发布六个 Ettin Reranker 重排序模型(17m、32m、68m、150m、400m、1b),基于 Ettin ModernBERT 编码器,蒸馏 self-mxbai-rerank-large-v2 分数训练,在 MTEB(eng, v2) Retrieval 达各自规模 SOTA。模型以 Sentence Transformers CrossEncoder 接口提供,三行代码可调用。同时发布 train-sentence-transformers Agent Skill(v5.5.0),允许 AI 编码智能体在用户数据上微调模型。训练配方、数据集和脚本已全部开源。
推出 Ettin 重排序器系列
摘要
今天我发布了六个新的 Sentence Transformers CrossEncoder 重排序器,它们在各自参数量级别上达到了最先进水平,基于 Ettin ModernBERT 编码器构建,同时发布了生成这些模型所用的数据和完整训练方案:
- cross-encoder/ettin-reranker-17m-v1
- cross-encoder/ettin-reranker-32m-v1
- cross-encoder/ettin-reranker-68m-v1
- cross-encoder/ettin-reranker-150m-v1
- cross-encoder/ettin-reranker-400m-v1
- cross-encoder/ettin-reranker-1b-v1
这些模型采用知识蒸馏方案训练:基于 mixedbread-ai/mxbai-rerank-large-v2 分值在 cross-encoder/ettin-reranker-v1-data 上进行逐点均方误差(pointwise MSE)蒸馏,而该数据集是 lightonai/embeddings-pre-training 的一个子集,并混合了 lightonai/embeddings-fine-tuning 的一个重排序后的子集。
我们的六个重排序器与 google/embeddinggemma-300m 搭配,在 MTEB(英语,v2)检索任务上的表现。请查看结果部分,获取另外五种嵌入器搭配的结果。
如果你对重排序器还不熟悉,想先了解"为什么",请跳转到"什么是重排序器,为什么要与嵌入器搭配?"。如果你只想直接使用模型,请跳转到"使用方式"。如果你想自己训练,请跳转到"训练"。
我利用新推出的 train-sentence-transformers Agent 技能(随 Sentence Transformers v5.5.0 发布)引导了上述训练方案。使用 `hf skills add train-sentence-transformers [--global] [--claude]` 安装它,然后让你的 AI 编码智能体(Claude Code、Codex、Cursor、Gemini CLI……)在你的数据上微调 SentenceTransformer、CrossEncoder 或 SparseEncoder 模型。
目录
- 什么是重排序器,为什么要与嵌入器搭配?
- Usage
- 端到端的检索-重排序流水线
- 架构细节
- Results
- MTEB(英语,v2)检索
- 速度
- Training
- 知识蒸馏方案
- 数据集
- 训练参数
- 评估
- 完整训练脚本
- 总结
- 致谢
什么是重排序器,为什么要与嵌入器搭配?
重排序器(又称逐点交叉编码器)是一种神经网络模型,它接收(查询,文档)对并输出单个相关性分数。与嵌入模型不同(嵌入模型分别对查询和文档进行编码,然后从两个嵌入向量计算它们的相似度),重排序器让两个文本在每一个 Transformer 层中相互关注。这种联合编码更准确,但也更昂贵:模型必须为每个(查询,文档)对运行一次,而不是为每个文本运行一次。
由于交叉编码器对整个语料库运行成本过高,常见的生产模式是“检索-然后-重排序”:一个快速的嵌入模型检索出前 K 个候选(便宜),然后一个交叉编码器仅对这些 K 个候选进行高精度的重新排序。总成本保持在可控范围内,而最终排序则更接近穷举式交叉编码器通过所能产生的结果。
在本博客文章中,我将交替使用“重排序器”和“交叉编码器”。
用法
发布的模型是标准的 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)
对于查询和候选列表,你还可以使用 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']}")
你可以将 cross-encoder/ettin-reranker-32m-v1 替换为任何其他大小,以在质量与速度之间进行权衡。得益于 ModernBERT 的长上下文预训练,所有六个模型都支持最多 8000 个 token 的上下文(这对于长文档重排序非常有用)。
建议安装相关内核并设置 `model_kwargs={"dtype": "bfloat16", "attn_implementation": "flash_attention_2"}` 以获得最高吞吐量。更多详情请参见下面的速度部分,但一般来说,相比默认加载方式,根据模型大小和序列长度,你可以预期获得 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
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.",
]
query = "Where was Apple founded?"
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()
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']}")
这与大多数现代搜索系统所使用的结构相同。检索器决定哪些内容进入漏斗,重排序器决定哪些胜出。
架构细节
所有六个重排序器共享相同的架构,仅在骨干网络规模上有所区别。骨干网络选自约翰霍普金斯大学Ettin套件中的六个Ettin编码器之一。这些是ModernBERT风格的模型,采用无填充注意力、RoPE位置编码、GeGLU,并在2万亿模型token的开放许可下进行预训练,支持最多8192 token的上下文。
在每个编码器之上,重排序器使用了一个包含4个模块的分类头,它模仿了ModernBertForSequenceClassification,但由Sentence Transformers的模块化组件构建而成。底层的Transformer是一个普通的AutoModel,而非AutoModelForSequenceClassification,这让我们能够对可变长度输入使用序列无填充技术以支持Flash Attention 2。在中等文档序列长度下,根据模型大小的不同,相比于fp32+SDPA可实现1.7倍到8.3倍的加速(完整基准测试请参见“速度”章节):
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) Retrieval
我通过MTEB的两阶段重排序流程,将每个发布的模型在完整的MTEB(英文,v2)检索基准测试(10个任务,前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 |
下面每张图表中的虚线“仅检索器”线是需要击败的标杆数字。低于该线的任何值意味着重排序器平均而言对检索流程有负面影响:
Full table of results (click to expand)6 组嵌入模型配对上的平均 NDCG@10,按降序排列。我们的六个模型以粗体显示,教师模型 mixedbread-ai/mxbai-rerank-large-v2 以下划线标注。
| 重排序器 | 参数 | MTEB(英文,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)。原生上下文评估结果可能更高。
Full table of NanoBEIR results (click to expand)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 上以约一半的参数规模,在 NDCG@10 上战胜了 33M 的 ms-marco-MiniLM-L12-v2,领先 +0.051(0.5576 vs 0.5066),在 NanoBEIR 上领先 +0.038(0.6746 vs 0.6369)。32M 模型在 MTEB 上以 +0.025(0.5779 vs 0.5526)领先 568M 的 BAAI/bge-reranker-v2-m3,参数差距达 17 倍。如果您一直在检索后重排序栈中默认使用某个旧版 MiniLM 重排序器,那么换用我们的 17M(或 32M)是一种低风险的直接替换,在两个基准测试上都能带来明显的质量提升。
继续往上,我们的 150M 是我在 MTEB 上测试过的 600M 以下范围内最强的重排序器,以 +0.005(0.5994 vs 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 vs 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(在 transformers 中目前既不支持 FA2 也不支持 SDPA)则使用 eager 模式。
对于每个模型,自动批处理搜索从批大小 8 开始,每次翻倍,直到 GPU 内存耗尽。在每个批大小下,我运行三次定时传递并保留中位数吞吐量,因此单次运气不好的运行不会影响数值。报告的吞吐量取的是胜出的批大小下的结果。
表1. 吞吐量(对/秒),全部使用 bfloat16。我们的六个重排序模型以粗体显示。
| 模型 | 参数量 | Attn | 对/秒 |
|---|---|---|---|
| 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 加载,这会导致输入保持填充(padding)状态。因此,注意力机制本身虽然运行了 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 目前既不支持 Flash Attention 2,也不支持 transformers 中的 SDPA。70M 的 mxbai-rerank-xsmall-v1 运行速度为每秒 2636 对,大约是我们 68M 模型吞吐量的一半,而参数量几乎相同。这些模型本身完全没有问题,只是无法使用现代注意力内核。
Same benchmark on a consumer 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 对 vs 每秒 221 对)。我们的 150M 模型相对于两个基于 ModernBERT 的 150M 同类产品仍保持明显领先,并且“教师替换”的结论依然成立:我们的 1B 模型吞吐量是 1.5B mxbai-rerank-large-v2 的 2.7 倍(每秒 189 对 vs 每秒 69 对)。
Same benchmark on 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 对 vs 每秒 143.9 对),甚至快于更小的 ms-marco-MiniLM-L4-v2(每秒 206.2 对)。不出所料,由于不再适用 unpadding,我们的 150M 模型与两个 150M 同类产品处于同一水平(每秒 14.0 对 vs 每秒 14.5 对和 14.7 对)。如果你的运行环境受 CPU 限制,我们的 17M 和 32M 模型是实用的选择。
为了解释速度提升的来源,下表使用相同的基准配置,对我们六个模型进行了 fp32+SDPA、bf16+SDPA 和 bf16+FA2 的对比扫描。FA2 列分为两部分:一部分是输入仍经过填充的情况(包装模型会看到的情况),另一部分是输入未经填充的情况(我们的模块化 Transformer 实际执行的情况)。最右侧列是我们的模型在启用 FA2 时默认使用的配置。
表2. 在自然 NQ 文档上,针对已发布的六种模型规模,在 max_length=512 条件下进行精度与注意力机制消融实验。每个单元格显示每秒处理的对数(pairs/second),括号内为相对于 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 |
从 bf16+FA2(无填充)相对于 fp32+SDPA 基线的总加速比来看,随着模型规模的增大而急剧增长: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(带填充)这一列大致对应的是:你在 model_kwargs 中将 sdpa 换成 flash_attention_2,但不对模型加载器做其他任何改动时所能测量到的结果。这正是表 1 中 gte-reranker-modernbert-base 和 granite-embedding-reranker-english-r2 所处的状况。
最后,从 bf16+FA2(带填充)切换到 bf16+FA2(无填充),可以获得 1.78 倍(1B 模型)到 2.45 倍(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",
},
)
使用 pip install kernels 安装 FA2。它针对多种 GPU 架构、CUDA 版本和操作系统提供了预编译内核。
其他 CrossEncoder 的一个注意事项:完整的加速效果仅适用于像 Ettin 重排序模型这样基于模块化 Transformer 构建的模型。如果将相同的两个标志应用于通过 AutoModelForSequenceClassification 加载的 CrossEncoder,你最终会落入表 2 中速度较慢的 bf16+FA2(带填充)那一列。
训练
下面的训练脚本最初来自 Sentence Transformers v5.5.0 中新增的 train-sentence-transformers Agent Skill 的输出。如果你使用 AI 编码智能体(如 Claude Code、Codex、Cursor、Gemini CLI 等),你可以安装该技能,并让它根据你的数据微调 SentenceTransformer、CrossEncoder 或 SparseEncoder 模型。该技能包含了针对基座模型选择、损失函数与评估器选择、难负例挖掘、知识蒸馏、LoRA、Matryoshka、多语言训练以及静态嵌入向量的版本感知指导,以及每种模型类型的模板脚本。
hf skills add train-sentence-transformers --claude
hf skills add train-sentence-transformers --global
像"用我的数据集中的(查询、文档)对微调一个交叉编码器重排序模型,挖掘难负样本,然后推送到我的 Hub 仓库"这样的提示词,会产生一个可运行的脚本,你可以在此基础上不断迭代。我就是这样开始着手下面这个方案的。
全部六个重排序模型都使用相同的单阶段方案进行训练。只有学习率和每设备批量大小会根据模型规模有所变化。完整的训练脚本大约有 150 行代码,并使用了一个已公开的数据集。
该方案在一次跨模型规模的扫描后便收敛了。每个规模的学习率通过在最终训练数据的约 15% 子集上进行小范围网格搜索来调整,得到的这些学习率在完整数据运行中直接迁移成功,无需重新调整。除了学习率之外,每个规模不需要单独调参。
知识蒸馏方案
大多数已发表的重排序模型方案都使用人工标注的相关性三元组(一个查询、一个正样本文档,以及可选的难负样本),并采用对比损失、逐点损失、成对损失或列表损失,例如分别使用 MultipleNegativesRankingLoss、BinaryCrossEntropyLoss、RankNetLoss 或 LambdaLoss。具体可参考我先前那篇题为"使用 Sentence Transformers 训练和微调重排序模型"的博客文章。
但这种方法存在一些实际和理论上的缺陷。首先,正样本需要人工标注,这在多个领域扩展时既昂贵又缓慢。其次,模型只能看到那些经过人工标注的(查询、文档)小子集的标签。尤其是在进行难负样本挖掘之后,最终会产生大量假负样本,例如"难负样本,难题"一文所展示的那样。第三,这种二元标注的性质与现实不符,现实中有些文档就是比其他文档更相关。
我在这里采用了不同的路线:从现有的强教师重排序模型进行逐点均方误差蒸馏。这个设置简单到可以用三行描述:
- 教师模型:mixedbread-ai/mxbai-rerank-large-v2(1.54B 参数)。
- 损失函数:对原始教师 logits(范围约 [−12, 22])使用 MSELoss,即不进行重缩放。
- 训练数据:约 1.43 亿个(查询、文档、教师分数)三元组。
数据集
我已经将训练数据发布为单个 Hugging Face 数据集:cross-encoder/ettin-reranker-v1-data,该数据集由两个来源组装而成。每个来源都保留为自己的分割,以便来源信息透明:
- LightOn 预训练数据(lightonai/embeddings-pre-training,未经整理):32 个分割,涵盖广泛领域的文本相似度信号(MTP、FW-EDU、Reddit、PAQ、S2ORC、Amazon、Wikipedia、MS MARCO 等)。我对部分分割的样本数量进行了限制,总共得到约 1.1 亿个(查询、文档、相似度)三元组。
- 来自 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 等人的分位数锚定方法,将每个查询的 2048 个候选文档降采样至 256 个(所有正例 + 前 16 个难例 + 约 239 个分位数锚定分层样本)。训练时,我从每个查询的 256 个候选中选取 64 个:32 个来自按评分排序的头部(正例加上最难负例),32 个中等难度负例,从教师排序中更靠后的区间采样而来。具体排名位置请参见数据集卡片。
总计:约 1.43 亿个(查询、文档、评分)三元组,外加一个保留的 5K 行评估分割(quora 的尾部),用于驱动训练期间的评估损失。
训练参数
大多数超参数在不同模型规模下保持恒定:
CrossEncoderTrainingArguments(
num_train_epochs=1,
per_device_train_batch_size=...,
gradient_accumulation_steps=1,
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,
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` × `world_size` × `gradient_accumulation_steps`。在单个 8 GPU 节点上,17m 模型的 1024 全局 batch 意味着 `per_device=128`。在 8 个节点上,则意味着 `per_device=8`。训练脚本根据 `global_batch_size // world_size` 计算出 `per_device_batch_size`,因此同一个脚本可适用于任意数量的节点。全局 batch size 本可以做得更一致,但我发现上述取值效果很好,并且不想仅仅为了一致性而重新调整它们。
评估
我在训练期间监控了 NanoBEIR 的平均 NDCG@10(每 5% 的步骤评估一次),并将其用作 `load_best_model_at_end` 的 `metric_for_best_model`。NanoBEIR 很快,因此我能在每次训练过程中进行 20 次评估。训练结束后,我同时评估了最佳检查点(根据 NanoBEIR)和最后一个检查点在完整 MTEB(英文,v2)检索基准上的表现。最终发布的检查点是在 MTEB 上表现更好的那一个。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)
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}"
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.")
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)
loss = MSELoss(model)
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,
)
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,
)
trainer = CrossEncoderTrainer(
model=model,
args=args,
train_dataset=train_dataset,
eval_dataset=eval_dataset,
loss=loss,
evaluator=evaluator,
)
if trainer.is_world_process_zero():
with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
evaluator(model)
trainer.train()
if trainer.is_world_process_zero():
with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
evaluator(model)
final_dir = f"models/{run_name}/final"
model.save_pretrained(final_dir)
if __name__ == "__main__":
main()
对于多节点训练(任何超过 17m/32m 的模型),用 `torchrun` 启动同一个脚本:
python train.py
torchrun --nproc_per_node=8 --nnodes=4 ... train.py
结论
ettin-reranker-v1 系列,使用一个简单的统一配方训练,在已发布的所有规模(最高 1B 参数)中均达到最先进水平。从强教师模型进行 pointwise MSE 蒸馏,并结合广泛领域和检索特定的混合数据,从 17M 到 1B 参数都能干净地扩展,不同规模之间仅有学习率和每设备 batch size 发生变化。
每个 ettin-reranker-v1 模型在 MTEB 和 NanoBEIR 上都以显著优势击败了 ms-marco-MiniLM-L*-v2 系列。`cross-encoder/ettin-reranker-150m-v1` 是我在 600M 以下范围内测试过的最强中档重排序器,`cross-encoder/ettin-reranker-400m-v1` 的 MTEB 得分与 1.54B 教师模型相差不到 0.0024,而 `cross-encoder/ettin-reranker-1b-v1` 与教师模型的差距仅为 0.0001。
所有模型都在一个地方:
- Models:
- `cross-encoder/ettin-reranker-17m-v1`
- `cross-encoder/ettin-reranker-32m-v1`
- `cross-encoder/ettin-reranker-68m-v1`
- cross-encoder/ettin-reranker-150m-v1
- cross-encoder/ettin-reranker-400m-v1
- cross-encoder/ettin-reranker-1b-v1
- 数据集:cross-encoder/ettin-reranker-v1-data,包含约 1.43 亿条(query,document,label)三元组,按 39 个命名子集保存,因此每条数据来源清晰可查。
- 训练脚本:即上文“Overall Training Script”中约 150 行的代码,所有六个模型均使用同一脚本。
如果你在这些模型之上构建了任何成果,请告诉我!我真心希望能看到大家的使用方式,如果你能利用发布的数据训练出更好的重排序器,那就更棒了。这套方案有意保持简洁,部分原因是为了给其他人留出充分的改进空间。训练一个更强的教师模型,同一套脚本就能持续产出更优秀的学生模型。
致谢
我要感谢 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",
}
本文提及的模型 10 个
本文提及的数据集 5 个
本文提及的集合 2 个
社区评论
这是一篇非常精彩的文章,感谢您的辛勤付出并分享方案!
不客气!很高兴您喜欢这篇文章 🤗
很棒的工作!喜欢您使用了分层采样。很高兴看到它在交叉编码器上同样威力强大!
当然!我尝试了几种变体,在我的测试中,top 和 stratified 的组合效果最好,但我从 2048 个文档中采样,所以完全分层意味着第二个文档已经与最相似的文档相距甚远。
多么干净的发布,祝贺 @tomaarsen!
谢谢 Maxime!你知道吗,我打赌这个训练脚本也能很好地应用于 LFM 模型 👀 不过,也许模型更喜欢一种生成式架构,其聊天模板要求模型生成 "yes" 或 "no",这些 token 的原始 logit 分数差异被用作预测。这正是教师模型的 Sentence Transformers 集成所使用的,效果相当不错:https://huggingface.co/mixedbread-ai/mxbai-rerank-large-v2
出色的工作!在信息检索中,重排序器通常用于实时管道中,因此一个紧凑且性能强大的重排序器正是我们许多人一直在等待的。感谢你们的出色工作!
是的,正是如此。在我看来,重排序器已经变得太大,难以保持可用性。希望这能激励其他人也制作更小的模型。此外,Ettin 基础模型非常出色,这帮助很大。
你好 @tomaarsen,
由于我经常在生产环境中使用 TEI(text-embeddings-inference),我提交了一个拉取请求,为 TEI 添加了对 Ettin 的支持,尽管我不确定它是否会被合并。
如此一来,Ettin 可以通过 TEI 作为稳定的 API 服务提供,我在测试中观察到大约 1.5 倍的推理加速!
- https://github.com/huggingface/text-embeddings-inference/pull/867#issuecomment-4537097113
· 或发表评论






