热点来源:Hugging Face Blog -《Multi-Vector (Late Interaction) Embedding Models》(https://huggingface.co/blog/multi-vector-encoder)
为什么这篇值得读
做 RAG 或 Agent 的知识库检索时,大部分人用的还是最经典的"单向量"方案:把整段文档压缩成一个向量,Query 也压缩成一个向量,最后做一次点积排序。这个方案的缺点是明摆着的——几百个 token 的段落要塞进一个固定向量,细节必然被平均掉。一个"绿色沙发、木腿、圆垫"的多条件查询,单向量很难让每个条件都找到自己的证据。
这次 Hugging Face 在 Sentence Transformers v6.0 里新增了第四种模型类型 MultiVectorEncoder,把 ColBERT 风格的晚期交互检索直接做进了库里,之前要单独用 PyLate 或 colpali-engine 才能做的事,现在一个 API 搞定。这篇文章把它的原理、最小代码、性能代价讲透,文末给出和 RAG/Agent 结合的推荐姿势。
一、三种检索模型,交互发生在哪一步
先看一个表格把位置摆清楚:
| 稠密 bi-encoder | 整段文本压缩成一个向量 | 打分时一次点积,Query 和文档几乎不交互 |
| 交叉编码器 cross-encoder | Query 和文档一起送进模型 | 最早期交互,精确但无法预计算索引 |
| 多向量 late interaction | 每个 token 一个向量,文档变成矩阵 | 打分阶段用 MaxSim 逐 token 对齐 |
单向量把信息压扁,交叉编码器精确但每条 Query 都要重新跑一遍模型、没法离线建索引。多向量走的是中间路线:文档离线编码成每个 token 一个向量(经典维度 128),Query 也编码成 token 向量序列,打分时再做精细的逐 token 对齐。检索质量向交叉编码器靠拢,索引又能提前算好。
二、MaxSim:软对齐是怎么发生的
多向量的打分公式是 ColBERT 的 MaxSim:
MaxSim(Q, D) = 对每个查询 token Qi,取它与所有文档 token 的最大点积,再求和
token 向量经过 L2 归一化后,每个点积就是一个 [-1, 1] 的余弦相似度。可以理解成"软对齐":每个查询 token 去找最能解释自己的那个文档 token。对齐不要求词面一致,因为 token 嵌入是上下文相关的。官方博客给了一个例子:查询 “Where do penguins live?” 里的 live,和文档 “Penguins inhabit Antarctica.” 里的 inhabit,相似度高达 0.94,两个词没有任何共享字符——这是 BM25 这类词法检索做不到的。
三、最小代码上手
v6.0 的核心 API 就三个方法,先看加载和编码:
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.",
]
query_embeddings = model.encode_query(queries)
document_embeddings = model.encode_document(documents)
print(query_embeddings[0].shape) # (10, 128)
print(document_embeddings[0].shape) # (10, 128)
print(document_embeddings[1].shape) # (19, 128)
这里最关键的一点:encode_query 和 encode_document 返回的是 2D 张量的列表,不是能直接堆叠的大张量,因为每个输入产出的 token 数不一样。第一个维度是 token 数,第二个维度是嵌入维度(这个例子是 128)。encode_query 内部会自动加查询标记、按检查点要求补到固定长度;encode_document 会自动加文档标记、按文档长度截断,还会从打分掩码里丢掉标点这类 skiplist token。
再看打分:
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.",
"Mars, known for its reddish appearance, is the Red Planet.",
])
scores = model.similarity(query_embeddings, document_embeddings)
print(scores) # tensor([[10.79, 11.11]])
similarity 算的是完整两两 MaxSim 矩阵,Mars 那条正确胜出。返回值是 1 行 2 列的矩阵:行对应 Query,列对应文档。注意分数量级是 10 左右,不是 0 到 1,原因见下一节。
四、分数为什么不能跨模型比较
MaxSim 是对查询 token 求和,所以分数量级跟着查询 token 数走,不同模型之间完全不可比。同一个问题,LateOn 编码成 12 个 token 分数落在 10 上下;ColBERTv2 会把每个查询补到恰好 32 个 token,分数直接到 20、30。想要有界的分数,换 MeanMaxSim——除以查询 token 数:
model = MultiVectorEncoder("lightonai/LateOn", similarity_fn_name="meanmaxsim")
# 等价写法:加载后 model.similarity_fn_name = "meanmaxsim"
print(model.similarity(query_embeddings, document_embeddings))
# tensor([[0.90, 0.93]])
这是写评测代码时最容易踩的坑:如果用 MaxSim 分数做阈值过滤,换个模型阈值就失效了;跨模型对比推荐统一用 meanmaxsim。
五、代价与收益:索引体积 42 倍,换来检索质量提升
每 token 一个向量,索引体积自然膨胀。官方用 4,874 条 Natural Questions 段落做了一次实测对比:
| 稠密 all-MiniLM-L6-v2 | 4,874 | 384 | 7.5 MB |
| 稠密 gte-modernbert-base | 4,874 | 768 | 15.0 MB |
| 多向量 LateOn | 608,414 | 128 | 311.5 MB |
多向量索引大约是 MiniLM 的 42 倍(平均每段 124.8 个 token 向量)。但别急着劝退——同一批向量用 fast-plaid(PLAID)近似索引后只有 92 MB,因为存的是质心 ID 加量化残差而不是原始向量,和人们已经在跑的稠密索引处于同一量级。
收益同样来自官方数据:同为 ModernBERT 骨干、同为 149M 参数、只差"每 token 一个向量还是每文档一个向量",NanoBEIR 的 13 个子集里 LateOn 赢 9 个,均值高约 1 个 NDCG@10 点。长文档场景差距更夸张:MLDR 基准上 mLateOn 77.92 对 mDenseOn 51.59——文档越长,塞进单个固定向量的信息损失越致命。
六、和 RAG/Agent 结合的正确姿势:Retrieve + Rerank
全量多向量检索精确但线性扫描,官方实测 4,874 段落在 RTX 3090 上索引 20 秒、单次查询 98 毫秒——适合几千篇文档,不适合几百万篇。所以工程上推荐的是两阶段:快速 bi-encoder 召回 top-k,再用多向量模型只对候选重新打分:
from sentence_transformers import SentenceTransformer, MultiVectorEncoder
from sentence_transformers.util import semantic_search
retriever = SentenceTransformer("jinaai/jina-embeddings-v5-text-nano-retrieval")
reranker = MultiVectorEncoder("perplexity-ai/pplx-embed-v1-late-0.6b", trust_remote_code=True)
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]
query_embeddings = reranker.encode_query([query])
document_embeddings = reranker.encode_document(candidates)
scores = reranker.similarity(query_embeddings, document_embeddings)[0]
这个模式里 retriever 是轻量 bi-encoder(索引保持普通稠密向量),只有 50 个候选被编码成多向量,token 向量是临时的。它相当于替代了交叉编码器在 RAG 重排环节的角色,但代价低得多:批量编码文档后一次矩阵乘法就能打完分,而不是每对 Query-文档各跑一次前向传播。对 Agent 的场景,这意味着把"召回一堆文档塞进上下文"换成"召回 + 多向量精排后再给 LLM",上下文质量更高、token 浪费更少。
七、几个容易踩的坑
总结
多向量检索不是银弹,它的定位很清晰:用索引体积换检索质量,在文档长、条件多、对精度敏感的场景里收益明显。Sentence Transformers v6.0 把这一整套能力收进 MultiVectorEncoder 一个类,对 RAG/Agent 开发者来说,最大的价值是终于可以在同一个库里完成"稠密召回 + 多向量重排"的完整链路。想动手验证的话,从官方的 natural-questions 示例跑一遍 4,874 段落的语义搜索,20 秒建索引、100 毫秒内出结果,比自己空想收益直观得多。相关版本与 API 细节以官方文档为准,本文未验证最新版本。






