← 文章 / AI技术
HuggingFace博客 6小时前 · 2026-08-29 15:05:41 · 5 阅读

使用 Sentence Transformers 构建 Multi-Vector(Late Interaction)Embedding 模型

Sentence Transformers 是一个 Python 库,可用于在检索增强生成、语义搜索等应用中使用和训练 embedding 与 reranker 模型。随着 v6.0 更新,它新增了第四种模型类型:MultiVectorEncoder,用于实现 ColBERT 风格的延迟交互检索。任何 PyLate checkpoint 和 Stanford-NLP ColBERT checkpoint 都可以直接加载到该模型中;用于视觉文档检索的 colpali-engine 模型也同样支持。它们都可以通过你熟悉的同一套 API 使用,就像 dense、sparse 和 reranker 模型一样。

普通 embedding 模型会将整段文本压缩成一个向量,而 multi-vector 模型则会为每个 token 保留一个向量,并使用 MaxSim 算子计算查询与文档的匹配分数。这样可以保留 token 级别的匹配信息,不必像单向量模型那样将其平均压缩,因此通常能获得更强的检索效果,代价是需要更大的索引。在视觉文档检索中,multi-vector 也是当前的先进方案:文本查询可以直接与页面图像匹配,中间无需 OCR。

本文将介绍如何使用这些模型:加载不同格式的 checkpoint、编码与计算分数、将模型接入搜索系统、处理页面图像,以及控制索引成本。下面的所有示例都只需执行 pip install -U sentence-transformers 即可运行。

本文主要介绍 multi-vector 模型的使用方法。如果你想了解如何训练这类模型,请参阅配套文章:使用 Sentence Transformers 训练和微调 Multi-Vector Embedding 模型

目录

什么是 Multi-Vector 模型?

稠密 embedding 模型会读取一段文本,并输出一个固定大小的向量。模型捕捉到的所有信息,都必须压缩进这 384、768 或 1024 个数字中,两个文本的相似度也只能通过这类摘要向量之间的一次点积来计算。这种方法的效果出奇地好,但这种压缩会带来特定的信息损失:一个罕见实体、一个精确标识符,或长段落中的关键短语,都必须争夺同一个向量里的有限空间。包含多个要求的查询也会遇到同样的问题。比如“带木腿和圆润坐垫的绿色沙发”,单个向量必须把这四个条件融合成一个点,因此,一张腿部样式不对的绿色沙发,最终也可能与真正符合要求的沙发距离很近。

Multi-Vector 模型也称为 late-interaction 模型或 ColBERT 风格模型,名称源自 ColBERT 论文。它绕过了这种压缩过程:模型仍然运行同一个 Transformer,但不会将各个 token 的 embedding pooling 成一个向量,而是先把每个 token embedding 投影到较低维度(经典设置为 128 维),然后完整保留下来。这样,一个包含 9 个 token 的文档得到的是一个 9×128 矩阵,而不是一个 1×128 向量。

查询与文档的交互会延后到打分阶段进行,这也是“late interaction”(晚交互)这一名称的由来。Cross-encoder 属于早期交互:两段文本会一起输入模型,精度较高,但无法提前计算,因为每次有新查询,都必须重新编码所有文档。上文介绍的 dense embedding model 属于 bi-encoder,几乎不发生交互——两个已经生成的摘要向量之间只需计算一次点积。这正是它能够一次性编码整个文档集合、并快速响应查询的原因。Late interaction 则介于两者之间:文档仍可独立编码并离线建立索引,但打分时会将每个查询 token 与每个文档 token 进行比较,为两者留下了更多交互空间。

Dense embedding versus multi-vector late interaction: a dense model encodes each text into one vector and scores with cosine similarity, while a multi-vector model keeps one vector per token and scores every query token against every document token with MaxSim

MaxSim 算子

打分采用 MaxSim:对每个查询 token,找出它与任意文档 token 之间的最高相似度,再将这些最高值在整个查询范围内求和。

MaxSim(Q,D)=∑Qi∈Qmax⁡Dj∈DQi⋅Dj\text{MaxSim}(Q, D) = \sum_{Q_i \in Q} \max_{D_j \in D} Q_i \cdot D_jMaxSim(Q,D)=Qi​∈Q∑​Dj​∈Dmax​Qi​⋅Dj​

由于 token embedding 都经过 L2 归一化,这些点积实际上都是 [-1, 1] 范围内的余弦相似度,因此最终得分会落在 [-num_query_tokens, num_query_tokens] 之间。

可以把这个算子理解为一种软对齐:每个查询 token 都会对应到最能解释它的那个文档 token,最终得分则反映了文档整体上对查询的支持程度。

这种对齐不必局限于词面,因为 token embedding 已经融入了上下文信息。使用 lightonai/mLateOn,将“Where do penguins live?”与“Penguins inhabit Antarctica.”进行编码后,查询中的 live 会以 0.94 的相似度,在文档中的 inhabit 上找到最佳匹配——这两个词甚至没有任何相同的字符!这正是词法检索做不到的地方:BM25 及其同类方法依赖词项本身,因此同义词和改写很容易被漏掉。当然,Dense embedding 模型也能弥合这一差距。Late interaction 的优势在于,它无需牺牲词项级匹配能力。当精确匹配很重要时(例如产品编码、姓氏或函数名),MaxSim 仍会单独保留对应 token;而单向量模型只能把它和其他内容一起平均压缩。它也不是严格的一对一匹配,因为多个查询 token 经常会落到同一个文档 token 上。

收益与代价

你得到的是更高的检索质量,尤其适用于以下场景:文档中的某个具体片段决定了相关性;查询包含多个要求,例如上文的沙发查询,每个要求都能分别找到对应证据;以及面对域外数据时,Dense 模型的压缩方式恰好是针对另一种数据分布调优的。模型会从训练查询中学习这种压缩方式,保留训练查询需要的信息,丢弃其他内容,而被丢弃的部分可能正是生产环境查询所关注的内容。文档越长,这种影响越明显,因为更多文本需要被压缩进同样大小的固定向量中。

代价则是索引体积。一篇文档不再对应一个向量,而是每个 token 对应一个向量,向量数量会大幅增加;虽然单个向量的维度更小,但只能部分抵消这一增长。使用 lightonai/LateOn 对 4,874 个 Natural Questions 段落进行编码后,共生成 608,414 个 token 向量,平均每个段落 124.8 个:

