@tduncan / Nvidia-Optimization-Stack-VLLM Mirror · read-only / commits / 5719a4e4e6
Fork 0

A high-throughput and memory-efficient inference and serving engine for LLMs

Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git

Synced Nov 8, 00:00 UTC

[Frontend] Support multimodal inputs for late-interaction scoring (ColQwen3) + NewModel: nvidia/nemotron-colembed (#34574)

Signed-off-by: craftsangjae <craftsangjae@gmail.com>

Kata Coder committed Feb 21, 2026 at 13:01 UTC 5719a4e4e601fb91274294d25370b7aad656d629
10 files changed +532 -66
docs/models/pooling_models.md
+59 -6
@@ -382,6 +382,7 @@ ColQwen3 is based on [ColPali](https://arxiv.org/abs/2407.01449), which extends
382 |---|---|---|
383 | `ColQwen3` | Qwen3-VL | `TomoroAI/tomoro-colqwen3-embed-4b`, `TomoroAI/tomoro-colqwen3-embed-8b` |
384 | `OpsColQwen3Model` | Qwen3-VL | `OpenSearch-AI/Ops-Colqwen3-4B`, `OpenSearch-AI/Ops-Colqwen3-8B` |
385 +| `Qwen3VLNemotronEmbedModel` | Qwen3-VL | `nvidia/nemotron-colembed-vl-4b-v2`, `nvidia/nemotron-colembed-vl-8b-v2` |
386
387 Start the server:
388
@@ -389,7 +390,9 @@ Start the server:
390 vllm serve TomoroAI/tomoro-colqwen3-embed-4b --max-model-len 4096
391 ```
392
392 -Then you can use the rerank endpoint:
393 +#### Text-only scoring and reranking
394 +
395 +Use the `/rerank` endpoint:
396
397 ```shell
398 curl -s http://localhost:8000/rerank -H "Content-Type: application/json" -d '{
@@ -403,7 +406,7 @@ curl -s http://localhost:8000/rerank -H "Content-Type: application/json" -d '{
406 }'
407 ```
408
406 -Or the score endpoint:
409 +Or the `/score` endpoint:
410
411 ```shell
412 curl -s http://localhost:8000/score -H "Content-Type: application/json" -d '{
@@ -413,7 +416,57 @@ curl -s http://localhost:8000/score -H "Content-Type: application/json" -d '{
416 }'
417 ```
418
416 -You can also get the raw token embeddings using the pooling endpoint with `token_embed` task:
419 +#### Multi-modal scoring and reranking (text query × image documents)
420 +
421 +The `/score` and `/rerank` endpoints also accept multi-modal inputs directly.
422 +Pass image documents using the `data_1`/`data_2` (for `/score`) or `documents` (for `/rerank`) fields
423 +with a `content` list containing `image_url` and `text` parts — the same format used by the
424 +OpenAI chat completion API:
425 +
426 +Score a text query against image documents:
427 +
428 +```shell
429 +curl -s http://localhost:8000/score -H "Content-Type: application/json" -d '{
430 + "model": "TomoroAI/tomoro-colqwen3-embed-4b",
431 + "data_1": "Retrieve the city of Beijing",
432 + "data_2": [
433 + {
434 + "content": [
435 + {"type": "image_url", "image_url": {"url": "data:image/png;base64,<BASE64>"}},
436 + {"type": "text", "text": "Describe the image."}
437 + ]
438 + }
439 + ]
440 +}'
441 +```
442 +
443 +Rerank image documents by a text query:
444 +
445 +```shell
446 +curl -s http://localhost:8000/rerank -H "Content-Type: application/json" -d '{
447 + "model": "TomoroAI/tomoro-colqwen3-embed-4b",
448 + "query": "Retrieve the city of Beijing",
449 + "documents": [
450 + {
451 + "content": [
452 + {"type": "image_url", "image_url": {"url": "data:image/png;base64,<BASE64_1>"}},
453 + {"type": "text", "text": "Describe the image."}
454 + ]
455 + },
456 + {
457 + "content": [
458 + {"type": "image_url", "image_url": {"url": "data:image/png;base64,<BASE64_2>"}},
459 + {"type": "text", "text": "Describe the image."}
460 + ]
461 + }
462 + ],
463 + "top_n": 2
464 +}'
465 +```
466 +
467 +#### Raw token embeddings
468 +
469 +You can also get the raw token embeddings using the `/pooling` endpoint with `token_embed` task:
470
471 ```shell
472 curl -s http://localhost:8000/pooling -H "Content-Type: application/json" -d '{
@@ -423,7 +476,7 @@ curl -s http://localhost:8000/pooling -H "Content-Type: application/json" -d '{
476 }'
477 ```
478
426 -For **image inputs**, use the chat-style `messages` field so that the vLLM multimodal processor handles them correctly:
479 +For **image inputs** via the pooling endpoint, use the chat-style `messages` field:
480
481 ```shell
482 curl -s http://localhost:8000/pooling -H "Content-Type: application/json" -d '{
@@ -440,10 +493,10 @@ curl -s http://localhost:8000/pooling -H "Content-Type: application/json" -d '{
493 }'
494 ```
495
443 -Examples can be found here:
496 +#### Examples
497
498 - Multi-vector retrieval: [examples/pooling/token_embed/colqwen3_token_embed_online.py](../../examples/pooling/token_embed/colqwen3_token_embed_online.py)
446 -- Reranking: [examples/pooling/score/colqwen3_rerank_online.py](../../examples/pooling/score/colqwen3_rerank_online.py)
499 +- Reranking (text + multi-modal): [examples/pooling/score/colqwen3_rerank_online.py](../../examples/pooling/score/colqwen3_rerank_online.py)
500
501 ### BAAI/bge-m3
502
examples/pooling/score/colqwen3_rerank_online.py
+129 -1
@@ -1,7 +1,8 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +# ruff: noqa: E501
4 """
4 -Example of using ColQwen3 late interaction model for reranking.
5 +Example of using ColQwen3 late interaction model for reranking and scoring.
6
7 ColQwen3 is a multi-modal ColBERT-style model based on Qwen3-VL.
8 It produces per-token embeddings and uses MaxSim scoring for retrieval
@@ -14,13 +15,65 @@ Then run this script:
15 python colqwen3_rerank_online.py
16 """
17
18 +import base64
19 +from io import BytesIO
20 +
21 import requests
22 +from PIL import Image
23
24 MODEL = "TomoroAI/tomoro-colqwen3-embed-4b"
25 BASE_URL = "http://127.0.0.1:8000"
26
27 headers = {"accept": "application/json", "Content-Type": "application/json"}
28
29 +# ── Image helpers ──────────────────────────────────────────
30 +
31 +
32 +def load_image(url: str) -> Image.Image:
33 + """Download an image from URL (handles Wikimedia 403)."""
34 + for hdrs in (
35 + {},
36 + {"User-Agent": "Mozilla/5.0 (compatible; ColQwen3-demo/1.0)"},
37 + ):
38 + resp = requests.get(url, headers=hdrs, timeout=15)
39 + if resp.status_code == 403:
40 + continue
41 + resp.raise_for_status()
42 + return Image.open(BytesIO(resp.content)).convert("RGB")
43 + raise RuntimeError(f"Could not fetch image from {url}")
44 +
45 +
46 +def encode_image_base64(image: Image.Image) -> str:
47 + """Encode a PIL image to a base64 data URI."""
48 + buf = BytesIO()
49 + image.save(buf, format="PNG")
50 + return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
51 +
52 +
53 +def make_image_content(image_url: str, text: str = "Describe the image.") -> dict:
54 + """Build a ScoreMultiModalParam dict from an image URL."""
55 + image = load_image(image_url)
56 + return {
57 + "content": [
58 + {
59 + "type": "image_url",
60 + "image_url": {"url": encode_image_base64(image)},
61 + },
62 + {"type": "text", "text": text},
63 + ]
64 + }
65 +
66 +
67 +# ── Sample image URLs ─────────────────────────────────────
68 +
69 +IMAGE_URLS = {
70 + "beijing": "https://upload.wikimedia.org/wikipedia/commons/6/61/Beijing_skyline_at_night.JPG",
71 + "london": "https://upload.wikimedia.org/wikipedia/commons/4/49/London_skyline.jpg",
72 + "singapore": "https://upload.wikimedia.org/wikipedia/commons/2/27/Singapore_skyline_2022.jpg",
73 +}
74 +
75 +# ── Text-only examples ────────────────────────────────────
76 +
77
78 def rerank_text():
79 """Text-only reranking via /rerank endpoint."""
@@ -120,11 +173,86 @@ def score_text_top_n():
173 print(f" {response.text[:300]}")
174
175
176 +# ── Multi-modal examples (text query × image documents) ──
177 +
178 +
179 +def score_text_vs_images():
180 + """Score a text query against image documents via /score."""
181 + print()
182 + print("=" * 60)
183 + print("4. Multi-modal scoring: text query vs image docs (/score)")
184 + print("=" * 60)
185 +
186 + query = "Retrieve the city of Beijing"
187 + labels = list(IMAGE_URLS.keys())
188 + print(f"\n Loading {len(labels)} images...")
189 + image_contents = [make_image_content(IMAGE_URLS[name]) for name in labels]
190 +
191 + data = {
192 + "model": MODEL,
193 + "data_1": query,
194 + "data_2": image_contents,
195 + }
196 +
197 + response = requests.post(f"{BASE_URL}/score", headers=headers, json=data)
198 +
199 + if response.status_code == 200:
200 + result = response.json()
201 + print(f'\n Query: "{query}"\n')
202 + for item in result["data"]:
203 + idx = item["index"]
204 + print(f" Doc {idx} [{labels[idx]}] score={item['score']:.4f}")
205 + else:
206 + print(f" Request failed: {response.status_code}")
207 + print(f" {response.text[:300]}")
208 +
209 +
210 +def rerank_text_vs_images():
211 + """Rerank image documents by a text query via /rerank."""
212 + print()
213 + print("=" * 60)
214 + print("5. Multi-modal reranking: text query vs image docs (/rerank)")
215 + print("=" * 60)
216 +
217 + query = "Retrieve the city of London"
218 + labels = list(IMAGE_URLS.keys())
219 + print(f"\n Loading {len(labels)} images...")
220 + image_contents = [make_image_content(IMAGE_URLS[name]) for name in labels]
221 +
222 + data = {
223 + "model": MODEL,
224 + "query": query,
225 + "documents": image_contents,
226 + "top_n": 2,
227 + }
228 +
229 + response = requests.post(f"{BASE_URL}/rerank", headers=headers, json=data)
230 +
231 + if response.status_code == 200:
232 + result = response.json()
233 + print(f'\n Query: "{query}"')
234 + print(f" Top {data['top_n']} results:\n")
235 + for item in result["results"]:
236 + idx = item["index"]
237 + print(f" [{item['relevance_score']:.4f}] {labels[idx]}")
238 + else:
239 + print(f" Request failed: {response.status_code}")
240 + print(f" {response.text[:300]}")
241 +
242 +
243 +# ── Main ──────────────────────────────────────────────────
244 +
245 +
246 def main():
247 + # Text-only
248 rerank_text()
249 score_text()
250 score_text_top_n()
251
252 + # Multi-modal (text query × image documents)
253 + score_text_vs_images()
254 + rerank_text_vs_images()
255 +
256
257 if __name__ == "__main__":
258 main()
tests/models/multimodal/pooling/test_colqwen3.py
+191
@@ -7,19 +7,31 @@ ColBERT-style late interaction scoring (MaxSim). It produces per-token
7 embeddings for both text and image inputs.
8 """
9
10 +import base64
11 +from io import BytesIO
12 +
13 import pytest
14 import torch
15 +from PIL import Image
16 +
17 +from vllm.entrypoints.chat_utils import (
18 + ChatCompletionContentPartImageParam,
19 + ChatCompletionContentPartTextParam,
20 +)
21 +from vllm.entrypoints.pooling.score.utils import ScoreMultiModalParam
22
23 from ....conftest import VllmRunner
24
25 MODELS = [
26 "TomoroAI/tomoro-colqwen3-embed-4b",
27 "OpenSearch-AI/Ops-Colqwen3-4B",
28 + "nvidia/nemotron-colembed-vl-4b-v2",
29 ]
30
31 EMBED_DIMS = {
32 "TomoroAI/tomoro-colqwen3-embed-4b": 320,
33 "OpenSearch-AI/Ops-Colqwen3-4B": 2560,
34 + "nvidia/nemotron-colembed-vl-4b-v2": 2560,
35 }
36
37 TEXT_QUERIES = [
@@ -33,6 +45,43 @@ TEXT_DOCUMENTS = [
45 ]
46
47 DTYPE = "half"
48 +GPU_MEMORY_UTILIZATION = 0.7
49 +
50 +
51 +def _make_base64_image(
52 + width: int = 64, height: int = 64, color: tuple[int, int, int] = (255, 0, 0)
53 +) -> str:
54 + """Create a small solid-color PNG image and return its base64 data URI."""
55 + img = Image.new("RGB", (width, height), color)
56 + buf = BytesIO()
57 + img.save(buf, format="PNG")
58 + b64 = base64.b64encode(buf.getvalue()).decode()
59 + return f"data:image/png;base64,{b64}"
60 +
61 +
62 +def _make_image_mm_param(
63 + image_uri: str,
64 + text: str | None = None,
65 +) -> ScoreMultiModalParam:
66 + """Build a ScoreMultiModalParam containing an image (and optional text)."""
67 + content: list = [
68 + ChatCompletionContentPartImageParam(
69 + type="image_url",
70 + image_url={"url": image_uri},
71 + ),
72 + ]
73 + if text is not None:
74 + content.append(
75 + ChatCompletionContentPartTextParam(type="text", text=text),
76 + )
77 + return ScoreMultiModalParam(content=content)
78 +
79 +
80 +def _make_text_mm_param(text: str) -> ScoreMultiModalParam:
81 + """Build a ScoreMultiModalParam containing only text."""
82 + return ScoreMultiModalParam(
83 + content=[ChatCompletionContentPartTextParam(type="text", text=text)],
84 + )
85
86
87 def _run_token_embed_test(
@@ -48,6 +97,7 @@ def _run_token_embed_test(
97 dtype=dtype,
98 max_model_len=4096,
99 enforce_eager=True,
100 + gpu_memory_utilization=GPU_MEMORY_UTILIZATION,
101 ) as vllm_model:
102 outputs = vllm_model.token_embed([TEXT_QUERIES[0]])
103
@@ -83,6 +133,7 @@ def _run_late_interaction_test(
133 dtype=dtype,
134 max_model_len=4096,
135 enforce_eager=True,
136 + gpu_memory_utilization=GPU_MEMORY_UTILIZATION,
137 ) as vllm_model:
138 q_outputs = vllm_model.token_embed([TEXT_QUERIES[0]])
139 d_outputs = vllm_model.token_embed([TEXT_DOCUMENTS[0]])
@@ -118,6 +169,7 @@ def _run_relevance_test(
169 dtype=dtype,
170 max_model_len=4096,
171 enforce_eager=True,
172 + gpu_memory_utilization=GPU_MEMORY_UTILIZATION,
173 ) as vllm_model:
174 scores = vllm_model.score(query, documents)
175
@@ -154,3 +206,142 @@ def test_colqwen3_relevance_ordering(
206 dtype: str,
207 ) -> None:
208 _run_relevance_test(vllm_runner, model, dtype=dtype)
209 +
210 +
211 +# ── Multimodal scoring tests ────────────────────────────────
212 +
213 +
214 +def _run_multimodal_text_query_image_docs_test(
215 + vllm_runner: type[VllmRunner],
216 + model: str,
217 + *,
218 + dtype: str,
219 +) -> None:
220 + """Score a text query against image documents via the multimodal path.
221 +
222 + Verifies that score_data_to_prompts correctly handles image content
223 + and produces valid MaxSim scores.
224 + """
225 + red_image = _make_base64_image(64, 64, color=(255, 0, 0))
226 + blue_image = _make_base64_image(64, 64, color=(0, 0, 255))
227 +
228 + query = "Describe the red object"
229 + image_docs = [
230 + _make_image_mm_param(red_image),
231 + _make_image_mm_param(blue_image),
232 + ]
233 +
234 + with vllm_runner(
235 + model,
236 + runner="pooling",
237 + dtype=dtype,
238 + max_model_len=4096,
239 + enforce_eager=True,
240 + gpu_memory_utilization=GPU_MEMORY_UTILIZATION,
241 + ) as vllm_model:
242 + scores = vllm_model.llm.score(query, image_docs)
243 +
244 + assert len(scores) == 2
245 + for s in scores:
246 + assert isinstance(s.outputs.score, float)
247 +
248 +
249 +def _run_multimodal_mixed_docs_test(
250 + vllm_runner: type[VllmRunner],
251 + model: str,
252 + *,
253 + dtype: str,
254 +) -> None:
255 + """Score a text query against a mix of text and image documents.
256 +
257 + Ensures the late-interaction path handles heterogeneous document
258 + types (plain strings alongside ScoreMultiModalParam images) in
259 + a single call.
260 + """
261 + red_image = _make_base64_image(64, 64, color=(255, 0, 0))
262 +
263 + query = "What is the capital of France?"
264 + documents: list = [
265 + "The capital of France is Paris.",
266 + _make_image_mm_param(red_image),
267 + ]
268 +
269 + with vllm_runner(
270 + model,
271 + runner="pooling",
272 + dtype=dtype,
273 + max_model_len=4096,
274 + enforce_eager=True,
275 + gpu_memory_utilization=GPU_MEMORY_UTILIZATION,
276 + ) as vllm_model:
277 + scores = vllm_model.llm.score(query, documents)
278 +
279 + assert len(scores) == 2
280 + for s in scores:
281 + assert isinstance(s.outputs.score, float)
282 + # Text document about France should score higher than a random image
283 + assert scores[0].outputs.score > scores[1].outputs.score
284 +
285 +
286 +def _run_multimodal_image_query_text_docs_test(
287 + vllm_runner: type[VllmRunner],
288 + model: str,
289 + *,
290 + dtype: str,
291 +) -> None:
292 + """Score an image query against text documents.
293 +
294 + Verifies the reverse direction: multimodal query with text-only
295 + documents through the late-interaction scoring path.
296 + """
297 + red_image = _make_base64_image(64, 64, color=(255, 0, 0))
298 + image_query = _make_image_mm_param(red_image, text="red color")
299 +
300 + documents = [
301 + "A bright red sports car.",
302 + "The weather forecast shows rain tomorrow.",
303 + ]
304 +
305 + with vllm_runner(
306 + model,
307 + runner="pooling",
308 + dtype=dtype,
309 + max_model_len=4096,
310 + enforce_eager=True,
311 + gpu_memory_utilization=GPU_MEMORY_UTILIZATION,
312 + ) as vllm_model:
313 + scores = vllm_model.llm.score(image_query, documents)
314 +
315 + assert len(scores) == 2
316 + for s in scores:
317 + assert isinstance(s.outputs.score, float)
318 +
319 +
320 +@pytest.mark.parametrize("model", MODELS)
321 +@pytest.mark.parametrize("dtype", [DTYPE])
322 +def test_colqwen3_multimodal_text_query_image_docs(
323 + vllm_runner,
324 + model: str,
325 + dtype: str,
326 +) -> None:
327 + _run_multimodal_text_query_image_docs_test(vllm_runner, model, dtype=dtype)
328 +
329 +
330 +@pytest.mark.parametrize("model", MODELS)
331 +@pytest.mark.parametrize("dtype", [DTYPE])
332 +def test_colqwen3_multimodal_mixed_docs(
333 + vllm_runner,
334 + model: str,
335 + dtype: str,
336 +) -> None:
337 + _run_multimodal_mixed_docs_test(vllm_runner, model, dtype=dtype)
338 +
339 +
340 +@pytest.mark.parametrize("model", MODELS)
341 +@pytest.mark.parametrize("dtype", [DTYPE])
342 +def test_colqwen3_multimodal_image_query_text_docs(
343 + vllm_runner,
344 + model: str,
345 + dtype: str,
346 +) -> None:
347 + _run_multimodal_image_query_text_docs_test(vllm_runner, model, dtype=dtype)
tests/models/registry.py
+3
@@ -603,6 +603,9 @@ _EMBEDDING_EXAMPLE_MODELS = {
603 "OpsColQwen3Model": _HfExamplesInfo(
604 "OpenSearch-AI/Ops-Colqwen3-4B", trust_remote_code=True
605 ),
606 + "Qwen3VLNemotronEmbedModel": _HfExamplesInfo(
607 + "nvidia/nemotron-colembed-vl-4b-v2",
608 + ),
609 "SiglipModel": _HfExamplesInfo("google/siglip-base-patch16-224"),
610 "PrithviGeoSpatialMAE": _HfExamplesInfo(
611 "ibm-nasa-geospatial/Prithvi-EO-2.0-300M-TL-Sen1Floods11",
vllm/entrypoints/llm.py
+9 -20
@@ -50,6 +50,7 @@ from vllm.entrypoints.pooling.score.utils import (
50 compress_token_type_ids,
51 compute_maxsim_score,
52 get_score_prompt,
53 + score_data_to_prompts,
54 validate_score_input,
55 )
56 from vllm.entrypoints.utils import log_non_default_args
@@ -1395,25 +1396,13 @@ class LLM:
1396
1397 tokenizer = self.get_tokenizer()
1398
1398 - # Extract text from ScoreData
1399 - text_1: list[str] = []
1400 - for text in data_1:
1401 - if not isinstance(text, str):
1402 - raise NotImplementedError(
1403 - "Late interaction scores currently do not support multimodal input."
1404 - )
1405 - text_1.append(text)
1406 -
1407 - text_2: list[str] = []
1408 - for text in data_2:
1409 - if not isinstance(text, str):
1410 - raise NotImplementedError(
1411 - "Late interaction scores currently do not support multimodal input."
1412 - )
1413 - text_2.append(text)
1399 + # Convert ScoreData to PromptType (handles both text and multimodal)
1400 + model_config = self.model_config
1401 + prompts_1 = score_data_to_prompts(data_1, "query", model_config)
1402 + prompts_2 = score_data_to_prompts(data_2, "document", model_config)
1403
1415 - encoded_output = self.encode(
1416 - text_1 + text_2,
1404 + encoded_output: list[PoolingRequestOutput] = self.encode(
1405 + prompts_1 + prompts_2,
1406 use_tqdm=use_tqdm,
1407 lora_request=lora_request,
1408 pooling_params=pooling_params,
@@ -1421,8 +1410,8 @@ class LLM:
1410 tokenization_kwargs=tokenization_kwargs,
1411 )
1412
1424 - encoded_output_1 = encoded_output[0 : len(text_1)]
1425 - encoded_output_2 = encoded_output[len(text_1) :]
1413 + encoded_output_1: list[PoolingRequestOutput] = encoded_output[: len(prompts_1)]
1414 + encoded_output_2: list[PoolingRequestOutput] = encoded_output[len(prompts_1) :]
1415
1416 if len(encoded_output_1) == 1:
1417 encoded_output_1 = encoded_output_1 * len(encoded_output_2)
vllm/entrypoints/pooling/score/serving.py
+60 -23
@@ -33,6 +33,7 @@ from vllm.entrypoints.pooling.score.utils import (
33 compress_token_type_ids,
34 compute_maxsim_score,
35 get_score_prompt,
36 + parse_score_data_single,
37 validate_score_input,
38 )
39 from vllm.inputs.data import ProcessorInputs, TokensPrompt, token_inputs
@@ -174,6 +175,43 @@ class ServingScores(OpenAIServing):
175
176 return final_res_batch
177
178 + def _preprocess_late_interaction_item(
179 + self,
180 + data: ScoreData,
181 + role: str,
182 + request: RerankRequest | ScoreRequest,
183 + tokenizer: TokenizerLike,
184 + tokenization_kwargs: dict[str, Any],
185 + ) -> tuple[str, TokensPrompt]:
186 + """Parse a single ScoreData into a text + optional multimodal
187 + TokensPrompt for late-interaction encoding.
188 +
189 + For plain strings, tokenises directly.
190 + For multimodal content parts, extracts text and multi_modal_data.
191 + """
192 + model_config = self.model_config
193 +
194 + if isinstance(data, str):
195 + text, mm_data, mm_uuids = data, None, None
196 + else:
197 + text, mm_data, mm_uuids = parse_score_data_single(data, role, model_config)
198 +
199 + prompt_inputs = tokenizer(text, **tokenization_kwargs)
200 + self._validate_input(request, prompt_inputs["input_ids"], text)
201 +
202 + engine_prompt = TokensPrompt(
203 + prompt_token_ids=prompt_inputs["input_ids"],
204 + )
205 +
206 + if mm_data is not None:
207 + engine_prompt["multi_modal_data"] = mm_data
208 + if mm_uuids is not None:
209 + engine_prompt["multi_modal_uuids"] = mm_uuids
210 + if request.mm_processor_kwargs is not None:
211 + engine_prompt["mm_processor_kwargs"] = request.mm_processor_kwargs
212 +
213 + return text, engine_prompt
214 +
215 async def _late_interaction_score(
216 self,
217 data_1: list[ScoreData],
@@ -189,37 +227,36 @@ class ServingScores(OpenAIServing):
227 Encodes queries and documents into per-token embeddings, then computes
228 MaxSim: sum over query tokens of max similarity to any document token.
229 """
192 - input_texts: list[str] = []
193 - for text in data_1 + data_2:
194 - if not isinstance(text, str):
195 - raise NotImplementedError(
196 - "Late interaction scores currently do not support multimodal input."
197 - )
198 - input_texts.append(text)
199 -
230 model_config = self.model_config
231 tokenizer = self.renderer.get_tokenizer()
232 + tokenization_kwargs = request.build_tok_params(model_config).get_encode_kwargs()
233
203 - encode_async = make_async(
204 - tokenizer.encode,
205 - executor=self._tokenizer_executor,
206 - )
234 + all_data = data_1 + data_2
235 + roles = ["query"] * len(data_1) + ["document"] * len(data_2)
236
208 - tokenization_kwargs = request.build_tok_params(model_config).get_encode_kwargs()
209 - tokenized_prompts = await asyncio.gather(
210 - *(encode_async(t, **tokenization_kwargs) for t in input_texts)
237 + preprocess_async = make_async(
238 + self._preprocess_late_interaction_item,
239 + executor=self._tokenizer_executor,
240 )
241
213 - engine_prompts: list[ProcessorInputs] = []
214 - for tok_result, input_text in zip(tokenized_prompts, input_texts):
215 - text_token_prompt = self._validate_input(request, tok_result, input_text)
216 -
217 - engine_prompts.append(
218 - token_inputs(
219 - text_token_prompt["prompt_token_ids"],
220 - prompt=input_text,
242 + preprocessed = await asyncio.gather(
243 + *(
244 + preprocess_async(
245 + data=d,
246 + role=r,
247 + request=request,
248 + tokenizer=tokenizer,
249 + tokenization_kwargs=tokenization_kwargs,
250 )
251 + for d, r in zip(all_data, roles)
252 )
253 + )
254 +
255 + input_texts: list[str] = []
256 + engine_prompts: list[TokensPrompt] = []
257 + for text, engine_prompt in preprocessed:
258 + input_texts.append(text)
259 + engine_prompts.append(engine_prompt)
260
261 # Schedule the request and get the result generator.
262 generators: list[AsyncGenerator[PoolingRequestOutput, None]] = []
vllm/entrypoints/pooling/score/utils.py
+71 -10
@@ -21,6 +21,7 @@ from vllm.entrypoints.chat_utils import (
21 _parse_chat_message_content_parts,
22 )
23 from vllm.inputs import TokensPrompt
24 +from vllm.inputs.data import PromptType, TextPrompt
25 from vllm.model_executor.models.interfaces import supports_score_template
26 from vllm.multimodal.inputs import MultiModalDataDict, MultiModalUUIDDict
27 from vllm.outputs import PoolingRequestOutput
@@ -153,31 +154,91 @@ def validate_score_input(
154 return score_input_1, score_input_2
155
156
157 +def _ensure_str(content: list[ConversationMessage]) -> str:
158 + """Extract a single string prompt from parsed conversation content."""
159 + assert len(content) == 1
160 + prompt = content[0]["content"]
161 + if prompt is not None and isinstance(prompt, str):
162 + return cast(str, prompt)
163 + raise ValueError(f"Only string content is supported, but got {content}.")
164 +
165 +
166 def parse_score_data(
167 data_1: ScoreData,
168 data_2: ScoreData,
169 model_config: ModelConfig,
170 ) -> tuple[str, str, MultiModalDataDict | None, MultiModalUUIDDict | None]:
171 + """Parse a query-document pair into text prompts and shared multi-modal
172 + data.
173 +
174 + Uses a **single** :class:`MultiModalItemTracker` so that multi-modal
175 + items from both inputs are merged into one ``mm_data`` dict. This is
176 + the correct behaviour for cross-encoder scoring, where query and
177 + document are concatenated into a single model prompt.
178 + """
179 mm_tracker = MultiModalItemTracker(model_config)
180
181 content_1 = _parse_score_content("query", data_1, mm_tracker)
182 content_2 = _parse_score_content("document", data_2, mm_tracker)
183
166 - def ensure_str(content: list[ConversationMessage]) -> str:
167 - assert len(content) == 1
168 - prompt = content[0]["content"]
169 - if prompt is not None and isinstance(prompt, str):
170 - return cast(str, prompt)
171 - else:
172 - raise ValueError(f"Only string content is supported, but got {content}.")
173 -
174 - prompt_1 = ensure_str(content_1)
175 - prompt_2 = ensure_str(content_2)
184 + prompt_1 = _ensure_str(content_1)
185 + prompt_2 = _ensure_str(content_2)
186 mm_items, mm_uuids = mm_tracker.resolve_items()
187
188 return prompt_1, prompt_2, mm_items, mm_uuids
189
190
191 +def parse_score_data_single(
192 + data: ScoreData,
193 + role: str,
194 + model_config: ModelConfig,
195 +) -> tuple[str, MultiModalDataDict | None, MultiModalUUIDDict | None]:
196 + """Parse **one** ScoreData into a text prompt and its own multi-modal
197 + data.
198 +
199 + Unlike :func:`parse_score_data`, each call creates an **independent**
200 + :class:`MultiModalItemTracker` so multi-modal items are kept separate.
201 + This is the correct behaviour for late-interaction scoring, where
202 + query and document are encoded independently.
203 + """
204 + mm_tracker = MultiModalItemTracker(model_config)
205 + content = _parse_score_content(role, data, mm_tracker)
206 +
207 + prompt = _ensure_str(content)
208 + mm_items, mm_uuids = mm_tracker.resolve_items()
209 + return prompt, mm_items, mm_uuids
210 +
211 +
212 +def score_data_to_prompts(
213 + data_list: list[ScoreData],
214 + role: str,
215 + model_config: ModelConfig,
216 +) -> list[PromptType]:
217 + """Convert a list of ScoreData into PromptType objects.
218 +
219 + For plain text inputs, returns the string directly.
220 + For multimodal inputs (list of content parts), parses them into
221 + a :class:`TextPrompt` with attached ``multi_modal_data`` /
222 + ``multi_modal_uuids``.
223 +
224 + This is used by late-interaction scoring where each query/document
225 + is encoded independently.
226 + """
227 + prompts: list[PromptType] = []
228 + for data in data_list:
229 + if isinstance(data, str):
230 + prompts.append(data)
231 + else:
232 + text, mm_data, mm_uuids = parse_score_data_single(data, role, model_config)
233 + prompt: TextPrompt = TextPrompt(prompt=text)
234 + if mm_data is not None:
235 + prompt["multi_modal_data"] = mm_data
236 + if mm_uuids is not None:
237 + prompt["multi_modal_uuids"] = mm_uuids
238 + prompts.append(prompt)
239 + return prompts
240 +
241 +
242 def _parse_score_content(
243 role: str,
244 data: ScoreData,
vllm/model_executor/models/colqwen3.py
+8 -6
@@ -16,6 +16,7 @@ Based on: Qwen3-VL backbone with custom text projection
16 Target models:
17 - TomoroAI/tomoro-colqwen3-embed-8b
18 - OpenSearch-AI/Ops-Colqwen3-4B
19 +- nvidia/nemotron-colembed-vl-4b-v2
20 """
21
22 from collections.abc import Iterable, Mapping
@@ -229,13 +230,14 @@ class ColQwen3Model(
230 if not isinstance(hidden_states, torch.Tensor):
231 return hidden_states # type: ignore
232
232 - proj_dtype = self.custom_text_proj.weight.dtype # type: ignore
233 - if hidden_states.dtype != proj_dtype:
234 - hidden_states = hidden_states.to(proj_dtype)
233 + if self.custom_text_proj is not None:
234 + proj_dtype = self.custom_text_proj.weight.dtype
235 + if hidden_states.dtype != proj_dtype:
236 + hidden_states = hidden_states.to(proj_dtype)
237 + hidden_states = self.custom_text_proj(hidden_states)
238
236 - # Project to embedding dimension and L2 normalize
237 - proj = self.custom_text_proj(hidden_states) # type: ignore
238 - return torch.nn.functional.normalize(proj, p=2, dim=-1)
239 + # L2 normalize
240 + return torch.nn.functional.normalize(hidden_states, p=2, dim=-1)
241
242 # Names used for the projection layer across different ColQwen3 variants
243 _PROJ_LAYER_NAMES = {
vllm/model_executor/models/registry.py
+1
@@ -256,6 +256,7 @@ _EMBEDDING_MODELS = {
256 "Qwen2VLForConditionalGeneration": ("qwen2_vl", "Qwen2VLForConditionalGeneration"), # noqa: E501
257 "ColQwen3": ("colqwen3", "ColQwen3Model"),
258 "OpsColQwen3Model": ("colqwen3", "ColQwen3Model"),
259 + "Qwen3VLNemotronEmbedModel": ("colqwen3", "ColQwen3Model"),
260 "SiglipModel": ("siglip", "SiglipEmbeddingModel"),
261 # Technically Terratorch models work on images, both in
262 # input and output. I am adding it here because it piggy-backs on embedding
vllm/transformers_utils/config.py
+1
@@ -76,6 +76,7 @@ _CONFIG_REGISTRY: dict[str, type[PretrainedConfig]] = LazyConfigDict(
76 chatglm="ChatGLMConfig",
77 colqwen3="ColQwen3Config",
78 ops_colqwen3="OpsColQwen3Config",
79 + qwen3_vl_nemotron_embed="Qwen3VLNemotronEmbedConfig",
80 deepseek_vl_v2="DeepseekVLV2Config",
81 deepseek_v32="DeepseekV3Config",
82 flex_olmo="FlexOlmoConfig",