@tduncan / Nvidia-Optimization-Stack-VLLM Mirror · read-only / commits / 4bed167768
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 15, 02:15 UTC

[Model][VLM] Support JinaVL Reranker (#20260)

Signed-off-by: shineran96 <shinewang96@gmail.com>

shineran96 committed Jul 11, 2025 at 01:43 UTC 4bed167768bd228b9251d77f7b338fb997d3aefe
15 files changed +993 -133
.buildkite/test-pipeline.yaml
+1 -1
@@ -282,7 +282,7 @@ steps:
282 - python3 offline_inference/llm_engine_example.py
283 - python3 offline_inference/audio_language.py --seed 0
284 - python3 offline_inference/vision_language.py --seed 0
285 - - python3 offline_inference/vision_language_embedding.py --seed 0
285 + - python3 offline_inference/vision_language_pooling.py --seed 0
286 - python3 offline_inference/vision_language_multi_image.py --seed 0
287 - VLLM_USE_V1=0 python3 others/tensorize_vllm_model.py --model facebook/opt-125m serialize --serialized-directory /tmp/ --suffix v1 && python3 others/tensorize_vllm_model.py --model facebook/opt-125m deserialize --path-to-tensors /tmp/vllm/facebook/opt-125m/v1/model.tensors
288 - python3 offline_inference/encoder_decoder.py
docs/models/supported_models.md
+8
@@ -712,6 +712,14 @@ The following table lists those that are tested in vLLM.
712
713 ---
714
715 +#### Scoring
716 +
717 +Specified using `--task score`.
718 +
719 +| Architecture | Models | Inputs | Example HF Models | [LoRA][lora-adapter] | [PP][distributed-serving] | [V1](gh-issue:8779) |
720 +|-------------------------------------|--------------------|----------|--------------------------|------------------------|-----------------------------|-----------------------|
721 +| `JinaVLForSequenceClassification` | JinaVL-based | T + I<sup>E+</sup> | `jinaai/jina-reranker-m0`, etc. | | | ✅︎ |
722 +
723 ## Model Support Policy
724
725 At vLLM, we are committed to facilitating the integration and support of third-party models within our ecosystem. Our approach is designed to balance the need for robustness and the practical limitations of supporting a wide range of models. Here’s how we manage third-party model support:
docs/serving/openai_compatible_server.md
+51 -3
@@ -537,7 +537,7 @@ The following extra parameters are supported:
537
538 ### Score API
539
540 -Our Score API can apply a cross-encoder model or an embedding model to predict scores for sentence pairs. When using an embedding model the score corresponds to the cosine similarity between each embedding pair.
540 +Our Score API can apply a cross-encoder model or an embedding model to predict scores for sentence or multimodal pairs. When using an embedding model the score corresponds to the cosine similarity between each embedding pair.
541 Usually, the score for a sentence pair refers to the similarity between two sentences, on a scale of 0 to 1.
542
543 You can find the documentation for cross encoder models at [sbert.net](https://www.sbert.net/docs/package_reference/cross_encoder/cross_encoder.html).
@@ -676,6 +676,55 @@ The total number of pairs is `len(text_2)`.
676 }
677 ```
678
679 +#### Multi-modal inputs
680 +
681 +You can pass multi-modal inputs to scoring models by passing `content` including a list of multi-modal input (image, etc.) in the request. Refer to the examples below for illustration.
682 +
683 +=== "JinaVL-Reranker"
684 +
685 + To serve the model:
686 +
687 + ```bash
688 + vllm serve jinaai/jina-reranker-m0
689 + ```
690 +
691 + Since the request schema is not defined by OpenAI client, we post a request to the server using the lower-level `requests` library:
692 +
693 + ??? Code
694 +
695 + ```python
696 + import requests
697 +
698 + response = requests.post(
699 + "http://localhost:8000/v1/score",
700 + json={
701 + "model": "jinaai/jina-reranker-m0",
702 + "text_1": "slm markdown",
703 + "text_2": {
704 + "content": [
705 + {
706 + "type": "image_url",
707 + "image_url": {
708 + "url": "https://raw.githubusercontent.com/jina-ai/multimodal-reranker-test/main/handelsblatt-preview.png"
709 + },
710 + },
711 + {
712 + "type": "image_url",
713 + "image_url": {
714 + "url": "https://raw.githubusercontent.com/jina-ai/multimodal-reranker-test/main/paper-11.png"
715 + },
716 + },
717 + ]
718 + }
719 + },
720 + )
721 + response.raise_for_status()
722 + response_json = response.json()
723 + print("Scoring output:", response_json["data"][0]["score"])
724 + print("Scoring output:", response_json["data"][1]["score"])
725 + ```
726 +Full example: <gh-file:examples/online_serving/openai_cross_encoder_score_for_multimodal.py>
727 +
728 #### Extra parameters
729
730 The following [pooling parameters][pooling-params] are supported.
@@ -695,8 +744,7 @@ The following extra parameters are supported:
744 ### Re-rank API
745
746 Our Re-rank API can apply an embedding model or a cross-encoder model to predict relevant scores between a single query, and
698 -each of a list of documents. Usually, the score for a sentence pair refers to the similarity between two sentences, on
699 -a scale of 0 to 1.
747 +each of a list of documents. Usually, the score for a sentence pair refers to the similarity between two sentences or multi-modal inputs (image, etc.), on a scale of 0 to 1.
748
749 You can find the documentation for cross encoder models at [sbert.net](https://www.sbert.net/docs/package_reference/cross_encoder/cross_encoder.html).
750
examples/offline_inference/vision_language_pooling.py renamed
+89 -7
@@ -2,7 +2,7 @@
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """
4 This example shows how to use vLLM for running offline inference with
5 -the correct prompt format on vision language models for multimodal embedding.
5 +the correct prompt format on vision language models for multimodal pooling.
6
7 For most models, the prompt format should follow corresponding examples
8 on HuggingFace model repository.
@@ -15,6 +15,7 @@ from typing import Literal, NamedTuple, Optional, TypedDict, Union, get_args
15 from PIL.Image import Image
16
17 from vllm import LLM, EngineArgs
18 +from vllm.entrypoints.score_utils import ScoreMultiModalParam
19 from vllm.multimodal.utils import fetch_image
20 from vllm.utils import FlexibleArgumentParser
21
@@ -35,14 +36,22 @@ class TextImageQuery(TypedDict):
36 image: Image
37
38
38 -QueryModality = Literal["text", "image", "text+image"]
39 -Query = Union[TextQuery, ImageQuery, TextImageQuery]
39 +class TextImagesQuery(TypedDict):
40 + modality: Literal["text+images"]
41 + text: str
42 + image: ScoreMultiModalParam
43 +
44 +
45 +QueryModality = Literal["text", "image", "text+image", "text+images"]
46 +Query = Union[TextQuery, ImageQuery, TextImageQuery, TextImagesQuery]
47
48
49 class ModelRequestData(NamedTuple):
50 engine_args: EngineArgs
44 - prompt: str
45 - image: Optional[Image]
51 + prompt: Optional[str] = None
52 + image: Optional[Image] = None
53 + query: Optional[str] = None
54 + documents: Optional[ScoreMultiModalParam] = None
55
56
57 def run_e5_v(query: Query) -> ModelRequestData:
@@ -107,6 +116,29 @@ def run_vlm2vec(query: Query) -> ModelRequestData:
116 )
117
118
119 +def run_jinavl_reranker(query: Query) -> ModelRequestData:
120 + if query["modality"] != "text+images":
121 + raise ValueError(f"Unsupported query modality: '{query['modality']}'")
122 +
123 + engine_args = EngineArgs(
124 + model="jinaai/jina-reranker-m0",
125 + task="score",
126 + max_model_len=32768,
127 + trust_remote_code=True,
128 + mm_processor_kwargs={
129 + "min_pixels": 3136,
130 + "max_pixels": 602112,
131 + },
132 + limit_mm_per_prompt={"image": 1},
133 + )
134 +
135 + return ModelRequestData(
136 + engine_args=engine_args,
137 + query=query["text"],
138 + documents=query["image"],
139 + )
140 +
141 +
142 def get_query(modality: QueryModality):
143 if modality == "text":
144 return TextQuery(modality="text", text="A dog sitting in the grass")
@@ -128,6 +160,28 @@ def get_query(modality: QueryModality):
160 ),
161 )
162
163 + if modality == "text+images":
164 + return TextImagesQuery(
165 + modality="text+images",
166 + text="slm markdown",
167 + image={
168 + "content": [
169 + {
170 + "type": "image_url",
171 + "image_url": {
172 + "url": "https://raw.githubusercontent.com/jina-ai/multimodal-reranker-test/main/handelsblatt-preview.png"
173 + },
174 + },
175 + {
176 + "type": "image_url",
177 + "image_url": {
178 + "url": "https://raw.githubusercontent.com/jina-ai/multimodal-reranker-test/main/paper-11.png"
179 + },
180 + },
181 + ]
182 + },
183 + )
184 +
185 msg = f"Modality {modality} is not supported."
186 raise ValueError(msg)
187
@@ -162,16 +216,31 @@ def run_encode(model: str, modality: QueryModality, seed: Optional[int]):
216 print("-" * 50)
217
218
219 +def run_score(model: str, modality: QueryModality, seed: Optional[int]):
220 + query = get_query(modality)
221 + req_data = model_example_map[model](query)
222 +
223 + engine_args = asdict(req_data.engine_args) | {"seed": seed}
224 + llm = LLM(**engine_args)
225 +
226 + outputs = llm.score(req_data.query, req_data.documents)
227 +
228 + print("-" * 30)
229 + print([output.outputs.score for output in outputs])
230 + print("-" * 30)
231 +
232 +
233 model_example_map = {
234 "e5_v": run_e5_v,
235 "vlm2vec": run_vlm2vec,
236 + "jinavl_reranker": run_jinavl_reranker,
237 }
238
239
240 def parse_args():
241 parser = FlexibleArgumentParser(
242 description="Demo on using vLLM for offline inference with "
174 - "vision language models for multimodal embedding"
243 + "vision language models for multimodal pooling tasks."
244 )
245 parser.add_argument(
246 "--model-name",
@@ -181,6 +250,14 @@ def parse_args():
250 choices=model_example_map.keys(),
251 help="The name of the embedding model.",
252 )
253 + parser.add_argument(
254 + "--task",
255 + "-t",
256 + type=str,
257 + default="embedding",
258 + choices=["embedding", "scoring"],
259 + help="The task type.",
260 + )
261 parser.add_argument(
262 "--modality",
263 type=str,
@@ -198,7 +275,12 @@ def parse_args():
275
276
277 def main(args: Namespace):
201 - run_encode(args.model_name, args.modality, args.seed)
278 + if args.task == "embedding":
279 + run_encode(args.model_name, args.modality, args.seed)
280 + elif args.task == "scoring":
281 + run_score(args.model_name, args.modality, args.seed)
282 + else:
283 + raise ValueError(f"Unsupported task: {args.task}")
284
285
286 if __name__ == "__main__":
examples/online_serving/openai_cross_encoder_score_for_multimodal.py new
+60
@@ -0,0 +1,60 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""
4 +Example online usage of Score API.
5 +
6 +Run `vllm serve <model> --task score` to start up the server in vLLM.
7 +"""
8 +
9 +import argparse
10 +import pprint
11 +
12 +import requests
13 +
14 +
15 +def post_http_request(prompt: dict, api_url: str) -> requests.Response:
16 + headers = {"User-Agent": "Test Client"}
17 + response = requests.post(api_url, headers=headers, json=prompt)
18 + return response
19 +
20 +
21 +def parse_args():
22 + parser = argparse.ArgumentParser()
23 + parser.add_argument("--host", type=str, default="localhost")
24 + parser.add_argument("--port", type=int, default=8000)
25 + parser.add_argument("--model", type=str, default="jinaai/jina-reranker-m0")
26 + return parser.parse_args()
27 +
28 +
29 +def main(args):
30 + api_url = f"http://{args.host}:{args.port}/score"
31 + model_name = args.model
32 +
33 + text_1 = "slm markdown"
34 + text_2 = {
35 + "content": [
36 + {
37 + "type": "image_url",
38 + "image_url": {
39 + "url": "https://raw.githubusercontent.com/jina-ai/multimodal-reranker-test/main/handelsblatt-preview.png"
40 + },
41 + },
42 + {
43 + "type": "image_url",
44 + "image_url": {
45 + "url": "https://raw.githubusercontent.com/jina-ai/multimodal-reranker-test/main/paper-11.png"
46 + },
47 + },
48 + ]
49 + }
50 + prompt = {"model": model_name, "text_1": text_1, "text_2": text_2}
51 + score_response = post_http_request(prompt=prompt, api_url=api_url)
52 + print("\nPrompt when text_1 is string and text_2 is a image list:")
53 + pprint.pprint(prompt)
54 + print("\nScore Response:")
55 + pprint.pprint(score_response.json())
56 +
57 +
58 +if __name__ == "__main__":
59 + args = parse_args()
60 + main(args)
tests/models/multimodal/pooling/test_jinavl_reranker.py new
+160
@@ -0,0 +1,160 @@
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 +model_name = "jinaai/jina-reranker-m0"
8 +
9 +mm_processor_kwargs = {
10 + "min_pixels": 3136,
11 + "max_pixels": 602112,
12 +}
13 +
14 +limit_mm_per_prompt = {"image": 2}
15 +
16 +
17 +def vllm_reranker(model_name,
18 + query,
19 + documents,
20 + query_type="text",
21 + doc_type="text"):
22 + from vllm import LLM
23 +
24 + model = LLM(
25 + model=model_name,
26 + task="score",
27 + max_model_len=32768,
28 + mm_processor_kwargs=mm_processor_kwargs,
29 + limit_mm_per_prompt=limit_mm_per_prompt,
30 + )
31 +
32 + def create_image_param(url: str):
33 + return {"type": "image_url", "image_url": {"url": f"{url}"}}
34 +
35 + if query_type == "image":
36 + query = {"content": [create_image_param(url) for url in query]}
37 +
38 + if doc_type == "image":
39 + documents = {"content": [create_image_param(url) for url in documents]}
40 +
41 + outputs = model.score(query, documents)
42 +
43 + return [output.outputs.score for output in outputs]
44 +
45 +
46 +def hf_reranker(model_name,
47 + query,
48 + documents,
49 + query_type="text",
50 + doc_type="text"):
51 +
52 + checkpoint_to_hf_mapper = {
53 + "visual.": "model.visual.",
54 + "model.": "model.language_model.",
55 + }
56 +
57 + model = AutoModel.from_pretrained(
58 + model_name,
59 + torch_dtype="auto",
60 + trust_remote_code=True,
61 + key_mapping=checkpoint_to_hf_mapper).to("cuda").eval()
62 +
63 + data_pairs = [[query[0], d] for d in documents]
64 +
65 + scores = model.compute_score(data_pairs,
66 + max_length=2048,
67 + query_type=query_type,
68 + doc_type=doc_type)
69 + return scores
70 +
71 +
72 +# Visual Documents Reranking
73 +@pytest.mark.parametrize("model_name", [model_name])
74 +def test_model_text_image(model_name):
75 +
76 + query = ["slm markdown"]
77 + documents = [
78 + "https://raw.githubusercontent.com/jina-ai/multimodal-reranker-test/main/handelsblatt-preview.png",
79 + "https://raw.githubusercontent.com/jina-ai/multimodal-reranker-test/main/paper-11.png",
80 + ]
81 +
82 + hf_outputs = hf_reranker(model_name, query, documents, "text", "image")
83 + vllm_outputs = vllm_reranker(model_name, query, documents, "text", "image")
84 +
85 + assert hf_outputs[0] == pytest.approx(vllm_outputs[0], rel=0.02)
86 + assert hf_outputs[1] == pytest.approx(vllm_outputs[1], rel=0.02)
87 +
88 +
89 +# Textual Documents Reranking
90 +@pytest.mark.parametrize("model_name", [model_name])
91 +def test_model_text_text(model_name):
92 +
93 + query = ["slm markdown"]
94 + documents = [
95 + """We present ReaderLM-v2, a compact 1.5 billion parameter language model designed for efficient
96 + web content extraction. Our model processes documents up to 512K tokens, transforming messy HTML
97 + into clean Markdown or JSON formats with high accuracy -- making it an ideal tool for grounding
98 + large language models. The models effectiveness results from two key innovations: (1) a three-stage
99 + data synthesis pipeline that generates high quality, diverse training data by iteratively drafting,
100 + refining, and critiquing web content extraction; and (2) a unified training framework combining
101 + continuous pre-training with multi-objective optimization. Intensive evaluation demonstrates that
102 + ReaderLM-v2 outperforms GPT-4o-2024-08-06 and other larger models by 15-20% on carefully curated
103 + benchmarks, particularly excelling at documents exceeding 100K tokens, while maintaining significantly
104 + lower computational requirements.""", # noqa: E501
105 + "数据提取么?为什么不用正则啊,你用正则不就全解决了么?",
106 + ]
107 +
108 + hf_outputs = hf_reranker(model_name, query, documents, "text", "text")
109 + vllm_outputs = vllm_reranker(model_name, query, documents, "text", "text")
110 +
111 + assert hf_outputs[0] == pytest.approx(vllm_outputs[0], rel=0.02)
112 + assert hf_outputs[1] == pytest.approx(vllm_outputs[1], rel=0.02)
113 +
114 +
115 +# Image Querying for Textual Documents
116 +@pytest.mark.parametrize("model_name", [model_name])
117 +def test_model_image_text(model_name):
118 +
119 + query = [
120 + "https://raw.githubusercontent.com/jina-ai/multimodal-reranker-test/main/paper-11.png"
121 + ]
122 + documents = [
123 + """We present ReaderLM-v2, a compact 1.5 billion parameter language model designed for efficient
124 + web content extraction. Our model processes documents up to 512K tokens, transforming messy HTML
125 + into clean Markdown or JSON formats with high accuracy -- making it an ideal tool for grounding
126 + large language models. The models effectiveness results from two key innovations: (1) a three-stage
127 + data synthesis pipeline that generates high quality, diverse training data by iteratively drafting,
128 + refining, and critiquing web content extraction; and (2) a unified training framework combining
129 + continuous pre-training with multi-objective optimization. Intensive evaluation demonstrates that
130 + ReaderLM-v2 outperforms GPT-4o-2024-08-06 and other larger models by 15-20% on carefully curated
131 + benchmarks, particularly excelling at documents exceeding 100K tokens, while maintaining significantly
132 + lower computational requirements.""", # noqa: E501
133 + "数据提取么?为什么不用正则啊,你用正则不就全解决了么?",
134 + ]
135 +
136 + hf_outputs = hf_reranker(model_name, query, documents, "image", "text")
137 + vllm_outputs = vllm_reranker(model_name, query, documents, "image", "text")
138 +
139 + assert hf_outputs[0] == pytest.approx(vllm_outputs[0], rel=0.02)
140 + assert hf_outputs[1] == pytest.approx(vllm_outputs[1], rel=0.02)
141 +
142 +
143 +# Image Querying for Image Documents
144 +@pytest.mark.parametrize("model_name", [model_name])
145 +def test_model_image_image(model_name):
146 +
147 + query = [
148 + "https://raw.githubusercontent.com/jina-ai/multimodal-reranker-test/main/paper-11.png"
149 + ]
150 + documents = [
151 + "https://raw.githubusercontent.com/jina-ai/multimodal-reranker-test/main/handelsblatt-preview.png",
152 + "https://raw.githubusercontent.com/jina-ai/multimodal-reranker-test/main/paper-11.png",
153 + ]
154 +
155 + hf_outputs = hf_reranker(model_name, query, documents, "image", "image")
156 + vllm_outputs = vllm_reranker(model_name, query, documents, "image",
157 + "image")
158 +
159 + assert hf_outputs[0] == pytest.approx(vllm_outputs[0], rel=0.02)
160 + assert hf_outputs[1] == pytest.approx(vllm_outputs[1], rel=0.02)
tests/models/registry.py
+3
@@ -432,6 +432,9 @@ _MULTIMODAL_EXAMPLE_MODELS = {
432 trust_remote_code=True), # noqa: E501
433 "MllamaForConditionalGeneration": _HfExamplesInfo("meta-llama/Llama-3.2-11B-Vision-Instruct"), # noqa: E501
434 "WhisperForConditionalGeneration": _HfExamplesInfo("openai/whisper-large-v3"), # noqa: E501
435 +
436 + # [Cross-encoder]
437 + "JinaVLForRanking": _HfExamplesInfo("jinaai/jina-reranker-m0"), # noqa: E501
438 }
439
440 _SPECULATIVE_DECODING_EXAMPLE_MODELS = {
vllm/entrypoints/llm.py
+122 -61
@@ -28,8 +28,11 @@ from vllm.entrypoints.chat_utils import (ChatCompletionMessageParam,
28 apply_mistral_chat_template,
29 parse_chat_messages,
30 resolve_chat_template_content_format)
31 -from vllm.entrypoints.score_utils import (_cosine_similarity,
32 - _validate_score_input_lens)
31 +from vllm.entrypoints.score_utils import (ScoreContentPartParam,
32 + ScoreMultiModalParam,
33 + _cosine_similarity,
34 + _validate_score_input_lens,
35 + get_score_prompt)
36 from vllm.entrypoints.utils import _validate_truncation_size
37 from vllm.inputs import PromptType, SingletonPrompt, TextPrompt, TokensPrompt
38 from vllm.inputs.parse import parse_and_batch_prompt
@@ -1187,8 +1190,8 @@ class LLM:
1190 def _cross_encoding_score(
1191 self,
1192 tokenizer: AnyTokenizer,
1190 - text_1: list[str],
1191 - text_2: list[str],
1193 + data_1: Union[list[str], list[ScoreContentPartParam]],
1194 + data_2: Union[list[str], list[ScoreContentPartParam]],
1195 truncate_prompt_tokens: Optional[int] = None,
1196 use_tqdm: Union[bool, Callable[..., tqdm]] = True,
1197 lora_request: Optional[Union[list[LoRARequest], LoRARequest]] = None,
@@ -1199,10 +1202,8 @@ class LLM:
1202 raise ValueError(
1203 "Score API is only enabled for `--task embed or score`")
1204
1202 - if len(text_1) == 1:
1203 - text_1 = text_1 * len(text_2)
1204 -
1205 - input_pairs = [(t1, t2) for t1, t2 in zip(text_1, text_2)]
1205 + if len(data_1) == 1:
1206 + data_1 = data_1 * len(data_2)
1207
1208 pooling_params = PoolingParams(use_cross_encoder=True)
1209 tokenization_kwargs: dict[str, Any] = {}
@@ -1211,19 +1212,41 @@ class LLM:
1212
1213 parsed_prompts = []
1214
1214 - for q, t in input_pairs:
1215 - if self.llm_engine.model_config.use_pad_token:
1216 - # cross_encoder models defaults to using pad_token.
1217 - prompt_inputs = tokenizer(text=q,
1218 - text_pair=t,
1219 - **tokenization_kwargs)
1220 - else:
1221 - # `llm as reranker` models defaults to not using pad_token.
1222 - prompt_inputs = tokenizer(text=q + t, **tokenization_kwargs)
1223 - engine_prompt = TokensPrompt(
1224 - prompt_token_ids=prompt_inputs["input_ids"],
1225 - token_type_ids=prompt_inputs.get("token_type_ids"))
1226 - parsed_prompts.append(engine_prompt)
1215 + input_pairs = [(t1, t2) for t1, t2 in zip(data_1, data_2)]
1216 +
1217 + if self.llm_engine.model_config.is_multimodal_model:
1218 +
1219 + model_config = self.llm_engine.model_config
1220 +
1221 + for q, d in input_pairs:
1222 + _, engine_prompt = get_score_prompt(
1223 + model_config=model_config,
1224 + data_1=q,
1225 + data_2=d,
1226 + tokenizer=tokenizer,
1227 + tokenization_kwargs=tokenization_kwargs,
1228 + )
1229 +
1230 + parsed_prompts.append(engine_prompt)
1231 +
1232 + else:
1233 +
1234 + for q, t in input_pairs:
1235 + if self.llm_engine.model_config.use_pad_token:
1236 + # cross_encoder models defaults to using pad_token.
1237 + prompt_inputs = tokenizer(
1238 + text=q, # type: ignore[arg-type]
1239 + text_pair=t, # type: ignore[arg-type]
1240 + **tokenization_kwargs)
1241 + else:
1242 + # `llm as reranker` models defaults to not using pad_token.
1243 + prompt_inputs = tokenizer(
1244 + text=q + t, # type: ignore[operator]
1245 + **tokenization_kwargs)
1246 + engine_prompt = TokensPrompt(
1247 + prompt_token_ids=prompt_inputs["input_ids"],
1248 + token_type_ids=prompt_inputs.get("token_type_ids"))
1249 + parsed_prompts.append(engine_prompt)
1250
1251 self._validate_and_add_requests(
1252 prompts=parsed_prompts,
@@ -1241,8 +1264,10 @@ class LLM:
1264
1265 def score(
1266 self,
1244 - text_1: Union[SingletonPrompt, Sequence[SingletonPrompt]],
1245 - text_2: Union[SingletonPrompt, Sequence[SingletonPrompt]],
1267 + data_1: Union[SingletonPrompt, Sequence[SingletonPrompt],
1268 + ScoreMultiModalParam],
1269 + data_2: Union[SingletonPrompt, Sequence[SingletonPrompt],
1270 + ScoreMultiModalParam],
1271 /,
1272 *,
1273 truncate_prompt_tokens: Optional[int] = None,
@@ -1250,22 +1275,30 @@ class LLM:
1275 lora_request: Optional[Union[list[LoRARequest], LoRARequest]] = None,
1276 prompt_adapter_request: Optional[PromptAdapterRequest] = None,
1277 ) -> list[ScoringRequestOutput]:
1253 - """Generate similarity scores for all pairs `<text,text_pair>`.
1278 + """Generate similarity scores for all pairs `<text,text_pair>` or
1279 + `<multi-modal data, multi-modal data pair>`.
1280
1281 The inputs can be `1 -> 1`, `1 -> N` or `N -> N`.
1256 - In the `1 - N` case the `text_1` sentence will be replicated `N`
1257 - times to pair with the `text_2` sentences.
1282 + In the `1 - N` case the `data_1` input will be replicated `N`
1283 + times to pair with the `data_2` inputs.
1284 The input pairs are used to build a list of prompts for the
1285 cross encoder model. This class automatically batches the prompts,
1286 considering the memory constraint. For the best performance, put all
1261 - of your texts into a single list and pass it to this method.
1287 + of your inputs into a single list and pass it to this method.
1288 +
1289 + Supports both text and multi-modal data (images, etc.) when used with
1290 + appropriate multi-modal models. For multi-modal inputs, ensure the
1291 + prompt structure matches the model's expected input format.
1292
1293 Args:
1264 - text_1: can be a single prompt or a list of prompts, in which
1265 - case it has to have the same length as the `text_2` list
1266 - text_2: The texts to pair with the query to form the input
1267 - to the LLM. See [PromptType][vllm.inputs.PromptType] for
1268 - more details about the format of each prompts.
1294 + data_1: Can be a single prompt, a list of prompts or
1295 + `ScoreMultiModalParam`, which can contain either text or
1296 + multi-modal data. When a list, it must have the same length as
1297 + the `data_2` list.
1298 + data_2: The data to pair with the query to form the input to
1299 + the LLM. Can be text or multi-modal data. See [PromptType]
1300 + [vllm.inputs.PromptType] for more details about the format of
1301 + each prompt.
1302 use_tqdm: If `True`, shows a tqdm progress bar.
1303 If a callable (e.g., `functools.partial(tqdm, leave=False)`),
1304 it is used to create the progress bar.
@@ -1306,42 +1339,70 @@ class LLM:
1339 # lists of tokens to the `text` and `text_pair` kwargs
1340 tokenizer = self.get_tokenizer()
1341
1309 - def ensure_str(prompt: SingletonPrompt):
1310 - if isinstance(prompt, dict):
1311 - if "multi_modal_data" in prompt:
1312 - raise ValueError("Multi-modal prompt is not "
1313 - "supported for scoring")
1314 - elif "prompt_token_ids" in prompt:
1315 - prompt = tokenizer.decode(
1316 - cast(TokensPrompt, prompt)["prompt_token_ids"])
1317 - elif "prompt" in prompt:
1318 - prompt = cast(TextPrompt, prompt)["prompt"]
1319 - assert type(prompt) is str
1320 - return prompt
1321 -
1322 - if isinstance(text_1, (str, dict)):
1323 - # Convert a single prompt to a list.
1324 - text_1 = [text_1]
1325 - input_text_1: list[str] = [ensure_str(t) for t in text_1]
1342 + if not self.llm_engine.model_config.is_multimodal_model:
1343
1327 - if isinstance(text_2, (str, dict)):
1328 - # Convert a single prompt to a list.
1329 - text_2 = [text_2]
1330 - input_text_2: list[str] = [ensure_str(t) for t in text_2]
1344 + def check_data_type(data: Union[SingletonPrompt,
1345 + Sequence[SingletonPrompt],
1346 + ScoreMultiModalParam]):
1347 + if isinstance(data, dict) and "content" in data:
1348 + raise ValueError(
1349 + f"ScoreMultiModalParam is not supported for {self.llm_engine.model_config.architecture}", # noqa: E501
1350 + )
1351 +
1352 + check_data_type(data_1)
1353 + check_data_type(data_2)
1354 +
1355 + def ensure_str(prompt: SingletonPrompt):
1356 + if isinstance(prompt, dict):
1357 + if "multi_modal_data" in prompt:
1358 + raise ValueError("Multi-modal prompt is not "
1359 + "supported for scoring")
1360 + elif "prompt_token_ids" in prompt:
1361 + prompt = tokenizer.decode(
1362 + cast(TokensPrompt, prompt)["prompt_token_ids"])
1363 + elif "prompt" in prompt:
1364 + prompt = cast(TextPrompt, prompt)["prompt"]
1365 + assert type(prompt) is str
1366 + return prompt
1367 +
1368 + if isinstance(data_1, (str, dict)):
1369 + # Convert a single prompt to a list.
1370 + data_1 = [data_1] # type: ignore[list-item]
1371 +
1372 + data_1 = [ensure_str(t) for t in data_1]
1373 +
1374 + if isinstance(data_2, (str, dict)):
1375 + # Convert a single prompt to a list.
1376 + data_2 = [data_2] # type: ignore[list-item]
1377 +
1378 + data_2 = [ensure_str(t) for t in data_2]
1379
1332 - _validate_score_input_lens(input_text_1, input_text_2)
1380 + if isinstance(data_1, dict) and "content" in data_1:
1381 + data_1 = data_1.get("content") # type: ignore[assignment]
1382 + elif isinstance(data_1, str):
1383 + data_1 = [data_1]
1384 +
1385 + if isinstance(data_2, dict) and "content" in data_2:
1386 + data_2 = data_2.get("content") # type: ignore[assignment]
1387 + elif isinstance(data_2, str):
1388 + data_2 = [data_2]
1389 +
1390 + _validate_score_input_lens(data_1, data_2) # type: ignore[arg-type]
1391
1392 if self.llm_engine.model_config.is_cross_encoder:
1335 - return self._cross_encoding_score(tokenizer, input_text_1,
1336 - input_text_2,
1337 - truncate_prompt_tokens, use_tqdm,
1338 - lora_request,
1339 - prompt_adapter_request)
1393 + return self._cross_encoding_score(
1394 + tokenizer,
1395 + data_1, # type: ignore[arg-type]
1396 + data_2, # type: ignore[arg-type]
1397 + truncate_prompt_tokens,
1398 + use_tqdm,
1399 + lora_request,
1400 + prompt_adapter_request)
1401 else:
1402 return self._embedding_score(
1403 tokenizer,
1343 - input_text_1, # type: ignore[arg-type]
1344 - input_text_2, # type: ignore[arg-type]
1404 + data_1, # type: ignore[arg-type]
1405 + data_2, # type: ignore[arg-type]
1406 truncate_prompt_tokens,
1407 use_tqdm,
1408 lora_request,
vllm/entrypoints/openai/protocol.py
+19 -5
@@ -24,6 +24,7 @@ from typing_extensions import TypeAlias
24 from vllm import envs
25 from vllm.entrypoints.chat_utils import (ChatCompletionMessageParam,
26 random_tool_call_id)
27 +from vllm.entrypoints.score_utils import ScoreMultiModalParam
28 from vllm.logger import init_logger
29 from vllm.pooling_params import PoolingParams
30 from vllm.sampling_params import (BeamSearchParams, GuidedDecodingParams,
@@ -1282,8 +1283,8 @@ PoolingRequest = Union[PoolingCompletionRequest, PoolingChatRequest]
1283
1284 class ScoreRequest(OpenAIBaseModel):
1285 model: Optional[str] = None
1285 - text_1: Union[list[str], str]
1286 - text_2: Union[list[str], str]
1286 + text_1: Union[list[str], str, ScoreMultiModalParam]
1287 + text_2: Union[list[str], str, ScoreMultiModalParam]
1288 truncate_prompt_tokens: Optional[Annotated[int, Field(ge=-1)]] = None
1289
1290 # --8<-- [start:score-pooling-params]
@@ -1291,6 +1292,12 @@ class ScoreRequest(OpenAIBaseModel):
1292 # --8<-- [end:score-pooling-params]
1293
1294 # --8<-- [start:score-extra-params]
1295 +
1296 + mm_processor_kwargs: Optional[dict[str, Any]] = Field(
1297 + default=None,
1298 + description=("Additional kwargs to pass to the HF processor."),
1299 + )
1300 +
1301 priority: int = Field(
1302 default=0,
1303 description=(
@@ -1308,8 +1315,8 @@ class ScoreRequest(OpenAIBaseModel):
1315
1316 class RerankRequest(OpenAIBaseModel):
1317 model: Optional[str] = None
1311 - query: str
1312 - documents: list[str]
1318 + query: Union[str, ScoreMultiModalParam]
1319 + documents: Union[list[str], ScoreMultiModalParam]
1320 top_n: int = Field(default_factory=lambda: 0)
1321 truncate_prompt_tokens: Optional[Annotated[int, Field(ge=-1)]] = None
1322
@@ -1318,6 +1325,12 @@ class RerankRequest(OpenAIBaseModel):
1325 # --8<-- [end:rerank-pooling-params]
1326
1327 # --8<-- [start:rerank-extra-params]
1328 +
1329 + mm_processor_kwargs: Optional[dict[str, Any]] = Field(
1330 + default=None,
1331 + description=("Additional kwargs to pass to the HF processor."),
1332 + )
1333 +
1334 priority: int = Field(
1335 default=0,
1336 description=(
@@ -1334,7 +1347,8 @@ class RerankRequest(OpenAIBaseModel):
1347
1348
1349 class RerankDocument(BaseModel):
1337 - text: str
1350 + text: Optional[str] = None
1351 + multi_modal: Optional[ScoreMultiModalParam] = None
1352
1353
1354 class RerankResult(BaseModel):
vllm/entrypoints/openai/serving_score.py
+110 -48
@@ -17,8 +17,11 @@ from vllm.entrypoints.openai.protocol import (ErrorResponse, RerankDocument,
17 ScoreResponseData, UsageInfo)
18 from vllm.entrypoints.openai.serving_engine import OpenAIServing
19 from vllm.entrypoints.openai.serving_models import OpenAIServingModels
20 -from vllm.entrypoints.score_utils import (_cosine_similarity,
21 - _validate_score_input_lens)
20 +from vllm.entrypoints.score_utils import (ScoreContentPartParam,
21 + ScoreMultiModalParam,
22 + _cosine_similarity,
23 + _validate_score_input_lens,
24 + get_score_prompt)
25 from vllm.entrypoints.utils import _validate_truncation_size
26 from vllm.inputs.data import TokensPrompt
27 from vllm.logger import init_logger
@@ -137,11 +140,34 @@ class ServingScores(OpenAIServing):
140
141 return final_res_batch
142
143 + def _preprocess_score(
144 + self,
145 + request: Union[RerankRequest, ScoreRequest],
146 + tokenizer: AnyTokenizer,
147 + tokenization_kwargs: dict[str, Any],
148 + data_1: Union[str, ScoreContentPartParam],
149 + data_2: Union[str, ScoreContentPartParam],
150 + ) -> tuple[str, TokensPrompt]:
151 +
152 + model_config = self.model_config
153 +
154 + full_prompt, engine_prompt = get_score_prompt(
155 + model_config=model_config,
156 + data_1=data_1,
157 + data_2=data_2,
158 + tokenizer=tokenizer,
159 + tokenization_kwargs=tokenization_kwargs,
160 + )
161 + if request.mm_processor_kwargs is not None:
162 + engine_prompt["mm_processor_kwargs"] = request.mm_processor_kwargs
163 +
164 + return full_prompt, engine_prompt
165 +
166 async def _cross_encoding_score(
167 self,
168 tokenizer: AnyTokenizer,
143 - texts_1: list[str],
144 - texts_2: list[str],
169 + data_1: Union[list[str], list[ScoreContentPartParam]],
170 + data_2: Union[list[str], list[ScoreContentPartParam]],
171 request: Union[RerankRequest, ScoreRequest],
172 request_id=str,
173 tokenization_kwargs: Optional[dict[str, Any]] = None,
@@ -154,46 +180,67 @@ class ServingScores(OpenAIServing):
180 request_prompts: list[str] = []
181 engine_prompts: list[TokensPrompt] = []
182
157 - if len(texts_1) == 1:
158 - texts_1 = texts_1 * len(texts_2)
159 -
160 - input_pairs = [(t1, t2) for t1, t2 in zip(texts_1, texts_2)]
183 + if len(data_1) == 1:
184 + data_1 = data_1 * len(data_2)
185
186 if isinstance(tokenizer, MistralTokenizer):
187 raise ValueError(
188 "MistralTokenizer not supported for cross-encoding")
189
166 - tokenize_async = make_async(tokenizer.__call__,
167 - executor=self._tokenizer_executor)
168 -
190 tokenization_kwargs = tokenization_kwargs or {}
170 - use_pad_token = self.model_config.use_pad_token
191
172 - if use_pad_token:
173 - # cross_encoder models defaults to using pad_token.
174 - tokenized_prompts = await asyncio.gather(
175 - *(tokenize_async(text=t1, text_pair=t2, **tokenization_kwargs)
176 - for t1, t2 in input_pairs))
177 - else:
178 - # `llm as reranker` models defaults to not using pad_token.
179 - tokenized_prompts = await asyncio.gather(
180 - *(tokenize_async(text=t1 + t2, **tokenization_kwargs)
181 - for t1, t2 in input_pairs))
192 + input_pairs = [(t1, t2) for t1, t2 in zip(data_1, data_2)]
193
183 - for prompt_inputs, (t1, t2) in zip(tokenized_prompts, input_pairs):
184 - sep_token = tokenizer.sep_token if (tokenizer.sep_token
185 - and use_pad_token) else ''
186 - request_prompt = f"{t1}{sep_token}{t2}"
194 + if self.model_config.is_multimodal_model:
195
188 - input_ids = prompt_inputs["input_ids"]
189 - text_token_prompt = \
190 - self._validate_input(request, input_ids, request_prompt)
191 - engine_prompt = TokensPrompt(
192 - prompt_token_ids=text_token_prompt["prompt_token_ids"],
193 - token_type_ids=prompt_inputs.get("token_type_ids"))
196 + preprocess_async = make_async(self._preprocess_score,
197 + executor=self._tokenizer_executor)
198 +
199 + preprocessed_prompts = await asyncio.gather(
200 + *(preprocess_async(request=request,
201 + tokenizer=tokenizer,
202 + tokenization_kwargs=tokenization_kwargs,
203 + data_1=t1,
204 + data_2=t2) for t1, t2 in input_pairs))
205 +
206 + for full_prompt, engine_prompt in preprocessed_prompts:
207 + request_prompts.append(full_prompt)
208 + engine_prompts.append(engine_prompt)
209
195 - request_prompts.append(request_prompt)
196 - engine_prompts.append(engine_prompt)
210 + else:
211 + tokenize_async = make_async(tokenizer.__call__,
212 + executor=self._tokenizer_executor)
213 + use_pad_token = self.model_config.use_pad_token
214 +
215 + if use_pad_token:
216 + # cross_encoder models defaults to using pad_token.
217 + tokenized_prompts = await asyncio.gather(*(
218 + tokenize_async(
219 + text=t1, # type: ignore[arg-type]
220 + text_pair=t2, # type: ignore[arg-type]
221 + **tokenization_kwargs) for t1, t2 in input_pairs))
222 + else:
223 + # `llm as reranker` models defaults to not using pad_token.
224 + tokenized_prompts = await asyncio.gather(*(
225 + tokenize_async(
226 + text=t1 + # type: ignore[operator]
227 + t2,
228 + **tokenization_kwargs) for t1, t2 in input_pairs))
229 +
230 + for prompt_inputs, (t1, t2) in zip(tokenized_prompts, input_pairs):
231 + sep_token = tokenizer.sep_token if (tokenizer.sep_token
232 + and use_pad_token) else ''
233 + request_prompt = f"{t1}{sep_token}{t2}"
234 +
235 + input_ids = prompt_inputs["input_ids"]
236 + text_token_prompt = \
237 + self._validate_input(request, input_ids, request_prompt)
238 + engine_prompt = TokensPrompt(
239 + prompt_token_ids=text_token_prompt["prompt_token_ids"],
240 + token_type_ids=prompt_inputs.get("token_type_ids"))
241 +
242 + request_prompts.append(request_prompt)
243 + engine_prompts.append(engine_prompt)
244
245 # Schedule the request and get the result generator.
246 generators: list[AsyncGenerator[PoolingRequestOutput, None]] = []
@@ -233,8 +280,8 @@ class ServingScores(OpenAIServing):
280
281 async def _run_scoring(
282 self,
236 - texts_1: Union[str, list[str]],
237 - texts_2: Union[str, list[str]],
283 + data_1: Union[list[str], str, ScoreMultiModalParam],
284 + data_2: Union[list[str], str, ScoreMultiModalParam],
285 request: Union[ScoreRequest, RerankRequest],
286 request_id: str,
287 raw_request: Optional[Request] = None,
@@ -259,18 +306,29 @@ class ServingScores(OpenAIServing):
306 trace_headers = (None if raw_request is None else await
307 self._get_trace_headers(raw_request.headers))
308
262 - if isinstance(texts_1, str):
263 - texts_1 = [texts_1]
264 - if isinstance(texts_2, str):
265 - texts_2 = [texts_2]
309 + if not self.model_config.is_multimodal_model and (isinstance(
310 + data_1, dict) or isinstance(data_2, dict)):
311 + raise ValueError(
312 + f"MultiModalParam is not supported for {self.model_config.architecture}" # noqa: E501
313 + )
314 +
315 + if isinstance(data_1, str):
316 + data_1 = [data_1]
317 + elif isinstance(data_1, dict):
318 + data_1 = data_1.get("content") # type: ignore[assignment]
319 +
320 + if isinstance(data_2, str):
321 + data_2 = [data_2]
322 + elif isinstance(data_2, dict):
323 + data_2 = data_2.get("content") # type: ignore[assignment]
324
267 - _validate_score_input_lens(texts_1, texts_2)
325 + _validate_score_input_lens(data_1, data_2) # type: ignore[arg-type]
326
327 if self.model_config.is_cross_encoder:
328 return await self._cross_encoding_score(
329 tokenizer=tokenizer,
272 - texts_1=texts_1,
273 - texts_2=texts_2,
330 + data_1=data_1, # type: ignore[arg-type]
331 + data_2=data_2, # type: ignore[arg-type]
332 request=request,
333 request_id=request_id,
334 tokenization_kwargs=tokenization_kwargs,
@@ -281,8 +339,8 @@ class ServingScores(OpenAIServing):
339 else:
340 return await self._embedding_score(
341 tokenizer=tokenizer,
284 - texts_1=texts_1,
285 - texts_2=texts_2,
342 + texts_1=data_1, # type: ignore[arg-type]
343 + texts_2=data_2, # type: ignore[arg-type]
344 request=request,
345 request_id=request_id,
346 tokenization_kwargs=tokenization_kwargs,
@@ -349,7 +407,9 @@ class ServingScores(OpenAIServing):
407
408 request_id = f"rerank-{self._base_request_id(raw_request)}"
409 documents = request.documents
352 - top_n = request.top_n if request.top_n > 0 else len(documents)
410 + top_n = request.top_n if request.top_n > 0 else (
411 + len(documents)
412 + if isinstance(documents, list) else len(documents["content"]))
413
414 try:
415 final_res_batch = await self._run_scoring(
@@ -410,7 +470,7 @@ class ServingScores(OpenAIServing):
470
471 def request_output_to_rerank_response(
472 self, final_res_batch: list[PoolingRequestOutput], request_id: str,
413 - model_name: str, documents: list[str],
473 + model_name: str, documents: Union[list[str], ScoreMultiModalParam],
474 top_n: int) -> RerankResponse:
475 """
476 Convert the output of do_rank to a RerankResponse
@@ -422,7 +482,9 @@ class ServingScores(OpenAIServing):
482
483 result = RerankResult(
484 index=idx,
425 - document=RerankDocument(text=documents[idx]),
485 + document=RerankDocument(text=documents[idx]) if isinstance(
486 + documents, list) else RerankDocument(
487 + multi_modal=documents["content"][idx]),
488 relevance_score=classify_res.outputs.score,
489 )
490 results.append(result)
vllm/entrypoints/score_utils.py
+152 -8
@@ -1,13 +1,41 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 -from typing import Union
3 +from typing import Any, Optional, Union, cast
4
5 from torch.nn import CosineSimilarity
6 +from typing_extensions import Required, TypeAlias, TypedDict
7
8 +from vllm.config import ModelConfig
9 +from vllm.entrypoints.chat_utils import (
10 + BaseMultiModalItemTracker, ChatCompletionContentPartImageEmbedsParam,
11 + ChatCompletionContentPartImageParam, ChatCompletionContentPartTextParam,
12 + MultiModalItemTracker, _ContentPart, _parse_chat_message_content_part)
13 +from vllm.inputs import TokensPrompt
14 +from vllm.model_executor.model_loader import get_model_cls
15 +from vllm.model_executor.models.interfaces import supports_score_template
16 +from vllm.multimodal.inputs import MultiModalDataDict
17 from vllm.outputs import PoolingRequestOutput
8 -from vllm.transformers_utils.tokenizer import (PreTrainedTokenizer,
18 +from vllm.transformers_utils.tokenizer import (AnyTokenizer,
19 + PreTrainedTokenizer,
20 PreTrainedTokenizerFast)
21
22 +ScoreContentPartParam: TypeAlias = Union[
23 + ChatCompletionContentPartImageParam,
24 + ChatCompletionContentPartImageEmbedsParam]
25 +
26 +
27 +class ScoreMultiModalParam(TypedDict, total=False):
28 + """
29 + A specialized parameter type for scoring multimodal content
30 +
31 + The reasons why don't reuse `CustomChatCompletionMessageParam` directly:
32 + 1. Score tasks don't need the 'role' field (user/assistant/system) that's required in chat completions
33 + 2. Including chat-specific fields would confuse users about their purpose in scoring
34 + 3. This is a more focused interface that only exposes what's needed for scoring
35 + """ # noqa: E501
36 + content: Required[list[ScoreContentPartParam]]
37 + """The multimodal contents"""
38 +
39
40 def _cosine_similarity(
41 tokenizer: Union[PreTrainedTokenizer, PreTrainedTokenizerFast],
@@ -39,12 +67,128 @@ def _cosine_similarity(
67
68
69 def _validate_score_input_lens(
42 - texts_1: Union[list[str], list[dict]],
43 - texts_2: Union[list[str], list[dict]],
70 + data_1: Union[list[str], list[ScoreContentPartParam]],
71 + data_2: Union[list[str], list[ScoreContentPartParam]],
72 ):
45 - if len(texts_1) > 1 and len(texts_1) != len(texts_2):
73 + len_1 = len(data_1)
74 + len_2 = len(data_2)
75 +
76 + if len_1 > 1 and len_1 != len_2:
77 raise ValueError("Input lengths must be either 1:1, 1:N or N:N")
47 - if len(texts_1) == 0:
78 + if len_1 == 0:
79 raise ValueError("At least one text element must be given")
49 - if len(texts_2) == 0:
50 - raise ValueError("At least one text_pair element must be given")
\ No newline at end of file
80 + if len_2 == 0:
81 + raise ValueError("At least one text_pair element must be given")
82 +
83 +
84 +def parse_score_data(
85 + data_1: Union[str, ScoreContentPartParam],
86 + data_2: Union[str, ScoreContentPartParam],
87 + model_config: ModelConfig,
88 + tokenizer: AnyTokenizer,
89 +) -> tuple[str, str, Optional[MultiModalDataDict]]:
90 + mm_tracker = MultiModalItemTracker(model_config, tokenizer)
91 +
92 + content_1 = _parse_score_content(data_1, mm_tracker)
93 +
94 + content_2 = _parse_score_content(data_2, mm_tracker)
95 +
96 + def ensure_str(content: Optional[_ContentPart]) -> str:
97 + if content is not None and isinstance(content, str):
98 + return cast(str, content)
99 + else:
100 + raise ValueError(
101 + f"Only string content is supported, but got {content}.")
102 +
103 + prompt_1 = ensure_str(content_1)
104 + prompt_2 = ensure_str(content_2)
105 +
106 + return prompt_1, prompt_2, mm_tracker.all_mm_data()
107 +
108 +
109 +def _parse_score_content(
110 + data: Union[str, ScoreContentPartParam],
111 + mm_tracker: BaseMultiModalItemTracker,
112 +) -> Optional[_ContentPart]:
113 +
114 + if isinstance(data, str):
115 + data = ChatCompletionContentPartTextParam(type="text", text=data)
116 +
117 + mm_parser = mm_tracker.create_parser()
118 +
119 + parse_res = _parse_chat_message_content_part(
120 + data,
121 + mm_parser,
122 + wrap_dicts=False,
123 + interleave_strings=False,
124 + )
125 +
126 + if parse_res:
127 + return parse_res
128 +
129 + mm_placeholder_storage = mm_parser.mm_placeholder_storage()
130 +
131 + if len(mm_placeholder_storage) != 1 or len(
132 + next(iter(mm_placeholder_storage.values()))) != 1:
133 + raise ValueError("Only one multi-modal item is supported")
134 +
135 + return next(iter(mm_placeholder_storage.values()))[0]
136 +
137 +
138 +def apply_score_template(
139 + model_config: ModelConfig,
140 + prompt_1: str,
141 + prompt_2: str,
142 +) -> str:
143 +
144 + model = get_model_cls(model_config)
145 + if supports_score_template(model):
146 + full_prompt = model.get_score_template(prompt_1, prompt_2)
147 + if full_prompt is None:
148 + raise ValueError("Get empty score template from model")
149 + return full_prompt
150 +
151 + raise ValueError(
152 + f"Unsupported model architecture: {model_config.architecture}")
153 +
154 +
155 +def post_process_tokens(
156 + model_config: ModelConfig,
157 + prompt: TokensPrompt,
158 +) -> None:
159 + """
160 + Perform architecture-specific manipulations on the input tokens.
161 +
162 + Note:
163 + This is an in-place operation.
164 + """
165 + model = get_model_cls(model_config)
166 + if supports_score_template(model):
167 + model.post_process_tokens(prompt)
168 +
169 +
170 +def get_score_prompt(
171 + model_config: ModelConfig,
172 + tokenizer: AnyTokenizer,
173 + tokenization_kwargs: dict[str, Any],
174 + data_1: Union[str, ScoreContentPartParam],
175 + data_2: Union[str, ScoreContentPartParam],
176 +) -> tuple[str, TokensPrompt]:
177 + prompt_1, prompt_2, mm_data = parse_score_data(
178 + data_1,
179 + data_2,
180 + model_config,
181 + tokenizer,
182 + )
183 +
184 + full_prompt = apply_score_template(model_config, prompt_1, prompt_2)
185 +
186 + prompt_inputs = tokenizer(full_prompt, **tokenization_kwargs)
187 +
188 + engine_prompt = TokensPrompt(prompt_token_ids=prompt_inputs["input_ids"])
189 +
190 + post_process_tokens(model_config, engine_prompt)
191 +
192 + if mm_data is not None:
193 + engine_prompt["multi_modal_data"] = mm_data
194 + return full_prompt, engine_prompt
vllm/model_executor/models/config.py
+10
@@ -170,6 +170,15 @@ class Qwen3ForSequenceClassificationConfig(VerifyAndUpdateConfig):
170 vllm_config.model_config.hf_config.method = "from_2_way_softmax"
171
172
173 +class JinaVLForSequenceClassificationConfig(VerifyAndUpdateConfig):
174 +
175 + @staticmethod
176 + def verify_and_update_config(vllm_config: "VllmConfig") -> None:
177 + config = vllm_config.model_config.hf_config
178 +
179 + config.num_labels = 1
180 +
181 +
182 class SnowflakeGteNewModelConfig(VerifyAndUpdateConfig):
183
184 @staticmethod
@@ -197,4 +206,5 @@ MODELS_CONFIG_MAP: dict[str, type[VerifyAndUpdateConfig]] = {
206 "NomicBertModel": NomicBertModelConfig,
207 "Qwen3ForSequenceClassification": Qwen3ForSequenceClassificationConfig,
208 "XLMRobertaModel": JinaRobertaModelConfig,
209 + "JinaVLForRanking": JinaVLForSequenceClassificationConfig,
210 }
vllm/model_executor/models/interfaces.py
+57
@@ -9,6 +9,7 @@ import torch
9 from torch import Tensor
10 from typing_extensions import Self, TypeIs
11
12 +from vllm.inputs import TokensPrompt
13 from vllm.logger import init_logger
14 from vllm.model_executor.layers.quantization.base_config import (
15 QuantizationConfig)
@@ -141,6 +142,62 @@ def supports_multimodal(
142 return isinstance(model, SupportsMultiModal)
143
144
145 +@runtime_checkable
146 +class SupportsScoreTemplate(Protocol):
147 + """The interface required for all models that support score template."""
148 +
149 + supports_score_template: ClassVar[Literal[True]] = True
150 + """
151 + A flag that indicates this model supports score template.
152 +
153 + Note:
154 + There is no need to redefine this flag if this class is in the
155 + MRO of your model class.
156 + """
157 +
158 + @classmethod
159 + def get_score_template(cls, query: str, document: str) -> Optional[str]:
160 + """
161 + Generate a full prompt by populating the score template with query and document content.
162 + """ # noqa: E501
163 + ...
164 +
165 + @classmethod
166 + def post_process_tokens(cls, prompt: TokensPrompt) -> None:
167 + """
168 + Perform architecture-specific manipulations on the input tokens.
169 + """
170 + ...
171 +
172 +
173 +# We can't use runtime_checkable with ClassVar for issubclass checks
174 +# so we need to treat the class as an instance and use isinstance instead
175 +@runtime_checkable
176 +class _SupportsScoreTemplateType(Protocol):
177 + supports_score_template: Literal[True]
178 +
179 +
180 +@overload
181 +def supports_score_template(
182 + model: type[object]) -> TypeIs[type[SupportsScoreTemplate]]:
183 + ...
184 +
185 +
186 +@overload
187 +def supports_score_template(model: object) -> TypeIs[SupportsScoreTemplate]:
188 + ...
189 +
190 +
191 +def supports_score_template(
192 + model: Union[type[object], object],
193 +) -> Union[TypeIs[type[SupportsScoreTemplate]], TypeIs[SupportsScoreTemplate]]:
194 +
195 + if isinstance(model, type):
196 + return isinstance(model, _SupportsScoreTemplateType)
197 +
198 + return isinstance(model, SupportsScoreTemplate)
199 +
200 +
201 @runtime_checkable
202 class SupportsLoRA(Protocol):
203 """The interface required for all models that support LoRA."""
vllm/model_executor/models/jina_vl.py new
+150
@@ -0,0 +1,150 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +from collections.abc import Iterable, Mapping
4 +from typing import Optional
5 +
6 +import torch
7 +import torch.nn as nn
8 +from transformers import BatchFeature, PretrainedConfig
9 +
10 +from vllm.config import VllmConfig
11 +from vllm.inputs import TokensPrompt
12 +from vllm.logger import init_logger
13 +from vllm.model_executor.layers.linear import (ColumnParallelLinear,
14 + RowParallelLinear)
15 +from vllm.model_executor.layers.pooler import Pooler, PoolingType
16 +from vllm.model_executor.pooling_metadata import PoolingMetadata
17 +from vllm.multimodal import MULTIMODAL_REGISTRY
18 +from vllm.sequence import IntermediateTensors, PoolerOutput
19 +
20 +from .interfaces import (SupportsCrossEncoding, SupportsMultiModal,
21 + SupportsScoreTemplate)
22 +from .qwen2_vl import (Qwen2VLDummyInputsBuilder,
23 + Qwen2VLForConditionalGeneration,
24 + Qwen2VLMultiModalProcessor, Qwen2VLProcessingInfo)
25 +from .utils import AutoWeightsLoader, WeightsMapper, maybe_prefix
26 +
27 +logger = init_logger(__name__)
28 +
29 +
30 +class JinaVLScorer(nn.Module):
31 +
32 + def __init__(self, config: PretrainedConfig):
33 + super().__init__()
34 + self.dense = ColumnParallelLinear(config.hidden_size,
35 + config.hidden_size,
36 + bias=True)
37 + self.out_proj = RowParallelLinear(config.hidden_size,
38 + config.num_labels,
39 + bias=True)
40 +
41 + def forward(self, x, **kwargs):
42 + x, _ = self.dense(x)
43 + x = torch.relu(x)
44 + x, _ = self.out_proj(x)
45 + return x
46 +
47 +
48 +class JinaVLMultiModalProcessor(Qwen2VLMultiModalProcessor):
49 +
50 + def _call_hf_processor(
51 + self,
52 + prompt: str,
53 + mm_data: Mapping[str, object],
54 + mm_kwargs: Mapping[str, object],
55 + tok_kwargs: Mapping[str, object],
56 + ) -> BatchFeature:
57 +
58 + # NOTE: We should reverse the order of the mm_data because the
59 + # query prompt is placed after the document prompt in the score
60 + # template for JinaVLForRanking model, but in mm_data they are
61 + # stored in the opposite order (query first, then document).
62 + for _, value in mm_data.items():
63 + value.reverse()
64 + return super()._call_hf_processor(prompt, mm_data, mm_kwargs,
65 + tok_kwargs)
66 +
67 +
68 +@MULTIMODAL_REGISTRY.register_processor(JinaVLMultiModalProcessor,
69 + info=Qwen2VLProcessingInfo,
70 + dummy_inputs=Qwen2VLDummyInputsBuilder)
71 +class JinaVLForSequenceClassification(Qwen2VLForConditionalGeneration,
72 + SupportsCrossEncoding,
73 + SupportsMultiModal,
74 + SupportsScoreTemplate):
75 + weight_mapper = WeightsMapper(
76 + orig_to_new_prefix={
77 + "score.0.": "score.dense.",
78 + "score.2.": "score.out_proj.",
79 + # mapping for new names in checkpoint saved after transformers v4.52
80 + "model.language_model.": "language_model.model.",
81 + "visual.": "visual.",
82 + # mapping for original checkpoint
83 + "lm_head.": "language_model.lm_head.",
84 + "model.": "language_model.model.",
85 + })
86 +
87 + def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
88 + super().__init__(vllm_config=vllm_config,
89 + prefix=maybe_prefix(prefix, "qwen2_vl"))
90 + config = vllm_config.model_config.hf_config
91 + pooler_config = vllm_config.model_config.pooler_config
92 +
93 + # logit bias for sigmoid normalization
94 + self.LOGIT_BIAS = 2.65
95 +
96 + self.score = JinaVLScorer(config)
97 +
98 + self._pooler = Pooler.from_config_with_defaults(
99 + pooler_config,
100 + pooling_type=PoolingType.LAST,
101 + normalize=False,
102 + softmax=True)
103 +
104 + @classmethod
105 + def get_placeholder_str(cls, modality: str, i: int) -> Optional[str]:
106 + if modality.startswith("image"):
107 + return "<|vision_start|><|image_pad|><|vision_end|>"
108 +
109 + raise ValueError("Only image modality is supported")
110 +
111 + @classmethod
112 + def get_score_template(cls, query: str, document: str) -> Optional[str]:
113 + return f"**Document**:\n{document}\n**Query**:\n{query}"
114 +
115 + @classmethod
116 + def post_process_tokens(cls, prompt: TokensPrompt) -> None:
117 +
118 + # add score target token at the end of prompt tokens
119 + prompt['prompt_token_ids'].append(100)
120 +
121 + def forward(
122 + self,
123 + input_ids: torch.Tensor,
124 + positions: torch.Tensor,
125 + intermediate_tensors: Optional[IntermediateTensors] = None,
126 + inputs_embeds: Optional[torch.Tensor] = None,
127 + **kwargs: object,
128 + ) -> torch.Tensor:
129 + hidden_states = super().forward(
130 + input_ids=input_ids,
131 + positions=positions,
132 + intermediate_tensors=intermediate_tensors,
133 + inputs_embeds=inputs_embeds,
134 + **kwargs,
135 + )
136 +
137 + logits = self.score(hidden_states) - self.LOGIT_BIAS
138 + return logits
139 +
140 + def pooler(
141 + self,
142 + hidden_states: torch.Tensor,
143 + pooling_metadata: PoolingMetadata,
144 + ) -> Optional[PoolerOutput]:
145 + return self._pooler(hidden_states, pooling_metadata)
146 +
147 + def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]):
148 +
149 + loader = AutoWeightsLoader(self)
150 + return loader.load_weights(weights, mapper=self.weight_mapper)
vllm/model_executor/models/registry.py
+1
@@ -181,6 +181,7 @@ _CROSS_ENCODER_MODELS = {
181 "GemmaForSequenceClassification": ("gemma", "GemmaForSequenceClassification"), # noqa: E501
182 "Qwen2ForSequenceClassification": ("qwen2", "Qwen2ForSequenceClassification"), # noqa: E501
183 "Qwen3ForSequenceClassification": ("qwen3", "Qwen3ForSequenceClassification"), # noqa: E501
184 + "JinaVLForRanking": ("jina_vl", "JinaVLForSequenceClassification"), # noqa: E501
185 }
186
187 _MULTIMODAL_MODELS = {