表示方式 向量数量 维度 float32 大小
Dense,all-MiniLM-L6-v2 4,874 384 7.5 MB
Dense,gte-modernbert-base 4,874 768 15.0 MB
Multi-vector,LateOn 608,414 128 311.5 MB

这大约是 MiniLM 索引存储空间的 42 倍,即每篇文档约 62 KiB。不过,索引通常会进行压缩。例如,同样的 608,414 个向量,存储为 fast-plaid 索引后只需 92 MB。这是因为 PLAID 为每个向量存储的是质心 ID 和量化后的残差,而不是完整向量。作为参考,对于这 4,874 篇文档,一个 4096 维的 dense 模型(例如 Qwen3-Embedding-8B)大约需要 80 MB。因此,压缩后的 multi-vector 索引与大家已经在使用的 dense 索引处于相近的存储规模。Token Pooling 可以在此之前减少向量数量,而 Retrieve and Rerank 则完全不需要构建索引。

本文会多次用到 PyLate,这里先简单介绍一下。Sentence Transformers 原本支持 dense 和 sparse 模型,但不支持 late interaction。为弥补这一空白,LightOn 基于 Sentence Transformers 构建了 PyLate,并加入了这类模型所需的训练、推理和检索功能。下面将要加载的许多模型都是用 PyLate 训练的,LightOn 还围绕它构建了完整生态,其中包括 fast-plaid——Indexing 一节会介绍的 late-interaction 索引。到了 v6.0,这些能力已经内置于 Sentence Transformers。

了解了这些取舍后,下面开始运行模型。

安装

Multi-vector 模型直接安装即可使用:

pip install -U sentence-transformers

如果要进行 ColPali 风格的视觉文档检索,还需要安装图像相关依赖(完整的可选依赖请参见 Installation;有关多模态支持的介绍,请参见 Multimodal Embedding & Reranker Models):

pip install -U "sentence-transformers[image]"

Sentence Transformers v6.0 要求 transformers v5.x、torch 2.2 及以上版本,以及 huggingface-hub v1.x。如果项目锁定了其中任一依赖的更低版本,请先规划升级。完整的破坏性变更列表请参阅迁移指南

加载模型

加载 Multi-Vector 模型的方式与加载其他 Sentence Transformers 模型完全相同:

from sentence_transformers import MultiVectorEncoder

model = MultiVectorEncoder("lightonai/LateOn")

要查找可用模型,可以在 Hub 上查看带有 multi-vectorsentence-transformers 标签的模型。带有这些标签的模型都可以使用上面的代码加载,无论它最初是 PyLate checkpoint、Stanford-NLP ColBERT checkpoint,还是用于视觉文档检索的 ColPali 系列模型。我们正在逐步完善整个生态,为所有兼容模型添加这些标签,因此列表还会不断扩充。

在底层,MultiVectorEncoder 可以读取这些 checkpoint 多年来采用的各种发布格式。因此,即使尚未添加标签,PyLate 和 Stanford-NLP checkpoint 也能直接加载:

from sentence_transformers import MultiVectorEncoder

# 原生 Sentence Transformers checkpoint。PyLate 基于相同的 schema 构建,
# 因此任何 PyLate checkpoint 都可以用同样的方式加载
model = MultiVectorEncoder("lightonai/LateOn")
model = MultiVectorEncoder("mixedbread-ai/mxbai-edge-colbert-v0-17m")
model = MultiVectorEncoder("LiquidAI/LFM2.5-ColBERT-350M", trust_remote_code=True)

# 任意 Stanford-NLP ColBERT checkpoint,程序会通过 `HF_ColBERT` 架构标记识别它们
# 内置的 projection weight 和训练配方来自 `artifact.metadata`
model = MultiVectorEncoder("colbert-ir/colbertv2.0")
model = MultiVectorEncoder("answerdotai/answerai-colbert-small-v1")

# 裸 Transformer:会追加一个全新的随机 projection,因此必须先进行训练
model = MultiVectorEncoder("answerdotai/ModernBERT-base")

视觉文档检索模型是个例外。ColPali 系列 checkpoint 使用 colpali-engine 自有的格式,其中不包含 Sentence Transformers 可利用的配置信息,因此每个 checkpoint 都需要先在其代码仓库中补充一小段配置才能加载。目前大部分工作已经完成,正在等待合并。有关当前支持情况以及现阶段的加载方式,请参阅 Supported Models

查看 Checkpoint 的配置

Multi-vector 模型包含一些因 checkpoint 而异的配置项,例如查询和文档的标记前缀、长度上限、是否使用 [MASK] token 将查询补齐,以及计算文档得分时跳过哪些 token。这些配置都保存在模块中,因此执行 print(model) 就能准确看到实际加载的内容。下面是原始的 ColBERTv2 checkpoint:它会将每个查询固定补齐到 32 个 token,并将文档截断到 180 个 token:

from sentence_transformers import MultiVectorEncoder

model = MultiVectorEncoder("colbert-ir/colbertv2.0")
print(model)
"""
MultiVectorEncoder(
  (0): Transformer({..., 'document_length': 180,
                    'query_expansion': {'strategy': 'fixed', 'attend': False, 'token': None, 'length': 32}})
  (1): Dense({'in_features': 768, 'out_features': 128, 'bias': False, ...})
  (2): MultiVectorMask({'skiplist_words': ['!', '"', '#', ...], 'skiplist_tasks': ['document'], ...})
  (3): Normalize({...})
)
"""
print(model.prompts)
# {'query': '[unused0] ', 'document': '[unused1] '}

这就是经典的 ColBERT 流程:Transformer 生成带上下文信息的 token embedding,token 级别的 Dense 将每个 embedding 投影到 128 维,MultiVectorMask 决定哪些 token 参与评分,最后由 token 级别的 Normalize 完成归一化。其他 checkpoint 的配置值可能不同。例如,lightonai/GTE-ModernColBERT-v1 同样使用这四个模块,但采用 [Q] [D] 作为提示词,不进行查询扩展,并将查询和文档长度分别限制为 48 和 300。

通常不需要手动调整这些配置,因为每个已发布的 checkpoint 都带有自己的配置。只有在基于未经配置的 backbone 构建模型时,才需要关注这些内容;具体方法请参阅 Creating Custom Models

