| 1 | # SPDX-License-Identifier: Apache-2.0 |
| 2 | # SPDX-FileCopyrightText: Copyright contributors to the vLLM project |
| 3 | |
| 4 | import pytest |
| 5 | from transformers import AutoModel |
| 6 | |
| 7 | from vllm.assets.base import VLLM_S3_BUCKET_URL |
| 8 | from vllm.entrypoints.chat_utils import ( |
| 9 | ChatCompletionContentPartImageEmbedsParam, |
| 10 | ChatCompletionContentPartImageParam, |
| 11 | ChatCompletionContentPartTextParam, |
| 12 | ) |
| 13 | from vllm.entrypoints.pooling.scoring.typing import ScoreMultiModalParam |
| 14 | |
| 15 | from ....conftest import HfRunner, VllmRunner |
| 16 | |
| 17 | MODELS = ["jinaai/jina-reranker-m0"] |
| 18 | |
| 19 | MM_PROCESSOR_KWARGS = { |
| 20 | "min_pixels": 3136, |
| 21 | "max_pixels": 602112, |
| 22 | } |
| 23 | |
| 24 | LIMIT_MM_PER_PROMPT = {"image": 2} |
| 25 | |
| 26 | CHECKPOINT_TO_HF_MAPPER = { |
| 27 | r"^visual\.": "model.visual.", |
| 28 | r"^model\.(?!language_model\.|visual\.)": "model.language_model.", |
| 29 | } |
| 30 | |
| 31 | HANDELSBLATT_IMAGE_URL = ( |
| 32 | f"{VLLM_S3_BUCKET_URL}/multimodal_asset/jinavl-handelsblatt-preview.png" |
| 33 | ) |
| 34 | PAPER_IMAGE_URL = f"{VLLM_S3_BUCKET_URL}/multimodal_asset/jinavl-paper-11.png" |
| 35 | |
| 36 | # Shared long text for test data |
| 37 | LONG_TEXT_DOC = """We present ReaderLM-v2, a compact 1.5 billion parameter language model designed for efficient |
| 38 | web content extraction. Our model processes documents up to 512K tokens, transforming messy HTML |
| 39 | into clean Markdown or JSON formats with high accuracy -- making it an ideal tool for grounding |
| 40 | large language models. The models effectiveness results from two key innovations: (1) a three-stage |
| 41 | data synthesis pipeline that generates high quality, diverse training data by iteratively drafting, |
| 42 | refining, and critiquing web content extraction; and (2) a unified training framework combining |
| 43 | continuous pre-training with multi-objective optimization. Intensive evaluation demonstrates that |
| 44 | ReaderLM-v2 outperforms GPT-4o-2024-08-06 and other larger models by 15-20% on carefully curated |
| 45 | benchmarks, particularly excelling at documents exceeding 100K tokens, while maintaining significantly |
| 46 | lower computational requirements.""" # noqa: E501 |
| 47 | |
| 48 | # Test data for different scenarios |
| 49 | TEXT_IMAGE_TEST_DATA = { |
| 50 | "query": [{"text": "slm markdown"}], |
| 51 | "documents": [ |
| 52 | {"image": HANDELSBLATT_IMAGE_URL}, |
| 53 | {"image": PAPER_IMAGE_URL}, |
| 54 | ], |
| 55 | } |
| 56 | |
| 57 | TEXT_TEXT_TEST_DATA = { |
| 58 | "query": [{"text": "slm markdown"}], |
| 59 | "documents": [ |
| 60 | {"text": LONG_TEXT_DOC}, |
| 61 | {"text": "数据提取么?为什么不用正则啊,你用正则不就全解决了么?"}, |
| 62 | ], |
| 63 | } |
| 64 | |
| 65 | IMAGE_TEXT_TEST_DATA = { |
| 66 | "query": [{"image": PAPER_IMAGE_URL}], |
| 67 | "documents": [ |
| 68 | {"text": LONG_TEXT_DOC}, |
| 69 | {"text": "数据提取么?为什么不用正则啊,你用正则不就全解决了么?"}, |
| 70 | ], |
| 71 | } |
| 72 | |
| 73 | IMAGE_IMAGE_TEST_DATA = { |
| 74 | "query": [{"image": PAPER_IMAGE_URL}], |
| 75 | "documents": [ |
| 76 | {"image": HANDELSBLATT_IMAGE_URL}, |
| 77 | {"image": PAPER_IMAGE_URL}, |
| 78 | ], |
| 79 | } |
| 80 | |
| 81 | TEXT_MIXED_DOCS_TEST_DATA = { |
| 82 | "query": [{"text": "slm markdown"}], |
| 83 | "documents": [ |
| 84 | {"text": LONG_TEXT_DOC}, |
| 85 | {"image": PAPER_IMAGE_URL}, |
| 86 | {"text": "数据提取么?为什么不用正则啊,你用正则不就全解决了么?"}, |
| 87 | {"image": HANDELSBLATT_IMAGE_URL}, |
| 88 | ], |
| 89 | } |
| 90 | |
| 91 | |
| 92 | def _normalize_image(image_val: str) -> str: |
| 93 | """Normalize image value to proper format for HF model.""" |
| 94 | return ( |
| 95 | image_val |
| 96 | if image_val.startswith(("http://", "https://")) |
| 97 | else f"data:image/png;base64,{image_val}" |
| 98 | ) |
| 99 | |
| 100 | |
| 101 | def create_score_multimodal_param( |
| 102 | content_parts: list[dict], |
| 103 | ) -> list[ScoreMultiModalParam]: |
| 104 | """Create a ScoreMultiModalParam from a list of content dictionaries. |
| 105 | |
| 106 | Each dict supports the following formats: |
| 107 | - Text: {'text': 'content'} |
| 108 | - Image URL: {'image': 'https://...'} |
| 109 | - Image Base64: {'image': 'base64_str'} |
| 110 | """ |
| 111 | formatted_content = [] |
| 112 | |
| 113 | for part in content_parts: |
| 114 | if "text" in part: |
| 115 | formatted_content.append( |
| 116 | ChatCompletionContentPartTextParam( |
| 117 | type="text", |
| 118 | text=part["text"], |
| 119 | ) |
| 120 | ) |
| 121 | elif "image" in part: |
| 122 | image_val = part["image"] |
| 123 | if image_val.startswith(("http://", "https://")): |
| 124 | formatted_content.append( |
| 125 | ChatCompletionContentPartImageParam( |
| 126 | type="image_url", |
| 127 | image_url={"url": image_val}, |
| 128 | ) |
| 129 | ) |
| 130 | else: |
| 131 | formatted_content.append( |
| 132 | ChatCompletionContentPartImageEmbedsParam( |
| 133 | type="image_embeds", image_embeds=image_val |
| 134 | ) |
| 135 | ) |
| 136 | |
| 137 | return [ScoreMultiModalParam(content=[content]) for content in formatted_content] |
| 138 | |
| 139 | |
| 140 | def _run_vllm( |
| 141 | vllm_runner: type[VllmRunner], |
| 142 | model: str, |
| 143 | dtype: str, |
| 144 | query_strs: list[dict[str, str]], |
| 145 | document_strs: list[dict[str, str]], |
| 146 | ) -> list[float]: |
| 147 | """Run vLLM reranker and return scores.""" |
| 148 | query = create_score_multimodal_param(query_strs) |
| 149 | documents = create_score_multimodal_param(document_strs) |
| 150 | |
| 151 | with vllm_runner( |
| 152 | model, |
| 153 | runner="pooling", |
| 154 | dtype=dtype, |
| 155 | max_num_seqs=2, |
| 156 | max_model_len=2048, |
| 157 | mm_processor_kwargs=MM_PROCESSOR_KWARGS, |
| 158 | limit_mm_per_prompt=LIMIT_MM_PER_PROMPT, |
| 159 | ) as vllm_model: |
| 160 | outputs = vllm_model.llm.score(query, documents) |
| 161 | |
| 162 | return [output.outputs.score for output in outputs] |
| 163 | |
| 164 | |
| 165 | def _run_hf( |
| 166 | hf_runner: type[HfRunner], |
| 167 | model: str, |
| 168 | dtype: str, |
| 169 | query_strs: list[dict[str, str]], |
| 170 | document_strs: list[dict[str, str]], |
| 171 | ) -> list[float]: |
| 172 | """Run HuggingFace reranker and return scores.""" |
| 173 | query = query_strs[0] |
| 174 | if "text" in query: |
| 175 | query_type = "text" |
| 176 | query_data = query["text"] |
| 177 | elif "image" in query: |
| 178 | query_type = "image" |
| 179 | query_data = _normalize_image(query["image"]) |
| 180 | else: |
| 181 | raise ValueError("Unsupported query format") |
| 182 | |
| 183 | scores: list[float] = [] |
| 184 | |
| 185 | with hf_runner( |
| 186 | model, |
| 187 | dtype=dtype, |
| 188 | trust_remote_code=True, |
| 189 | auto_cls=AutoModel, |
| 190 | model_kwargs={"key_mapping": CHECKPOINT_TO_HF_MAPPER}, |
| 191 | ) as hf_model: |
| 192 | for doc in document_strs: |
| 193 | if "text" in doc: |
| 194 | score = hf_model.model.compute_score( |
| 195 | [[query_data, doc["text"]]], |
| 196 | max_length=2048, |
| 197 | query_type=query_type, |
| 198 | doc_type="text", |
| 199 | ) |
| 200 | scores.append(score) |
| 201 | elif "image" in doc: |
| 202 | score = hf_model.model.compute_score( |
| 203 | [[query_data, doc["image"]]], |
| 204 | max_length=2048, |
| 205 | query_type=query_type, |
| 206 | doc_type="image", |
| 207 | ) |
| 208 | scores.append(score) |
| 209 | return scores |
| 210 | |
| 211 | |
| 212 | def _run_test( |
| 213 | hf_runner: type[HfRunner], |
| 214 | vllm_runner: type[VllmRunner], |
| 215 | model: str, |
| 216 | dtype: str, |
| 217 | query_strs: list[dict[str, str]], |
| 218 | document_strs: list[dict[str, str]], |
| 219 | ) -> None: |
| 220 | """Run comparison test between vLLM and HuggingFace implementations.""" |
| 221 | # NOTE: take care of the order. run vLLM first, and then run HF. |
| 222 | # vLLM needs a fresh new process without cuda initialization. |
| 223 | # if we run HF first, the cuda initialization will be done and it |
| 224 | # will hurt multiprocessing backend with fork method (the default method). |
| 225 | |
| 226 | vllm_outputs = _run_vllm(vllm_runner, model, dtype, query_strs, document_strs) |
| 227 | hf_outputs = _run_hf(hf_runner, model, dtype, query_strs, document_strs) |
| 228 | |
| 229 | # Compare outputs |
| 230 | assert len(hf_outputs) == len(vllm_outputs), ( |
| 231 | f"Output length mismatch: HF={len(hf_outputs)}, vLLM={len(vllm_outputs)}" |
| 232 | ) |
| 233 | |
| 234 | for i, (hf_score, vllm_score) in enumerate(zip(hf_outputs, vllm_outputs)): |
| 235 | assert hf_score == pytest.approx(vllm_score, rel=0.02), ( |
| 236 | f"Score mismatch at index {i}: HF={hf_score}, vLLM={vllm_score}" |
| 237 | ) |
| 238 | |
| 239 | |
| 240 | @pytest.mark.parametrize("model", MODELS) |
| 241 | @pytest.mark.parametrize("dtype", ["half"]) |
| 242 | def test_model_text_image( |
| 243 | hf_runner, |
| 244 | vllm_runner, |
| 245 | model: str, |
| 246 | dtype: str, |
| 247 | ) -> None: |
| 248 | """Visual Documents Reranking.""" |
| 249 | _run_test( |
| 250 | hf_runner, |
| 251 | vllm_runner, |
| 252 | model, |
| 253 | dtype, |
| 254 | TEXT_IMAGE_TEST_DATA["query"], |
| 255 | TEXT_IMAGE_TEST_DATA["documents"], |
| 256 | ) |
| 257 | |
| 258 | |
| 259 | @pytest.mark.parametrize("model", MODELS) |
| 260 | @pytest.mark.parametrize("dtype", ["half"]) |
| 261 | def test_model_text_text( |
| 262 | hf_runner, |
| 263 | vllm_runner, |
| 264 | model: str, |
| 265 | dtype: str, |
| 266 | ) -> None: |
| 267 | """Textual Documents Reranking.""" |
| 268 | _run_test( |
| 269 | hf_runner, |
| 270 | vllm_runner, |
| 271 | model, |
| 272 | dtype, |
| 273 | TEXT_TEXT_TEST_DATA["query"], |
| 274 | TEXT_TEXT_TEST_DATA["documents"], |
| 275 | ) |
| 276 | |
| 277 | |
| 278 | @pytest.mark.parametrize("model", MODELS) |
| 279 | @pytest.mark.parametrize("dtype", ["half"]) |
| 280 | def test_model_image_text( |
| 281 | hf_runner, |
| 282 | vllm_runner, |
| 283 | model: str, |
| 284 | dtype: str, |
| 285 | ) -> None: |
| 286 | """Image Querying for Textual Documents.""" |
| 287 | _run_test( |
| 288 | hf_runner, |
| 289 | vllm_runner, |
| 290 | model, |
| 291 | dtype, |
| 292 | IMAGE_TEXT_TEST_DATA["query"], |
| 293 | IMAGE_TEXT_TEST_DATA["documents"], |
| 294 | ) |
| 295 | |
| 296 | |
| 297 | @pytest.mark.parametrize("model", MODELS) |
| 298 | @pytest.mark.parametrize("dtype", ["half"]) |
| 299 | def test_model_image_image( |
| 300 | hf_runner, |
| 301 | vllm_runner, |
| 302 | model: str, |
| 303 | dtype: str, |
| 304 | ) -> None: |
| 305 | """Image Querying for Image Documents.""" |
| 306 | _run_test( |
| 307 | hf_runner, |
| 308 | vllm_runner, |
| 309 | model, |
| 310 | dtype, |
| 311 | IMAGE_IMAGE_TEST_DATA["query"], |
| 312 | IMAGE_IMAGE_TEST_DATA["documents"], |
| 313 | ) |
| 314 | |
| 315 | |
| 316 | @pytest.mark.parametrize("model", MODELS) |
| 317 | @pytest.mark.parametrize("dtype", ["half"]) |
| 318 | def test_model_text_mixed_documents( |
| 319 | hf_runner, |
| 320 | vllm_runner, |
| 321 | model: str, |
| 322 | dtype: str, |
| 323 | ) -> None: |
| 324 | """Text Query for Mixed Text and Image Documents.""" |
| 325 | _run_test( |
| 326 | hf_runner, |
| 327 | vllm_runner, |
| 328 | model, |
| 329 | dtype, |
| 330 | TEXT_MIXED_DOCS_TEST_DATA["query"], |
| 331 | TEXT_MIXED_DOCS_TEST_DATA["documents"], |
| 332 | ) |