| 1 | # SPDX-License-Identifier: Apache-2.0 |
| 2 | # SPDX-FileCopyrightText: Copyright contributors to the vLLM project |
| 3 | |
| 4 | import json |
| 5 | from typing import TypedDict |
| 6 | |
| 7 | import pytest |
| 8 | import requests |
| 9 | |
| 10 | from tests.utils import RemoteOpenAIServer |
| 11 | from vllm.entrypoints.pooling.pooling.protocol import IOProcessorResponse |
| 12 | |
| 13 | |
| 14 | # Test configuration for ColBERT query plugin |
| 15 | class ModelConfig(TypedDict): |
| 16 | model_name: str |
| 17 | plugin: str |
| 18 | query_input: str |
| 19 | document_input: str |
| 20 | hf_overrides: str |
| 21 | embedding_dim: int |
| 22 | query_maxlen: int |
| 23 | |
| 24 | |
| 25 | model_config: ModelConfig = { |
| 26 | "model_name": "jinaai/jina-colbert-v2", |
| 27 | "plugin": "colbert_query_plugin", |
| 28 | "query_input": "What is machine learning?", |
| 29 | "document_input": "Machine learning is a subset of artificial intelligence.", |
| 30 | "hf_overrides": json.dumps({"architectures": ["ColBERTJinaRobertaModel"]}), |
| 31 | "embedding_dim": 128, |
| 32 | "query_maxlen": 32, |
| 33 | } |
| 34 | |
| 35 | |
| 36 | def _get_attr_or_val(obj: object | dict, key: str): |
| 37 | if isinstance(obj, dict) and key in obj: |
| 38 | return obj[key] |
| 39 | return getattr(obj, key, None) |
| 40 | |
| 41 | |
| 42 | def _check_token_embeddings(entry, expected_input_type: str): |
| 43 | assert _get_attr_or_val(entry, "object") == "embedding" |
| 44 | assert _get_attr_or_val(entry, "input_type") == expected_input_type |
| 45 | |
| 46 | embedding = _get_attr_or_val(entry, "embedding") |
| 47 | assert isinstance(embedding, list) and len(embedding) > 0 |
| 48 | for token_embedding in embedding: |
| 49 | assert isinstance(token_embedding, list) |
| 50 | assert len(token_embedding) == model_config["embedding_dim"] |
| 51 | return embedding |
| 52 | |
| 53 | |
| 54 | @pytest.fixture(scope="module") |
| 55 | def server(): |
| 56 | args = [ |
| 57 | "--runner", |
| 58 | "pooling", |
| 59 | "--enforce-eager", |
| 60 | "--max-num-seqs", |
| 61 | "32", |
| 62 | "--trust-remote-code", |
| 63 | "--hf_overrides", |
| 64 | model_config["hf_overrides"], |
| 65 | "--io-processor-plugin", |
| 66 | model_config["plugin"], |
| 67 | ] |
| 68 | |
| 69 | with RemoteOpenAIServer(model_config["model_name"], args) as remote_server: |
| 70 | yield remote_server |
| 71 | |
| 72 | |
| 73 | def _post_pooling(server: RemoteOpenAIServer, data: dict): |
| 74 | request_payload = { |
| 75 | "model": model_config["model_name"], |
| 76 | "task": "plugin", |
| 77 | "data": data, |
| 78 | } |
| 79 | ret = requests.post(server.url_for("pooling"), json=request_payload) |
| 80 | ret.raise_for_status() |
| 81 | response = ret.json() |
| 82 | parsed_response: object = IOProcessorResponse(**response).data |
| 83 | assert parsed_response |
| 84 | return parsed_response |
| 85 | |
| 86 | |
| 87 | def test_colbert_query_plugin_query_online(server: RemoteOpenAIServer): |
| 88 | """Queries are expanded to exactly query_maxlen token vectors.""" |
| 89 | parsed_response = _post_pooling( |
| 90 | server, {"input": model_config["query_input"], "input_type": "query"} |
| 91 | ) |
| 92 | |
| 93 | data = _get_attr_or_val(parsed_response, "data") |
| 94 | assert len(data) == 1 |
| 95 | |
| 96 | embedding = _check_token_embeddings(data[0], "query") |
| 97 | assert len(embedding) == model_config["query_maxlen"] |
| 98 | |
| 99 | usage = _get_attr_or_val(parsed_response, "usage") |
| 100 | assert _get_attr_or_val(usage, "prompt_tokens") == model_config["query_maxlen"] |
| 101 | |
| 102 | |
| 103 | def test_colbert_query_plugin_document_online(server: RemoteOpenAIServer): |
| 104 | """Documents return one vector per token, with no mask expansion.""" |
| 105 | parsed_response = _post_pooling( |
| 106 | server, {"input": model_config["document_input"], "input_type": "document"} |
| 107 | ) |
| 108 | |
| 109 | data = _get_attr_or_val(parsed_response, "data") |
| 110 | assert len(data) == 1 |
| 111 | |
| 112 | embedding = _check_token_embeddings(data[0], "document") |
| 113 | # No query expansion: number of vectors tracks the input length. |
| 114 | assert len(embedding) != model_config["query_maxlen"] |
| 115 | |
| 116 | usage = _get_attr_or_val(parsed_response, "usage") |
| 117 | assert _get_attr_or_val(usage, "prompt_tokens") == len(embedding) |
| 118 | |
| 119 | |
| 120 | def test_colbert_query_plugin_missing_input_type_online(server: RemoteOpenAIServer): |
| 121 | """input_type is required; omitting it is rejected.""" |
| 122 | request_payload = { |
| 123 | "model": model_config["model_name"], |
| 124 | "task": "plugin", |
| 125 | "data": {"input": model_config["document_input"]}, |
| 126 | } |
| 127 | ret = requests.post(server.url_for("pooling"), json=request_payload) |
| 128 | assert ret.status_code == 400 |
| 129 | |
| 130 | |
| 131 | def test_colbert_query_plugin_batch_online(server: RemoteOpenAIServer): |
| 132 | """A list input returns one entry per prompt.""" |
| 133 | queries = ["What is machine learning?", "What is deep learning?"] |
| 134 | parsed_response = _post_pooling(server, {"input": queries, "input_type": "query"}) |
| 135 | |
| 136 | data = _get_attr_or_val(parsed_response, "data") |
| 137 | assert len(data) == len(queries) |
| 138 | for i, entry in enumerate(data): |
| 139 | assert _get_attr_or_val(entry, "index") == i |
| 140 | embedding = _check_token_embeddings(entry, "query") |
| 141 | assert len(embedding) == model_config["query_maxlen"] |
| 142 | |
| 143 | |
| 144 | @pytest.mark.parametrize("input_type", ["query", "document"]) |
| 145 | def test_colbert_query_plugin_offline(vllm_runner, input_type: str): |
| 146 | """Test the ColBERT query plugin in offline mode.""" |
| 147 | input_text = ( |
| 148 | model_config["query_input"] |
| 149 | if input_type == "query" |
| 150 | else model_config["document_input"] |
| 151 | ) |
| 152 | prompt = { |
| 153 | "data": { |
| 154 | "input": input_text, |
| 155 | "input_type": input_type, |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | with vllm_runner( |
| 160 | model_config["model_name"], |
| 161 | runner="pooling", |
| 162 | enforce_eager=True, |
| 163 | max_num_seqs=32, |
| 164 | trust_remote_code=True, |
| 165 | io_processor_plugin=model_config["plugin"], |
| 166 | hf_overrides=json.loads(model_config["hf_overrides"]), |
| 167 | default_torch_num_threads=1, |
| 168 | ) as llm_runner: |
| 169 | llm = llm_runner.get_llm() |
| 170 | pooler_output = llm.encode(prompt, pooling_task="plugin") |
| 171 | |
| 172 | response = pooler_output[0].outputs |
| 173 | assert len(response.data) == 1 |
| 174 | |
| 175 | embedding = _check_token_embeddings(response.data[0], input_type) |
| 176 | if input_type == "query": |
| 177 | assert len(embedding) == model_config["query_maxlen"] |
| 178 | else: |
| 179 | assert len(embedding) != model_config["query_maxlen"] |
| 180 | |
| 181 | assert response.usage.prompt_tokens == len(embedding) |
| 182 | assert response.usage.total_tokens == response.usage.prompt_tokens |
| 183 | |
| 184 | |
| 185 | def test_colbert_query_plugin_offline_multiple_inputs(vllm_runner): |
| 186 | """Test the ColBERT query plugin with multiple inputs in offline mode.""" |
| 187 | queries = [ |
| 188 | "What is machine learning?", |
| 189 | "What is deep learning?", |
| 190 | "Why?", |
| 191 | ] |
| 192 | prompts = { |
| 193 | "data": { |
| 194 | "input": queries, |
| 195 | "input_type": "query", |
| 196 | } |
| 197 | } |
| 198 | |
| 199 | with vllm_runner( |
| 200 | model_config["model_name"], |
| 201 | runner="pooling", |
| 202 | enforce_eager=True, |
| 203 | max_num_seqs=32, |
| 204 | trust_remote_code=True, |
| 205 | io_processor_plugin=model_config["plugin"], |
| 206 | hf_overrides=json.loads(model_config["hf_overrides"]), |
| 207 | default_torch_num_threads=1, |
| 208 | ) as llm_runner: |
| 209 | llm = llm_runner.get_llm() |
| 210 | pooler_output = llm.encode(prompts, pooling_task="plugin") |
| 211 | |
| 212 | response = pooler_output[0].outputs |
| 213 | assert len(response.data) == len(queries) |
| 214 | |
| 215 | for i, entry in enumerate(response.data): |
| 216 | assert entry.index == i |
| 217 | embedding = _check_token_embeddings(entry, "query") |
| 218 | assert len(embedding) == model_config["query_maxlen"] |
| 219 | |
| 220 | expected_tokens = model_config["query_maxlen"] * len(queries) |
| 221 | assert response.usage.prompt_tokens == expected_tokens |
| 222 | assert response.usage.total_tokens == response.usage.prompt_tokens |