不过,有一个参数值得结合你自己的数据重点检查:document_length 会截断文本,超出长度上限的内容不会进入索引。例如,一段 662 个 token 的文本经过 LateOn 的 300 上限处理后,最终只得到 273 个向量,剩余内容会直接被丢弃。这些 checkpoint 大多是在较短文本上训练的,因此如果你的文本块超过长度上限,可以在单次调用中提高上限:encode_document(..., processing_kwargs={"text": {"max_length": 512}})。但要注意,这意味着模型运行时的输入长度会超过训练长度,同时索引规模也大致会按比例增长。Multi-vector 模型通常能够较好地适应这种情况。在长文档检索基准 MLDR 上,上述两个模型的多语言版本清楚地体现了这一差距:mLateOn 得分为 77.92,而 mDenseOn 为 51.59

编码查询和文档

Multi-vector 模型具有非对称性,因此查询和文档需要使用不同的前缀、长度上限和评分掩码。许多 dense 模型中,查询和文档可以互换使用;但对于 Multi-vector 模型,必须分别调用 encode_query()encode_document(),才能得到正确的 embedding:

from sentence_transformers import MultiVectorEncoder

model = MultiVectorEncoder("lightonai/mLateOn")

queries = ["What is the capital of France?"]
documents = [
    "Paris is the capital of France.",
    "Berlin is the capital and largest city of Germany, by both area and population.",
]

query_embeddings = model.encode_query(queries)
document_embeddings = model.encode_document(documents)

print(query_embeddings[0].shape)
# (10, 128)
print(document_embeddings[0].shape, document_embeddings[1].shape)
# (10, 128) (19, 128)

返回结果是一个由二维张量组成的列表,每个输入对应一个张量,形状为 (num_tokens, embedding_dim)。与 dense embedding 不同,这些张量无法堆叠成一个规则的矩形张量,因为每个输入包含的 token 数量不同。第二个文档比第一个更长,因此返回的矩阵行数也更多。

每个方法调用都会自动应用模型自身的处理规则。encode_query 会在查询前添加查询标记;如果 checkpoint 有要求,还会将查询扩展到固定长度,并截断至查询长度。encode_document 会添加文档标记,截断至文档长度,并从评分掩码中移除排除列表里的 token(大多数 checkpoint 会排除标点符号)。

常用的 encode() 参数仍然适用,因此 batch_sizeshow_progress_barconvert_to_numpydevice 和多进程池都可以按预期使用:

document_embeddings = model.encode_document(
    documents,
    batch_size=64,
    show_progress_bar=True,
)

使用 MaxSim 进行评分

model.similarity() 会计算所有查询与文档组合的 MaxSim 矩阵:

from sentence_transformers import MultiVectorEncoder

model = MultiVectorEncoder("lightonai/LateOn")

query_embeddings = model.encode_query(["Which planet is known as the Red Planet?"])
document_embeddings = model.encode_document([
    "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.",
])

scores = model.similarity(query_embeddings, document_embeddings)
print(scores)
# tensor([[10.7942, 11.1104, 10.9743, 11.0811]])

结果符合预期:Mars 得分最高,不过后面的几个结果也紧随其后。Saturn 也包含字面短语 “the Red Planet”,而 Jupiter 是一颗带有红色斑点的行星,因此这三个文档中都有不少内容能被 token 级别的算子匹配到。关键在于最终的排序。

正如 GLInt 通过测量完整候选池中的分数分布所展示的那样,分数往往会集中在如此接近的范围内。MaxSim 会为每个查询 token 取一个最大值,因此文档通常都能为每个查询 token 提供某个还不错的最佳匹配,分数自然会从一个较高的下限开始。此外,上下文化的 token embedding 具有各向异性,会聚集在狭窄的锥形区域内,而不是均匀散开,所以即使是任意配对的 token,往往也能得到较高分数。

如果你已经有匹配好的查询—文档对,只想获取每一对的分数,而不是完整的相似度矩阵,还可以使用 model.similarity_pairwise()

scores = model.similarity_pairwise(query_embeddings, document_embeddings[:1])
print(scores)
# tensor([10.7942])

分数大小与 MeanMaxSim

MaxSim 会对所有查询 token 的分数求和,因此分数大小会随查询 token 数量变化。这意味着,使用不同查询处理方式的模型之间无法直接比较分数。上面的火星查询经过 LateOn 编码后包含 12 个 token。将同一查询和同一批文档输入 ColBERTv2;由于 ColBERTv2 会将每个查询补齐或截断到固定的 32 个 token,最终得到的分数范围就会完全不同:

model = MultiVectorEncoder("colbert-ir/colbertv2.0")
# ... same encode_query / encode_document / similarity calls ...
print(scores)
# tensor([[12.7970, 27.1945, 23.8495, 24.5656]])

在同一个模型内,只要看排序即可;但如果希望分数落在有界范围内,可以将模型的相似度函数切换为 MeanMaxSim,它会除以查询 token 数量。回到 LateOn:

model = MultiVectorEncoder("lightonai/LateOn", similarity_fn_name="meanmaxsim")
# or on an already-loaded model: model.similarity_fn_name = "meanmaxsim"

print(model.similarity(query_embeddings, document_embeddings))
# tensor([[0.8995, 0.9259, 0.9145, 0.9234]])

这样一来,每个分数都是平均余弦相似度,理论范围为 [-1, 1],但在实际使用中通常只会落在 [0, 1] 内。

语义搜索

如果语料库规模较小,直接对全部语料执行穷举式 MaxSim 是最简单有效的方案。先将语料库编码一次,然后对每个查询与全部文档进行打分:

import time

from datasets import load_dataset

from sentence_transformers import MultiVectorEncoder

dataset = load_dataset("sentence-transformers/natural-questions", split="train[:5000]")
# Several questions share an answer passage, so drop repeats but keep the order
corpus = list(dict.fromkeys(dataset["answer"]))  # 5,000 rows -> 4,874 passages

model = MultiVectorEncoder("lightonai/LateOn")
corpus_embeddings = model.encode_document(corpus, show_progress_bar=True)

query = "when did richmond last play in a preliminary final"
start = time.perf_counter()
query_embeddings = model.encode_query([query])
scores = model.similarity(query_embeddings, corpus_embeddings)[0]  # 98ms
top_scores, top_indices = scores.topk(3)
print(f"Search took {(time.perf_counter() - start) * 1000:.1f}ms")

