main
py 244 lines 7.18 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Tests for ColPali late interaction model for multi-modal retrieval.
4
5 ColPali is a multi-vector retrieval model based on PaliGemma backbone
6 (SigLIP + Gemma) with ColBERT-style late interaction scoring (MaxSim).
7 It produces per-token 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 from vllm.platforms import current_platform
23
24 from ....conftest import VllmRunner
25
26 MODELS = [
27 "vidore/colpali-v1.3-hf",
28 ]
29
30 EMBED_DIMS = {
31 "vidore/colpali-v1.3-hf": 128,
32 }
33
34 TEXT_QUERIES = [
35 "What is the capital of France?",
36 "Describe the contents of the document.",
37 ]
38
39 TEXT_DOCUMENTS = [
40 "The capital of France is Paris.",
41 "This document contains important financial data.",
42 ]
43
44 DTYPE = "half"
45 GPU_MEMORY_UTILIZATION = 0.7
46
47
48 def _make_base64_image(
49 width: int = 64, height: int = 64, color: tuple[int, int, int] = (255, 0, 0)
50 ) -> str:
51 """Create a small solid-color PNG image and return its base64 data URI."""
52 img = Image.new("RGB", (width, height), color)
53 buf = BytesIO()
54 img.save(buf, format="PNG")
55 b64 = base64.b64encode(buf.getvalue()).decode()
56 return f"data:image/png;base64,{b64}"
57
58
59 def _make_image_mm_param(
60 image_uri: str,
61 text: str | None = None,
62 ) -> ScoreMultiModalParam:
63 """Build a ScoreMultiModalParam containing an image (and optional text)."""
64 content: list = [
65 ChatCompletionContentPartImageParam(
66 type="image_url",
67 image_url={"url": image_uri},
68 ),
69 ]
70 if text is not None:
71 content.append(
72 ChatCompletionContentPartTextParam(type="text", text=text),
73 )
74 return ScoreMultiModalParam(content=content)
75
76
77 def _run_token_embed_test(
78 vllm_model: VllmRunner,
79 model: str,
80 ) -> None:
81 """Verify per-token embedding shape and L2 normalization."""
82 outputs = vllm_model.token_embed([TEXT_QUERIES[0]])
83
84 assert len(outputs) == 1
85 emb = torch.tensor(outputs[0])
86 # Token embeddings should be 2D: [num_tokens, embed_dim]
87 assert emb.dim() == 2
88 assert emb.shape[1] == EMBED_DIMS[model]
89 assert emb.shape[0] > 1
90
91 # Verify L2 normalization
92 norms = torch.norm(emb, p=2, dim=-1)
93 torch.testing.assert_close(
94 norms,
95 torch.ones_like(norms),
96 rtol=1e-2,
97 atol=1e-2,
98 )
99
100
101 def _run_late_interaction_test(
102 vllm_model: VllmRunner,
103 ) -> None:
104 """Verify MaxSim scoring matches manual computation."""
105 from vllm.entrypoints.pooling.scoring.utils import compute_maxsim_score
106
107 q_outputs = vllm_model.token_embed([TEXT_QUERIES[0]])
108 d_outputs = vllm_model.token_embed([TEXT_DOCUMENTS[0]])
109
110 q_emb = torch.tensor(q_outputs[0])
111 d_emb = torch.tensor(d_outputs[0])
112
113 manual_score = compute_maxsim_score(q_emb, d_emb).item()
114
115 vllm_scores = vllm_model.score(TEXT_QUERIES[0], TEXT_DOCUMENTS[0])
116
117 assert len(vllm_scores) == 1
118 assert vllm_scores[0] == pytest.approx(manual_score, rel=0.01)
119
120
121 def _run_relevance_test(
122 vllm_model: VllmRunner,
123 ) -> None:
124 """Verify that relevant documents score higher than irrelevant ones."""
125 query = "What is machine learning?"
126 documents = [
127 "Machine learning is a subset of artificial intelligence.",
128 "The weather forecast shows rain tomorrow.",
129 "Deep learning uses neural networks for complex tasks.",
130 ]
131
132 scores = vllm_model.score(query, documents)
133
134 assert len(scores) == 3
135 assert scores[0] > scores[1], "ML doc should score higher than weather doc"
136 assert scores[2] > scores[1], "DL doc should score higher than weather doc"
137
138
139 # ── Multimodal scoring tests ────────────────────────────────
140
141
142 def _run_multimodal_text_query_image_docs_test(
143 vllm_model: VllmRunner,
144 ) -> None:
145 """Score a text query against image documents via the multimodal path."""
146 red_image = _make_base64_image(64, 64, color=(255, 0, 0))
147 blue_image = _make_base64_image(64, 64, color=(0, 0, 255))
148
149 query = "Describe the red object"
150 image_docs = [
151 _make_image_mm_param(red_image),
152 _make_image_mm_param(blue_image),
153 ]
154 scores = vllm_model.llm.score(query, image_docs)
155
156 assert len(scores) == 2
157 for s in scores:
158 assert isinstance(s.outputs.score, float)
159
160
161 def _run_multimodal_mixed_docs_test(
162 vllm_model: VllmRunner,
163 ) -> None:
164 """Score a text query against a mix of text and image documents."""
165 red_image = _make_base64_image(64, 64, color=(255, 0, 0))
166
167 query = "What is the capital of France?"
168 documents: list = [
169 "The capital of France is Paris.",
170 _make_image_mm_param(red_image),
171 ]
172
173 scores = vllm_model.llm.score(query, documents)
174
175 assert len(scores) == 2
176 for s in scores:
177 assert isinstance(s.outputs.score, float)
178 # Text document about France should score higher than a random image
179 assert scores[0].outputs.score > scores[1].outputs.score
180
181
182 def _run_multimodal_image_query_text_docs_test(
183 vllm_model: VllmRunner,
184 ) -> None:
185 """Score an image query against text documents."""
186 red_image = _make_base64_image(64, 64, color=(255, 0, 0))
187 image_query = _make_image_mm_param(red_image, text="red color")
188
189 documents = [
190 "A bright red sports car.",
191 "The weather forecast shows rain tomorrow.",
192 ]
193
194 scores = vllm_model.llm.score(image_query, documents)
195
196 assert len(scores) == 2
197 for s in scores:
198 assert isinstance(s.outputs.score, float)
199
200
201 @pytest.mark.parametrize("model", MODELS)
202 @pytest.mark.parametrize("dtype", [DTYPE])
203 def test_colpali_default_runner(
204 vllm_runner,
205 model: str,
206 dtype: str,
207 ) -> None:
208 with vllm_runner(
209 model,
210 runner="pooling",
211 dtype=dtype,
212 max_model_len=4096,
213 enforce_eager=True,
214 gpu_memory_utilization=GPU_MEMORY_UTILIZATION,
215 ) as vllm_model:
216 _run_token_embed_test(vllm_model, model)
217 _run_late_interaction_test(vllm_model)
218 _run_relevance_test(vllm_model)
219 _run_multimodal_mixed_docs_test(vllm_model)
220 _run_multimodal_image_query_text_docs_test(vllm_model)
221
222
223 @pytest.mark.parametrize("model", MODELS)
224 @pytest.mark.parametrize("dtype", [DTYPE])
225 def test_colpali_v2_multimodal_text_query_image_docs(
226 vllm_runner,
227 monkeypatch: pytest.MonkeyPatch,
228 model: str,
229 dtype: str,
230 ) -> None:
231 monkeypatch.setenv("VLLM_USE_V2_MODEL_RUNNER", "1")
232 attention_backend = "FLASH_ATTN" if current_platform.is_cuda() else None
233 with vllm_runner(
234 model,
235 runner="pooling",
236 dtype=dtype,
237 max_model_len=4096,
238 enforce_eager=True,
239 gpu_memory_utilization=GPU_MEMORY_UTILIZATION,
240 attention_backend=attention_backend,
241 kernel_config={"enable_flashinfer_autotune": False},
242 ) as vllm_model:
243 assert vllm_model.llm.llm_engine.vllm_config.use_v2_model_runner
244 _run_multimodal_text_query_image_docs_test(vllm_model)