Multi-Vector (Late Interaction) Embedding Models with Sentence Transformers Hugging Face's Sentence Transformers library v6.0 adds a fourth model type, MultiVectorEncoder, enabling ColBERT-style late interaction retrieval. The update allows loading PyLate, Stanford-NLP ColBERT, and colpali-engine checkpoints directly, supporting stronger retrieval and state-of-the-art visual document retrieval without OCR. Multi-vector models keep one vector per token and use MaxSim scoring, trading larger index size for improved token-level matching. Sentence Similarity • 0.1B • Updated • 205k • 201 Multi-Vector Late Interaction Embedding Models with Sentence Transformers Update on GitHub https://github.com/huggingface/blog/blob/main/multi-vector-encoder.md Sentence Transformers https://sbert.net/ 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 https://github.com/lightonai/pylate checkpoint and any Stanford-NLP ColBERT https://github.com/stanford-futuredata/ColBERT checkpoint loads straight into it, and colpali-engine https://github.com/illuin-tech/colpali 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? what-are-multi-vector-models Installation installation Loading a Model loading-a-model Encoding Queries and Documents encoding-queries-and-documents Scoring with MaxSim scoring-with-maxsim Semantic Search semantic-search Retrieve and Rerank retrieve-and-rerank Indexing indexing Visual Document Retrieval visual-document-retrieval Audio Retrieval audio-retrieval Video Retrieval video-retrieval Interpretability interpretability Token Pooling token-pooling Speeding Up Inference speeding-up-inference Evaluating a Model evaluating-a-model Coming from PyLate or colpali-engine coming-from-pylate-or-colpali-engine Supported Models supported-models Acknowledgements acknowledgements Additional Resources 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 https://arxiv.org/abs/2004.12832 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. 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 https://huggingface.co/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 https://huggingface.co/lightonai/LateOn produced 608,414 token vectors, an average of 124.8 per passage: | Representation | Vectors | Dimensions | float32 size | |---|---|---|---| | Dense, all-MiniLM-L6-v2 | gte-modernbert-base LateOn 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 indexing 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 https://huggingface.co/Qwen/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 token-pooling cuts the vector count before any of that, and Retrieve and Rerank retrieve-and-rerank avoids building an index at all. PyLate https://github.com/lightonai/pylate comes up throughout this post, so briefly: Sentence Transformers handled dense and sparse models but not late interaction, so LightOn https://huggingface.co/lightonai 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 https://github.com/lightonai/fast-plaid , the late-interaction index that turns up in Indexing 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 https://sbert.net/docs/installation.html for all extras, and Multimodal Embedding & Reranker Models https://huggingface.co/blog/multimodal-sentence-transformers 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: python from sentence transformers import MultiVectorEncoder model = MultiVectorEncoder "lightonai/LateOn" To find models that work, look for the multi-vector and sentence-transformers tags https://huggingface.co/models?library=sentence-transformers&other=multi-vector 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: python 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 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: python 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 https://sbert.net/docs/multi vector encoder/usage/custom models.html . 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 https://huggingface.co/datasets/Shitao/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 https://huggingface.co/blog/lightonai/mdenseon-mlateon long-document-retrieval-mldr . 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 https://sbert.net/docs/package reference/multi vector encoder/model.html sentence transformers.multi vector encoder.model.MultiVectorEncoder.encode query and are required to get correct embeddings: https://sbert.net/docs/package reference/multi vector encoder/model.html sentence transformers.multi vector encoder.model.MultiVectorEncoder.encode document encode document python 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 https://sbert.net/docs/package reference/multi vector encoder/model.html sentence transformers.multi vector encoder.model.MultiVectorEncoder.similarity computes the full all-pairs MaxSim matrix: python 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 https://huggingface.co/blog/chungimungi/glint 1-mining-in-maxsim-space 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 https://sbert.net/docs/package reference/multi vector encoder/model.html sentence transformers.multi vector encoder.model.MultiVectorEncoder.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: python 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 https://github.com/huggingface/sentence-transformers/blob/main/examples/multi vector encoder/applications/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 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: python 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 https://github.com/huggingface/sentence-transformers/blob/main/examples/multi vector encoder/applications/retrieve rerank.py , which prints the timings of both stages. Indexing Several vector databases index and score multi-vectors natively: Qdrant https://qdrant.tech/documentation/concepts/vectors/ since v1.10, Weaviate https://docs.weaviate.io/weaviate/tutorials/multi-vector-embeddings since v1.29, Vespa https://blog.vespa.ai/announcing-long-context-colbert-in-vespa/ for years now, LanceDB https://docs.lancedb.com/search/multivector-search since v0.15.0, and VectorChord https://docs.vectorchord.ai/vectorchord/usage/indexing-with-maxsim-operators.html , which adds a MaxSim operator to Postgres that plain pgvector doesn't have. Milvus https://milvus.io/docs/array-of-structs.md 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 https://github.com/lightonai/fast-plaid is a pip install away and implements PLAID directly, and PyLate https://github.com/lightonai/pylate wraps it in a fuller retrieval stack. A few others get you partway. OpenSearch https://docs.opensearch.org/latest/search-plugins/search-relevance/rerank-by-field-late-interaction/ and Elasticsearch https://www.elastic.co/docs/reference/elasticsearch/mapping-reference/rank-vectors can rescore candidates with MaxSim but not retrieve on it, and the Elasticsearch field is additionally in technical preview and Enterprise-tier. turbopuffer https://turbopuffer.com/docs/schema 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 visual-document-retrieval go into any of these unchanged. There are simply more vectors per document, which is what makes Token Pooling 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 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 https://github.com/lightonai/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. python 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 https://qdrant.tech/documentation/concepts/vectors/ 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. 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 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 retrieve-and-rerank pattern. At 4,874 documents the full scan costs 18ms and is exact, but that doesn't extrapolate. Weaviate Weaviate https://docs.weaviate.io/weaviate/tutorials/multi-vector-embeddings 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. python 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 https://docs.vespa.ai/en/tensor-user-guide.html also runs in a container, but pyvespa starts it for you, so there's no separate docker run . python 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" is a mapped dimension over the variable token count, "x" the dense 128-dim vector package = ApplicationPackage name="colbert", schema= Schema name="doc", document=Document fields= Field name="text", type="string", indexing= "summary" , Field name="colbert", type="tensor