for score, index in zip(top_scores.tolist(), top_indices.tolist()):
    print(f"{score:.4f}  {corpus[index][:100]}")
"""
Search took 122.7ms
11.9192  Richmond Football Club Richmond began 2017 with 5 straight wins, a feat it had not achieved
11.7591  2017 AFL Grand Final The 2017 AFL Grand Final was an Australian rules football game contest
11.6710  Battle of Appomattox Court House The Battle of Appomattox Court House (Virginia, U.S.), fou
"""

这些 4,874 个段落在 RTX 3090 上只需 20 秒即可完成编码;每次搜索端到端耗时约 120 毫秒,其中大部分时间都花在针对全部 608,414 个 token 向量进行 MaxSim 评分上。这种方式虽然是精确搜索,但耗时会随语料库中的 token 总数线性增长,而且需要将所有 token 向量保存在内存中。因此,它更适合几千篇文档的场景,而不是数百万篇文档。可运行的脚本版本见 semantic_search.py

超过这个规模后,就需要真正的 late-interaction 索引,而 Sentence Transformers 并未内置这类索引。不过这也不是问题:这些索引可以直接存储 encode_document 生成的结果。你只需在这里完成编码,再把 token embeddings 交给专门处理它们的工具即可。Indexing 一节提供了四种方案的可运行代码片段,紧接着的下一节则介绍如何完全跳过索引。

召回与重排

即使不维护 late-interaction 索引,也可以通过将 multi-vector 模型用作 reranker,获得相近的 late-interaction 效果。先用速度更快的 bi-encoder 从大规模语料库中筛出少量候选,再由 multi-vector 模型对这些候选重新打分:

from datasets import load_dataset

from sentence_transformers import MultiVectorEncoder, SentenceTransformer
from sentence_transformers.util import semantic_search

dataset = load_dataset("sentence-transformers/natural-questions", split="train[:50000]")
corpus = list(dict.fromkeys(dataset["answer"]))

retriever = SentenceTransformer("jinaai/jina-embeddings-v5-text-nano-retrieval")
reranker = MultiVectorEncoder("perplexity-ai/pplx-embed-v1-late-0.6b", trust_remote_code=True)

# 第一阶段:使用快速 bi-encoder 一次性为语料库建立索引
corpus_embeddings = retriever.encode_document(corpus, convert_to_tensor=True, show_progress_bar=True)

# 检索排名前 50 的结果
query = "when did richmond last play in a preliminary final"
hits = semantic_search(retriever.encode_query([query], convert_to_tensor=True), corpus_embeddings, top_k=50)[0]
candidates = [corpus[hit["corpus_id"]] for hit in hits]

# 第二阶段:使用 MaxSim,仅对这些候选重新打分
query_embeddings = reranker.encode_query([query])
document_embeddings = reranker.encode_document(candidates)
scores = reranker.similarity(query_embeddings, document_embeddings)[0]

for index in scores.argsort(descending=True)[:3].tolist():
    print(f"{scores[index].item():.4f}  {candidates[index][:100]}")

整个过程中,只有这 50 个候选会被编码成 multi-vector,因此索引仍是普通的 dense 索引,token 向量也只是临时生成。这与 retrieve-and-rerank 流程中的 cross-encoder 作用相同,但 multi-vector 模型对每个候选的处理成本低得多。你可以一次批量编码文档,再通过矩阵乘法为它们打分,而不必针对每个查询-文档对分别执行一次前向传播。可运行的脚本是 retrieve_rerank.py,它会输出两个阶段各自的耗时。

索引

有几种向量数据库原生支持多向量的索引和打分:Qdrant 从 v1.10 开始支持,Weaviate 从 v1.29 开始支持,Vespa 多年来一直支持,LanceDB 从 v0.15.0 开始支持;VectorChord 则为 Postgres 添加了原生 pgvector 不具备的 MaxSim 操作符。Milvus 在 v2.6.4 中也加入了这一行列,不过对应功能归在 array-of-structs 下,而不是它另称为 multi-vector search 的那个无关功能。如果你完全不想运行服务器,安装 LightOn 的 fast-plaid 即可直接使用 PLAID;PyLate 则在此基础上封装了更完整的检索栈。

还有一些方案只能满足部分需求。OpenSearchElasticsearch 可以用 MaxSim 对候选结果重新评分,但不能直接据此检索;而且 Elasticsearch 的对应字段目前仍处于技术预览阶段,并且只面向 Enterprise 版本。turbopuffer 的 late-interaction 索引则还处于私有 Beta 阶段。

下面的代码片段虽然索引的是文本,但本身并不依赖文本。无论文档是文本片段、页面图像、音频片段还是视频,encode_document 返回的都是同样的 token-vector 矩阵列表,因此视觉文档检索中的 ColPali 风格模型无需修改,就能用于这些方案。只是每个文档包含的向量更多,所以在这些场景下更值得尽早采用Token Pooling

fast-plaid、Qdrant、Weaviate 和 Vespa 接收的都是 encode_document 的直接输出,因此除了客户端库不同,前面的代码完全一致。下面分别给出四者的可运行示例,使用的是语义搜索示例中的 4,874 个段落和 608,414 个 token 向量。每个示例还记录了在同一台机器(RTX 3090、i7-13700K)上的写入和查询耗时,除了代码中展示的设置外没有做任何调优,方便直观看到各方案的工作量级。四者的查询速度都快于该节中 model.similarity 的 98 毫秒,而且其中三个可以在 CPU 上完成,因为这里唯一使用 GPU 的是 fast-plaid。

四者返回的前三个段落及其顺序,都与本文前面使用 PyTorch MaxSim 穷举得到的结果完全一致;三个数据库的得分精确到小数点后四位也都与之相同!这是因为它们会为每个文档计算得分。在当前规模下,这种做法完全可行,同时也排除了近似算法带来的变量。fast-plaid 的设计本身就是近似的,因此得分会略有不同。每个示例下方的说明都会介绍切换到近似索引后有哪些变化——排名偏移正是从这里开始出现的。

fast-plaid

fast-plaid 是 LightOn 用 Rust 实现的 PLAID,也是 ColBERT 最初围绕构建的索引。它无需启动服务器,并且可以直接读取 encode_document 返回的张量,不需要进行任何转换。

# pip install sentence-transformers datasets fast-plaid
from datasets import load_dataset
from fast_plaid import search
from sentence_transformers import MultiVectorEncoder

