Sentence Transformers、マルチベクトル(後期相互作用)埋め込みモデルをサポート
本文の状態
日本語全文を表示中
詳細モードで約32分の本文を読めます。
同じ出来事の情報源
この情報源を基点に整理
Hugging Face Blog
Hugging Face は、検索や類似度計算の精度向上に寄与するマルチベクトル(後期相互作用)型埋め込みモデルを Sentence Transformers ライブラリで利用可能にしたと報告した。
AI深層分析を開く2026年8月18日 23:28
AI深層分析
キーポイント
Multi-Vector (Late Interaction) の概念解説
従来の単一ベクトル表現ではなく、文書を複数のベクトルに分割して表現し、検索時にクエリと各セグメントを詳細に対比する Late Interaction 方式の仕組みが説明されている。
Sentence Transformers による実装
LightOn AI の研究者らが、既存の Sentence Transformers ライブラリを活用して Multi-Vector モデルを実際に構築・利用する方法を具体的なコード例と共に示している。
検索精度と効率性の向上
この手法が従来の方法に比べて検索精度を大幅に向上させる一方で、計算コストやインデックスの複雑さといった課題についても言及されている。
Sentence Transformers v6.0 の新機能
v6.0 アップデートにより ColBERT スタイルの遅延相互作用検索に対応する「MultiVectorEncoder」モデルタイプが追加された。これにより、PyLate や Stanford-NLP のチェックポイント、さらには視覚文書検索用の colpali-engine モデルも既存 API で利用可能になった。
マルチベクトルモデルの仕組みと利点
通常の埋め込みモデルがテキスト全体を一つのベクトルに圧縮するのに対し、マルチベクトルモデルはトークンごとにベクトルを保持し MaxSim 演算子でスコアリングを行う。これにより単一ベクトルでは失われるトークンレベルのマッチング情報を維持でき、検索精度が向上する一方、インデックスサイズは大きくなる。
重要な引用
Multi-Vector (Late Interaction) Embedding Models
Sentence Transformers
Where a regular embedding model compresses a whole text into one vector, a multi-vector model keeps one vector per token and scores query against document with the MaxSim operator.
It's also the state of the art for visual document retrieval, where a text query is matched against page images directly, with no OCR step in between.
編集コメントを表示
編集コメント
検索精度の向上を目的とした Late Interaction 型の実装例が、人気ライブラリである Sentence Transformers を通じて公開された点は、実務レベルでの技術導入を容易にする。LightOn AI の研究者らが中心となったこの解説は、RAG システムの性能改善を検討する開発者にとって即戦力となる知見を提供している。
Source Article
元記事を日本語で読む
本文に関係しない購読案内、埋め込み通知、サイト内プロモーションは除いています。
記事一覧に戻る
Sentence Transformers は、検索拡張生成(RAG)やセマンティック検索などに応用可能な埋め込みモデルや再ランク付けモデルの活用・学習を可能にする Python ライブラリです。v6.0 のアップデートにより、新たに 4 つ目のモデルタイプとして MultiVectorEncoder が追加されました。これにより、ColBERT スタイルの遅延相互作用(late interaction)検索が可能になります。
PyLate や Stanford-NLP ColBERT のチェックポイントはそのまま読み込むことができ、colpali-engine による視覚的文書検索モデルも、既存の密なベクトル(dense)、スパース、再ランク付けモデルと同じ親しみやすい API を通じて利用できます。
通常の埋め込みモデルが全文を一つのベクトルに圧縮するのに対し、マルチベクトルモデルはトークンごとに 1 つのベクトルを保持し、クエリとドキュメントの間で MaxSim オペレータを用いてスコアリングを行います。これにより、単一のベクトルでは平均化されて失われてしまうトークンレベルの詳細なマッチング情報を維持できるため、インデックスサイズが大きくなるという代償を払うことで、より強力な検索結果が得られます。また、これは OCR 処理を介さずにテキストクエリを直接ページ画像と照合する視覚的文書検索においても、現在最も優れた手法(state of the art)となっています。
本ブログ記事では、マルチベクトル(後期相互作用)埋め込みモデルの活用方法をご紹介します。各種チェックポイント形式の読み込みやエンコーディング、スコアリング、検索スタックへの統合、ページ画像での実行、そしてインデックスコストの抑制まで、すべて pip install -U sentence-transformers 一つで動作します。
目次
- マルチベクトルモデルとは? MaxSim演算子について
- メリットとコスト
- インストール方法
- モデルの読み込み チェックポイント設定の確認
- クエリとドキュメントのエンコーディング
- MaxSimによるスコアリング スコアの規模とMeanMaxSim
- セマンティック検索
- 検索と再ランク付け
- インデックス作成
- ビジュアルドキュメント検索
- オーディオ検索
- ビデオ検索
- 解釈可能性
- トークンプーリング
- 推論速度の向上
- モデルの評価
- PyLate や colpali-engine からの移行
- 対応モデル一覧
- 謝辞
- 追加リソース
マルチベクトルモデルとは?
密度埋め込みモデルはテキストを読み取り、単一の固定サイズベクトルを返します。モデルが検出したすべての情報を、384、768、あるいは 1024 という少数の数値に収める必要があります。類似度計算も、こうした要約ベクトル同士の単一の内積で完結します。
このアプローチは非常に効果的ですが、圧縮には特定の弱点があります。希少な実体や正確な識別子、長い文章内の重要な節など、いずれも同じベクトル内で他の要素とスペースを争わなければなりません。複数の要件を持つクエリも、同様の壁にぶつかります。
例えば「木製の脚と丸いクッションの緑色のソファ」という検索の場合、単一のベクトルはこれら 4 つの要素を一つの点に統合しなければなりません。その結果、脚が異なる緑色のソファが、実際に探しているソファの近くに位置してしまい、誤った結果を返す可能性があります。
一方、マルチベクトルモデル(ColBERT 論文 ColBERT paper にちなんで「後期相互作用」や「ColBERT スタイル」とも呼ばれます)は、この圧縮を回避します。使用するトランスフォーマー自体は同じですが、トークン埋め込みを一つのベクトルに集約(プーリング)する代わりに、各トークンの埋め込みを小さな次元(従来は 128)に投影し、すべてのトークンを保持します。
9 トークンからなる文書は、1x128 のベクトルではなく、9x128 の行列として扱われます。
クエリとドキュメントの相互作用は、スコアリング時に初めて行われます。これが「後期相互作用(late interaction)」という名前の由来です。
クロスエンコーダは早期に相互作用を行います。2 つのテキストを同時にモデルに通すため精度は高いですが、事前計算できる余地がありません。新しいクエリごとにすべてのドキュメントを再エンコードする必要があるからです。一方、上記の密埋め込みモデル(dense embedding model)のようなバイエンコーダは、ほとんど相互作用しません。完成した 2 つの要約間の単一の内積計算だけです。この特性こそが、「コレクションを一度だけエンコードして高速に検索できる」理由となっています。
後期相互作用はこの中間に位置します。ドキュメントは依然として独立してエンコードされ、オフラインでインデックス化できます。しかしスコアリングでは、すべてのクエリトークンをすべてのドキュメントトークンと比較するため、両者の間でより多くの相互作用が可能になります。
MaxSim オペレーター
スコアリングには MaxSim が使用されます。各クエリトークンについて、任意のドキュメントトークンとの類似度の最大値を求め、その最大値をクエリ全体で合計します。
MaxSim(Q,D)=∑Qi∈QmaxDj∈DQi⋅Dj
トークン埋め込みは L2 正規化されているため、各内積計算の結果は [-1, 1] の範囲にあるコサイン類似度となります。したがって、合計値も [-num_query_tokens, num_query_tokens] の範囲に収まります。
このオペレーターを「ソフトアライメント」として解釈できます。各クエリトークンは、それを最もよく説明するドキュメントトークンへと指向し、スコアはドキュメント全体がクエリをどの程度サポートしているかを示します。
トークン埋め込みは文脈化されているため、一致させる必要のある言葉が字面通りである必要はありません。lightonai/mLateOn(https://huggingface.co/lightonai/mLateOn)を用いて、「Where do penguins live?」というクエリと「Penguins inhabit Antarctica.」というドキュメントを照合すると、クエリのトークン live は、文字の共通部分がないにもかかわらず、0.94 の高いスコアで inhabit と最もよく一致します。これは従来の語彙ベースの検索では不可能なことです。BM25 やその類いの手法は用語そのものを必要とするため、同義語や言い換え表現を見逃してしまいます。もちろん、密埋め込みモデル(Dense embedding models)もこのギャップを埋める役割を果たしますが、「Late Interaction」が追加する価値は、他の方向性を犠牲にしない点にあります。例えば、製品コードや姓、関数名など「完全一致」が重要となる場合でも、MaxSim はそのトークンを単独で保持しています。一方、シングルベクトルモデルでは、そのトークンは他の要素と平均化されてしまうためです。
また、これは一対一の対応ではありません。複数のクエリトークンが、同じドキュメントトークンに収束することもしばしばあります。
得られるもの、そしてコスト
ドキュメントの特定の断片が関連性の鍵となるクエリや、ソファの例のように各要件に独自の根拠が必要となる多要件クエリ、そして密なモデルの圧縮が異なる分布向けに調整された分野外データにおいて、検索精度を向上させることができます。この圧縮は学習用クエリから獲得されるため、モデルは必要な情報を保持し、他をすべて捨てるように学習します。その結果、実際の運用で求められる情報が含まれている可能性さえあります。ドキュメントが長くなるほどこの効果は顕著になります。なぜなら、より多くのテキストを同じ固定サイズのベクトルに収めなければならないからです。
その代償として、インデックスサイズが大きくなります。ドキュメントあたり1つのベクトルではなく、トークンあたり1つのベクトルとなるため、ベクトル数が大幅に増加します。次元が小さくなることで一部は相殺されますが、それでも膨大な数になります。`lightonai/LateOn` を用いて 4,874 の Natural Questions パッセージをエンコードした結果、トークンベクトル数は 608,414 に達し、パッセージあたり平均 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 倍の保存容量に相当し、1 パッセージあたり 62 KiB です。ただし、インデックスは通常圧縮されます。例えば、同じ 608,414 個のベクトルでも、PLAID インデックス(fast-plaid)では 92 MB に収まります。これは PLAID がベクトルそのものではなく、セントロイド ID と量子化された残差を各ベクトルに格納するためです。
規模の比較として、4096 次元の密型モデルである Qwen3-Embedding-8B を使用した場合、同じ 4,874 パッセージで約 80 MB の容量が必要になります。つまり、圧縮されたマルチベクトルインデックスは、すでに運用されている密型インデックスと同等の規模と言えます。
Token Pooling はこれらよりも前にベクトル数を削減し、Retrieve and Rerank ではインデックス自体を構築する必要すらありません。
本稿 throughout で PyLate が登場しますが、簡単に説明すると、Sentence Transformers は密型モデルと疎型モデルには対応していましたが、後期相互作用(late interaction)には対応していませんでした。そこで LightOn はそのギャップを埋めるため Sentence Transformers をベースに PyLate を構築し、これらのモデルに必要なトレーニング、推論、検索の機能を追加しました。
以下で取り扱うモデルの多くは PyLate で訓練されており、LightOn はこれを取り巻くエコシステムも整備しています。例えば fast-plaid は後期相互作用用のインデックスであり、Indexing セクションで登場します。
v6.0 ではこれらの機能が Sentence Transformers 自体に統合されました。
このトレードオフを踏まえて、モデルを実行してみましょう。
インストール
マルチベクトルモデルは通常のインストールで動作します:
pip install -U sentence-transformers
ColPali 形式の視覚的ドキュメント検索を行うには、画像依存関係も必要です。すべての追加機能については インストール を、マルチモーダルサポート全般については Multimodal Embedding & Reranker Models をご覧ください。
pip install -U "sentence-transformers[image]"
Sentence Transformers v6.0 では
transformersv5.x、torch2.2 以上、およびhuggingface-hubv1.x が必要です。これらのライブラリをそれより低いバージョンに固定する場合は、事前にアップグレードの計画を立ててください。破壊的変更の全リストについては マイグレーションガイド を参照してください。
モデルの読み込み
マルチベクトルモデルの読み込みは、他の Sentence Transformers モデルと同様です。
from sentence_transformers import MultiVectorEncoder
model = MultiVectorEncoder("lightonai/LateOn")
動作するモデルを探すには、Hugging Face の Hub で `multi-vector` と `sentence-transformers` のタグ を確認してください。これらのタグが付与されたモデルは、PyLate チェックポイントや Stanford-NLP の ColBERT チェックポイントから始まったものでも、視覚ドキュメント検索用の ColPali ファミリーモデルであっても、上記のコードで読み込めます。現在、機能するすべてのモデルにこのタグを付与する取り組みを進めており、対応リストは拡大し続けています。
内部では MultiVectorEncoder が、過去数年間にわたってチェックポイントが公開されてきたさまざまなフォーマットを読み込みます。そのため、まだタグが付与されていない場合でも、PyLate や Stanford-NLP のチェックポイントは直接読み込むことができます。
from sentence_transformers import MultiVectorEncoder
# Native Sentence Transformers checkpoints. PyLate builds on the same schema,
# so any PyLate checkpoint loads identically
model = MultiVectorEncoder("lightonai/LateOn")
model = MultiVectorEncoder("mixedbread-ai/mxbai-edge-colbert-v0-17m")
model = MultiVectorEncoder("LiquidAI/LFM2.5-ColBERT-350M", trust_remote_code=True)
# Any Stanford-NLP ColBERT checkpoint, detected via the `HF_ColBERT` architecture
# marker. The inline projection weight and the recipe come from `artifact.metadata`
model = MultiVectorEncoder("colbert-ir/colbertv2.0")
model = MultiVectorEncoder("answerdotai/answerai-colbert-small-v1")
# A bare transformer: a fresh random projection is appended, so training is required
model = MultiVectorEncoder("answerdotai/ModernBERT-base")
視覚文書検索モデルは例外扱いとなります。ColPali ファミリーのチェックポイントは、Sentence Transformers が利用できない独自形式で提供されるため、読み込む前にリポジトリに小さな設定を追加する必要があります。この作業の大半は既に完了しており、マージ待ちの状態です。現在の状況や今日からロードする方法については、「対応モデル」セクションをご覧ください。
チェックポイント構成の確認
マルチベクトルモデルには、チェックポイントごとに異なるいくつかの設定項目があります。具体的には、クエリとドキュメントのプレフィックスマーカー、長さの上限、クエリを [MASK] トークンでパディングするかどうか、そしてドキュメントのスコアリング時にどのトークンをスキップするかといった設定です。これらすべての設定はモジュール構成ファイルに含まれているため、print(model) を実行すれば、読み込まれたモデルの詳細が確認できます。
以下に、ColBERTv2 のオリジナルチェックポイントを示します。このモデルでは、クエリは常に 32 トークンにパディングされ、ドキュメントは 180 トークンで切り捨てられます。
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 が文脈を考慮したトークン埋め込みを生成し、各トークンを128次元に投影するトークンレベルの Dense 層がそれに続きます。
MultiVectorMask は、スコアリング時にどのトークンがカウントされるかを決定し、トークンレベルで正規化(Normalize)を行います。
他のチェックポイントでは異なる値が設定されます。lightonai/GTE-ModernColBERT-v1 は、同じ 4 つのモジュールを使用し、[Q] と [D] のプロンプトを採用しますが、クエリ拡張は行わず、上限をそれぞれ 48 と 300 に設定しています。
リリースされたチェックポイントの構成を直接触ることは、ほとんどありません。各チェックポイントは自動的に設定されるためです。ただし、基盤モデルから独自にモデルを構築する場合は、カスタムモデルの作成 の手順を確認する必要があります。
一つだけ確認すべきパラメータがあります。document_length はカットオフ値として機能するため、この値を超えた部分はインデックスに反映されません。例えば、LateOn モデルのキャップである 300 トークンを超える 662 トークンの文章を処理すると、最初の 300 トークン分が 273 ベクトルに変換されるだけで、残りの部分は完全に切り捨てられてしまいます。
これらのチェックポイントは主に短い文章で訓練されているため、もしご自身のデータチャンクがこのキャップを超えている場合は、単一の呼び出し処理で encode_document(..., processing_kwargs={"text": {"max_length": 512}}) のように値を引き上げることで対応可能です。ただし、これはモデルが学習した長さを超える処理を行うことになる点と、インデックスのサイズがそれに比例して増大する点を留意してください。多ベクトルモデルはこうしたケースに対して比較的寛容に動作します。
長文検索を評価するベンチマーク MLDR において、上記のペアの多言語版を比較するとその差が明確に現れます。mLateOn は 77.92 のスコアを示すのに対し、mDenseOn は 51.59 です。
クエリとドキュメントのエンコーディング
マルチベクトルモデルは非対称です。クエリとドキュメントは、異なるプレフィックス、異なる長さの上限、そして異なるスコアリングマスクを経由します。
多くの密着型(dense)モデルでは両者が互換性を持つ一方で、マルチベクトルモデルでは正しい埋め込みを得るために、`encode_query()` と `encode_document()` の両方が必要となります。
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)
返される結果は、入力ごとの 2D テンソルのリストです。各テンソルの形状は (num_tokens, embedding_dim) です。密な埋め込みとは異なり、すべての入力でトークン数が異なるため、これらを一つの長方形のテンソルにスタックすることはできません。2 つ目の文書は 1 つ目よりも長いため、より高い行列として返されます。
各呼び出しでは、モデル固有の処理が自動的に適用されます。encode_query はクエリマーカーを先頭に追加し、チェックポイントの要件に応じてクエリを固定長に拡張し、クエリ長の上限でキャップします。一方、encode_document はドキュメントマーカーを先頭に追加し、ドキュメント長の上限でキャップするとともに、スコアリングマスクからスキplist されたトークン(多くのチェックポイントでは句読点など)を除外します。
通常の encode() 引数はすべて引き続き適用されるため、batch_size、show_progress_bar、convert_to_tensor、device、マルチプロセスプールも問題なく動作します。
期待通りの結果となります。
document_embeddings = model.encode_document(
documents,
batch_size=64,
convert_to_tensor=True,
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]])
火星が勝利するのは当然です。しかし、上位の候補同士が非常に近いスコアである点に注目してください。土星には「赤い惑星」という文字列そのものが含まれており、木星も赤い斑点を持つ惑星です。つまり、トークンレベルの演算子であれば、これら 3 つの天体すべてに対して十分に反応できる要素が存在します。重要なのは、この順序付けです。
スコアはしばしばこのように互いに近い値になります。これは GLInt が候補プール全体にわたる分布を測定した結果でも示されています。MaxSim はクエリトークンごとに「最大値」を採用するため、文書は通常すべてのクエリトークンに対してそれなりの最良の一致を提供し、スコアはある下限値から始まります。また、コンテキスト化されたトークンの埋め込みは異方性を持ち、広がって分布するのではなく狭い円錐状にクラスタリングされるため、任意のトークンペアでも高いスコアになりがちです。
すでにマッチしたペアが用意されている場合や、完全な類似度行列ではなくペアごとのスコアのみが必要な場合は、`model.similarity_pairwise()` を使用できます。
scores = model.similarity_pairwise(query_embeddings, document_embeddings[:1])
print(scores)
# tensor([10.7942])
スコアの絶対値と MeanMaxSim
MaxSim はクエリトークンに対して合計値を計算するため、その値はクエリトークンの数に比例して大きくなります。このため、異なるクエリの構成を持つモデル間でスコアを直接比較することはできません。
LateOn では、上の「赤い惑星」のクエリが 12 トークンとしてエンコードされます。同じクエリとドキュメントを ColBERTv2 に通すと、すべてのクエリは 32 トークンにパディングまたは切り捨てられるため、得られるスコアの範囲は全く異なるものになります。
model = MultiVectorEncoder("colbert-ir/colbertv2.0")
# ... same encode_query / encode_document / similarity calls ...
print(scores)
# tensor([[12.7970, 27.1945, 23.8495, 24.5656]])
1 つのモデル内では順序付けさえできれば十分ですが、スコアを一定の範囲に収めたい場合は、類似度関数を MeanMaxSim に変更し、クエリトークン数で割るようにします。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, convert_to_tensor=True, show_progress_bar=True)
query = "when did richmond last play in a preliminary final"
start = time.perf_counter()
query_embeddings = model.encode_query([query], convert_to_tensor=True)
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
"""
RTX 3090 で 4,874 パッセージのエンコードに要したのは約 20 秒でした。一方、検索 1 回あたりの所要時間は約 120ms です。その大部分は、608,414 トークンベクトルすべてに対する MaxSim スコアリングに費やされています。この方法は正確ですが、総トークン数に対して線形にスケーリングし、すべてのトークンベクトルをメモリ上に保持する必要があります。そのため、数百万ドキュメントではなく数千ドキュメント規模のデータセットに適しています。
このスクリプトの実行可能バージョンは semantic_search.py です。
このサイズを超えると、Sentence Transformers には実装されていない本格的な後期相互作用(late-interaction)インデックスが必要になります。ただし、同ライブラリがそれを提供しなくても問題ありません。なぜなら、これらのインデックスは encode_document が生成したデータをそのまま保持する仕組みだからです。つまり、ここではエンコードを行い、トークン埋め込みベクトルをそれ専用のシステムに渡せばよいのです。
Indexing セクションでは、4 つの選択肢に関する実働スニペットを確認できます。また、その直下のセクションではインデックス自体をスキップする方法についても解説しています。
Retrieve and Rerank(検索と再ランク付け)
後期相互作用インデックスを維持しなくても、マルチベクトルモデルを *reranker*(再ランク付け器)として利用することで、同等の品質を実現できます。高速なバイエンコーダーが大量のコーパスから候補を数件に絞り込み、その後マルチベクトルモデルがその候補のみに対して再スコアリングを行います。
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)
# First stage: index the corpus once with a fast bi-encoder
corpus_embeddings = retriever.encode_document(corpus, convert_to_tensor=True, show_progress_bar=True)
# Retrieve the top 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]
# Second stage: rescore just those candidates with 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 件の候補のみであり、インデックスは通常の密な(dense)インデックスのまま維持されます。トークンベクトルは一時的なものに過ぎません。これはクロスエンコーダーが検索と再ランク付けのスタックで果たす役割と同じですが、マルチベクトルモデルの方が候補あたりの計算コストは大幅に低くなります。文書エンコードをバッチ処理で行い、行列乗算によってスコアリングするのです。これにより、クエリとドキュメントのペアごとに順次フォワードパスを実行する必要がなくなります。
両段階の処理時間を計測できる実行可能スクリプトは retrieve_rerank.py です。
Indexing
複数のベクトルデータベースは、ネイティブでマルチベクトルのインデックス作成とスコアリングをサポートしています。Qdrant は v1.10 から、Weaviate は v1.29 から、Vespa は長年、LanceDB は v0.15.0 からそれぞれ対応しています。また、VectorChord は Postgres に標準の pgvector にはない MaxSim オペレーターを追加する機能を提供します。Milvus も v2.15.4 で加わりましたが、これは「マルチベクトル検索」という名称とは異なり、「構造体の配列(array-of-structs)」という別の特徴として実装されています。
サーバーを起動したくない場合でも、LightOn の fast-plaid は pip install 一つで利用でき、PLAID を直接実装しています。PyLate はこれをより包括的な検索スタックとしてラップしたものです。
その他にも、部分的に機能するツールがあります。OpenSearch と Elasticsearch は MaxSim を用いて候補の再スコアリングは可能ですが、MaxSim による検索自体はできません。さらに Elasticsearch のこの機能は技術プレビュー段階であり、Enterprise タイヤーでのみ利用可能です。turbopuffer は現在、非公開ベータ版で後期相互作用(late-interaction)インデックスを提供しています。
以下のスニペットはテキストをインデックス化しますが、その処理自体にテキスト固有の制限はありません。encode_document は、ドキュメントが段落、ページ画像、音声クリップ、あるいは動画のいずれであっても、同じトークン・ベクトル行列のリストを返します。そのため、Visual Document Retrieval で紹介された ColPali 型モデルは、これらのデータ形式に対して変更を加えずにそのまま適用可能です。重要なのは、ドキュメントあたりのベクトル数が増える点であり、これが Token Pooling の有効性を高める要因となっています。
fast-plaid、Qdrant、Weaviate、Vespa はすべて encode_document が返す値をそのまま受け取ります。つまり、クライアントライブラリに至るまでのコードは共通です。以下に、Semantic Search の例で用いた 4,874 件のパスセージと 608,414 個のトークンベクトルを対象に動作確認した各システムのコードスニペットを示します。これらはすべて、RTX 3090 と i7-13700K を搭載した単一マシン上で実行された結果であり、コード内で明示されている設定以外には何らのチューニングも行っていません。これは作業の規模感を把握するための目安です。
4 つのシステムはいずれも、同セクションで model.similarity が要した 98ms よりも高速にクエリに応答します。さらに、fast-plaid を除く 3 つは CPU のみで動作しています。
本記事で先に紹介した PyTorch の MaxSim を用いた全件検索と同じく、4 つの手法すべてが同じ 3 つのパッセージを同一順序で返しました。さらに、これら 3 つのデータベースはスコアを小数点以下 4 桁まで再現しています。これは、各スニペットがすべての文書に対してスコアを計算しているためです。この規模であれば計算コストも許容範囲であり、近似による誤差という変数を排除できるからです。
一方、fast-plaid は設計上近似アルゴリズムを採用しているため、スコアはわずかに異なります。各手法の注釈には、近似インデックスへ切り替えた際に何が変わるのか、そしてランク付けがどこからずれていくのかについて説明されています。
fast-plaid
fast-plaid は、ColBERT の元々構築された基盤となったインデックス PLAID を LightOn が Rust で実装したものです。サーバーを起動する必要はなく、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, convert_to_tensor=True)
query_embedding = model.encode_query(query, convert_to_tensor=True)
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 インスタンスで同じパスを指定すれば、毎回埋め込みベクトルから再構築するのではなく、検索や追加文書の登録のために既存のインデックスを開くことができます。このコーパスでは、fast-plaid は 92 MB の容量しか占用しませんが、生の float32 ベクトルの場合は 311.5 MB を必要とします。
4 つの手法の中で、これは唯一近似計算であり、このセクション内で MaxSim(最大類似度)とのスコアが一致しない箇所です。PLAID はセントロイドを用いて剪定し、量子化された残差を保存するため、先に計算した 11.9192 / 11.7591 / 11.6710 の値に対して、スコアは両方向に数百分の誤差が生じます。ただしランキング自体には影響がなく、これが PLAID が採用しているトレードオフです。この手法は、本稿のような小規模なデータセットではなく、全データをスキャンすることが現実的ではないはるかに大規模なコーパスを対象に設計されています。
Qdrant
Qdrant を利用するにはサーバーが必要です。以下のコマンドで Docker コンテナを起動できます。
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 never walks the HNSW graph, so skip building one
hnsw_config=models.HnswConfigDiff(m=0),
),
)
# 4,874 documents (608,414 token vectors) ingested in 26.3s
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 # 18ms
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 自体も、後期相互作用は数百件の候補を再ランク付けする用途として使い、コレクション全体をスキャンする用途には使わないよう提案しており、これは Retrieve and Rerank パターンに該当します。4,874 ドキュメントの場合、フルスキャンでも 18ms で正確な結果が得られますが、この数値から他の規模への外挿はできません。
Weaviate
Weaviate を利用するにはサーバーも必要です。以下のコマンドで実行できます:docker run -p 8080:8080 -p 50051:50051 cr.weaviate.io/semitechnologies/weaviate:1.34.0。マルチベクトル機能を利用するにはバージョン 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 turns on MaxSim late interaction
vector_config=[Configure.MultiVectors.self_provided(name="colbert")],
properties=[Property(name="text", data_type=DataType.TEXT)],
)
# 4,874 documents (608,414 token vectors) ingested in 41s
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),
) # 17ms
for result in results.objects:
# Weaviate reports the MaxSim score as a negated distance
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()
ここではデフォルト設定で十分です。Weaviate の動的な ef パラメータは、上位 3 件を返すクエリに対して 100 に解決されますが、このランク付けの精度は約 32 以上からすでに正確になります。この誤差範囲は Weaviate の仕様というより、埋め込みモデル自体の特性に由来するものです。そのため、デフォルト値が通用すると安易に推測せず、ご自身のモデルで確認することをお勧めします。
Weaviate は MUVERA エンコーディングにも対応しており、テストではインgestion が 3 倍、クエリが 1.8 倍高速化されました。ただし、このサイズにおいては、その速度向上に見合うほどの精度低下を招くため、推奨はできません。実際、正解となる 3 番目のパッセージは上位 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 =原文を表示
Sentence Transformers is a Python library for using and training embedding and reranker models for applications like retrieval augmented generation, semantic search, and more. With the v6.0 update, it gains a fourth model type: MultiVectorEncoder, for ColBERT-style late interaction retrieval. Any PyLate checkpoint and any Stanford-NLP ColBERT checkpoint loads straight into it, and colpali-engine models for visual document retrieval can be used too, through the same familiar API you already use for dense, sparse, and reranker models.
Where a regular embedding model compresses a whole text into one vector, a multi-vector model keeps one vector per token and scores query against document with the MaxSim operator. That preserves token-level matching information that a single vector has to average away, which usually means stronger retrieval at the cost of a bigger index. It's also the state of the art for visual document retrieval, where a text query is matched against page images directly, with no OCR step in between.
In this blogpost, we'll show you how to use these models: loading the various checkpoint formats, encoding and scoring, plugging them into a search stack, running them on page images, and keeping the index affordable. Everything below runs on a plain pip install -U sentence-transformers.
Table of Contents
- What are Multi-Vector Models?
The MaxSim Operator
- What You Gain, and What It Costs
- Installation
- Loading a Model
Inspecting What a Checkpoint Configured
- Encoding Queries and Documents
- Scoring with MaxSim
Score Magnitude and MeanMaxSim
- Semantic Search
- Retrieve and Rerank
- Indexing
- Visual Document Retrieval
- Audio Retrieval
- Video Retrieval
- Interpretability
- Token Pooling
- Speeding Up Inference
- Evaluating a Model
- Coming from PyLate or colpali-engine
- Supported Models
- Acknowledgements
- Additional Resources
What are Multi-Vector Models?
A dense embedding model reads a text and returns a single fixed-size vector. Everything the model noticed has to fit in those 384, 768, or 1024 numbers, and similarity is one dot product between two such summaries. This works remarkably well, but the compression is lossy in a specific way: a rare entity, an exact identifier, or one crucial clause in a long passage all have to compete for room in the same vector. A query with several requirements at once runs into the same wall. For "green sofa with wooden legs and rounded cushions", a single vector has to blend all four into one point, so a green sofa with the wrong legs ends up sitting close to the one you actually asked for.
A multi-vector model (also called a late-interaction or ColBERT-style model, after the ColBERT paper) skips that compression. It runs the same transformer, but instead of pooling the token embeddings into one vector, it projects each token embedding down to a small dimension (classically 128) and keeps all of them. A 9-token document becomes a 9x128 matrix, not a 1x128 vector.
The interaction between query and document is then deferred until scoring time, which is where the name "late interaction" comes from. A cross-encoder interacts early: both texts go through the model together, which is accurate but leaves nothing to precompute, since every document has to be re-encoded for each new query. A bi-encoder, which is what the dense embedding model above is, barely interacts at all (one dot product between two finished summaries), and that is exactly what lets you encode a collection once and query it fast. Late interaction sits in between: documents are still encoded independently and can be indexed offline, but scoring compares every query token against every document token, which leaves far more room for the two to interact.
The MaxSim Operator
Scoring uses MaxSim: for each query token, take its highest similarity against any document token, then sum those maxima across the query.
MaxSim(Q,D)=∑Qi∈QmaxDj∈DQi⋅Dj\text{MaxSim}(Q, D) = \sum_{Q_i \in Q} \max_{D_j \in D} Q_i \cdot D_j
Because the token embeddings are L2-normalized, each of those dot products is a cosine similarity in [-1, 1], so the whole sum lands within [-num_query_tokens, num_query_tokens].
You can read the operator as a soft alignment: every query token points at the one document token that best explains it, and the score is how well the document supports the query overall.
The alignment doesn't have to be lexical, since the token embeddings are contextualized. Encode "Where do penguins live?" against "Penguins inhabit Antarctica." with lightonai/mLateOn and the query token live finds its best match on inhabit at 0.94, a word it shares no characters with! That is the thing lexical retrieval cannot do, BM25 and its relatives need the term itself, so synonyms and paraphrases slip past them. Dense embedding models bridge that gap as well, of course. What late interaction adds is that it does so without giving up the other direction: when an exact match is what matters (a product code, a surname, a function name), MaxSim still has that token sitting there on its own, where a single-vector model had to average it in with everything else. It isn't one-to-one either, since several query tokens routinely settle on the same document token.
What You Gain, and What It Costs
You gain retrieval quality, particularly on queries where one specific piece of a document is what makes it relevant, on multi-requirement queries like the sofa above where each requirement gets to find its own evidence, and on out-of-domain data where a dense model's compression was tuned for a different distribution. That compression is learned from the training queries, so the model learns to keep what they needed and drop everything else, which may include exactly what your production queries ask about. The effect grows with document length, since more text has to fit in the same fixed vector.
The cost is index size. One vector per token instead of one vector per document is a lot more vectors, only partly offset by the smaller dimension. Encoding 4,874 Natural Questions passages with lightonai/LateOn produced 608,414 token vectors, an average of 124.8 per passage:
| Representation | Vectors | Dimensions | float32 size |
|---|---|---|---|
| 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 |
That's about 42x the storage of the MiniLM index, or 62 KiB per passage. However, indexes are often compressed, e.g. the same 608,414 vectors take 92 MB as a fast-plaid index, since PLAID stores a centroid id plus a quantized residual per vector rather than the vector itself. For scale, a 4096-dimensional dense model like Qwen3-Embedding-8B would need about 80 MB for these same 4,874 passages, so a compressed multi-vector index sits in the same territory as the dense indexes people already run. Token Pooling cuts the vector count before any of that, and Retrieve and Rerank avoids building an index at all.
PyLate comes up throughout this post, so briefly: Sentence Transformers handled dense and sparse models but not late interaction, so LightOn built PyLate on top of it to close that gap, adding the training, inference, and retrieval pieces these models need. Much of what you'll load below was trained with it, and LightOn built an ecosystem around it too, including fast-plaid, the late-interaction index that turns up in Indexing. With v6.0 those capabilities live in Sentence Transformers itself.
With the tradeoff in mind, let's get a model running.
Installation
Multi-vector models work with a plain install:
pip install -U sentence-transformers
For ColPali-style visual document retrieval, you also need the image dependencies (see Installation for all extras, and Multimodal Embedding & Reranker Models for multimodal support in general):
pip install -U "sentence-transformers[image]"
Sentence Transformers v6.0 requires transformers v5.x, torch 2.2+, and huggingface-hub v1.x. If you pin any of those lower, plan the upgrade first. See the Migration Guide for the full list of breaking changes.
Loading a Model
Loading a multi-vector model looks exactly like loading any other Sentence Transformers model:
from sentence_transformers import MultiVectorEncoder
model = MultiVectorEncoder("lightonai/LateOn")
To find models that work, look for the multi-vector and sentence-transformers tags on the Hub. Any model with those tags loads with the line above, whether it started life as a PyLate checkpoint, a Stanford-NLP ColBERT checkpoint, or a ColPali-family model for visual document retrieval. We're working through the ecosystem to get that tag onto every model that works, so the list keeps growing.
Underneath, MultiVectorEncoder reads each of the formats these checkpoints have been published in over the years, so PyLate and Stanford-NLP checkpoints load directly even where the tag hasn't been added yet:
from sentence_transformers import MultiVectorEncoder
# Native Sentence Transformers checkpoints. PyLate builds on the same schema,
# so any PyLate checkpoint loads identically
model = MultiVectorEncoder("lightonai/LateOn")
model = MultiVectorEncoder("mixedbread-ai/mxbai-edge-colbert-v0-17m")
model = MultiVectorEncoder("LiquidAI/LFM2.5-ColBERT-350M", trust_remote_code=True)
# Any Stanford-NLP ColBERT checkpoint, detected via the `HF_ColBERT` architecture
# marker. The inline projection weight and the recipe come from `artifact.metadata`
model = MultiVectorEncoder("colbert-ir/colbertv2.0")
model = MultiVectorEncoder("answerdotai/answerai-colbert-small-v1")
# A bare transformer: a fresh random projection is appended, so training is required
model = MultiVectorEncoder("answerdotai/ModernBERT-base")
Visual document retrieval models are the exception. ColPali-family checkpoints ship in colpali-engine's own format, which carries no information Sentence Transformers can use, so each one needs a small configuration added to its repository before it loads. Most of that work is done and waiting to be merged. See Supported Models for the current state and how to load them today.
Inspecting What a Checkpoint Configured
Multi-vector models carry a handful of recipe knobs that differ per checkpoint: marker prefixes for queries and documents, length caps, whether queries are padded out with [MASK] tokens, and which tokens are skipped when scoring documents. All of them live in the module configs, so print(model) shows you exactly what you loaded. Here's the original ColBERTv2 checkpoint, which pads every query to exactly 32 tokens and truncates documents at 180:
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] '}
That's the classic ColBERT pipeline: a Transformer producing contextualized token embeddings, a token-level Dense projecting each of them to 128 dimensions, a MultiVectorMask deciding which tokens count during scoring, and a token-level Normalize. Other checkpoints fill in different values. lightonai/GTE-ModernColBERT-v1 uses the same four modules with [Q] and [D] prompts, no query expansion, and caps of 48 and 300.
You rarely need to touch any of this, since every released checkpoint configures its own. It matters when you build a model from a bare backbone, which is covered in Creating Custom Models.
One value is worth checking against your own data, though. document_length truncates, so anything past it never reaches the index. For example, a 662-token passage through LateOn's cap of 300 comes back as 273 vectors, with the rest of the passage simply gone. Most of these checkpoints were trained on short passages, so if your chunks are longer than the cap, you can lift it for a single call with encode_document(..., processing_kwargs={"text": {"max_length": 512}}), keeping in mind that you would be running the model past the length it was trained on and that the index grows roughly in proportion. Multi-vector models tend to tolerate that well. On MLDR, a long-document retrieval benchmark, the multilingual siblings of the pair above show the gap clearly: mLateOn scores 77.92 against mDenseOn's 51.59.
Encoding Queries and Documents
Multi-vector models are asymmetric: queries and documents go through different prefixes, different length caps, and different scoring masks. Unlike many dense models, where the two are interchangeable, encode_query() and encode_document() are required to get correct embeddings:
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)
Note what you get back: a *list* of 2D tensors, one per input, each of shape (num_tokens, embedding_dim). Unlike dense embeddings, you can't stack these into one rectangular tensor, because every input has its own token count. The second document is longer than the first, so it comes back as a taller matrix.
Each call applies the model's own recipe for you. encode_query prepends the query marker, expands the query to a fixed length if the checkpoint asks for it, and caps it at the query length. encode_document prepends the document marker, caps at the document length, and drops any skiplisted tokens (punctuation, for most checkpoints) from the scoring mask.
The usual encode() arguments all still apply, so batch_size, show_progress_bar, convert_to_tensor, device, and multi-process pools work the way you'd expect:
document_embeddings = model.encode_document(
documents,
batch_size=64,
convert_to_tensor=True,
show_progress_bar=True,
)
Scoring with MaxSim
model.similarity() computes the full all-pairs MaxSim matrix:
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 wins, as it should. Note how close the runners-up are: Saturn also contains the literal phrase "the Red Planet", and Jupiter is a planet with a red spot, so a token-level operator has plenty to latch onto in all three. The ordering is what matters.
Scores often sit this close together, as GLInt shows by measuring the spread across a full candidate pool. MaxSim takes a *maximum* per query token, so a document will usually give every query token some decent best match, and scores start from a floor. Contextualized token embeddings are also anisotropic, clustering in a narrow cone rather than spreading out, so even arbitrary token pairs tend to score high.
There is also model.similarity_pairwise(), for when you already have matched pairs and just want the pair scores instead of the full similarity matrix:
scores = model.similarity_pairwise(query_embeddings, document_embeddings[:1])
print(scores)
# tensor([10.7942])
Score Magnitude and MeanMaxSim
MaxSim sums over query tokens, so its magnitude scales with how many query tokens there are, which means you can't compare scores across models with different query recipes. LateOn encodes the Red Planet query above as 12 tokens. Run that same query and those same documents through ColBERTv2, which pads and truncates every query to exactly 32 tokens, and the scores land in a completely different range:
model = MultiVectorEncoder("colbert-ir/colbertv2.0")
# ... same encode_query / encode_document / similarity calls ...
print(scores)
# tensor([[12.7970, 27.1945, 23.8495, 24.5656]])
Within one model the ordering is all you need, but if you want scores on a bounded scale, switch the model's similarity function to MeanMaxSim, which divides by the query token count. Back on 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]])
Now every score is an average cosine similarity in [-1, 1], although you'll only see [0, 1] in practice.
Semantic Search
If your corpus is small, exhaustive MaxSim over all of it is the simplest thing that works. Encode the corpus once, then score each query against everything:
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, convert_to_tensor=True, show_progress_bar=True)
query = "when did richmond last play in a preliminary final"
start = time.perf_counter()
query_embeddings = model.encode_query([query], convert_to_tensor=True)
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
"""
Those 4,874 passages encoded in 20 seconds on an RTX 3090, and each search takes about 120ms end to end, most of that the MaxSim scoring against all 608,414 token vectors. This is exact, but it scales linearly in total corpus tokens and keeps every token vector in memory, so reach for it when you have a few thousand documents rather than a few million. The runnable version of this script is semantic_search.py.
Past that size you want a real late-interaction index, which Sentence Transformers doesn't ship. It doesn't need to: these indexes store whatever encode_document produced, so you encode here and hand the token embeddings to something built for them. Indexing has working snippets for four of the options, and the section directly below covers how to skip the index entirely.
Retrieve and Rerank
You can also get late-interaction quality without maintaining a late-interaction index, by using a multi-vector model as your *reranker*. A fast bi-encoder narrows a large corpus to a handful of candidates, then the multi-vector model rescores only those:
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)
# First stage: index the corpus once with a fast bi-encoder
corpus_embeddings = retriever.encode_document(corpus, convert_to_tensor=True, show_progress_bar=True)
# Retrieve the top 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]
# Second stage: rescore just those candidates with 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]}")
Only the 50 candidates are ever encoded as multi-vectors, so your index stays a normal dense index and the token vectors are transient. This is the same role a cross-encoder plays in a retrieve-and-rerank stack, but a multi-vector model is considerably cheaper per candidate. You encode the documents in one batch and score them with a matrix multiplication, instead of one forward pass per query-document pair. The runnable script is retrieve_rerank.py, which prints the timings of both stages.
Indexing
Several vector databases index and score multi-vectors natively: Qdrant since v1.10, Weaviate since v1.29, Vespa for years now, LanceDB since v0.15.0, and VectorChord, which adds a MaxSim operator to Postgres that plain pgvector doesn't have. Milvus joined them in v2.6.4, under array-of-structs rather than the unrelated feature it calls multi-vector search. If you would rather not run a server at all, LightOn's fast-plaid is a pip install away and implements PLAID directly, and PyLate wraps it in a fuller retrieval stack.
A few others get you partway. OpenSearch and Elasticsearch can rescore candidates with MaxSim but not retrieve on it, and the Elasticsearch field is additionally in technical preview and Enterprise-tier. turbopuffer has late-interaction indexing in private beta.
The snippets below index text, but nothing in them is text-specific. encode_document hands back the same list of token-vector matrices whether the document was a passage, a page image, an audio clip, or a video, so the ColPali-style models from Visual Document Retrieval go into any of these unchanged. There are simply more vectors per document, which is what makes Token Pooling worth reaching for sooner there.
fast-plaid, Qdrant, Weaviate, and Vespa all take exactly what encode_document returns, so the code is the same up to the client library. Here's a working snippet for each, run against the 4,874 passages and 608,414 token vectors from the Semantic Search example. Each one carries the ingestion and query times it produced on one machine (RTX 3090, i7-13700K), with no tuning beyond what the code shows, to give a sense of the shape of the work. All four answer the query faster than the 98ms model.similarity took in that section, and three of them do it on the CPU, since fast-plaid is the only one here using the GPU.
All four returned the same three passages in the same order as the exhaustive PyTorch MaxSim earlier in this post, and the three databases reproduce its scores to four decimals! That is because their snippets score every document, which is affordable at this size and removes approximation as a variable. fast-plaid is approximate by design, so its scores differ slightly. The notes under each one say what changes when you switch to an approximate index, which is where rankings start to drift.
fast-plaid
fast-plaid is LightOn's Rust implementation of PLAID, the index ColBERT was originally built around. There's no server to start, and it reads the tensors encode_document hands back without any conversion.
# 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, convert_to_tensor=True)
query_embedding = model.encode_query(query, convert_to_tensor=True)
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
"""
The index argument is a directory, not just a label, so the index is written to disk as it is built. Pointing a new FastPlaid at the same path reopens it for searching or for adding more documents, instead of rebuilding from the embeddings each time. On this corpus it occupies 92 MB, against 311.5 MB for the raw float32 vectors.
This is the only one of the four that is approximate, and it is the one place in this section where the scores do not match the exhaustive MaxSim. PLAID prunes with centroids and stores quantized residuals, so the three scores drift by a few hundredths in both directions against the 11.9192 / 11.7591 / 11.6710 computed earlier. The ranking is unaffected here, and that is the trade PLAID is making: it was designed for corpora far larger than this one, where scanning everything is not an option.
Qdrant
Qdrant needs a server: docker run -p 6333:6333 qdrant/qdrant. The client also has a local mode (QdrantClient(":memory:")) that needs no server, but it's a pure-Python reimplementation, so use it for trying things out rather than for timing them.
# 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 never walks the HNSW graph, so skip building one
hnsw_config=models.HnswConfigDiff(m=0),
),
)
# 4,874 documents (608,414 token vectors) ingested in 26.3s
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 # 18ms
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 is the only comparator Qdrant offers, and hnsw_config=HnswConfigDiff(m=0) is their recommendation for late-interaction fields, since the vectors are used for rescoring rather than graph traversal. Note that Qdrant themselves suggest reserving late interaction for reranking a few hundred candidates rather than scanning a whole collection, which is the Retrieve and Rerank pattern. At 4,874 documents the full scan costs 18ms and is exact, but that doesn't extrapolate.
Weaviate
Weaviate needs a server too: docker run -p 8080:8080 -p 50051:50051 cr.weaviate.io/semitechnologies/weaviate:1.34.0. Multi-vector support needs 1.29 or newer, and the embedded mode isn't available on 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 turns on MaxSim late interaction
vector_config=[Configure.MultiVectors.self_provided(name="colbert")],
properties=[Property(name="text", data_type=DataType.TEXT)],
)
# 4,874 documents (608,414 token vectors) ingested in 41s
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),
) # 17ms
for result in results.objects:
# Weaviate reports the MaxSim score as a negated distance
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()
Defaults are enough here: Weaviate's dynamic ef resolves to 100 for a top-3 query, and this ranking is already exact from about 32 upward. That margin is a property of the embeddings rather than of Weaviate, so it's worth confirming on your own model instead of assuming the defaults hold.
Weaviate also supports MUVERA encoding, which made ingestion 3x faster and queries 1.8x faster in our test. It cost far more accuracy than that speed is worth at this size though: the correct third passage didn't appear even in its top 50.
Vespa
Vespa also runs in a container, but pyvespa starts it for you, so there's no separate 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 =
今日のまとめ
AIデイリーブリーフで今日の重要ニュースをまとめ読み