main
py 268 lines 7.88 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Tests for ColQwen3 late interaction model for multi-modal retrieval.
4
5 ColQwen3 is a multi-vector retrieval model based on Qwen3-VL backbone with
6 ColBERT-style late interaction scoring (MaxSim). It produces per-token
7 embeddings for both text and image inputs.
8 """
9
10 from io import BytesIO
11
12 import pybase64 as base64
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.scoring.typing 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 = [
38 "What is the capital of France?",
39 "Describe the contents of the document.",
40 ]
41
42 TEXT_DOCUMENTS = [
43 "The capital of France is Paris.",
44 "This document contains important financial data.",
45 ]
46
47 DTYPE = "half"
48 # The Tomoro model needs room for its vision encoder and a 4096-token KV cache
49 # on the 16 GiB devices used by the H200 MIG test lane.
50 GPU_MEMORY_UTILIZATION = 0.8
51
52
53 @pytest.fixture(scope="module", params=MODELS)
54 def colqwen3_model(request, vllm_runner):
55 model = request.param
56 with vllm_runner(
57 model,
58 runner="pooling",
59 dtype=DTYPE,
60 max_model_len=4096,
61 enforce_eager=True,
62 gpu_memory_utilization=GPU_MEMORY_UTILIZATION,
63 ) as vllm_model:
64 yield model, vllm_model
65
66
67 def _make_base64_image(
68 width: int = 64, height: int = 64, color: tuple[int, int, int] = (255, 0, 0)
69 ) -> str:
70 """Create a small solid-color PNG image and return its base64 data URI."""
71 img = Image.new("RGB", (width, height), color)
72 buf = BytesIO()
73 img.save(buf, format="PNG")
74 b64 = base64.b64encode(buf.getvalue()).decode()
75 return f"data:image/png;base64,{b64}"
76
77
78 def _make_image_mm_param(
79 image_uri: str,
80 text: str | None = None,
81 ) -> ScoreMultiModalParam:
82 """Build a ScoreMultiModalParam containing an image (and optional text)."""
83 content: list = [
84 ChatCompletionContentPartImageParam(
85 type="image_url",
86 image_url={"url": image_uri},
87 ),
88 ]
89 if text is not None:
90 content.append(
91 ChatCompletionContentPartTextParam(type="text", text=text),
92 )
93 return ScoreMultiModalParam(content=content)
94
95
96 def _make_text_mm_param(text: str) -> ScoreMultiModalParam:
97 """Build a ScoreMultiModalParam containing only text."""
98 return ScoreMultiModalParam(
99 content=[ChatCompletionContentPartTextParam(type="text", text=text)],
100 )
101
102
103 def _run_token_embed_test(
104 vllm_model: VllmRunner,
105 model: str,
106 ) -> None:
107 """Verify per-token embedding shape and L2 normalization."""
108 outputs = vllm_model.token_embed([TEXT_QUERIES[0]])
109
110 assert len(outputs) == 1
111 emb = torch.tensor(outputs[0])
112 # Token embeddings should be 2D: [num_tokens, embed_dim]
113 assert emb.dim() == 2
114 assert emb.shape[1] == EMBED_DIMS[model]
115 assert emb.shape[0] > 1
116
117 # Verify L2 normalization
118 norms = torch.norm(emb, p=2, dim=-1)
119 torch.testing.assert_close(
120 norms,
121 torch.ones_like(norms),
122 rtol=1e-2,
123 atol=1e-2,
124 )
125
126
127 def _run_late_interaction_test(
128 vllm_model: VllmRunner,
129 ) -> None:
130 """Verify MaxSim scoring matches manual computation."""
131 from vllm.entrypoints.pooling.scoring.utils import compute_maxsim_score
132
133 q_outputs = vllm_model.token_embed([TEXT_QUERIES[0]])
134 d_outputs = vllm_model.token_embed([TEXT_DOCUMENTS[0]])
135
136 q_emb = torch.tensor(q_outputs[0])
137 d_emb = torch.tensor(d_outputs[0])
138
139 manual_score = compute_maxsim_score(q_emb, d_emb).item()
140
141 vllm_scores = vllm_model.score(TEXT_QUERIES[0], TEXT_DOCUMENTS[0])
142
143 assert len(vllm_scores) == 1
144 assert vllm_scores[0] == pytest.approx(manual_score, rel=0.01)
145
146
147 def _run_relevance_test(
148 vllm_model: VllmRunner,
149 ) -> None:
150 """Verify that relevant documents score higher than irrelevant ones."""
151 query = "What is machine learning?"
152 documents = [
153 "Machine learning is a subset of artificial intelligence.",
154 "The weather forecast shows rain tomorrow.",
155 "Deep learning uses neural networks for complex tasks.",
156 ]
157
158 scores = vllm_model.score(query, documents)
159
160 assert len(scores) == 3
161 assert scores[0] > scores[1], "ML doc should score higher than weather doc"
162 assert scores[2] > scores[1], "DL doc should score higher than weather doc"
163
164
165 def test_colqwen3_token_embed(colqwen3_model) -> None:
166 model, vllm_model = colqwen3_model
167 _run_token_embed_test(vllm_model, model)
168
169
170 def test_colqwen3_late_interaction_scoring(colqwen3_model) -> None:
171 _, vllm_model = colqwen3_model
172 _run_late_interaction_test(vllm_model)
173
174
175 def test_colqwen3_relevance_ordering(colqwen3_model) -> None:
176 _, vllm_model = colqwen3_model
177 _run_relevance_test(vllm_model)
178
179
180 # ── Multimodal scoring tests ────────────────────────────────
181
182
183 def _run_multimodal_text_query_image_docs_test(
184 vllm_model: VllmRunner,
185 ) -> None:
186 """Score a text query against image documents via the multimodal path.
187
188 Verifies that score_data_to_prompts correctly handles image content
189 and produces valid MaxSim scores.
190 """
191 red_image = _make_base64_image(64, 64, color=(255, 0, 0))
192 blue_image = _make_base64_image(64, 64, color=(0, 0, 255))
193
194 query = "Describe the red object"
195 image_docs = [
196 _make_image_mm_param(red_image),
197 _make_image_mm_param(blue_image),
198 ]
199
200 scores = vllm_model.llm.score(query, image_docs)
201
202 assert len(scores) == 2
203 for s in scores:
204 assert isinstance(s.outputs.score, float)
205
206
207 def _run_multimodal_mixed_docs_test(
208 vllm_model: VllmRunner,
209 ) -> None:
210 """Score a text query against a mix of text and image documents.
211
212 Ensures the late-interaction path handles heterogeneous document
213 types (plain strings alongside ScoreMultiModalParam images) in
214 a single call.
215 """
216 red_image = _make_base64_image(64, 64, color=(255, 0, 0))
217
218 query = "What is the capital of France?"
219 documents: list = [
220 "The capital of France is Paris.",
221 _make_image_mm_param(red_image),
222 ]
223
224 scores = vllm_model.llm.score(query, documents)
225
226 assert len(scores) == 2
227 for s in scores:
228 assert isinstance(s.outputs.score, float)
229 # Text document about France should score higher than a random image
230 assert scores[0].outputs.score > scores[1].outputs.score
231
232
233 def _run_multimodal_image_query_text_docs_test(
234 vllm_model: VllmRunner,
235 ) -> None:
236 """Score an image query against text documents.
237
238 Verifies the reverse direction: multimodal query with text-only
239 documents through the late-interaction scoring path.
240 """
241 red_image = _make_base64_image(64, 64, color=(255, 0, 0))
242 image_query = _make_image_mm_param(red_image, text="red color")
243
244 documents = [
245 "A bright red sports car.",
246 "The weather forecast shows rain tomorrow.",
247 ]
248
249 scores = vllm_model.llm.score(image_query, documents)
250
251 assert len(scores) == 2
252 for s in scores:
253 assert isinstance(s.outputs.score, float)
254
255
256 def test_colqwen3_multimodal_text_query_image_docs(colqwen3_model) -> None:
257 _, vllm_model = colqwen3_model
258 _run_multimodal_text_query_image_docs_test(vllm_model)
259
260
261 def test_colqwen3_multimodal_mixed_docs(colqwen3_model) -> None:
262 _, vllm_model = colqwen3_model
263 _run_multimodal_mixed_docs_test(vllm_model)
264
265
266 def test_colqwen3_multimodal_image_query_text_docs(colqwen3_model) -> None:
267 _, vllm_model = colqwen3_model
268 _run_multimodal_image_query_text_docs_test(vllm_model)