dataset = load_dataset("sentence-transformers/natural-questions", split="train[:5000]")
corpus = list(dict.fromkeys(dataset["answer"]))
model = MultiVectorEncoder("lightonai/LateOn")
query = "when did richmond last play in a preliminary final"

document_embeddings = model.encode_document(corpus, batch_size=32)
query_embedding = model.encode_query(query)

fast_plaid = search.FastPlaid(index="natural-questions", device="cuda")

# 4,874 documents (608,414 token vectors) indexed in 5s
fast_plaid.create(documents_embeddings=document_embeddings)

results = fast_plaid.search(queries_embeddings=query_embedding.unsqueeze(0), top_k=3)  # 11ms

for index, score in results[0]:
    print(f"{score:.4f}  {corpus[index][:90]}")
"""
11.8828  Richmond Football Club Richmond began 2017 with 5 straight wins, a feat it had not achieve
11.7676  2017 AFL Grand Final The 2017 AFL Grand Final was an Australian rules football game contes
11.6758  Battle of Appomattox Court House The Battle of Appomattox Court House (Virginia, U.S.), fo
"""

index 参数指定的是目录,而不只是一个标签,因此索引会在构建过程中直接写入磁盘。之后,只要让新的 FastPlaid 指向同一路径,就能重新打开索引进行搜索或添加文档,无需每次都从 embedding 重新构建。在这个语料库上,索引占用 92 MB,而原始 float32 向量占用 311.5 MB。

四种方案中,只有这一种采用近似检索;本节中也只有它的分数与穷举式 MaxSim 不完全一致。PLAID 会利用质心进行剪枝,并存储量化后的残差,因此三个分数相较于前面计算出的 11.9192 / 11.7591 / 11.6710,分别出现了几百分之一的上下浮动。这里的排序没有变化,这正是 PLAID 的取舍。它面向的是规模大得多的语料库——在那种场景下,逐一扫描所有文档并不可行。

Qdrant

Qdrant 需要启动服务器:docker run -p 6333:6333 qdrant/qdrant。客户端也提供无需服务器的本地模式(QdrantClient(":memory:")),但它是纯 Python 的重新实现,因此适合用来尝试功能,不适合用于性能计时。

# pip install sentence-transformers datasets qdrant-client
from datasets import load_dataset
from qdrant_client import QdrantClient, models
from sentence_transformers import MultiVectorEncoder

dataset = load_dataset("sentence-transformers/natural-questions", split="train[:5000]")
corpus = list(dict.fromkeys(dataset["answer"]))
model = MultiVectorEncoder("lightonai/LateOn")
query = "when did richmond last play in a preliminary final"

document_embeddings = model.encode_document(corpus, batch_size=32)
query_embedding = model.encode_query(query)

client = QdrantClient("http://localhost:6333")
client.create_collection(
    collection_name="natural-questions",
    vectors_config=models.VectorParams(
        size=model.get_embedding_dimension(),
        distance=models.Distance.COSINE,
        multivector_config=models.MultiVectorConfig(
            comparator=models.MultiVectorComparator.MAX_SIM
        ),
        # MaxSim 不会遍历 HNSW 图,因此无需构建 HNSW 图
        hnsw_config=models.HnswConfigDiff(m=0),
    ),
)

# 26.3 秒写入 4,874 篇文档(共 608,414 个 token 向量)
client.upload_points(
    collection_name="natural-questions",
    points=[
        models.PointStruct(id=idx, vector=embedding, payload={"text": text})
        for idx, (embedding, text) in enumerate(zip(document_embeddings, corpus))
    ],
    batch_size=64,
)

results = client.query_points(
    collection_name="natural-questions",
    query=query_embedding,
    limit=3,
    with_payload=True,
).points  # 18 毫秒

for result in results:
    print(f"{result.score:.4f}  {result.payload['text'][:90]}")
"""
11.9192  Richmond Football Club Richmond began 2017 with 5 straight wins, a feat it had not achieve
11.7591  2017 AFL Grand Final The 2017 AFL Grand Final was an Australian rules football game contes
11.6710  Battle of Appomattox Court House The Battle of Appomattox Court House (Virginia, U.S.), fo
"""

MAX_SIM 是 Qdrant 提供的唯一比较器。对于 late interaction 字段,Qdrant 推荐使用 hnsw_config=HnswConfigDiff(m=0),因为这些向量用于重新排序,而不是遍历图。需要注意的是,Qdrant 官方建议仅在几百个候选结果上执行 late interaction 重排,而不是扫描整个集合,这正是检索并重排(Retrieve and Rerank)模式。在 4,874 篇文档上,全量扫描耗时 18 毫秒,并且结果是精确的;但不能据此推断数据规模扩大后仍能保持这样的性能。

Weaviate

Weaviate 同样需要启动服务器:docker run -p 8080:8080 -p 50051:50051 cr.weaviate.io/semitechnologies/weaviate:1.34.0。Multi-vector 支持需要 1.29 或更高版本,且 Windows 不支持嵌入式模式。

# pip install sentence-transformers datasets weaviate-client
import weaviate
from datasets import load_dataset
from sentence_transformers import MultiVectorEncoder
from weaviate.classes.config import Configure, DataType, Property
from weaviate.classes.query import MetadataQuery

dataset = load_dataset("sentence-transformers/natural-questions", split="train[:5000]")
corpus = list(dict.fromkeys(dataset["answer"]))
model = MultiVectorEncoder("lightonai/LateOn")
query = "when did richmond last play in a preliminary final"

document_embeddings = model.encode_document(corpus, batch_size=32)
query_embedding = model.encode_query(query)

client = weaviate.connect_to_local()
collection = client.collections.create(
    "Documents",
    # self_provided 启用 MaxSim 迟交互
    vector_config=[Configure.MultiVectors.self_provided(name="colbert")],
    properties=[Property(name="text", data_type=DataType.TEXT)],
)

# 用时 41 秒导入 4,874 个文档(608,414 个 token 向量)
with collection.batch.fixed_size(batch_size=64) as batch:
    for text, embedding in zip(corpus, document_embeddings):
        batch.add_object(properties={"text": text}, vector={"colbert": embedding.tolist()})

results = collection.query.near_vector(
    near_vector=query_embedding.tolist(),
    target_vector="colbert",
    limit=3,
    return_metadata=MetadataQuery(distance=True),
)  # 17 毫秒

for result in results.objects:
    # Weaviate 将 MaxSim 分数以取负后的距离形式返回
    print(f"{-result.metadata.distance:.4f}  {result.properties['text'][:90]}")
"""
11.9192  Richmond Football Club Richmond began 2017 with 5 straight wins, a feat it had not achieve
11.7591  2017 AFL Grand Final The 2017 AFL Grand Final was an Australian rules football game contes
11.6710  Battle of Appomattox Court House The Battle of Appomattox Court House (Virginia, U.S.), fo
"""

client.close()

这里使用默认设置就够了。对于 top-3 查询,Weaviate 的动态 ef 会设为 100,而当它大约达到 32 以上时,排序结果就已经是精确的了。不过,这个余量取决于 embedding,而不是 Weaviate 本身。因此,最好在自己的模型上进行验证,不要想当然地认为默认值同样适用。

Weaviate 还支持 MUVERA 编码。在我们的测试中,它让数据写入速度提升了 3 倍,查询速度提升了 1.8 倍。不过,在这个规模下,准确率的损失远不值得用这样的速度提升来换取:即使查看前 50 个结果,也找不到正确的第三段文本。

Vespa

Vespa 同样运行在容器中,但 pyvespa 会自动为你启动容器,因此不需要单独执行 docker run

# pip install sentence-transformers datasets pyvespa
from datasets import load_dataset
from sentence_transformers import MultiVectorEncoder
from vespa.deployment import VespaDocker
from vespa.package import (
    ApplicationPackage, Document, Field, FirstPhaseRanking, Function, RankProfile, Schema,
)

dataset = load_dataset("sentence-transformers/natural-questions", split="train[:5000]")
corpus = list(dict.fromkeys(dataset["answer"]))
model = MultiVectorEncoder("lightonai/LateOn")
query = "when did richmond last play in a preliminary final"

document_embeddings = model.encode_document(corpus, batch_size=32)
query_embedding = model.encode_query(query)

# “dt” 是表示可变 token 数量的 mapped 维度,“x” 是 128 维 dense 向量
package = ApplicationPackage(
    name="colbert",
    schema=[
        Schema(
            name="doc",
            document=Document(fields=[
                Field(name="text", type="string", indexing=["summary"]),
                Field(name="colbert", type="tensor<float>(dt{}, x[128])", indexing=["attribute"]),
            ]),
            rank_profiles=[
                RankProfile(
                    name="colbert",
                    inputs=[("query(qt)", "tensor<float>(qt{}, x[128])")],
                    functions=[Function(
                        name="max_sim",  # 对每个 query token,取文档 token 中得分最高的一个,再求和
                        expression="sum(reduce(sum(query(qt) * attribute(colbert), x), max, dt), qt)",
                    )],
                    first_phase=FirstPhaseRanking(expression="max_sim"),
                )
            ],
        )
    ],
)
app = VespaDocker(port=8080).deploy(application_package=package)  # 启动约需 40 秒

# 对文档和 query,Vespa 都会将 mixed tensor 读取为 {token index: vector}
def to_tensor(embedding):
    return {str(token): vector for token, vector in enumerate(embedding.tolist())}

# 约 80 秒完成 4,874 个文档(608,414 个 token 向量)的写入
app.feed_iterable(
    ({"id": str(idx), "fields": {"text": text, "colbert": to_tensor(embedding)}}
     for idx, (text, embedding) in enumerate(zip(corpus, document_embeddings))),
    schema="doc",
)

response = app.query(body={
    "yql": "select text from doc where true",
    "ranking.profile": "colbert",
    "hits": 3,
    "input.query(qt)": to_tensor(query_embedding),
})  # 热启动约 75 毫秒,首次调用约 115 毫秒

for hit in response.hits:
    print(f"{hit['relevance']:.4f}  {hit['fields']['text'][:90]}")
"""
11.9192  Richmond Football Club Richmond began 2017 with 5 straight wins, a feat it had not achieve
11.7591  2017 AFL Grand Final The 2017 AFL Grand Final was an Australian rules football game contes
11.6710  Battle of Appomattox Court House The Battle of Appomattox Court House (Virginia, U.S.), fo
"""

四种方案中,Vespa 需要最完整的前置配置,因为你声明的是一条排序流水线,而不只是创建一个索引。作为交换,你可以把 MaxSim 写成张量表达式,清楚地看到它具体计算了什么。这个版本将 MaxSim 放在 where true 下的 first-phase 中,对全部 4,874 个文档进行打分,因此输出结果与穷举式 MaxSim 完全一致。但这显然不是 Vespa 在大规模场景下的推荐做法。他们的 ColBERT 示例应用 会存储经过 int8 二值化的向量,并将 MaxSim 放到 second-phase,用于对第一阶段筛出的较低成本候选结果进行重排。

切换到这种分阶段配置时需要格外注意:second-phase 默认只会对排名最高的 100 个候选重新打分,而在这里,这个窗口导致三个正确段落中有两个完全没有参与打分。将 rerank-count 调高到覆盖整个候选集即可解决这一问题。不过在当前规模下,分阶段版本的速度仍然不如直接扫描全部文档。

视觉文档检索

对于视觉文档检索,Late Interaction 代表了当前的先进水平:它可以直接将文本查询与页面图像进行匹配,同时保留图表、表格和版式信息,无需经过 OCR。这正是 ColPali 系列模型的工作方式。这些模型 checkpoint 可以通过同一个 API 加载和运行,其中 revision 用于固定到那个为该模型添加 Sentence Transformers 配置的开放 pull request(完整列表请参见支持的模型)。图像文档可以通过 URL、本地路径或 PIL 图像传入:

from sentence_transformers import MultiVectorEncoder

model = MultiVectorEncoder("vidore/colqwen2.5-v0.2")

queries = [
    "What is the variable represented on the y-axis of the graph?",
    "Total outlay is maximum in which year?",
]
images = [
    "https://huggingface.co/datasets/sentence-transformers/example-documents/resolve/main/doc1.jpg",
    "https://huggingface.co/datasets/sentence-transformers/example-documents/resolve/main/doc2.jpg",
    "https://huggingface.co/datasets/sentence-transformers/example-documents/resolve/main/doc3.jpg",
    "https://huggingface.co/datasets/sentence-transformers/example-documents/resolve/main/doc4.jpg",
]

query_embeddings = model.encode_query(queries)
document_embeddings = model.encode_document(images)
print(query_embeddings[0].shape, document_embeddings[0].shape)
# (25, 128) (755, 128)

scores = model.similarity(query_embeddings, document_embeddings)
print(scores)
# tensor([[13.8672, 12.3115, 12.1670, 11.0293],
#         [ 7.2012, 14.7207,  6.9414,  6.9746]])

每个查询都检索到了对应的页面(即对角线位置)。第二个查询的区分度明显高于第一个,因为四个页面中只有一个讨论了随时间变化的支出。

代码本身没有变化。底层的 processor 会处理视觉提示和图像 patch,而 MaxSim 则会计算查询文本 token 与文档图像 patch 之间的匹配分数。一个页面通常包含许多彼此独立的区域,这正是 late interaction 很适合这一场景的原因:如果只使用单个向量,就必须把图表、表格和三段文字压缩成一个摘要。不过,这种细粒度表示也会占用更多索引空间。上面的形状表示:一个页面对应 755 个 token 向量,而查询只有 25 个;相比之下,前文的 Natural Questions passage 平均约有 125 个 token。因此,在这里应当比处理纯文本时更早考虑 token pooling

这些模型都是 VLM,因此需要提前规划好内存配置。Supported Models 中的表格涵盖了从 252M 到 8.8B 参数的模型;其中较小的模型在 CPU 上仍然实用,而参数量达到数十亿的模型则不然。

页面图像是最常见的情况,但并非唯一的非文本模态。Sentence Transformers 支持文本、图像、音频和视频,具体取决于模型所用 processor 支持哪些模态,可通过 model.modalities 查看。单个文档也可以组合多种模态:将类似 {"text": ..., "image": ...} 的字典作为输入,而不是传入单一值。Multimodal Embedding & Reranker Models 更全面地介绍了 Sentence Transformers 中的多模态模型;Usage 文档 则详细列出了每种模态支持的输入格式。

音频检索

vidore/colqwen-omni-v0.1 基于 Qwen2.5-Omni 构建,支持全部四种模态。使用它检索录音对话,与检索页面一样,只需调用两个方法:

# pip install -U "sentence-transformers[audio,video]"
import torch
from datasets import Audio, load_dataset

from sentence_transformers import MultiVectorEncoder

model = MultiVectorEncoder(
    "vidore/colqwen-omni-v0.1",
    model_kwargs={"dtype": torch.bfloat16},
)
print(model.modalities)
# ['text', 'image', 'audio', 'video', 'message']

# 20 recorded conversations, averaging 28 seconds each
dataset = load_dataset("eustlb/dailytalk-conversations-grouped", split="train[:20]")
dataset = dataset.cast_column("audio", Audio(sampling_rate=16_000))
audio = [row["array"] for row in dataset["audio"]]  # raw mono waveforms, float32 at 16 kHz

query_embeddings = model.encode_query(["medicine for car nausea"])
document_embeddings = model.encode_document(audio, batch_size=2)
scores = model.similarity(query_embeddings, document_embeddings)[0]

top_scores, top_indices = scores.topk(3)
for score, index in zip(top_scores.tolist(), top_indices.tolist()):
    print(f"{score:.4f}  {' / '.join(dataset[index]['texts'][:2])}")
"""
50.8902  Excuse me? Do you have anything for a carsickness? / Yes, but you look fine.
46.1028  Excuse me, could you tell me where you have got that music book? / Certainly. Let me see. Oh, it's on that shelf.
46.0514  Jeff, I'm going to the supermarket. Do you want to come with me? I think the supermarket is closed now.
"""

ColQwen-Omni 只使用图文对进行训练,因此音频检索完全是 zero-shot:训练过程中从未接触过音频样本,整个流程中也没有任何转录步骤。查询使用的是 nausea,录音中说的却是 carsickness,但它依然能从 20 段录音中,以明显优势找出那段药房对话。

视频检索

视频检索的原理相同,但必须对帧进行采样,否则很快就会耗尽显存。其发布博文对此直言不讳:视频“非常耗费内存,因此更适合短视频片段”:

import torch

from sentence_transformers import MultiVectorEncoder

model = MultiVectorEncoder(
    "vidore/colqwen-omni-v0.1",
    model_kwargs={"dtype": torch.bfloat16},
)

# 稀疏的低分辨率帧:使用 0.5 fps,而不是完整帧率
model[0].processing_kwargs.update(
    {"video": {"max_pixels": 32 * 28 * 28, "do_sample_frames": True, "fps": 0.5}}
)

query_embeddings = model.encode_query(["How to cook Mapo Tofu?"])
document_embeddings = model.encode_document([
    "https://huggingface.co/datasets/sentence-transformers/example-documents/resolve/main/mapo_tofu.mp4",
    "https://huggingface.co/datasets/sentence-transformers/example-documents/resolve/main/zhajiang_noodle.mp4",
], batch_size=1)
print(model.similarity(query_embeddings, document_embeddings))
# tensor([[53.3100, 51.0561]])

在 1 fps 和完整分辨率下,同样两段视频会分别生成 8,426 和 5,137 个 token 向量,显存峰值达到 20.8 GB;而采用这里的设置时,只生成 4,240 和 2,446 个向量,显存占用为 12.5 GB。模型本身还要占用 9.0 GB 显存。无论采用哪种设置,排序结果都完全一致。长音频也应采用类似的处理方式;发布博文建议将其切分为 30 秒的片段,每段大约对应 800 个 token。

可解释性

由于 MaxSim 是对每个查询 token 的最大匹配分数求和,因此排序结果可以精确拆解:文档最终得分中的每一部分,都对应一个查询 token 和一个文档 token。这样一来,你就能准确回答“它为什么排在这里”,而不必只凭直觉判断。

对于图像文档,sentence_transformers.multi_vector_encoder.interpretability 会将这种分解结果叠加到页面上,生成标准的 ColPali 热力图:既可以对整个查询进行聚合,也可以为每个查询 token 单独生成一张热力图。针对上文的支出页面提问“水资源和电力方面花了多少钱?”,water 这个 token 关注的是这里:

MaxSim heatmap of the query token "water" overlaid on a 1971 US budget outlays page, with the brightest patch on the "Water Resources & Power" bar of the lower chart

heatmap.py 是可直接运行的版本,其中还包含将文档 embedding 与图像 patch 网格对齐所需的 masking 步骤。

文本文档没有可供叠加的 patch 网格,但同样的分解方式依然适用。text_similarity_map.py 会先对语料库进行排序,再逐个 token 分析排名第一的结果得分。下面展示的是前文提到的 Natural Questions 语料库,以及拥有 3200 万参数的 mxbai-edge-colbert-v0-32m

查询:里士满上一次参加预选决赛是什么时候
通过穷举 MaxSim 检索出的 4874 篇文档中排名前 3 的结果(191.0ms):
  12.3489  Richmond Football Club Richmond 在 2017 年开局连胜 5 场,这是自 19
  12.1771  2017 AFL Grand Final 2017 年 AFL 总决赛是一场澳式足球比赛,由两支球队展开角逐
  12.0591  2018 UEFA Champions League Final 2018 年 UEFA Champions League 决赛是该届赛事的最终比赛

  查询 token       最佳文档 token      相似度   占比
  when              since                 0.9154    7.4%
  did               had                   0.9675    7.8%
  rich              rich                  0.9764    7.9%
  mond              mond                  0.9856    8.0%
  last              to                    0.9249    7.5%
  play              game                  0.9384    7.6%
  in                the                   0.9732    7.9%
  a                 a                     0.9587    7.8%
  preliminary       preliminary           0.9394    7.6%
  final             final                 0.9654    7.8%
  --------------------------------------------------------
  3 个特殊 token                         2.8038   22.7%
  MaxSim 得分                           12.3489  100.0%

richmondpreliminaryfinal 都匹配到了文档中的同名 token;when 匹配到的是 sinceplay 匹配到的是 game。3 个特殊 token 虽然不包含查询内容,却贡献了 22.7% 的得分。表格下方还会显示原始段落,并在原文中高亮这些匹配到的 token。

Token Pooling

如果索引占用的空间让你感到担忧,最有效的办法就是减少存储的 token 向量数量。HierarchicalTokenPooling 实现了 Clavié、Chaffin 和 Adams 提出的 token pooling 技术:先根据余弦距离,使用 Ward linkage 对每篇文档的 token 向量进行聚类,再用每个簇的均值向量替代整个簇,最终保留约为原数量 1 / pool_factor 的 token。由于同一篇文档中有大量 token 向量彼此接近,被丢弃的部分大多是冗余信息,而不是有效信号:

from datasets import load_dataset

from sentence_transformers import MultiVectorEncoder
from sentence_transformers.multi_vector_encoder.modules import HierarchicalTokenPooling

dataset = load_dataset("sentence-transformers/natural-questions", split="train[:5000]")
documents = list(dict.fromkeys(dataset["answer"]))

model = MultiVectorEncoder("lightonai/LateOn")

pooling = HierarchicalTokenPooling(pool_factor=2)
document_embeddings = model.encode_document(documents, token_pooling=pooling)

根据希望在哪个阶段承担这部分开销,可以在以下三处应用:

# 1. 在每次编码时应用,如上所示
document_embeddings = model.encode_document(documents, token_pooling=pooling)

# 2. 独立应用于已经保存的 embedding(例如由 [num_tokens, num_dims] tensor 组成的列表)
pooled = pooling.pool(document_embeddings)

# 3. 集成到模型中,让所有使用该 checkpoint 的调用方都获得经过 pooling 的文档 embedding
model.append(HierarchicalTokenPooling(pool_factor=2))
model.save_pretrained("my-pooled-colbert")

默认情况下,pooling 只应用于文档。这是因为查询本身较短,而且查询端更不能承受信息失真。在前文使用的 Natural Questions 语料库上,压缩规模基本与 pool_factor 一致;对全部 608k 个 token 向量进行 pooling 只用了约 6 秒:

pool_factor Token 向量数 压缩倍数 float32 索引大小
1(关闭) 608,414 1.00x 311.5 MB
2 305,438 1.99x 156.4 MB
3 204,407 2.98x 104.7 MB
4 153,936 3.95x 78.8 MB

对于查询中的某个 token,聚类中心的匹配效果不如簇内最佳成员;而且簇越粗,这种差距越明显。原始实验在 BEIR 上评估了这一代价,发现影响很小:pool_factor=2 时,平均检索性能为不合并 token 时的 100.6%;pool_factor=3 时为 99.0%。索引大小减半且几乎不损失性能,显然很划算,因此可以先从 2 开始。不过,具体代价取决于你的语料库,确定压缩系数前,最好先用评估器实测。可运行的对比脚本见 token_pooling.py

pool_factor 能提高到多大,也部分取决于模型本身。LightOn 的层次化池化正则化就是为此设计的:通过调整 embedding 空间,降低池化带来的损失,并报告称在 5 倍压缩下仍能保留 99.4% 的性能。Sentence Transformers 目前还不支持使用该正则化进行训练,但生成的 checkpoint 仍是普通的 PyLate 模型,因此lightonai/LateOn-hpool-regularized可以像其他模型一样加载和池化。

加速推理

Multi-vector 模型使用的后端机制与 Sentence Transformers 中的其他模型相同,因此支持 torch(默认)、onnxopenvino,以及半精度、Flash Attention 和 torch.compile

在 GPU 上,我们测得的最佳配置是使用 Flash Attention 的 fp16:吞吐量达到无 Flash Attention 的 fp32 的 2.44 倍,同时检索质量没有可测量的损失。Flash Attention 对 Multi-vector 模型尤其有效,因为文档只会被截断,不会为了统一长度而填充,所以一个 batch 中的序列长度差异很大,正好可以利用 unpadding:

from sentence_transformers import MultiVectorEncoder

model = MultiVectorEncoder(
    "lightonai/GTE-ModernColBERT-v1",
    model_kwargs={"attn_implementation": "flash_attention_2", "dtype": "float16"},
)
Multi-vector backend benchmarks on GPU
GPU
Multi-vector backend benchmarks on CPU
CPU

不参与注意力计算的查询扩展模型(attend=False,包括 Stanford-NLP 的检查点,如 colbert-ir/colbertv2.0answerdotai/answerai-colbert-small-v1)在加载时不支持 Flash Attention。Flash Attention 会移除 attention_mask=0 的位置,因此 MaxSim 打分所使用的 [MASK] 扩展 token 永远不会获得注意力更新。对于这类模型,请使用 "sdpa"

原始来源: HuggingFace博客

评论 (0)