Text Ranking
Transformers
Safetensors
multilingual
t5gemma2
text2text-generation
reranker
encoder-decoder
FBNL
Retrieval
RAG
cosyy commited on
Commit
6f7a484
·
verified ·
1 Parent(s): bdc942d

Upload 34 files

Browse files
Files changed (34) hide show
  1. vllm_support/README.md +279 -0
  2. vllm_support/build/lib/kalm_t5gemma2_vllm_plugin/__init__.py +23 -0
  3. vllm_support/build/lib/kalm_t5gemma2_vllm_plugin/cli.py +172 -0
  4. vllm_support/build/lib/kalm_t5gemma2_vllm_plugin/client.py +125 -0
  5. vllm_support/build/lib/kalm_t5gemma2_vllm_plugin/constants.py +105 -0
  6. vllm_support/build/lib/kalm_t5gemma2_vllm_plugin/modeling.py +265 -0
  7. vllm_support/build/lib/kalm_t5gemma2_vllm_plugin/modeling_score.py +189 -0
  8. vllm_support/build/lib/kalm_t5gemma2_vllm_plugin/processing.py +173 -0
  9. vllm_support/build/lib/kalm_t5gemma2_vllm_plugin/register.py +15 -0
  10. vllm_support/build/lib/kalm_t5gemma2_vllm_plugin/reranker.py +357 -0
  11. vllm_support/build/lib/kalm_t5gemma2_vllm_plugin/server.py +215 -0
  12. vllm_support/examples/call_online_api.py +6 -0
  13. vllm_support/examples/offline_usage.py +17 -0
  14. vllm_support/examples/rerank_request.json +10 -0
  15. vllm_support/examples/sample_pairs.jsonl +2 -0
  16. vllm_support/examples/score_request.json +16 -0
  17. vllm_support/examples/start_online_server.sh +20 -0
  18. vllm_support/pyproject.toml +33 -0
  19. vllm_support/src/kalm_t5gemma2_vllm_plugin.egg-info/PKG-INFO +292 -0
  20. vllm_support/src/kalm_t5gemma2_vllm_plugin.egg-info/SOURCES.txt +18 -0
  21. vllm_support/src/kalm_t5gemma2_vllm_plugin.egg-info/dependency_links.txt +1 -0
  22. vllm_support/src/kalm_t5gemma2_vllm_plugin.egg-info/entry_points.txt +7 -0
  23. vllm_support/src/kalm_t5gemma2_vllm_plugin.egg-info/requires.txt +6 -0
  24. vllm_support/src/kalm_t5gemma2_vllm_plugin.egg-info/top_level.txt +1 -0
  25. vllm_support/src/kalm_t5gemma2_vllm_plugin/__init__.py +23 -0
  26. vllm_support/src/kalm_t5gemma2_vllm_plugin/cli.py +172 -0
  27. vllm_support/src/kalm_t5gemma2_vllm_plugin/client.py +125 -0
  28. vllm_support/src/kalm_t5gemma2_vllm_plugin/constants.py +105 -0
  29. vllm_support/src/kalm_t5gemma2_vllm_plugin/modeling.py +265 -0
  30. vllm_support/src/kalm_t5gemma2_vllm_plugin/modeling_score.py +189 -0
  31. vllm_support/src/kalm_t5gemma2_vllm_plugin/processing.py +173 -0
  32. vllm_support/src/kalm_t5gemma2_vllm_plugin/register.py +15 -0
  33. vllm_support/src/kalm_t5gemma2_vllm_plugin/reranker.py +357 -0
  34. vllm_support/src/kalm_t5gemma2_vllm_plugin/server.py +215 -0
vllm_support/README.md ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # KaLM-Reranker-V1-Small vLLM Support
2
+
3
+ This directory contains the experimental vLLM 0.19.1 adapter for
4
+ `KaLM-Embedding/KaLM-Reranker-V1-Small`. It supports offline Python and CLI
5
+ reranking plus an optional FastAPI service.
6
+
7
+ The adapter does not modify or retrain the checkpoint. It reads the original
8
+ decoder logits for the single-token answers `yes` and `no` and returns:
9
+
10
+ ```text
11
+ margin = yes_logit - no_logit
12
+ score = sigmoid(margin) = P(yes)
13
+ ```
14
+
15
+ ## Tested environment
16
+
17
+ - Linux and NVIDIA CUDA
18
+ - Python 3.12
19
+ - vLLM 0.19.1
20
+ - Transformers 5.6.2
21
+ - PyTorch 2.10.0
22
+ - BF16, one GPU
23
+
24
+ The package intentionally rejects other vLLM versions and
25
+ `tensor_parallel_size != 1`. These combinations have not been validated.
26
+
27
+ ## Installation
28
+
29
+ Create an environment and download the model repository:
30
+
31
+ ```bash
32
+ conda create -n kalm-vllm python=3.12 -y
33
+ conda activate kalm-vllm
34
+
35
+ pip install "vllm==0.19.1" "transformers==5.6.2"
36
+ pip install "fastapi>=0.136,<0.137" "uvicorn>=0.46,<0.47"
37
+
38
+ hf download KaLM-Embedding/KaLM-Reranker-V1-Small \
39
+ --local-dir ./KaLM-Reranker-V1-Small
40
+ pip install ./KaLM-Reranker-V1-Small/vllm_support --no-deps
41
+ export VLLM_PLUGINS=kalm_t5gemma2
42
+ ```
43
+
44
+ The model can also be loaded directly by its Hugging Face ID. In that case,
45
+ only download the `vllm_support` directory before installing the plugin:
46
+
47
+ ```bash
48
+ hf download KaLM-Embedding/KaLM-Reranker-V1-Small \
49
+ --include "vllm_support/**" \
50
+ --local-dir ./KaLM-Reranker-V1-Small
51
+ pip install ./KaLM-Reranker-V1-Small/vllm_support --no-deps
52
+ ```
53
+
54
+ ## Offline Python API
55
+
56
+ ```python
57
+ from kalm_t5gemma2_vllm_plugin import KaLMVLLMReranker
58
+
59
+ query = "What is the capital of China?"
60
+ documents = [
61
+ "The capital of China is Beijing.",
62
+ "Gravity attracts bodies toward one another.",
63
+ ]
64
+
65
+ pairs = [(query, document) for document in documents]
66
+ with KaLMVLLMReranker(
67
+ "KaLM-Embedding/KaLM-Reranker-V1-Small",
68
+ query_max_length=512,
69
+ document_max_length=1024,
70
+ encoder_chunk_size=4,
71
+ max_model_len=2048,
72
+ batch_size=32,
73
+ ) as reranker:
74
+ print(reranker.predict(pairs))
75
+ print(reranker.predict(pairs, return_margin=True))
76
+ print(reranker.rank(query, documents))
77
+ ```
78
+
79
+ Expected BF16 scores are approximately:
80
+
81
+ ```text
82
+ [0.99980897, 0.00000493699]
83
+ ```
84
+
85
+ `predict()` preserves input order. `rank()` returns score-descending results
86
+ with the original document index in `corpus_id`.
87
+
88
+ ## Offline CLI
89
+
90
+ Run the built-in example:
91
+
92
+ ```bash
93
+ kalm-vllm-rerank --return-margin
94
+ ```
95
+
96
+ Score JSONL input:
97
+
98
+ ```bash
99
+ kalm-vllm-rerank \
100
+ --input-jsonl ./KaLM-Reranker-V1-Small/vllm_support/examples/sample_pairs.jsonl \
101
+ --output-jsonl ./scores.jsonl \
102
+ --return-margin
103
+ ```
104
+
105
+ Each input line must contain `query` and `document`. Optional fields are `id`
106
+ and `instruction`. `--top-k N` groups rows by exact query text, sorts each
107
+ group by score, and keeps its first `N` documents.
108
+
109
+ ## Online service
110
+
111
+ Start one model instance:
112
+
113
+ ```bash
114
+ kalm-vllm-serve \
115
+ --host 0.0.0.0 \
116
+ --port 8000 \
117
+ --model KaLM-Embedding/KaLM-Reranker-V1-Small \
118
+ --encoder-chunk-size 4
119
+ ```
120
+
121
+ The portable startup script exposes the same settings through environment
122
+ variables:
123
+
124
+ ```bash
125
+ CUDA_VISIBLE_DEVICES=0 PORT=8000 \
126
+ ./KaLM-Reranker-V1-Small/vllm_support/examples/start_online_server.sh
127
+ ```
128
+
129
+ In a second terminal, check health and send built-in demo requests:
130
+
131
+ ```bash
132
+ kalm-vllm-client --health
133
+ kalm-vllm-client --endpoint rerank --return-margin
134
+ kalm-vllm-client --endpoint score --return-margin
135
+ ```
136
+
137
+ For custom input, pass one JSON object with `--json-file`. Use `/rerank` for
138
+ one query against multiple documents:
139
+
140
+ ```bash
141
+ kalm-vllm-client \
142
+ --endpoint rerank \
143
+ --json-file ./KaLM-Reranker-V1-Small/vllm_support/examples/rerank_request.json \
144
+ --return-margin \
145
+ --top-k 10
146
+ ```
147
+
148
+ Use `/score` for a batch of independent query-document pairs:
149
+
150
+ ```bash
151
+ kalm-vllm-client \
152
+ --endpoint score \
153
+ --json-file ./KaLM-Reranker-V1-Small/vllm_support/examples/score_request.json \
154
+ --return-margin
155
+ ```
156
+
157
+ When `--json-file` is used, `--return-margin` sets
158
+ `"return_margin": true` in the outgoing request, and `--top-k` overrides the
159
+ JSON value for `/rerank`.
160
+
161
+ ### `POST /rerank`
162
+
163
+ ```json
164
+ {
165
+ "query": "What is the capital of China?",
166
+ "documents": [
167
+ "The capital of China is Beijing.",
168
+ "Gravity attracts bodies toward one another."
169
+ ],
170
+ "instruction": "Given a query, retrieve documents that answer the query.",
171
+ "top_k": null,
172
+ "return_margin": true
173
+ }
174
+ ```
175
+
176
+ Results are returned in descending score order:
177
+
178
+ ```json
179
+ {
180
+ "object": "rerank",
181
+ "results": [
182
+ {"index": 0, "score": 0.9998089, "margin": 8.5625},
183
+ {"index": 1, "score": 0.00000493699, "margin": -12.21875}
184
+ ]
185
+ }
186
+ ```
187
+
188
+ ### `POST /score`
189
+
190
+ ```json
191
+ {
192
+ "pairs": [
193
+ {
194
+ "id": "doc-1",
195
+ "query": "What is the capital of China?",
196
+ "document": "The capital of China is Beijing."
197
+ }
198
+ ],
199
+ "instruction": null,
200
+ "return_margin": false
201
+ }
202
+ ```
203
+
204
+ `/score` accepts multiple entries in `pairs`, preserves their input order and
205
+ includes an input `id` when provided.
206
+
207
+ ### `GET /health`
208
+
209
+ Returns service status and the effective model, length, chunking, dtype and
210
+ memory settings.
211
+
212
+ ## Configuration
213
+
214
+ | Setting | Default | Meaning |
215
+ | --- | ---: | --- |
216
+ | `query_max_length` | `512` | Maximum raw query tokens before prompt insertion |
217
+ | `document_max_length` | `1024` | Maximum encoder tokens for `<Document>: ...` |
218
+ | `encoder_chunk_size` | `4` | Mean-pooling factor; one of `1,2,4,8,16,32` |
219
+ | `max_model_len` | `2048` | vLLM engine context budget |
220
+ | `batch_size` | `32` | Pairs passed to each `LLM.classify()` call |
221
+ | `dtype` | `bfloat16` | Model compute dtype |
222
+ | `gpu_memory_utilization` | `0.85` | vLLM GPU memory fraction |
223
+ | `tensor_parallel_size` | `1` | Only supported value in this release |
224
+
225
+ The query and document limits belong to separate decoder and encoder streams;
226
+ they are not a combined cross-encoder token limit. Larger values are
227
+ configurable but have not been validated up to the model card's full 128K
228
+ limit.
229
+
230
+ ## Limitations
231
+
232
+ - This is a custom `LLM.classify()` plugin, not vLLM's native HTTP `/score`
233
+ implementation.
234
+ - The shim uses vLLM scheduling and pooling interfaces but executes the
235
+ T5Gemma2 semantic forward through Transformers. It is not a complete
236
+ vLLM-native kernel implementation and should not be used to claim native
237
+ vLLM throughput.
238
+ - Online serving is a single-process FastAPI wrapper around one model instance.
239
+ - `encoder_chunk_size=None`, `null`, or an empty string falls back to `4`; it
240
+ does not disable pooling in this release.
241
+
242
+ ## Troubleshooting
243
+
244
+ **The plugin is not discovered**
245
+
246
+ Reinstall the package and ensure the environment variable includes its entry
247
+ point name:
248
+
249
+ ```bash
250
+ pip install ./KaLM-Reranker-V1-Small/vllm_support --no-deps --force-reinstall
251
+ export VLLM_PLUGINS=kalm_t5gemma2
252
+ ```
253
+
254
+ **The adapter reports an unsupported vLLM version**
255
+
256
+ Install exactly `vllm==0.19.1`. Internal model and processor APIs are version
257
+ sensitive.
258
+
259
+ **The tokenizer check fails**
260
+
261
+ Confirm that the tokenizer belongs to this Small checkpoint. The adapter
262
+ requires `yes -> 4443` and `no -> 1904`.
263
+
264
+ **CUDA runs out of memory**
265
+
266
+ Reduce `batch_size`, `document_max_length`, `query_max_length`,
267
+ `max_model_len`, or `gpu_memory_utilization`.
268
+
269
+ **CUDA initialization fails with error 803**
270
+
271
+ The process may be resolving a CUDA compatibility library before the host
272
+ driver library. On common Debian/Ubuntu layouts, retry with:
273
+
274
+ ```bash
275
+ export LD_LIBRARY_PATH="/lib/x86_64-linux-gnu:/usr/lib/x86_64-linux-gnu${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
276
+ ```
277
+
278
+ The provided `start_online_server.sh` applies this adjustment automatically
279
+ when both directories exist.
vllm_support/build/lib/kalm_t5gemma2_vllm_plugin/__init__.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from .constants import (
4
+ ARCHITECTURE,
5
+ MODEL_ID,
6
+ PLUGIN_NAME,
7
+ SUPPORTED_ENCODER_CHUNK_SIZES,
8
+ TEXT_MODALITY,
9
+ )
10
+ from .reranker import KaLMVLLMOfflineReranker, KaLMVLLMReranker
11
+
12
+ __version__ = "0.1.0"
13
+
14
+ __all__ = [
15
+ "ARCHITECTURE",
16
+ "KaLMVLLMOfflineReranker",
17
+ "KaLMVLLMReranker",
18
+ "MODEL_ID",
19
+ "PLUGIN_NAME",
20
+ "SUPPORTED_ENCODER_CHUNK_SIZES",
21
+ "TEXT_MODALITY",
22
+ "__version__",
23
+ ]
vllm_support/build/lib/kalm_t5gemma2_vllm_plugin/cli.py ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from collections import OrderedDict
7
+ from pathlib import Path
8
+ from typing import Any, Iterable, TextIO
9
+
10
+ from .constants import (
11
+ DEFAULT_DOCUMENT_MAX_LENGTH,
12
+ DEFAULT_ENCODER_CHUNK_SIZE,
13
+ DEFAULT_MAX_MODEL_LEN,
14
+ DEFAULT_QUERY_MAX_LENGTH,
15
+ MODEL_ID,
16
+ SAMPLE_DOCUMENTS,
17
+ SAMPLE_QUERY,
18
+ SUPPORTED_ENCODER_CHUNK_SIZES,
19
+ parse_encoder_chunk_size,
20
+ )
21
+ from .reranker import KaLMVLLMReranker
22
+
23
+
24
+ def build_parser() -> argparse.ArgumentParser:
25
+ parser = argparse.ArgumentParser(
26
+ description="Offline KaLM-Reranker-V1-Nano scoring with vLLM 0.19.1.",
27
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
28
+ )
29
+ parser.add_argument("--input-jsonl", type=Path)
30
+ parser.add_argument("--output-jsonl", type=Path)
31
+ parser.add_argument("--model", default=MODEL_ID)
32
+ parser.add_argument("--query-max-length", type=int, default=DEFAULT_QUERY_MAX_LENGTH)
33
+ parser.add_argument(
34
+ "--document-max-length", type=int, default=DEFAULT_DOCUMENT_MAX_LENGTH
35
+ )
36
+ parser.add_argument(
37
+ "--encoder-chunk-size",
38
+ default=str(DEFAULT_ENCODER_CHUNK_SIZE),
39
+ help=f"One of {sorted(SUPPORTED_ENCODER_CHUNK_SIZES)}.",
40
+ )
41
+ parser.add_argument("--max-model-len", type=int, default=DEFAULT_MAX_MODEL_LEN)
42
+ parser.add_argument("--batch-size", type=int, default=32)
43
+ parser.add_argument("--return-margin", action="store_true")
44
+ parser.add_argument("--top-k", type=int)
45
+ parser.add_argument("--dtype", default="bfloat16")
46
+ parser.add_argument("--gpu-memory-utilization", type=float, default=0.85)
47
+ parser.add_argument("--tensor-parallel-size", type=int, default=1)
48
+ return parser
49
+
50
+
51
+ def _read_jsonl(path: Path) -> list[dict[str, Any]]:
52
+ rows: list[dict[str, Any]] = []
53
+ with path.open("r", encoding="utf-8") as handle:
54
+ for line_no, line in enumerate(handle, start=1):
55
+ if not line.strip():
56
+ continue
57
+ try:
58
+ row = json.loads(line)
59
+ except json.JSONDecodeError as error:
60
+ raise ValueError(f"{path}:{line_no}: invalid JSON.") from error
61
+ if not isinstance(row, dict):
62
+ raise ValueError(f"{path}:{line_no}: expected a JSON object.")
63
+ if not isinstance(row.get("query"), str):
64
+ raise ValueError(f"{path}:{line_no}: 'query' must be a string.")
65
+ if not isinstance(row.get("document"), str):
66
+ raise ValueError(f"{path}:{line_no}: 'document' must be a string.")
67
+ if "instruction" in row and not isinstance(row["instruction"], str):
68
+ raise ValueError(f"{path}:{line_no}: 'instruction' must be a string.")
69
+ rows.append(row)
70
+ return rows
71
+
72
+
73
+ def _write_jsonl(rows: Iterable[dict[str, Any]], output: TextIO) -> None:
74
+ for row in rows:
75
+ output.write(json.dumps(row, ensure_ascii=False) + "\n")
76
+ output.flush()
77
+
78
+
79
+ def _score_rows(
80
+ reranker: KaLMVLLMReranker,
81
+ rows: list[dict[str, Any]],
82
+ *,
83
+ return_margin: bool,
84
+ top_k: int | None,
85
+ ) -> list[dict[str, Any]]:
86
+ grouped: OrderedDict[str | None, list[tuple[int, dict[str, Any]]]] = OrderedDict()
87
+ for index, row in enumerate(rows):
88
+ grouped.setdefault(row.get("instruction"), []).append((index, row))
89
+
90
+ scored: dict[int, dict[str, Any]] = {}
91
+ for instruction, items in grouped.items():
92
+ predictions = reranker.predict(
93
+ [(row["query"], row["document"]) for _, row in items],
94
+ instruction=instruction,
95
+ return_margin=True,
96
+ )
97
+ for (index, row), prediction in zip(items, predictions):
98
+ assert isinstance(prediction, dict)
99
+ result = {
100
+ "id": row.get("id", index),
101
+ "query": row["query"],
102
+ "document": row["document"],
103
+ "score": prediction["score"],
104
+ }
105
+ if "instruction" in row:
106
+ result["instruction"] = row["instruction"]
107
+ if return_margin:
108
+ result["margin"] = prediction["margin"]
109
+ scored[index] = result
110
+
111
+ ordered = [scored[index] for index in range(len(rows))]
112
+ if top_k is None:
113
+ return ordered
114
+ if top_k < 0:
115
+ raise ValueError("--top-k must be non-negative.")
116
+ by_query: OrderedDict[str, list[dict[str, Any]]] = OrderedDict()
117
+ for row in ordered:
118
+ by_query.setdefault(str(row["query"]), []).append(row)
119
+ output: list[dict[str, Any]] = []
120
+ for group in by_query.values():
121
+ group.sort(key=lambda item: float(item["score"]), reverse=True)
122
+ output.extend(group[:top_k])
123
+ return output
124
+
125
+
126
+ def main() -> int:
127
+ args = build_parser().parse_args()
128
+ with KaLMVLLMReranker(
129
+ args.model,
130
+ query_max_length=args.query_max_length,
131
+ document_max_length=args.document_max_length,
132
+ encoder_chunk_size=parse_encoder_chunk_size(args.encoder_chunk_size),
133
+ max_model_len=args.max_model_len,
134
+ batch_size=args.batch_size,
135
+ dtype=args.dtype,
136
+ gpu_memory_utilization=args.gpu_memory_utilization,
137
+ tensor_parallel_size=args.tensor_parallel_size,
138
+ ) as reranker:
139
+ if args.input_jsonl is None:
140
+ rankings = reranker.rank(
141
+ SAMPLE_QUERY,
142
+ SAMPLE_DOCUMENTS,
143
+ top_k=args.top_k,
144
+ return_margin=args.return_margin,
145
+ )
146
+ rows = [
147
+ {
148
+ "query": SAMPLE_QUERY,
149
+ "document": SAMPLE_DOCUMENTS[int(item["corpus_id"])],
150
+ **item,
151
+ }
152
+ for item in rankings
153
+ ]
154
+ else:
155
+ rows = _score_rows(
156
+ reranker,
157
+ _read_jsonl(args.input_jsonl),
158
+ return_margin=args.return_margin,
159
+ top_k=args.top_k,
160
+ )
161
+
162
+ if args.output_jsonl is None:
163
+ _write_jsonl(rows, sys.stdout)
164
+ else:
165
+ args.output_jsonl.parent.mkdir(parents=True, exist_ok=True)
166
+ with args.output_jsonl.open("w", encoding="utf-8") as handle:
167
+ _write_jsonl(rows, handle)
168
+ return 0
169
+
170
+
171
+ if __name__ == "__main__":
172
+ raise SystemExit(main())
vllm_support/build/lib/kalm_t5gemma2_vllm_plugin/client.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ import urllib.error
7
+ import urllib.request
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from .constants import SAMPLE_DOCUMENTS, SAMPLE_QUERY
12
+
13
+
14
+ def build_parser() -> argparse.ArgumentParser:
15
+ parser = argparse.ArgumentParser(
16
+ description="Call the KaLM vLLM FastAPI service.",
17
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
18
+ )
19
+ parser.add_argument("--base-url", default="http://127.0.0.1:8000")
20
+ parser.add_argument("--health", action="store_true")
21
+ parser.add_argument("--endpoint", choices=("rerank", "score"), default="rerank")
22
+ parser.add_argument("--json-file", type=Path)
23
+ parser.add_argument("--return-margin", action="store_true")
24
+ parser.add_argument("--top-k", type=int)
25
+ parser.add_argument("--timeout", type=float, default=600.0)
26
+ return parser
27
+
28
+
29
+ def request_json(
30
+ method: str,
31
+ url: str,
32
+ *,
33
+ payload: dict[str, Any] | None = None,
34
+ timeout: float = 600.0,
35
+ ) -> dict[str, Any]:
36
+ data = None
37
+ headers = {"Accept": "application/json"}
38
+ if payload is not None:
39
+ data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
40
+ headers["Content-Type"] = "application/json"
41
+ request = urllib.request.Request(url, data=data, headers=headers, method=method)
42
+ opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
43
+ try:
44
+ with opener.open(request, timeout=timeout) as response:
45
+ raw = response.read().decode("utf-8")
46
+ except urllib.error.HTTPError as error:
47
+ detail = error.read().decode("utf-8", errors="replace")
48
+ raise RuntimeError(f"HTTP {error.code} from {url}: {detail}") from error
49
+ return json.loads(raw)
50
+
51
+
52
+ def _demo_payload(
53
+ endpoint: str,
54
+ return_margin: bool,
55
+ top_k: int | None,
56
+ ) -> dict[str, Any]:
57
+ if endpoint == "rerank":
58
+ return {
59
+ "query": SAMPLE_QUERY,
60
+ "documents": list(SAMPLE_DOCUMENTS),
61
+ "top_k": top_k,
62
+ "return_margin": return_margin,
63
+ }
64
+ return {
65
+ "pairs": [
66
+ {
67
+ "id": "positive",
68
+ "query": SAMPLE_QUERY,
69
+ "document": SAMPLE_DOCUMENTS[0],
70
+ },
71
+ {
72
+ "id": "negative",
73
+ "query": SAMPLE_QUERY,
74
+ "document": SAMPLE_DOCUMENTS[1],
75
+ },
76
+ ],
77
+ "return_margin": return_margin,
78
+ }
79
+
80
+
81
+ def _load_payload(path: Path) -> dict[str, Any]:
82
+ with path.open("r", encoding="utf-8") as handle:
83
+ payload = json.load(handle)
84
+ if not isinstance(payload, dict):
85
+ raise ValueError(f"{path} must contain one JSON object.")
86
+ return payload
87
+
88
+
89
+ def _request_payload(args: argparse.Namespace) -> dict[str, Any]:
90
+ payload = (
91
+ _load_payload(args.json_file)
92
+ if args.json_file is not None
93
+ else _demo_payload(args.endpoint, args.return_margin, args.top_k)
94
+ )
95
+ if args.return_margin:
96
+ payload["return_margin"] = True
97
+ if args.top_k is not None:
98
+ if args.endpoint != "rerank":
99
+ raise ValueError("--top-k is only valid with --endpoint rerank.")
100
+ if args.top_k < 0:
101
+ raise ValueError("--top-k must be non-negative.")
102
+ payload["top_k"] = args.top_k
103
+ return payload
104
+
105
+
106
+ def main() -> int:
107
+ args = build_parser().parse_args()
108
+ base_url = args.base_url.rstrip("/")
109
+ if args.health:
110
+ response = request_json("GET", f"{base_url}/health", timeout=args.timeout)
111
+ else:
112
+ payload = _request_payload(args)
113
+ response = request_json(
114
+ "POST",
115
+ f"{base_url}/{args.endpoint}",
116
+ payload=payload,
117
+ timeout=args.timeout,
118
+ )
119
+ json.dump(response, sys.stdout, ensure_ascii=False, indent=2)
120
+ sys.stdout.write("\n")
121
+ return 0
122
+
123
+
124
+ if __name__ == "__main__":
125
+ raise SystemExit(main())
vllm_support/build/lib/kalm_t5gemma2_vllm_plugin/constants.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Iterable
4
+
5
+
6
+ MODEL_ID = "KaLM-Embedding/KaLM-Reranker-V1-Nano"
7
+ PLUGIN_NAME = "kalm_t5gemma2"
8
+ ARCHITECTURE = "T5Gemma2VllmScoreClassification"
9
+ TEXT_MODALITY = "text"
10
+
11
+ DEFAULT_INSTRUCTION = "Given a query, retrieve documents that answer the query."
12
+ DEFAULT_SYSTEM_INSTRUCTION = (
13
+ "Judge whether the Document meets the requirements based on the Query and "
14
+ 'the Instruct provided. Note that the answer can only be "yes" or "no".'
15
+ )
16
+
17
+ DEFAULT_QUERY_MAX_LENGTH = 512
18
+ DEFAULT_DOCUMENT_MAX_LENGTH = 1024
19
+ DEFAULT_MAX_MODEL_LEN = 2048
20
+ DEFAULT_ENCODER_CHUNK_SIZE = 4
21
+ DEFAULT_DECODER_PAD_TO_MULTIPLE_OF = 8
22
+ SUPPORTED_ENCODER_CHUNK_SIZES = frozenset({1, 2, 4, 8, 16, 32})
23
+
24
+ YES_TOKEN_TEXT = "yes"
25
+ NO_TOKEN_TEXT = "no"
26
+ YES_TOKEN_ID = 4443
27
+ NO_TOKEN_ID = 1904
28
+
29
+ SAMPLE_QUERY = "What is the capital of China?"
30
+ SAMPLE_DOCUMENTS = (
31
+ "The capital of China is Beijing.",
32
+ "Gravity attracts bodies toward one another.",
33
+ )
34
+
35
+
36
+ def parse_encoder_chunk_size(value: object) -> int:
37
+ if value is None:
38
+ return DEFAULT_ENCODER_CHUNK_SIZE
39
+ if isinstance(value, str):
40
+ normalized = value.strip().lower()
41
+ if normalized in {"", "none", "null"}:
42
+ return DEFAULT_ENCODER_CHUNK_SIZE
43
+ try:
44
+ parsed = int(normalized)
45
+ except ValueError as error:
46
+ raise ValueError(_chunk_size_error(value)) from error
47
+ else:
48
+ parsed = int(value)
49
+ if parsed not in SUPPORTED_ENCODER_CHUNK_SIZES:
50
+ raise ValueError(_chunk_size_error(value))
51
+ return parsed
52
+
53
+
54
+ def _chunk_size_error(value: object) -> str:
55
+ return (
56
+ "encoder_chunk_size must be one of "
57
+ f"{sorted(SUPPORTED_ENCODER_CHUNK_SIZES)}; got {value!r}."
58
+ )
59
+
60
+
61
+ def encoder_text(document: str) -> str:
62
+ return f"<Document>: {document}"
63
+
64
+
65
+ def decoder_text(
66
+ tokenizer,
67
+ query: str,
68
+ *,
69
+ instruction: str = DEFAULT_INSTRUCTION,
70
+ system_instruction: str = DEFAULT_SYSTEM_INSTRUCTION,
71
+ query_max_length: int = DEFAULT_QUERY_MAX_LENGTH,
72
+ ) -> str:
73
+ query_ids = tokenizer(
74
+ query,
75
+ add_special_tokens=False,
76
+ truncation=True,
77
+ max_length=query_max_length,
78
+ )["input_ids"]
79
+ truncated_query = tokenizer.decode(
80
+ query_ids,
81
+ skip_special_tokens=False,
82
+ clean_up_tokenization_spaces=False,
83
+ )
84
+ return (
85
+ "<bos><start_of_turn>user\n"
86
+ f"{system_instruction}\n\n"
87
+ f"<Instruct>: {instruction}\n"
88
+ f"<Query>: {truncated_query}<end_of_turn>\n"
89
+ "<start_of_turn>model\n\n\n\n"
90
+ )
91
+
92
+
93
+ def validate_answer_tokens(tokenizer) -> None:
94
+ expected: Iterable[tuple[str, int]] = (
95
+ (YES_TOKEN_TEXT, YES_TOKEN_ID),
96
+ (NO_TOKEN_TEXT, NO_TOKEN_ID),
97
+ )
98
+ for text, token_id in expected:
99
+ actual = tokenizer.encode(text, add_special_tokens=False)
100
+ if actual != [token_id]:
101
+ raise RuntimeError(
102
+ f"Unexpected tokenization for {text!r}: expected {[token_id]}, "
103
+ f"got {actual}."
104
+ )
105
+
vllm_support/build/lib/kalm_t5gemma2_vllm_plugin/modeling.py ADDED
@@ -0,0 +1,265 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ import os
5
+ from typing import Iterable
6
+
7
+ import torch
8
+ import torch.nn as nn
9
+ from transformers.modeling_outputs import BaseModelOutput
10
+ from vllm.model_executor.layers.pooler import DispatchPooler
11
+ from vllm.multimodal import MULTIMODAL_REGISTRY
12
+
13
+ from .constants import (
14
+ DEFAULT_DECODER_PAD_TO_MULTIPLE_OF,
15
+ DEFAULT_ENCODER_CHUNK_SIZE,
16
+ NO_TOKEN_ID,
17
+ TEXT_MODALITY,
18
+ YES_TOKEN_ID,
19
+ )
20
+ from .modeling_score import T5Gemma2ForScoreClassification
21
+ from .processing import (
22
+ TextEncoderDummyInputsBuilder,
23
+ TextEncoderProcessingInfo,
24
+ TextEncoderProcessor,
25
+ )
26
+
27
+
28
+ def _as_token_rows(value: object) -> list[torch.Tensor]:
29
+ if isinstance(value, torch.Tensor):
30
+ if value.ndim == 1:
31
+ return [value]
32
+ if value.ndim == 2:
33
+ return [row for row in value]
34
+ raise ValueError(f"encoder_input_ids must be 1D/2D, got {value.shape}.")
35
+ if isinstance(value, list):
36
+ return [
37
+ item.flatten()
38
+ if isinstance(item, torch.Tensor)
39
+ else torch.tensor(item, dtype=torch.long)
40
+ for item in value
41
+ ]
42
+ raise TypeError(f"Unsupported encoder_input_ids type: {type(value)!r}")
43
+
44
+
45
+ def _split_by_position_zero(
46
+ input_ids: torch.Tensor,
47
+ positions: torch.Tensor,
48
+ ) -> tuple[list[torch.Tensor], list[int]]:
49
+ flat_ids = input_ids.flatten()
50
+ starts = (positions.flatten() == 0).nonzero(as_tuple=False).flatten().tolist()
51
+ if not starts:
52
+ return [], []
53
+ starts.append(int(flat_ids.numel()))
54
+ rows: list[torch.Tensor] = []
55
+ last_indices: list[int] = []
56
+ for start, end in zip(starts[:-1], starts[1:]):
57
+ if end > start:
58
+ rows.append(flat_ids[start:end])
59
+ last_indices.append(end - 1)
60
+ return rows, last_indices
61
+
62
+
63
+ def _debug(message: str) -> None:
64
+ if os.environ.get("KALM_VLLM_DEBUG") == "1":
65
+ print(f"[kalm-vllm-debug] {message}", flush=True)
66
+
67
+
68
+ @MULTIMODAL_REGISTRY.register_processor(
69
+ TextEncoderProcessor,
70
+ info=TextEncoderProcessingInfo,
71
+ dummy_inputs=TextEncoderDummyInputsBuilder,
72
+ )
73
+ class T5Gemma2VllmScoreClassification(nn.Module):
74
+ is_pooling_model = True
75
+ supports_multimodal = True
76
+ score_type = "cross-encoder"
77
+ attn_type = "encoder_decoder"
78
+ default_seq_pooling_type = "LAST"
79
+ default_tok_pooling_type = "ALL"
80
+
81
+ def __init__(self, *, vllm_config, prefix: str = "") -> None:
82
+ super().__init__()
83
+ self.vllm_config = vllm_config
84
+ self.model_config = vllm_config.model_config
85
+ self.config = self.model_config.hf_config
86
+ self.config.num_labels = 1
87
+ self.config.yes_token_id = int(
88
+ getattr(self.config, "yes_token_id", YES_TOKEN_ID)
89
+ )
90
+ self.config.no_token_id = int(
91
+ getattr(self.config, "no_token_id", NO_TOKEN_ID)
92
+ )
93
+ self.config.encoder_chunk_size = getattr(
94
+ self.config,
95
+ "encoder_chunk_size",
96
+ DEFAULT_ENCODER_CHUNK_SIZE,
97
+ )
98
+ self.encoder_chunk_size = self.config.encoder_chunk_size
99
+ self.config.decoder_pad_to_multiple_of = int(
100
+ getattr(
101
+ self.config,
102
+ "decoder_pad_to_multiple_of",
103
+ DEFAULT_DECODER_PAD_TO_MULTIPLE_OF,
104
+ )
105
+ )
106
+ self.decoder_pad_to_multiple_of = self.config.decoder_pad_to_multiple_of
107
+ self.pad_token_id = int(getattr(self.config, "pad_token_id", 0) or 0)
108
+
109
+ self.score_model = T5Gemma2ForScoreClassification.from_pretrained(
110
+ self.model_config.model,
111
+ trust_remote_code=True,
112
+ dtype=self.model_config.dtype,
113
+ )
114
+ self.score_model.config.yes_token_id = self.config.yes_token_id
115
+ self.score_model.config.no_token_id = self.config.no_token_id
116
+ self.score_model.config.num_labels = 1
117
+ self.score_model.config.encoder_chunk_size = self.encoder_chunk_size
118
+ self.score_model.yes_token_id = self.config.yes_token_id
119
+ self.score_model.no_token_id = self.config.no_token_id
120
+ self.score_model.encoder_chunk_size = self.encoder_chunk_size
121
+ self.score_model._validate_score_config()
122
+ self.score_model.eval()
123
+
124
+ pooler_config = self.model_config.pooler_config
125
+ assert pooler_config is not None
126
+ self.pooler = DispatchPooler.for_seq_cls(pooler_config)
127
+
128
+ def get_language_model(self):
129
+ return self
130
+
131
+ def get_num_mm_encoder_tokens(self, num_tokens: int) -> int:
132
+ if self.encoder_chunk_size is None:
133
+ return int(num_tokens)
134
+ return int(math.ceil(num_tokens / int(self.encoder_chunk_size)))
135
+
136
+ def embed_input_ids(self, input_ids: torch.Tensor, *args, **kwargs) -> torch.Tensor:
137
+ return self.score_model.get_input_embeddings()(input_ids)
138
+
139
+ def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
140
+ # The semantic wrapper loads the same checkpoint directly.
141
+ for _ in weights:
142
+ pass
143
+ return set(self.state_dict().keys())
144
+
145
+ def embed_multimodal(self, **kwargs: object) -> list[torch.Tensor]:
146
+ if "encoder_input_ids" not in kwargs:
147
+ raise ValueError(f"Missing {TEXT_MODALITY} encoder_input_ids.")
148
+ rows = _as_token_rows(kwargs["encoder_input_ids"])
149
+ device = next(self.score_model.parameters()).device
150
+ outputs: list[torch.Tensor] = []
151
+ with torch.inference_mode():
152
+ for row in rows:
153
+ input_ids = row.to(device=device, dtype=torch.long).unsqueeze(0)
154
+ attention_mask = torch.ones_like(input_ids)
155
+ raw_encoder_outputs = self.score_model.get_encoder()(
156
+ input_ids=input_ids,
157
+ attention_mask=attention_mask,
158
+ return_dict=True,
159
+ )
160
+ hidden = raw_encoder_outputs.last_hidden_state
161
+ if self.encoder_chunk_size is not None:
162
+ hidden, _ = self.score_model._pool_encoder_chunks(
163
+ hidden,
164
+ attention_mask,
165
+ int(self.encoder_chunk_size),
166
+ )
167
+ item = hidden.squeeze(0).contiguous()
168
+ _debug(f"encoder output shape={tuple(item.shape)}")
169
+ outputs.append(item)
170
+ return outputs
171
+
172
+ def _pad_decoder_rows(
173
+ self,
174
+ rows: list[torch.Tensor],
175
+ device: torch.device,
176
+ ) -> tuple[torch.Tensor, torch.Tensor]:
177
+ max_len = max(int(row.numel()) for row in rows)
178
+ if self.decoder_pad_to_multiple_of > 1:
179
+ multiple = self.decoder_pad_to_multiple_of
180
+ max_len = int(math.ceil(max_len / multiple) * multiple)
181
+ batch = torch.full(
182
+ (len(rows), max_len),
183
+ self.pad_token_id,
184
+ dtype=torch.long,
185
+ device=device,
186
+ )
187
+ mask = torch.zeros_like(batch)
188
+ for index, row in enumerate(rows):
189
+ row = row.to(device=device, dtype=torch.long)
190
+ length = int(row.numel())
191
+ batch[index, :length] = row
192
+ mask[index, :length] = 1
193
+ return batch, mask
194
+
195
+ @staticmethod
196
+ def _pad_encoder_outputs(
197
+ encoder_outputs: list[torch.Tensor],
198
+ device: torch.device,
199
+ ) -> tuple[torch.Tensor, torch.Tensor]:
200
+ max_len = max(int(item.shape[0]) for item in encoder_outputs)
201
+ hidden_size = int(encoder_outputs[0].shape[-1])
202
+ batch = torch.zeros(
203
+ (len(encoder_outputs), max_len, hidden_size),
204
+ dtype=encoder_outputs[0].dtype,
205
+ device=device,
206
+ )
207
+ mask = torch.zeros(
208
+ (len(encoder_outputs), max_len),
209
+ dtype=torch.long,
210
+ device=device,
211
+ )
212
+ for index, item in enumerate(encoder_outputs):
213
+ item = item.to(device=device)
214
+ length = int(item.shape[0])
215
+ batch[index, :length] = item
216
+ mask[index, :length] = 1
217
+ return batch, mask
218
+
219
+ def forward(
220
+ self,
221
+ input_ids: torch.Tensor | None,
222
+ positions: torch.Tensor,
223
+ intermediate_tensors=None,
224
+ inputs_embeds: torch.Tensor | None = None,
225
+ encoder_outputs: list[torch.Tensor] | torch.Tensor | None = None,
226
+ **kwargs,
227
+ ) -> torch.Tensor:
228
+ if input_ids is None:
229
+ raise ValueError("Decoder input_ids are required.")
230
+ decoder_rows, last_indices = _split_by_position_zero(input_ids, positions)
231
+ hidden = input_ids.new_zeros((input_ids.numel(), 1), dtype=torch.float32)
232
+ if not decoder_rows:
233
+ return hidden
234
+ if encoder_outputs is None:
235
+ return hidden
236
+
237
+ encoder_list = (
238
+ [item for item in encoder_outputs]
239
+ if isinstance(encoder_outputs, torch.Tensor)
240
+ else list(encoder_outputs)
241
+ )
242
+ if len(encoder_list) != len(decoder_rows):
243
+ raise ValueError(
244
+ "Mismatched encoder/decoder batch sizes: "
245
+ f"{len(encoder_list)} vs {len(decoder_rows)}."
246
+ )
247
+
248
+ device = next(self.score_model.parameters()).device
249
+ decoder_batch, decoder_mask = self._pad_decoder_rows(decoder_rows, device)
250
+ encoder_batch, encoder_mask = self._pad_encoder_outputs(encoder_list, device)
251
+ with torch.inference_mode():
252
+ outputs = self.score_model(
253
+ encoder_outputs=BaseModelOutput(last_hidden_state=encoder_batch),
254
+ attention_mask=encoder_mask,
255
+ decoder_input_ids=decoder_batch,
256
+ decoder_attention_mask=decoder_mask,
257
+ )
258
+ margins = outputs.logits.squeeze(-1).to(hidden.device, dtype=hidden.dtype)
259
+ _debug(f"margins={margins.float().tolist()}")
260
+ for row_index, last_index in enumerate(last_indices):
261
+ hidden[last_index, 0] = margins[row_index]
262
+ return hidden
263
+
264
+
265
+ __all__ = ["T5Gemma2VllmScoreClassification"]
vllm_support/build/lib/kalm_t5gemma2_vllm_plugin/modeling_score.py ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional, Tuple
4
+
5
+ import torch
6
+ import torch.nn.functional as F
7
+ from transformers.modeling_outputs import BaseModelOutput, SequenceClassifierOutput
8
+ from transformers.models.t5gemma2.modeling_t5gemma2 import (
9
+ T5Gemma2ForConditionalGeneration,
10
+ )
11
+
12
+ from .constants import (
13
+ DEFAULT_ENCODER_CHUNK_SIZE,
14
+ NO_TOKEN_ID,
15
+ YES_TOKEN_ID,
16
+ )
17
+
18
+
19
+ class T5Gemma2ForScoreClassification(T5Gemma2ForConditionalGeneration):
20
+ """Expose the pretrained yes/no decision as one classification logit."""
21
+
22
+ def __init__(self, config):
23
+ super().__init__(config)
24
+ self.num_labels = 1
25
+ self.yes_token_id = int(getattr(config, "yes_token_id", YES_TOKEN_ID))
26
+ self.no_token_id = int(getattr(config, "no_token_id", NO_TOKEN_ID))
27
+ self.encoder_chunk_size = getattr(
28
+ config, "encoder_chunk_size", DEFAULT_ENCODER_CHUNK_SIZE
29
+ )
30
+ if self.encoder_chunk_size is not None:
31
+ self.encoder_chunk_size = int(self.encoder_chunk_size)
32
+ self._validate_score_config()
33
+
34
+ def _validate_score_config(self) -> None:
35
+ vocab_size = int(getattr(self.config, "vocab_size", 0))
36
+ if vocab_size <= 0:
37
+ raise ValueError("config.vocab_size must be a positive integer.")
38
+ for name, token_id in (
39
+ ("yes_token_id", self.yes_token_id),
40
+ ("no_token_id", self.no_token_id),
41
+ ):
42
+ if token_id < 0 or token_id >= vocab_size:
43
+ raise ValueError(
44
+ f"{name}={token_id} is outside vocab_size={vocab_size}."
45
+ )
46
+ if self.encoder_chunk_size is not None and self.encoder_chunk_size <= 0:
47
+ raise ValueError("encoder_chunk_size must be positive or None.")
48
+
49
+ self.config.num_labels = 1
50
+ self.config.yes_token_id = self.yes_token_id
51
+ self.config.no_token_id = self.no_token_id
52
+ self.config.encoder_chunk_size = self.encoder_chunk_size
53
+
54
+ @staticmethod
55
+ def _pool_encoder_chunks(
56
+ hidden_states: torch.Tensor,
57
+ attention_mask: torch.Tensor,
58
+ chunk_size: int,
59
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
60
+ batch_size, sequence_length, hidden_size = hidden_states.shape
61
+ num_chunks = (sequence_length + chunk_size - 1) // chunk_size
62
+ padded_length = num_chunks * chunk_size
63
+ pad_length = padded_length - sequence_length
64
+ if pad_length:
65
+ hidden_states = F.pad(hidden_states, (0, 0, 0, pad_length))
66
+ attention_mask = F.pad(attention_mask, (0, pad_length))
67
+
68
+ hidden_states = hidden_states.view(
69
+ batch_size, num_chunks, chunk_size, hidden_size
70
+ )
71
+ chunk_mask = attention_mask.view(batch_size, num_chunks, chunk_size)
72
+ expanded_mask = chunk_mask.unsqueeze(-1).to(hidden_states.dtype)
73
+ pooled_hidden = (hidden_states * expanded_mask).sum(dim=2)
74
+ pooled_hidden /= chunk_mask.sum(dim=2).clamp(min=1).unsqueeze(-1)
75
+ pooled_mask = (chunk_mask.sum(dim=2) > 0).to(attention_mask.dtype)
76
+ return pooled_hidden, pooled_mask
77
+
78
+ def _forward_with_optional_chunk_pooling(
79
+ self,
80
+ *,
81
+ input_ids: Optional[torch.Tensor],
82
+ attention_mask: Optional[torch.Tensor],
83
+ decoder_input_ids: Optional[torch.Tensor],
84
+ decoder_attention_mask: Optional[torch.Tensor],
85
+ encoder_outputs=None,
86
+ **kwargs,
87
+ ):
88
+ if self.encoder_chunk_size is None or encoder_outputs is not None:
89
+ return super().forward(
90
+ input_ids=input_ids,
91
+ attention_mask=attention_mask,
92
+ decoder_input_ids=decoder_input_ids,
93
+ decoder_attention_mask=decoder_attention_mask,
94
+ encoder_outputs=encoder_outputs,
95
+ return_dict=True,
96
+ **kwargs,
97
+ )
98
+ if input_ids is None:
99
+ raise ValueError("input_ids are required when encoder_outputs is None.")
100
+ if attention_mask is None:
101
+ attention_mask = torch.ones_like(input_ids)
102
+
103
+ raw_encoder_outputs = self.get_encoder()(
104
+ input_ids=input_ids,
105
+ attention_mask=attention_mask,
106
+ return_dict=True,
107
+ )
108
+ pooled_hidden, pooled_mask = self._pool_encoder_chunks(
109
+ raw_encoder_outputs.last_hidden_state,
110
+ attention_mask,
111
+ self.encoder_chunk_size,
112
+ )
113
+ return super().forward(
114
+ encoder_outputs=BaseModelOutput(last_hidden_state=pooled_hidden),
115
+ attention_mask=pooled_mask,
116
+ decoder_input_ids=decoder_input_ids,
117
+ decoder_attention_mask=decoder_attention_mask,
118
+ return_dict=True,
119
+ **kwargs,
120
+ )
121
+
122
+ def forward(
123
+ self,
124
+ input_ids: Optional[torch.Tensor] = None,
125
+ attention_mask: Optional[torch.Tensor] = None,
126
+ decoder_input_ids: Optional[torch.Tensor] = None,
127
+ decoder_attention_mask: Optional[torch.Tensor] = None,
128
+ encoder_outputs=None,
129
+ labels: Optional[torch.Tensor] = None,
130
+ output_attentions: Optional[bool] = None,
131
+ output_hidden_states: Optional[bool] = None,
132
+ return_dict: Optional[bool] = None,
133
+ **kwargs,
134
+ ) -> SequenceClassifierOutput:
135
+ if labels is not None:
136
+ raise NotImplementedError("This wrapper is inference-only.")
137
+ if decoder_input_ids is None:
138
+ raise ValueError("decoder_input_ids are required.")
139
+ if decoder_input_ids.shape[0] == 0:
140
+ raise ValueError("empty batches are not supported.")
141
+ if input_ids is not None and input_ids.shape[0] == 0:
142
+ raise ValueError("empty batches are not supported.")
143
+
144
+ outputs = self._forward_with_optional_chunk_pooling(
145
+ input_ids=input_ids,
146
+ attention_mask=attention_mask,
147
+ decoder_input_ids=decoder_input_ids,
148
+ decoder_attention_mask=decoder_attention_mask,
149
+ encoder_outputs=encoder_outputs,
150
+ output_attentions=output_attentions,
151
+ output_hidden_states=output_hidden_states,
152
+ **kwargs,
153
+ )
154
+ logits = outputs.logits
155
+ if decoder_attention_mask is None:
156
+ sequence_lengths = torch.full(
157
+ (logits.shape[0],),
158
+ logits.shape[1] - 1,
159
+ dtype=torch.long,
160
+ device=logits.device,
161
+ )
162
+ else:
163
+ sequence_lengths = decoder_attention_mask.sum(dim=1).to(torch.long) - 1
164
+ if (sequence_lengths < 0).any():
165
+ raise ValueError("decoder_attention_mask contains an empty sequence.")
166
+
167
+ batch_indices = torch.arange(logits.shape[0], device=logits.device)
168
+ last_logits = logits[batch_indices, sequence_lengths]
169
+ yes_no_logits = torch.stack(
170
+ (
171
+ last_logits[:, self.yes_token_id],
172
+ last_logits[:, self.no_token_id],
173
+ ),
174
+ dim=-1,
175
+ ).float()
176
+ if not torch.isfinite(yes_no_logits).all():
177
+ bad_count = (~torch.isfinite(yes_no_logits).all(dim=-1)).sum().item()
178
+ raise RuntimeError(f"Non-finite yes/no logits for {bad_count} input(s).")
179
+
180
+ margin = yes_no_logits[:, 0] - yes_no_logits[:, 1]
181
+ return SequenceClassifierOutput(
182
+ logits=margin[:, None],
183
+ hidden_states=getattr(outputs, "hidden_states", None),
184
+ attentions=getattr(outputs, "attentions", None),
185
+ )
186
+
187
+
188
+ __all__ = ["T5Gemma2ForScoreClassification"]
189
+
vllm_support/build/lib/kalm_t5gemma2_vllm_plugin/processing.py ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ import hashlib
5
+ from typing import Any
6
+
7
+ import torch
8
+ from transformers.feature_extraction_utils import BatchFeature
9
+ from vllm.inputs import MultiModalDataDict, mm_enc_dec_input, mm_input
10
+ from vllm.multimodal.inputs import MultiModalFieldConfig, PlaceholderRange
11
+ from vllm.multimodal.parse import ModalityDataItems, MultiModalDataItems
12
+ from vllm.multimodal.processing import (
13
+ BaseDummyInputsBuilder,
14
+ BaseProcessingInfo,
15
+ EncDecMultiModalProcessor,
16
+ ProcessorInputs,
17
+ TimingContext,
18
+ )
19
+
20
+ from .constants import TEXT_MODALITY
21
+
22
+
23
+ class TextTokenItems(ModalityDataItems[list[str], str]):
24
+ def __init__(self, data: list[str]) -> None:
25
+ super().__init__(data, TEXT_MODALITY)
26
+
27
+ def get_count(self) -> int:
28
+ return len(self.data)
29
+
30
+ def get(self, index: int) -> str:
31
+ return self.data[index]
32
+
33
+ def get_processor_data(self) -> Mapping[str, object]:
34
+ return {}
35
+
36
+ def get_passthrough_data(self) -> Mapping[str, object]:
37
+ return {}
38
+
39
+
40
+ class TextEncoderProcessingInfo(BaseProcessingInfo):
41
+ def get_supported_mm_limits(self) -> Mapping[str, int | None]:
42
+ return {TEXT_MODALITY: 1}
43
+
44
+ def get_mm_max_tokens_per_item(
45
+ self,
46
+ seq_len: int,
47
+ mm_counts: Mapping[str, int],
48
+ ) -> Mapping[str, int] | None:
49
+ return {TEXT_MODALITY: seq_len}
50
+
51
+ def parse_mm_data(
52
+ self,
53
+ mm_data: MultiModalDataDict,
54
+ *,
55
+ validate: bool = True,
56
+ ) -> MultiModalDataItems:
57
+ text_data = mm_data.get(TEXT_MODALITY)
58
+ if text_data is None:
59
+ items = TextTokenItems([])
60
+ elif isinstance(text_data, str):
61
+ items = TextTokenItems([text_data])
62
+ elif isinstance(text_data, list):
63
+ items = TextTokenItems([str(item) for item in text_data])
64
+ else:
65
+ items = TextTokenItems([str(text_data)])
66
+ if validate:
67
+ self.validate_num_items(TEXT_MODALITY, items.get_count())
68
+ return MultiModalDataItems({TEXT_MODALITY: items})
69
+
70
+
71
+ class TextEncoderDummyInputsBuilder(
72
+ BaseDummyInputsBuilder[TextEncoderProcessingInfo]
73
+ ):
74
+ def get_dummy_text(self, mm_counts: Mapping[str, int]) -> str:
75
+ return "<Document>: dummy"
76
+
77
+ def get_dummy_mm_data(
78
+ self,
79
+ seq_len: int,
80
+ mm_counts: Mapping[str, int],
81
+ mm_options: Mapping[str, Any],
82
+ ) -> MultiModalDataDict:
83
+ count = mm_counts.get(TEXT_MODALITY, 0)
84
+ return {TEXT_MODALITY: ["<Document>: dummy"] * count}
85
+
86
+
87
+ class TextEncoderProcessor(EncDecMultiModalProcessor[TextEncoderProcessingInfo]):
88
+ skip_decoder_start_token = True
89
+
90
+ def create_encoder_prompt(
91
+ self,
92
+ prompt: str | list[int],
93
+ mm_items: MultiModalDataItems,
94
+ ) -> str | list[int]:
95
+ return prompt
96
+
97
+ def _get_mm_fields_config(
98
+ self,
99
+ hf_inputs: BatchFeature,
100
+ hf_processor_mm_kwargs: Mapping[str, object],
101
+ ) -> Mapping[str, MultiModalFieldConfig]:
102
+ return {
103
+ "encoder_input_ids": MultiModalFieldConfig.batched(
104
+ TEXT_MODALITY,
105
+ keep_on_cpu=True,
106
+ )
107
+ }
108
+
109
+ def _get_prompt_updates(self, *args, **kwargs):
110
+ return []
111
+
112
+ def apply(
113
+ self,
114
+ inputs: ProcessorInputs,
115
+ timing_ctx: TimingContext,
116
+ ):
117
+ tokenizer = self.info.get_tokenizer()
118
+ if isinstance(inputs.prompt, str):
119
+ encoder_ids = tokenizer.encode(inputs.prompt, add_special_tokens=False)
120
+ encoder_prompt_text = inputs.prompt
121
+ else:
122
+ encoder_ids = list(inputs.prompt)
123
+ encoder_prompt_text = None
124
+ if not encoder_ids:
125
+ raise ValueError("The text encoder prompt cannot be empty.")
126
+
127
+ tensor = torch.tensor([encoder_ids], dtype=torch.long)
128
+ mm_kwargs = self._build_mm_kwargs(tensor)
129
+ text_items = inputs.mm_data_items.get(TEXT_MODALITY)
130
+ text_for_hash = (
131
+ text_items.get(0)
132
+ if text_items is not None and text_items.get_count() > 0
133
+ else repr(encoder_ids)
134
+ )
135
+ digest = hashlib.sha256(str(text_for_hash).encode("utf-8")).hexdigest()
136
+ mm_hashes = {TEXT_MODALITY: [f"{TEXT_MODALITY}:{digest}"]}
137
+ mm_placeholders = {
138
+ TEXT_MODALITY: [PlaceholderRange(offset=0, length=len(encoder_ids))]
139
+ }
140
+ encoder_inputs = mm_input(
141
+ prompt_token_ids=encoder_ids,
142
+ prompt=encoder_prompt_text,
143
+ mm_kwargs=mm_kwargs,
144
+ mm_hashes=mm_hashes,
145
+ mm_placeholders=mm_placeholders,
146
+ )
147
+ return mm_enc_dec_input(
148
+ encoder_inputs,
149
+ decoder_prompt_token_ids=encoder_ids,
150
+ decoder_prompt=encoder_prompt_text,
151
+ )
152
+
153
+ def _build_mm_kwargs(self, encoder_input_ids: torch.Tensor):
154
+ return self._kwargs_from_batch_feature(
155
+ BatchFeature({"encoder_input_ids": encoder_input_ids})
156
+ )
157
+
158
+ def _kwargs_from_batch_feature(self, batch: BatchFeature):
159
+ from vllm.multimodal.inputs import MultiModalKwargsItems
160
+
161
+ return MultiModalKwargsItems.from_hf_inputs(
162
+ batch,
163
+ self._get_mm_fields_config(batch, {}),
164
+ )
165
+
166
+
167
+ __all__ = [
168
+ "TextEncoderDummyInputsBuilder",
169
+ "TextEncoderProcessingInfo",
170
+ "TextEncoderProcessor",
171
+ "TextTokenItems",
172
+ ]
173
+
vllm_support/build/lib/kalm_t5gemma2_vllm_plugin/register.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from vllm import ModelRegistry
4
+
5
+ from .constants import ARCHITECTURE
6
+
7
+
8
+ def register() -> None:
9
+ ModelRegistry.register_model(
10
+ ARCHITECTURE,
11
+ "kalm_t5gemma2_vllm_plugin.modeling:T5Gemma2VllmScoreClassification",
12
+ )
13
+
14
+
15
+ __all__ = ["register"]
vllm_support/build/lib/kalm_t5gemma2_vllm_plugin/reranker.py ADDED
@@ -0,0 +1,357 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import importlib.metadata
4
+ import math
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Any, Iterable, Optional, Sequence
8
+
9
+ from .constants import (
10
+ ARCHITECTURE,
11
+ DEFAULT_DECODER_PAD_TO_MULTIPLE_OF,
12
+ DEFAULT_DOCUMENT_MAX_LENGTH,
13
+ DEFAULT_ENCODER_CHUNK_SIZE,
14
+ DEFAULT_INSTRUCTION,
15
+ DEFAULT_MAX_MODEL_LEN,
16
+ DEFAULT_QUERY_MAX_LENGTH,
17
+ DEFAULT_SYSTEM_INSTRUCTION,
18
+ MODEL_ID,
19
+ NO_TOKEN_ID,
20
+ PLUGIN_NAME,
21
+ SUPPORTED_ENCODER_CHUNK_SIZES,
22
+ TEXT_MODALITY,
23
+ YES_TOKEN_ID,
24
+ decoder_text,
25
+ encoder_text,
26
+ parse_encoder_chunk_size,
27
+ validate_answer_tokens,
28
+ )
29
+
30
+
31
+ TESTED_VLLM_VERSION = "0.19.1"
32
+
33
+
34
+ def _positive_int(value: int, name: str) -> int:
35
+ parsed = int(value)
36
+ if parsed <= 0:
37
+ raise ValueError(f"{name} must be a positive integer.")
38
+ return parsed
39
+
40
+
41
+ def _sigmoid(value: float) -> float:
42
+ if value >= 0:
43
+ scale = math.exp(-value)
44
+ return 1.0 / (1.0 + scale)
45
+ scale = math.exp(value)
46
+ return scale / (1.0 + scale)
47
+
48
+
49
+ def build_hf_overrides(encoder_chunk_size: int) -> dict[str, object]:
50
+ return {
51
+ "architectures": [ARCHITECTURE],
52
+ "num_labels": 1,
53
+ "yes_token_id": YES_TOKEN_ID,
54
+ "no_token_id": NO_TOKEN_ID,
55
+ "encoder_chunk_size": encoder_chunk_size,
56
+ "decoder_pad_to_multiple_of": DEFAULT_DECODER_PAD_TO_MULTIPLE_OF,
57
+ "problem_type": "regression",
58
+ }
59
+
60
+
61
+ def _enable_plugin() -> None:
62
+ allowed = os.environ.get("VLLM_PLUGINS")
63
+ if allowed is None:
64
+ os.environ["VLLM_PLUGINS"] = PLUGIN_NAME
65
+ return
66
+ names = {item.strip() for item in allowed.split(",") if item.strip()}
67
+ if PLUGIN_NAME not in names:
68
+ raise RuntimeError(
69
+ f"VLLM_PLUGINS={allowed!r} excludes {PLUGIN_NAME!r}. Add the plugin "
70
+ "name or unset VLLM_PLUGINS."
71
+ )
72
+
73
+
74
+ def check_runtime() -> None:
75
+ installed_vllm = importlib.metadata.version("vllm")
76
+ if installed_vllm != TESTED_VLLM_VERSION:
77
+ raise RuntimeError(
78
+ f"This adapter requires vLLM {TESTED_VLLM_VERSION}; "
79
+ f"found {installed_vllm}."
80
+ )
81
+
82
+ discovered = {
83
+ entry.name: entry.value
84
+ for entry in importlib.metadata.entry_points(group="vllm.general_plugins")
85
+ }
86
+ if PLUGIN_NAME not in discovered:
87
+ raise RuntimeError(
88
+ f"vLLM plugin {PLUGIN_NAME!r} is not installed. Install the "
89
+ "vllm_support package before creating the reranker."
90
+ )
91
+
92
+ from vllm.model_executor.models import ModelRegistry
93
+ from vllm.plugins import load_general_plugins
94
+
95
+ load_general_plugins()
96
+ if ARCHITECTURE not in set(ModelRegistry.get_supported_archs()):
97
+ raise RuntimeError(f"{ARCHITECTURE} was not registered in ModelRegistry.")
98
+
99
+
100
+ class KaLMVLLMReranker:
101
+ """Single-GPU vLLM adapter preserving the original KaLM score contract."""
102
+
103
+ def __init__(
104
+ self,
105
+ model: str | Path = MODEL_ID,
106
+ *,
107
+ query_max_length: int = DEFAULT_QUERY_MAX_LENGTH,
108
+ document_max_length: int = DEFAULT_DOCUMENT_MAX_LENGTH,
109
+ encoder_chunk_size: object = DEFAULT_ENCODER_CHUNK_SIZE,
110
+ dtype: str = "bfloat16",
111
+ tensor_parallel_size: int = 1,
112
+ max_model_len: int = DEFAULT_MAX_MODEL_LEN,
113
+ gpu_memory_utilization: float = 0.85,
114
+ batch_size: int = 32,
115
+ instruction: str = DEFAULT_INSTRUCTION,
116
+ system_instruction: str = DEFAULT_SYSTEM_INSTRUCTION,
117
+ skip_runtime_check: bool = False,
118
+ ) -> None:
119
+ self.model = str(model)
120
+ self.query_max_length = _positive_int(
121
+ query_max_length, "query_max_length"
122
+ )
123
+ self.document_max_length = _positive_int(
124
+ document_max_length, "document_max_length"
125
+ )
126
+ self.encoder_chunk_size = parse_encoder_chunk_size(encoder_chunk_size)
127
+ self.dtype = str(dtype)
128
+ self.tensor_parallel_size = _positive_int(
129
+ tensor_parallel_size, "tensor_parallel_size"
130
+ )
131
+ if self.tensor_parallel_size != 1:
132
+ raise ValueError(
133
+ "The published adapter supports tensor_parallel_size=1 only."
134
+ )
135
+ self.max_model_len = _positive_int(max_model_len, "max_model_len")
136
+ self.gpu_memory_utilization = float(gpu_memory_utilization)
137
+ if not 0 < self.gpu_memory_utilization <= 1:
138
+ raise ValueError("gpu_memory_utilization must be in the interval (0, 1].")
139
+ self.batch_size = _positive_int(batch_size, "batch_size")
140
+ if not isinstance(instruction, str) or not isinstance(
141
+ system_instruction, str
142
+ ):
143
+ raise TypeError("instruction and system_instruction must be strings.")
144
+ self.instruction = instruction
145
+ self.system_instruction = system_instruction
146
+
147
+ _enable_plugin()
148
+ if not skip_runtime_check:
149
+ check_runtime()
150
+
151
+ from transformers import AutoTokenizer
152
+
153
+ self.tokenizer = AutoTokenizer.from_pretrained(
154
+ self.model,
155
+ trust_remote_code=True,
156
+ )
157
+ validate_answer_tokens(self.tokenizer)
158
+
159
+ from vllm import LLM
160
+ from vllm.pooling_params import PoolingParams
161
+
162
+ self.pooling_params = PoolingParams(use_activation=False)
163
+ self.llm = LLM(
164
+ model=self.model,
165
+ runner="pooling",
166
+ trust_remote_code=True,
167
+ hf_overrides=build_hf_overrides(self.encoder_chunk_size),
168
+ dtype=self.dtype,
169
+ tensor_parallel_size=1,
170
+ max_model_len=self.max_model_len,
171
+ gpu_memory_utilization=self.gpu_memory_utilization,
172
+ enforce_eager=True,
173
+ limit_mm_per_prompt={TEXT_MODALITY: 1},
174
+ )
175
+
176
+ def close(self) -> None:
177
+ llm = getattr(self, "llm", None)
178
+ if llm is None:
179
+ return
180
+ engine = getattr(llm, "llm_engine", None)
181
+ engine_core = getattr(engine, "engine_core", None)
182
+ shutdown = getattr(engine_core, "shutdown", None)
183
+ if callable(shutdown):
184
+ shutdown()
185
+ self.llm = None
186
+
187
+ def __enter__(self) -> "KaLMVLLMReranker":
188
+ return self
189
+
190
+ def __exit__(self, exc_type, exc_value, traceback) -> None:
191
+ self.close()
192
+
193
+ def _encoder_ids(self, document: str) -> tuple[str, list[int]]:
194
+ text = encoder_text(document)
195
+ token_ids = self.tokenizer(
196
+ text,
197
+ add_special_tokens=False,
198
+ truncation=True,
199
+ max_length=self.document_max_length,
200
+ )["input_ids"]
201
+ if not token_ids:
202
+ raise ValueError("Encoded document prompt is empty.")
203
+ return text, list(token_ids)
204
+
205
+ def _decoder_ids(self, query: str, instruction: str) -> tuple[str, list[int]]:
206
+ text = decoder_text(
207
+ self.tokenizer,
208
+ query,
209
+ instruction=instruction,
210
+ system_instruction=self.system_instruction,
211
+ query_max_length=self.query_max_length,
212
+ )
213
+ token_ids = self.tokenizer.encode(text, add_special_tokens=False)
214
+ if not token_ids:
215
+ raise ValueError("Encoded decoder prompt is empty.")
216
+ return text, list(token_ids)
217
+
218
+ def _prompt(self, query: str, document: str, instruction: str):
219
+ from vllm.inputs import ExplicitEncoderDecoderPrompt, TokensPrompt
220
+
221
+ encoder_prompt, encoder_ids = self._encoder_ids(document)
222
+ decoder_prompt, decoder_ids = self._decoder_ids(query, instruction)
223
+ return ExplicitEncoderDecoderPrompt(
224
+ encoder_prompt=TokensPrompt(
225
+ prompt_token_ids=encoder_ids,
226
+ prompt=encoder_prompt,
227
+ multi_modal_data={TEXT_MODALITY: [encoder_prompt]},
228
+ ),
229
+ decoder_prompt=TokensPrompt(
230
+ prompt_token_ids=decoder_ids,
231
+ prompt=decoder_prompt,
232
+ ),
233
+ )
234
+
235
+ @staticmethod
236
+ def _validate_pairs(
237
+ pairs: Sequence[tuple[str, str]],
238
+ ) -> list[tuple[str, str]]:
239
+ if isinstance(pairs, (str, bytes)) or not isinstance(pairs, Sequence):
240
+ raise TypeError("pairs must be a sequence of (query, document) pairs.")
241
+ validated: list[tuple[str, str]] = []
242
+ for index, pair in enumerate(pairs):
243
+ if (
244
+ isinstance(pair, (str, bytes))
245
+ or not isinstance(pair, Sequence)
246
+ or len(pair) != 2
247
+ ):
248
+ raise ValueError(f"pairs[{index}] must contain exactly two strings.")
249
+ query, document = pair
250
+ if not isinstance(query, str) or not isinstance(document, str):
251
+ raise TypeError(f"pairs[{index}] must contain exactly two strings.")
252
+ validated.append((query, document))
253
+ return validated
254
+
255
+ @staticmethod
256
+ def _margins_from_outputs(outputs: Iterable[Any]) -> list[float]:
257
+ margins: list[float] = []
258
+ for output in outputs:
259
+ values = output.outputs.probs
260
+ if len(values) != 1:
261
+ raise RuntimeError(f"Expected one raw margin, got {values}.")
262
+ margin = float(values[0])
263
+ if not math.isfinite(margin):
264
+ raise RuntimeError(f"vLLM returned a non-finite margin: {margin}.")
265
+ margins.append(margin)
266
+ return margins
267
+
268
+ def predict(
269
+ self,
270
+ pairs: Sequence[tuple[str, str]],
271
+ *,
272
+ instruction: Optional[str] = None,
273
+ return_margin: bool = False,
274
+ ) -> list[float] | list[dict[str, float]]:
275
+ validated_pairs = self._validate_pairs(pairs)
276
+ if not validated_pairs:
277
+ return []
278
+ effective_instruction = self.instruction if instruction is None else instruction
279
+ if not isinstance(effective_instruction, str):
280
+ raise TypeError("instruction must be a string or None.")
281
+
282
+ margins: list[float] = []
283
+ for start in range(0, len(validated_pairs), self.batch_size):
284
+ batch = validated_pairs[start : start + self.batch_size]
285
+ prompts = [
286
+ self._prompt(query, document, effective_instruction)
287
+ for query, document in batch
288
+ ]
289
+ if self.llm is None:
290
+ raise RuntimeError("The reranker has been closed.")
291
+ outputs = self.llm.classify(
292
+ prompts,
293
+ pooling_params=self.pooling_params,
294
+ use_tqdm=False,
295
+ )
296
+ margins.extend(self._margins_from_outputs(outputs))
297
+
298
+ scores = [_sigmoid(margin) for margin in margins]
299
+ if return_margin:
300
+ return [
301
+ {"score": score, "margin": margin}
302
+ for score, margin in zip(scores, margins)
303
+ ]
304
+ return scores
305
+
306
+ def rank(
307
+ self,
308
+ query: str,
309
+ documents: Sequence[str],
310
+ *,
311
+ instruction: Optional[str] = None,
312
+ top_k: Optional[int] = None,
313
+ return_margin: bool = False,
314
+ ) -> list[dict[str, float | int]]:
315
+ if not isinstance(query, str):
316
+ raise TypeError("query must be a string.")
317
+ if isinstance(documents, (str, bytes)) or not isinstance(documents, Sequence):
318
+ raise TypeError("documents must be a sequence of strings.")
319
+ if any(not isinstance(document, str) for document in documents):
320
+ raise TypeError("every document must be a string.")
321
+ if top_k is not None:
322
+ top_k = int(top_k)
323
+ if top_k < 0:
324
+ raise ValueError("top_k must be non-negative or None.")
325
+
326
+ predictions = self.predict(
327
+ [(query, document) for document in documents],
328
+ instruction=instruction,
329
+ return_margin=return_margin,
330
+ )
331
+ rankings: list[dict[str, float | int]] = []
332
+ for corpus_id, prediction in enumerate(predictions):
333
+ if return_margin:
334
+ assert isinstance(prediction, dict)
335
+ item: dict[str, float | int] = {
336
+ "corpus_id": corpus_id,
337
+ "score": prediction["score"],
338
+ "margin": prediction["margin"],
339
+ }
340
+ else:
341
+ assert isinstance(prediction, float)
342
+ item = {"corpus_id": corpus_id, "score": prediction}
343
+ rankings.append(item)
344
+ rankings.sort(key=lambda item: float(item["score"]), reverse=True)
345
+ return rankings if top_k is None else rankings[:top_k]
346
+
347
+
348
+ KaLMVLLMOfflineReranker = KaLMVLLMReranker
349
+
350
+ __all__ = [
351
+ "KaLMVLLMOfflineReranker",
352
+ "KaLMVLLMReranker",
353
+ "SUPPORTED_ENCODER_CHUNK_SIZES",
354
+ "build_hf_overrides",
355
+ "check_runtime",
356
+ "parse_encoder_chunk_size",
357
+ ]
vllm_support/build/lib/kalm_t5gemma2_vllm_plugin/server.py ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import threading
5
+ import time
6
+ from pathlib import Path
7
+ from typing import Any, Optional
8
+
9
+ from fastapi import FastAPI, HTTPException
10
+ from pydantic import BaseModel, Field
11
+
12
+ from .constants import (
13
+ DEFAULT_DOCUMENT_MAX_LENGTH,
14
+ DEFAULT_ENCODER_CHUNK_SIZE,
15
+ DEFAULT_MAX_MODEL_LEN,
16
+ DEFAULT_QUERY_MAX_LENGTH,
17
+ MODEL_ID,
18
+ SUPPORTED_ENCODER_CHUNK_SIZES,
19
+ parse_encoder_chunk_size,
20
+ )
21
+ from .reranker import KaLMVLLMReranker
22
+
23
+
24
+ class RerankRequest(BaseModel):
25
+ query: str
26
+ documents: list[str] = Field(min_length=1)
27
+ instruction: Optional[str] = None
28
+ top_k: Optional[int] = None
29
+ return_margin: bool = False
30
+
31
+
32
+ class ScorePair(BaseModel):
33
+ query: str
34
+ document: str
35
+ id: Optional[Any] = None
36
+
37
+
38
+ class ScoreRequest(BaseModel):
39
+ pairs: list[ScorePair] = Field(min_length=1)
40
+ instruction: Optional[str] = None
41
+ return_margin: bool = False
42
+
43
+
44
+ class OnlineRerankerService:
45
+ def __init__(self, args: argparse.Namespace) -> None:
46
+ self.model = str(args.model)
47
+ self.query_max_length = int(args.query_max_length)
48
+ self.document_max_length = int(args.document_max_length)
49
+ self.encoder_chunk_size = parse_encoder_chunk_size(args.encoder_chunk_size)
50
+ self.max_model_len = int(args.max_model_len)
51
+ self.batch_size = int(args.batch_size)
52
+ self.dtype = str(args.dtype)
53
+ self.gpu_memory_utilization = float(args.gpu_memory_utilization)
54
+ self.started_at = time.time()
55
+ self.lock = threading.Lock()
56
+ self.reranker = KaLMVLLMReranker(
57
+ self.model,
58
+ query_max_length=self.query_max_length,
59
+ document_max_length=self.document_max_length,
60
+ encoder_chunk_size=self.encoder_chunk_size,
61
+ max_model_len=self.max_model_len,
62
+ batch_size=self.batch_size,
63
+ dtype=self.dtype,
64
+ gpu_memory_utilization=self.gpu_memory_utilization,
65
+ tensor_parallel_size=args.tensor_parallel_size,
66
+ )
67
+
68
+ def config(self) -> dict[str, Any]:
69
+ return {
70
+ "model": self.model,
71
+ "query_max_length": self.query_max_length,
72
+ "document_max_length": self.document_max_length,
73
+ "encoder_chunk_size": self.encoder_chunk_size,
74
+ "max_model_len": self.max_model_len,
75
+ "batch_size": self.batch_size,
76
+ "dtype": self.dtype,
77
+ "gpu_memory_utilization": self.gpu_memory_utilization,
78
+ "tensor_parallel_size": 1,
79
+ "supported_encoder_chunk_sizes": sorted(
80
+ SUPPORTED_ENCODER_CHUNK_SIZES
81
+ ),
82
+ }
83
+
84
+ def health(self) -> dict[str, Any]:
85
+ return {
86
+ "status": "ok",
87
+ "uptime_seconds": round(time.time() - self.started_at, 3),
88
+ **self.config(),
89
+ }
90
+
91
+ def close(self) -> None:
92
+ self.reranker.close()
93
+
94
+ def rerank(self, request: RerankRequest) -> dict[str, Any]:
95
+ if request.top_k is not None and request.top_k < 0:
96
+ raise ValueError("top_k must be non-negative or null.")
97
+ with self.lock:
98
+ rankings = self.reranker.rank(
99
+ request.query,
100
+ request.documents,
101
+ instruction=request.instruction,
102
+ top_k=request.top_k,
103
+ return_margin=request.return_margin,
104
+ )
105
+ results: list[dict[str, Any]] = []
106
+ for item in rankings:
107
+ result = {
108
+ "index": int(item["corpus_id"]),
109
+ "score": float(item["score"]),
110
+ }
111
+ if request.return_margin:
112
+ result["margin"] = float(item["margin"])
113
+ results.append(result)
114
+ return {"object": "rerank", "results": results}
115
+
116
+ def score(self, request: ScoreRequest) -> dict[str, Any]:
117
+ pairs = [(item.query, item.document) for item in request.pairs]
118
+ with self.lock:
119
+ predictions = self.reranker.predict(
120
+ pairs,
121
+ instruction=request.instruction,
122
+ return_margin=True,
123
+ )
124
+ results: list[dict[str, Any]] = []
125
+ for index, (pair, prediction) in enumerate(zip(request.pairs, predictions)):
126
+ assert isinstance(prediction, dict)
127
+ result = {
128
+ "index": index,
129
+ "score": float(prediction["score"]),
130
+ }
131
+ if pair.id is not None:
132
+ result["id"] = pair.id
133
+ if request.return_margin:
134
+ result["margin"] = float(prediction["margin"])
135
+ results.append(result)
136
+ return {"object": "score", "results": results}
137
+
138
+
139
+ def build_parser() -> argparse.ArgumentParser:
140
+ parser = argparse.ArgumentParser(
141
+ description="FastAPI server for the KaLM vLLM adapter.",
142
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
143
+ )
144
+ parser.add_argument("--host", default="0.0.0.0")
145
+ parser.add_argument("--port", type=int, default=8000)
146
+ parser.add_argument("--model", default=MODEL_ID)
147
+ parser.add_argument("--query-max-length", type=int, default=DEFAULT_QUERY_MAX_LENGTH)
148
+ parser.add_argument(
149
+ "--document-max-length", type=int, default=DEFAULT_DOCUMENT_MAX_LENGTH
150
+ )
151
+ parser.add_argument(
152
+ "--encoder-chunk-size", default=str(DEFAULT_ENCODER_CHUNK_SIZE)
153
+ )
154
+ parser.add_argument("--max-model-len", type=int, default=DEFAULT_MAX_MODEL_LEN)
155
+ parser.add_argument("--batch-size", type=int, default=32)
156
+ parser.add_argument("--dtype", default="bfloat16")
157
+ parser.add_argument("--gpu-memory-utilization", type=float, default=0.85)
158
+ parser.add_argument("--tensor-parallel-size", type=int, default=1)
159
+ parser.add_argument("--log-level", default="info")
160
+ return parser
161
+
162
+
163
+ def create_app(service: OnlineRerankerService) -> FastAPI:
164
+ app = FastAPI(title="KaLM vLLM Online Reranker", version="0.1.0")
165
+ app.state.service = service
166
+ app.add_event_handler("shutdown", service.close)
167
+
168
+ @app.get("/health")
169
+ def health():
170
+ return app.state.service.health()
171
+
172
+ @app.post("/rerank")
173
+ def rerank(request: RerankRequest):
174
+ try:
175
+ return app.state.service.rerank(request)
176
+ except (TypeError, ValueError) as error:
177
+ raise HTTPException(status_code=400, detail=str(error)) from error
178
+
179
+ @app.post("/score")
180
+ def score(request: ScoreRequest):
181
+ try:
182
+ return app.state.service.score(request)
183
+ except (TypeError, ValueError) as error:
184
+ raise HTTPException(status_code=400, detail=str(error)) from error
185
+
186
+ return app
187
+
188
+
189
+ def main() -> int:
190
+ args = build_parser().parse_args()
191
+ args.encoder_chunk_size = parse_encoder_chunk_size(args.encoder_chunk_size)
192
+ print("=== KaLM vLLM Online Reranker ===", flush=True)
193
+ print(f"model: {args.model}", flush=True)
194
+ print(f"query_max_length: {args.query_max_length}", flush=True)
195
+ print(f"document_max_length: {args.document_max_length}", flush=True)
196
+ print(f"encoder_chunk_size: {args.encoder_chunk_size}", flush=True)
197
+ print(f"max_model_len: {args.max_model_len}", flush=True)
198
+ print(f"listen: http://{args.host}:{args.port}", flush=True)
199
+
200
+ service = OnlineRerankerService(args)
201
+ app = create_app(service)
202
+ import uvicorn
203
+
204
+ uvicorn.run(
205
+ app,
206
+ host=args.host,
207
+ port=args.port,
208
+ log_level=args.log_level,
209
+ workers=1,
210
+ )
211
+ return 0
212
+
213
+
214
+ if __name__ == "__main__":
215
+ raise SystemExit(main())
vllm_support/examples/call_online_api.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ from kalm_t5gemma2_vllm_plugin.client import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
6
+
vllm_support/examples/offline_usage.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from kalm_t5gemma2_vllm_plugin import KaLMVLLMReranker
2
+
3
+
4
+ query = "What is the capital of China?"
5
+ documents = [
6
+ "The capital of China is Beijing.",
7
+ "Gravity attracts bodies toward one another.",
8
+ ]
9
+
10
+ with KaLMVLLMReranker(
11
+ "KaLM-Embedding/KaLM-Reranker-V1-Nano",
12
+ encoder_chunk_size=4,
13
+ query_max_length=512,
14
+ document_max_length=1024,
15
+ ) as reranker:
16
+ print(reranker.predict([(query, document) for document in documents]))
17
+ print(reranker.rank(query, documents))
vllm_support/examples/rerank_request.json ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "query": "What is the capital of China?",
3
+ "documents": [
4
+ "The capital of China is Beijing.",
5
+ "Gravity attracts bodies toward one another."
6
+ ],
7
+ "instruction": "Given a query, retrieve documents that answer the query.",
8
+ "top_k": null,
9
+ "return_margin": false
10
+ }
vllm_support/examples/sample_pairs.jsonl ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ {"id":"positive","query":"What is the capital of China?","document":"The capital of China is Beijing."}
2
+ {"id":"negative","query":"What is the capital of China?","document":"Gravity attracts bodies toward one another."}
vllm_support/examples/score_request.json ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "pairs": [
3
+ {
4
+ "id": "positive",
5
+ "query": "What is the capital of China?",
6
+ "document": "The capital of China is Beijing."
7
+ },
8
+ {
9
+ "id": "negative",
10
+ "query": "What is the capital of China?",
11
+ "document": "Gravity attracts bodies toward one another."
12
+ }
13
+ ],
14
+ "instruction": null,
15
+ "return_margin": false
16
+ }
vllm_support/examples/start_online_server.sh ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ export CUDA_VISIBLE_DEVICES="${CUDA_VISIBLE_DEVICES:-0}"
5
+ export VLLM_PLUGINS="${VLLM_PLUGINS:-kalm_t5gemma2}"
6
+ if [[ -d /lib/x86_64-linux-gnu && -d /usr/lib/x86_64-linux-gnu ]]; then
7
+ export LD_LIBRARY_PATH="/lib/x86_64-linux-gnu:/usr/lib/x86_64-linux-gnu${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
8
+ fi
9
+
10
+ exec kalm-vllm-serve \
11
+ --host "${HOST:-0.0.0.0}" \
12
+ --port "${PORT:-8000}" \
13
+ --model "${MODEL:-KaLM-Embedding/KaLM-Reranker-V1-Nano}" \
14
+ --query-max-length "${QUERY_MAX_LENGTH:-512}" \
15
+ --document-max-length "${DOCUMENT_MAX_LENGTH:-1024}" \
16
+ --encoder-chunk-size "${ENCODER_CHUNK_SIZE:-4}" \
17
+ --max-model-len "${MAX_MODEL_LEN:-2048}" \
18
+ --batch-size "${BATCH_SIZE:-32}" \
19
+ --dtype "${DTYPE:-bfloat16}" \
20
+ --gpu-memory-utilization "${GPU_MEMORY_UTILIZATION:-0.85}"
vllm_support/pyproject.toml ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=64"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "kalm-t5gemma2-vllm-plugin"
7
+ version = "0.1.0"
8
+ description = "vLLM 0.19.1 adapter for KaLM-Reranker-V1-Nano"
9
+ readme = "README.md"
10
+ requires-python = ">=3.12,<3.13"
11
+ license = {text = "Apache-2.0"}
12
+ dependencies = [
13
+ "transformers==5.6.2",
14
+ "vllm==0.19.1",
15
+ ]
16
+
17
+ [project.optional-dependencies]
18
+ online = [
19
+ "fastapi>=0.136,<0.137",
20
+ "uvicorn>=0.46,<0.47",
21
+ ]
22
+
23
+ [project.entry-points."vllm.general_plugins"]
24
+ kalm_t5gemma2 = "kalm_t5gemma2_vllm_plugin.register:register"
25
+
26
+ [project.scripts]
27
+ kalm-vllm-rerank = "kalm_t5gemma2_vllm_plugin.cli:main"
28
+ kalm-vllm-serve = "kalm_t5gemma2_vllm_plugin.server:main"
29
+ kalm-vllm-client = "kalm_t5gemma2_vllm_plugin.client:main"
30
+
31
+ [tool.setuptools.packages.find]
32
+ where = ["src"]
33
+
vllm_support/src/kalm_t5gemma2_vllm_plugin.egg-info/PKG-INFO ADDED
@@ -0,0 +1,292 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Metadata-Version: 2.4
2
+ Name: kalm-t5gemma2-vllm-plugin
3
+ Version: 0.1.0
4
+ Summary: vLLM 0.19.1 adapter for KaLM-Reranker-V1-Nano
5
+ License: Apache-2.0
6
+ Requires-Python: <3.13,>=3.12
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: transformers==5.6.2
9
+ Requires-Dist: vllm==0.19.1
10
+ Provides-Extra: online
11
+ Requires-Dist: fastapi<0.137,>=0.136; extra == "online"
12
+ Requires-Dist: uvicorn<0.47,>=0.46; extra == "online"
13
+
14
+ # KaLM-Reranker-V1-Nano vLLM Support
15
+
16
+ This directory contains the experimental vLLM 0.19.1 adapter for
17
+ `KaLM-Embedding/KaLM-Reranker-V1-Nano`. It supports offline Python and CLI
18
+ reranking plus an optional FastAPI service.
19
+
20
+ The adapter does not modify or retrain the checkpoint. It reads the original
21
+ decoder logits for the single-token answers `yes` and `no` and returns:
22
+
23
+ ```text
24
+ margin = yes_logit - no_logit
25
+ score = sigmoid(margin) = P(yes)
26
+ ```
27
+
28
+ ## Tested environment
29
+
30
+ - Linux and NVIDIA CUDA
31
+ - Python 3.12
32
+ - vLLM 0.19.1
33
+ - Transformers 5.6.2
34
+ - PyTorch 2.10.0
35
+ - BF16, one GPU
36
+
37
+ The package intentionally rejects other vLLM versions and
38
+ `tensor_parallel_size != 1`. These combinations have not been validated.
39
+
40
+ ## Installation
41
+
42
+ Create an environment and download the model repository:
43
+
44
+ ```bash
45
+ conda create -n kalm-vllm python=3.12 -y
46
+ conda activate kalm-vllm
47
+
48
+ pip install "vllm==0.19.1" "transformers==5.6.2"
49
+ pip install "fastapi>=0.136,<0.137" "uvicorn>=0.46,<0.47"
50
+
51
+ hf download KaLM-Embedding/KaLM-Reranker-V1-Nano \
52
+ --local-dir ./KaLM-Reranker-V1-Nano
53
+ pip install ./KaLM-Reranker-V1-Nano/vllm_support --no-deps
54
+ export VLLM_PLUGINS=kalm_t5gemma2
55
+ ```
56
+
57
+ The model can also be loaded directly by its Hugging Face ID. In that case,
58
+ only download the `vllm_support` directory before installing the plugin:
59
+
60
+ ```bash
61
+ hf download KaLM-Embedding/KaLM-Reranker-V1-Nano \
62
+ --include "vllm_support/**" \
63
+ --local-dir ./KaLM-Reranker-V1-Nano
64
+ pip install ./KaLM-Reranker-V1-Nano/vllm_support --no-deps
65
+ ```
66
+
67
+ ## Offline Python API
68
+
69
+ ```python
70
+ from kalm_t5gemma2_vllm_plugin import KaLMVLLMReranker
71
+
72
+ query = "What is the capital of China?"
73
+ documents = [
74
+ "The capital of China is Beijing.",
75
+ "Gravity attracts bodies toward one another.",
76
+ ]
77
+
78
+ pairs = [(query, document) for document in documents]
79
+ with KaLMVLLMReranker(
80
+ "KaLM-Embedding/KaLM-Reranker-V1-Nano",
81
+ query_max_length=512,
82
+ document_max_length=1024,
83
+ encoder_chunk_size=4,
84
+ max_model_len=2048,
85
+ batch_size=32,
86
+ ) as reranker:
87
+ print(reranker.predict(pairs))
88
+ print(reranker.predict(pairs, return_margin=True))
89
+ print(reranker.rank(query, documents))
90
+ ```
91
+
92
+ Expected BF16 scores are approximately:
93
+
94
+ ```text
95
+ [0.99980897, 0.00000493699]
96
+ ```
97
+
98
+ `predict()` preserves input order. `rank()` returns score-descending results
99
+ with the original document index in `corpus_id`.
100
+
101
+ ## Offline CLI
102
+
103
+ Run the built-in example:
104
+
105
+ ```bash
106
+ kalm-vllm-rerank --return-margin
107
+ ```
108
+
109
+ Score JSONL input:
110
+
111
+ ```bash
112
+ kalm-vllm-rerank \
113
+ --input-jsonl ./KaLM-Reranker-V1-Nano/vllm_support/examples/sample_pairs.jsonl \
114
+ --output-jsonl ./scores.jsonl \
115
+ --return-margin
116
+ ```
117
+
118
+ Each input line must contain `query` and `document`. Optional fields are `id`
119
+ and `instruction`. `--top-k N` groups rows by exact query text, sorts each
120
+ group by score, and keeps its first `N` documents.
121
+
122
+ ## Online service
123
+
124
+ Start one model instance:
125
+
126
+ ```bash
127
+ kalm-vllm-serve \
128
+ --host 0.0.0.0 \
129
+ --port 8000 \
130
+ --model KaLM-Embedding/KaLM-Reranker-V1-Nano \
131
+ --encoder-chunk-size 4
132
+ ```
133
+
134
+ The portable startup script exposes the same settings through environment
135
+ variables:
136
+
137
+ ```bash
138
+ CUDA_VISIBLE_DEVICES=0 PORT=8000 \
139
+ ./KaLM-Reranker-V1-Nano/vllm_support/examples/start_online_server.sh
140
+ ```
141
+
142
+ In a second terminal, check health and send built-in demo requests:
143
+
144
+ ```bash
145
+ kalm-vllm-client --health
146
+ kalm-vllm-client --endpoint rerank --return-margin
147
+ kalm-vllm-client --endpoint score --return-margin
148
+ ```
149
+
150
+ For custom input, pass one JSON object with `--json-file`. Use `/rerank` for
151
+ one query against multiple documents:
152
+
153
+ ```bash
154
+ kalm-vllm-client \
155
+ --endpoint rerank \
156
+ --json-file ./KaLM-Reranker-V1-Nano/vllm_support/examples/rerank_request.json \
157
+ --return-margin \
158
+ --top-k 10
159
+ ```
160
+
161
+ Use `/score` for a batch of independent query-document pairs:
162
+
163
+ ```bash
164
+ kalm-vllm-client \
165
+ --endpoint score \
166
+ --json-file ./KaLM-Reranker-V1-Nano/vllm_support/examples/score_request.json \
167
+ --return-margin
168
+ ```
169
+
170
+ When `--json-file` is used, `--return-margin` sets
171
+ `"return_margin": true` in the outgoing request, and `--top-k` overrides the
172
+ JSON value for `/rerank`.
173
+
174
+ ### `POST /rerank`
175
+
176
+ ```json
177
+ {
178
+ "query": "What is the capital of China?",
179
+ "documents": [
180
+ "The capital of China is Beijing.",
181
+ "Gravity attracts bodies toward one another."
182
+ ],
183
+ "instruction": "Given a query, retrieve documents that answer the query.",
184
+ "top_k": null,
185
+ "return_margin": true
186
+ }
187
+ ```
188
+
189
+ Results are returned in descending score order:
190
+
191
+ ```json
192
+ {
193
+ "object": "rerank",
194
+ "results": [
195
+ {"index": 0, "score": 0.9998089, "margin": 8.5625},
196
+ {"index": 1, "score": 0.00000493699, "margin": -12.21875}
197
+ ]
198
+ }
199
+ ```
200
+
201
+ ### `POST /score`
202
+
203
+ ```json
204
+ {
205
+ "pairs": [
206
+ {
207
+ "id": "doc-1",
208
+ "query": "What is the capital of China?",
209
+ "document": "The capital of China is Beijing."
210
+ }
211
+ ],
212
+ "instruction": null,
213
+ "return_margin": false
214
+ }
215
+ ```
216
+
217
+ `/score` accepts multiple entries in `pairs`, preserves their input order and
218
+ includes an input `id` when provided.
219
+
220
+ ### `GET /health`
221
+
222
+ Returns service status and the effective model, length, chunking, dtype and
223
+ memory settings.
224
+
225
+ ## Configuration
226
+
227
+ | Setting | Default | Meaning |
228
+ | --- | ---: | --- |
229
+ | `query_max_length` | `512` | Maximum raw query tokens before prompt insertion |
230
+ | `document_max_length` | `1024` | Maximum encoder tokens for `<Document>: ...` |
231
+ | `encoder_chunk_size` | `4` | Mean-pooling factor; one of `1,2,4,8,16,32` |
232
+ | `max_model_len` | `2048` | vLLM engine context budget |
233
+ | `batch_size` | `32` | Pairs passed to each `LLM.classify()` call |
234
+ | `dtype` | `bfloat16` | Model compute dtype |
235
+ | `gpu_memory_utilization` | `0.85` | vLLM GPU memory fraction |
236
+ | `tensor_parallel_size` | `1` | Only supported value in this release |
237
+
238
+ The query and document limits belong to separate decoder and encoder streams;
239
+ they are not a combined cross-encoder token limit. Larger values are
240
+ configurable but have not been validated up to the model card's full 128K
241
+ limit.
242
+
243
+ ## Limitations
244
+
245
+ - This is a custom `LLM.classify()` plugin, not vLLM's native HTTP `/score`
246
+ implementation.
247
+ - The shim uses vLLM scheduling and pooling interfaces but executes the
248
+ T5Gemma2 semantic forward through Transformers. It is not a complete
249
+ vLLM-native kernel implementation and should not be used to claim native
250
+ vLLM throughput.
251
+ - Online serving is a single-process FastAPI wrapper around one model instance.
252
+ - `encoder_chunk_size=None`, `null`, or an empty string falls back to `4`; it
253
+ does not disable pooling in this release.
254
+
255
+ ## Troubleshooting
256
+
257
+ **The plugin is not discovered**
258
+
259
+ Reinstall the package and ensure the environment variable includes its entry
260
+ point name:
261
+
262
+ ```bash
263
+ pip install ./KaLM-Reranker-V1-Nano/vllm_support --no-deps --force-reinstall
264
+ export VLLM_PLUGINS=kalm_t5gemma2
265
+ ```
266
+
267
+ **The adapter reports an unsupported vLLM version**
268
+
269
+ Install exactly `vllm==0.19.1`. Internal model and processor APIs are version
270
+ sensitive.
271
+
272
+ **The tokenizer check fails**
273
+
274
+ Confirm that the tokenizer belongs to this Nano checkpoint. The adapter
275
+ requires `yes -> 4443` and `no -> 1904`.
276
+
277
+ **CUDA runs out of memory**
278
+
279
+ Reduce `batch_size`, `document_max_length`, `query_max_length`,
280
+ `max_model_len`, or `gpu_memory_utilization`.
281
+
282
+ **CUDA initialization fails with error 803**
283
+
284
+ The process may be resolving a CUDA compatibility library before the host
285
+ driver library. On common Debian/Ubuntu layouts, retry with:
286
+
287
+ ```bash
288
+ export LD_LIBRARY_PATH="/lib/x86_64-linux-gnu:/usr/lib/x86_64-linux-gnu${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
289
+ ```
290
+
291
+ The provided `start_online_server.sh` applies this adjustment automatically
292
+ when both directories exist.
vllm_support/src/kalm_t5gemma2_vllm_plugin.egg-info/SOURCES.txt ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ README.md
2
+ pyproject.toml
3
+ src/kalm_t5gemma2_vllm_plugin/__init__.py
4
+ src/kalm_t5gemma2_vllm_plugin/cli.py
5
+ src/kalm_t5gemma2_vllm_plugin/client.py
6
+ src/kalm_t5gemma2_vllm_plugin/constants.py
7
+ src/kalm_t5gemma2_vllm_plugin/modeling.py
8
+ src/kalm_t5gemma2_vllm_plugin/modeling_score.py
9
+ src/kalm_t5gemma2_vllm_plugin/processing.py
10
+ src/kalm_t5gemma2_vllm_plugin/register.py
11
+ src/kalm_t5gemma2_vllm_plugin/reranker.py
12
+ src/kalm_t5gemma2_vllm_plugin/server.py
13
+ src/kalm_t5gemma2_vllm_plugin.egg-info/PKG-INFO
14
+ src/kalm_t5gemma2_vllm_plugin.egg-info/SOURCES.txt
15
+ src/kalm_t5gemma2_vllm_plugin.egg-info/dependency_links.txt
16
+ src/kalm_t5gemma2_vllm_plugin.egg-info/entry_points.txt
17
+ src/kalm_t5gemma2_vllm_plugin.egg-info/requires.txt
18
+ src/kalm_t5gemma2_vllm_plugin.egg-info/top_level.txt
vllm_support/src/kalm_t5gemma2_vllm_plugin.egg-info/dependency_links.txt ADDED
@@ -0,0 +1 @@
 
 
1
+
vllm_support/src/kalm_t5gemma2_vllm_plugin.egg-info/entry_points.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ [console_scripts]
2
+ kalm-vllm-client = kalm_t5gemma2_vllm_plugin.client:main
3
+ kalm-vllm-rerank = kalm_t5gemma2_vllm_plugin.cli:main
4
+ kalm-vllm-serve = kalm_t5gemma2_vllm_plugin.server:main
5
+
6
+ [vllm.general_plugins]
7
+ kalm_t5gemma2 = kalm_t5gemma2_vllm_plugin.register:register
vllm_support/src/kalm_t5gemma2_vllm_plugin.egg-info/requires.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ transformers==5.6.2
2
+ vllm==0.19.1
3
+
4
+ [online]
5
+ fastapi<0.137,>=0.136
6
+ uvicorn<0.47,>=0.46
vllm_support/src/kalm_t5gemma2_vllm_plugin.egg-info/top_level.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ kalm_t5gemma2_vllm_plugin
vllm_support/src/kalm_t5gemma2_vllm_plugin/__init__.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from .constants import (
4
+ ARCHITECTURE,
5
+ MODEL_ID,
6
+ PLUGIN_NAME,
7
+ SUPPORTED_ENCODER_CHUNK_SIZES,
8
+ TEXT_MODALITY,
9
+ )
10
+ from .reranker import KaLMVLLMOfflineReranker, KaLMVLLMReranker
11
+
12
+ __version__ = "0.1.0"
13
+
14
+ __all__ = [
15
+ "ARCHITECTURE",
16
+ "KaLMVLLMOfflineReranker",
17
+ "KaLMVLLMReranker",
18
+ "MODEL_ID",
19
+ "PLUGIN_NAME",
20
+ "SUPPORTED_ENCODER_CHUNK_SIZES",
21
+ "TEXT_MODALITY",
22
+ "__version__",
23
+ ]
vllm_support/src/kalm_t5gemma2_vllm_plugin/cli.py ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from collections import OrderedDict
7
+ from pathlib import Path
8
+ from typing import Any, Iterable, TextIO
9
+
10
+ from .constants import (
11
+ DEFAULT_DOCUMENT_MAX_LENGTH,
12
+ DEFAULT_ENCODER_CHUNK_SIZE,
13
+ DEFAULT_MAX_MODEL_LEN,
14
+ DEFAULT_QUERY_MAX_LENGTH,
15
+ MODEL_ID,
16
+ SAMPLE_DOCUMENTS,
17
+ SAMPLE_QUERY,
18
+ SUPPORTED_ENCODER_CHUNK_SIZES,
19
+ parse_encoder_chunk_size,
20
+ )
21
+ from .reranker import KaLMVLLMReranker
22
+
23
+
24
+ def build_parser() -> argparse.ArgumentParser:
25
+ parser = argparse.ArgumentParser(
26
+ description="Offline KaLM-Reranker-V1-Nano scoring with vLLM 0.19.1.",
27
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
28
+ )
29
+ parser.add_argument("--input-jsonl", type=Path)
30
+ parser.add_argument("--output-jsonl", type=Path)
31
+ parser.add_argument("--model", default=MODEL_ID)
32
+ parser.add_argument("--query-max-length", type=int, default=DEFAULT_QUERY_MAX_LENGTH)
33
+ parser.add_argument(
34
+ "--document-max-length", type=int, default=DEFAULT_DOCUMENT_MAX_LENGTH
35
+ )
36
+ parser.add_argument(
37
+ "--encoder-chunk-size",
38
+ default=str(DEFAULT_ENCODER_CHUNK_SIZE),
39
+ help=f"One of {sorted(SUPPORTED_ENCODER_CHUNK_SIZES)}.",
40
+ )
41
+ parser.add_argument("--max-model-len", type=int, default=DEFAULT_MAX_MODEL_LEN)
42
+ parser.add_argument("--batch-size", type=int, default=32)
43
+ parser.add_argument("--return-margin", action="store_true")
44
+ parser.add_argument("--top-k", type=int)
45
+ parser.add_argument("--dtype", default="bfloat16")
46
+ parser.add_argument("--gpu-memory-utilization", type=float, default=0.85)
47
+ parser.add_argument("--tensor-parallel-size", type=int, default=1)
48
+ return parser
49
+
50
+
51
+ def _read_jsonl(path: Path) -> list[dict[str, Any]]:
52
+ rows: list[dict[str, Any]] = []
53
+ with path.open("r", encoding="utf-8") as handle:
54
+ for line_no, line in enumerate(handle, start=1):
55
+ if not line.strip():
56
+ continue
57
+ try:
58
+ row = json.loads(line)
59
+ except json.JSONDecodeError as error:
60
+ raise ValueError(f"{path}:{line_no}: invalid JSON.") from error
61
+ if not isinstance(row, dict):
62
+ raise ValueError(f"{path}:{line_no}: expected a JSON object.")
63
+ if not isinstance(row.get("query"), str):
64
+ raise ValueError(f"{path}:{line_no}: 'query' must be a string.")
65
+ if not isinstance(row.get("document"), str):
66
+ raise ValueError(f"{path}:{line_no}: 'document' must be a string.")
67
+ if "instruction" in row and not isinstance(row["instruction"], str):
68
+ raise ValueError(f"{path}:{line_no}: 'instruction' must be a string.")
69
+ rows.append(row)
70
+ return rows
71
+
72
+
73
+ def _write_jsonl(rows: Iterable[dict[str, Any]], output: TextIO) -> None:
74
+ for row in rows:
75
+ output.write(json.dumps(row, ensure_ascii=False) + "\n")
76
+ output.flush()
77
+
78
+
79
+ def _score_rows(
80
+ reranker: KaLMVLLMReranker,
81
+ rows: list[dict[str, Any]],
82
+ *,
83
+ return_margin: bool,
84
+ top_k: int | None,
85
+ ) -> list[dict[str, Any]]:
86
+ grouped: OrderedDict[str | None, list[tuple[int, dict[str, Any]]]] = OrderedDict()
87
+ for index, row in enumerate(rows):
88
+ grouped.setdefault(row.get("instruction"), []).append((index, row))
89
+
90
+ scored: dict[int, dict[str, Any]] = {}
91
+ for instruction, items in grouped.items():
92
+ predictions = reranker.predict(
93
+ [(row["query"], row["document"]) for _, row in items],
94
+ instruction=instruction,
95
+ return_margin=True,
96
+ )
97
+ for (index, row), prediction in zip(items, predictions):
98
+ assert isinstance(prediction, dict)
99
+ result = {
100
+ "id": row.get("id", index),
101
+ "query": row["query"],
102
+ "document": row["document"],
103
+ "score": prediction["score"],
104
+ }
105
+ if "instruction" in row:
106
+ result["instruction"] = row["instruction"]
107
+ if return_margin:
108
+ result["margin"] = prediction["margin"]
109
+ scored[index] = result
110
+
111
+ ordered = [scored[index] for index in range(len(rows))]
112
+ if top_k is None:
113
+ return ordered
114
+ if top_k < 0:
115
+ raise ValueError("--top-k must be non-negative.")
116
+ by_query: OrderedDict[str, list[dict[str, Any]]] = OrderedDict()
117
+ for row in ordered:
118
+ by_query.setdefault(str(row["query"]), []).append(row)
119
+ output: list[dict[str, Any]] = []
120
+ for group in by_query.values():
121
+ group.sort(key=lambda item: float(item["score"]), reverse=True)
122
+ output.extend(group[:top_k])
123
+ return output
124
+
125
+
126
+ def main() -> int:
127
+ args = build_parser().parse_args()
128
+ with KaLMVLLMReranker(
129
+ args.model,
130
+ query_max_length=args.query_max_length,
131
+ document_max_length=args.document_max_length,
132
+ encoder_chunk_size=parse_encoder_chunk_size(args.encoder_chunk_size),
133
+ max_model_len=args.max_model_len,
134
+ batch_size=args.batch_size,
135
+ dtype=args.dtype,
136
+ gpu_memory_utilization=args.gpu_memory_utilization,
137
+ tensor_parallel_size=args.tensor_parallel_size,
138
+ ) as reranker:
139
+ if args.input_jsonl is None:
140
+ rankings = reranker.rank(
141
+ SAMPLE_QUERY,
142
+ SAMPLE_DOCUMENTS,
143
+ top_k=args.top_k,
144
+ return_margin=args.return_margin,
145
+ )
146
+ rows = [
147
+ {
148
+ "query": SAMPLE_QUERY,
149
+ "document": SAMPLE_DOCUMENTS[int(item["corpus_id"])],
150
+ **item,
151
+ }
152
+ for item in rankings
153
+ ]
154
+ else:
155
+ rows = _score_rows(
156
+ reranker,
157
+ _read_jsonl(args.input_jsonl),
158
+ return_margin=args.return_margin,
159
+ top_k=args.top_k,
160
+ )
161
+
162
+ if args.output_jsonl is None:
163
+ _write_jsonl(rows, sys.stdout)
164
+ else:
165
+ args.output_jsonl.parent.mkdir(parents=True, exist_ok=True)
166
+ with args.output_jsonl.open("w", encoding="utf-8") as handle:
167
+ _write_jsonl(rows, handle)
168
+ return 0
169
+
170
+
171
+ if __name__ == "__main__":
172
+ raise SystemExit(main())
vllm_support/src/kalm_t5gemma2_vllm_plugin/client.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ import urllib.error
7
+ import urllib.request
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from .constants import SAMPLE_DOCUMENTS, SAMPLE_QUERY
12
+
13
+
14
+ def build_parser() -> argparse.ArgumentParser:
15
+ parser = argparse.ArgumentParser(
16
+ description="Call the KaLM vLLM FastAPI service.",
17
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
18
+ )
19
+ parser.add_argument("--base-url", default="http://127.0.0.1:8000")
20
+ parser.add_argument("--health", action="store_true")
21
+ parser.add_argument("--endpoint", choices=("rerank", "score"), default="rerank")
22
+ parser.add_argument("--json-file", type=Path)
23
+ parser.add_argument("--return-margin", action="store_true")
24
+ parser.add_argument("--top-k", type=int)
25
+ parser.add_argument("--timeout", type=float, default=600.0)
26
+ return parser
27
+
28
+
29
+ def request_json(
30
+ method: str,
31
+ url: str,
32
+ *,
33
+ payload: dict[str, Any] | None = None,
34
+ timeout: float = 600.0,
35
+ ) -> dict[str, Any]:
36
+ data = None
37
+ headers = {"Accept": "application/json"}
38
+ if payload is not None:
39
+ data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
40
+ headers["Content-Type"] = "application/json"
41
+ request = urllib.request.Request(url, data=data, headers=headers, method=method)
42
+ opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
43
+ try:
44
+ with opener.open(request, timeout=timeout) as response:
45
+ raw = response.read().decode("utf-8")
46
+ except urllib.error.HTTPError as error:
47
+ detail = error.read().decode("utf-8", errors="replace")
48
+ raise RuntimeError(f"HTTP {error.code} from {url}: {detail}") from error
49
+ return json.loads(raw)
50
+
51
+
52
+ def _demo_payload(
53
+ endpoint: str,
54
+ return_margin: bool,
55
+ top_k: int | None,
56
+ ) -> dict[str, Any]:
57
+ if endpoint == "rerank":
58
+ return {
59
+ "query": SAMPLE_QUERY,
60
+ "documents": list(SAMPLE_DOCUMENTS),
61
+ "top_k": top_k,
62
+ "return_margin": return_margin,
63
+ }
64
+ return {
65
+ "pairs": [
66
+ {
67
+ "id": "positive",
68
+ "query": SAMPLE_QUERY,
69
+ "document": SAMPLE_DOCUMENTS[0],
70
+ },
71
+ {
72
+ "id": "negative",
73
+ "query": SAMPLE_QUERY,
74
+ "document": SAMPLE_DOCUMENTS[1],
75
+ },
76
+ ],
77
+ "return_margin": return_margin,
78
+ }
79
+
80
+
81
+ def _load_payload(path: Path) -> dict[str, Any]:
82
+ with path.open("r", encoding="utf-8") as handle:
83
+ payload = json.load(handle)
84
+ if not isinstance(payload, dict):
85
+ raise ValueError(f"{path} must contain one JSON object.")
86
+ return payload
87
+
88
+
89
+ def _request_payload(args: argparse.Namespace) -> dict[str, Any]:
90
+ payload = (
91
+ _load_payload(args.json_file)
92
+ if args.json_file is not None
93
+ else _demo_payload(args.endpoint, args.return_margin, args.top_k)
94
+ )
95
+ if args.return_margin:
96
+ payload["return_margin"] = True
97
+ if args.top_k is not None:
98
+ if args.endpoint != "rerank":
99
+ raise ValueError("--top-k is only valid with --endpoint rerank.")
100
+ if args.top_k < 0:
101
+ raise ValueError("--top-k must be non-negative.")
102
+ payload["top_k"] = args.top_k
103
+ return payload
104
+
105
+
106
+ def main() -> int:
107
+ args = build_parser().parse_args()
108
+ base_url = args.base_url.rstrip("/")
109
+ if args.health:
110
+ response = request_json("GET", f"{base_url}/health", timeout=args.timeout)
111
+ else:
112
+ payload = _request_payload(args)
113
+ response = request_json(
114
+ "POST",
115
+ f"{base_url}/{args.endpoint}",
116
+ payload=payload,
117
+ timeout=args.timeout,
118
+ )
119
+ json.dump(response, sys.stdout, ensure_ascii=False, indent=2)
120
+ sys.stdout.write("\n")
121
+ return 0
122
+
123
+
124
+ if __name__ == "__main__":
125
+ raise SystemExit(main())
vllm_support/src/kalm_t5gemma2_vllm_plugin/constants.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Iterable
4
+
5
+
6
+ MODEL_ID = "KaLM-Embedding/KaLM-Reranker-V1-Nano"
7
+ PLUGIN_NAME = "kalm_t5gemma2"
8
+ ARCHITECTURE = "T5Gemma2VllmScoreClassification"
9
+ TEXT_MODALITY = "text"
10
+
11
+ DEFAULT_INSTRUCTION = "Given a query, retrieve documents that answer the query."
12
+ DEFAULT_SYSTEM_INSTRUCTION = (
13
+ "Judge whether the Document meets the requirements based on the Query and "
14
+ 'the Instruct provided. Note that the answer can only be "yes" or "no".'
15
+ )
16
+
17
+ DEFAULT_QUERY_MAX_LENGTH = 512
18
+ DEFAULT_DOCUMENT_MAX_LENGTH = 1024
19
+ DEFAULT_MAX_MODEL_LEN = 2048
20
+ DEFAULT_ENCODER_CHUNK_SIZE = 4
21
+ DEFAULT_DECODER_PAD_TO_MULTIPLE_OF = 8
22
+ SUPPORTED_ENCODER_CHUNK_SIZES = frozenset({1, 2, 4, 8, 16, 32})
23
+
24
+ YES_TOKEN_TEXT = "yes"
25
+ NO_TOKEN_TEXT = "no"
26
+ YES_TOKEN_ID = 4443
27
+ NO_TOKEN_ID = 1904
28
+
29
+ SAMPLE_QUERY = "What is the capital of China?"
30
+ SAMPLE_DOCUMENTS = (
31
+ "The capital of China is Beijing.",
32
+ "Gravity attracts bodies toward one another.",
33
+ )
34
+
35
+
36
+ def parse_encoder_chunk_size(value: object) -> int:
37
+ if value is None:
38
+ return DEFAULT_ENCODER_CHUNK_SIZE
39
+ if isinstance(value, str):
40
+ normalized = value.strip().lower()
41
+ if normalized in {"", "none", "null"}:
42
+ return DEFAULT_ENCODER_CHUNK_SIZE
43
+ try:
44
+ parsed = int(normalized)
45
+ except ValueError as error:
46
+ raise ValueError(_chunk_size_error(value)) from error
47
+ else:
48
+ parsed = int(value)
49
+ if parsed not in SUPPORTED_ENCODER_CHUNK_SIZES:
50
+ raise ValueError(_chunk_size_error(value))
51
+ return parsed
52
+
53
+
54
+ def _chunk_size_error(value: object) -> str:
55
+ return (
56
+ "encoder_chunk_size must be one of "
57
+ f"{sorted(SUPPORTED_ENCODER_CHUNK_SIZES)}; got {value!r}."
58
+ )
59
+
60
+
61
+ def encoder_text(document: str) -> str:
62
+ return f"<Document>: {document}"
63
+
64
+
65
+ def decoder_text(
66
+ tokenizer,
67
+ query: str,
68
+ *,
69
+ instruction: str = DEFAULT_INSTRUCTION,
70
+ system_instruction: str = DEFAULT_SYSTEM_INSTRUCTION,
71
+ query_max_length: int = DEFAULT_QUERY_MAX_LENGTH,
72
+ ) -> str:
73
+ query_ids = tokenizer(
74
+ query,
75
+ add_special_tokens=False,
76
+ truncation=True,
77
+ max_length=query_max_length,
78
+ )["input_ids"]
79
+ truncated_query = tokenizer.decode(
80
+ query_ids,
81
+ skip_special_tokens=False,
82
+ clean_up_tokenization_spaces=False,
83
+ )
84
+ return (
85
+ "<bos><start_of_turn>user\n"
86
+ f"{system_instruction}\n\n"
87
+ f"<Instruct>: {instruction}\n"
88
+ f"<Query>: {truncated_query}<end_of_turn>\n"
89
+ "<start_of_turn>model\n\n\n\n"
90
+ )
91
+
92
+
93
+ def validate_answer_tokens(tokenizer) -> None:
94
+ expected: Iterable[tuple[str, int]] = (
95
+ (YES_TOKEN_TEXT, YES_TOKEN_ID),
96
+ (NO_TOKEN_TEXT, NO_TOKEN_ID),
97
+ )
98
+ for text, token_id in expected:
99
+ actual = tokenizer.encode(text, add_special_tokens=False)
100
+ if actual != [token_id]:
101
+ raise RuntimeError(
102
+ f"Unexpected tokenization for {text!r}: expected {[token_id]}, "
103
+ f"got {actual}."
104
+ )
105
+
vllm_support/src/kalm_t5gemma2_vllm_plugin/modeling.py ADDED
@@ -0,0 +1,265 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ import os
5
+ from typing import Iterable
6
+
7
+ import torch
8
+ import torch.nn as nn
9
+ from transformers.modeling_outputs import BaseModelOutput
10
+ from vllm.model_executor.layers.pooler import DispatchPooler
11
+ from vllm.multimodal import MULTIMODAL_REGISTRY
12
+
13
+ from .constants import (
14
+ DEFAULT_DECODER_PAD_TO_MULTIPLE_OF,
15
+ DEFAULT_ENCODER_CHUNK_SIZE,
16
+ NO_TOKEN_ID,
17
+ TEXT_MODALITY,
18
+ YES_TOKEN_ID,
19
+ )
20
+ from .modeling_score import T5Gemma2ForScoreClassification
21
+ from .processing import (
22
+ TextEncoderDummyInputsBuilder,
23
+ TextEncoderProcessingInfo,
24
+ TextEncoderProcessor,
25
+ )
26
+
27
+
28
+ def _as_token_rows(value: object) -> list[torch.Tensor]:
29
+ if isinstance(value, torch.Tensor):
30
+ if value.ndim == 1:
31
+ return [value]
32
+ if value.ndim == 2:
33
+ return [row for row in value]
34
+ raise ValueError(f"encoder_input_ids must be 1D/2D, got {value.shape}.")
35
+ if isinstance(value, list):
36
+ return [
37
+ item.flatten()
38
+ if isinstance(item, torch.Tensor)
39
+ else torch.tensor(item, dtype=torch.long)
40
+ for item in value
41
+ ]
42
+ raise TypeError(f"Unsupported encoder_input_ids type: {type(value)!r}")
43
+
44
+
45
+ def _split_by_position_zero(
46
+ input_ids: torch.Tensor,
47
+ positions: torch.Tensor,
48
+ ) -> tuple[list[torch.Tensor], list[int]]:
49
+ flat_ids = input_ids.flatten()
50
+ starts = (positions.flatten() == 0).nonzero(as_tuple=False).flatten().tolist()
51
+ if not starts:
52
+ return [], []
53
+ starts.append(int(flat_ids.numel()))
54
+ rows: list[torch.Tensor] = []
55
+ last_indices: list[int] = []
56
+ for start, end in zip(starts[:-1], starts[1:]):
57
+ if end > start:
58
+ rows.append(flat_ids[start:end])
59
+ last_indices.append(end - 1)
60
+ return rows, last_indices
61
+
62
+
63
+ def _debug(message: str) -> None:
64
+ if os.environ.get("KALM_VLLM_DEBUG") == "1":
65
+ print(f"[kalm-vllm-debug] {message}", flush=True)
66
+
67
+
68
+ @MULTIMODAL_REGISTRY.register_processor(
69
+ TextEncoderProcessor,
70
+ info=TextEncoderProcessingInfo,
71
+ dummy_inputs=TextEncoderDummyInputsBuilder,
72
+ )
73
+ class T5Gemma2VllmScoreClassification(nn.Module):
74
+ is_pooling_model = True
75
+ supports_multimodal = True
76
+ score_type = "cross-encoder"
77
+ attn_type = "encoder_decoder"
78
+ default_seq_pooling_type = "LAST"
79
+ default_tok_pooling_type = "ALL"
80
+
81
+ def __init__(self, *, vllm_config, prefix: str = "") -> None:
82
+ super().__init__()
83
+ self.vllm_config = vllm_config
84
+ self.model_config = vllm_config.model_config
85
+ self.config = self.model_config.hf_config
86
+ self.config.num_labels = 1
87
+ self.config.yes_token_id = int(
88
+ getattr(self.config, "yes_token_id", YES_TOKEN_ID)
89
+ )
90
+ self.config.no_token_id = int(
91
+ getattr(self.config, "no_token_id", NO_TOKEN_ID)
92
+ )
93
+ self.config.encoder_chunk_size = getattr(
94
+ self.config,
95
+ "encoder_chunk_size",
96
+ DEFAULT_ENCODER_CHUNK_SIZE,
97
+ )
98
+ self.encoder_chunk_size = self.config.encoder_chunk_size
99
+ self.config.decoder_pad_to_multiple_of = int(
100
+ getattr(
101
+ self.config,
102
+ "decoder_pad_to_multiple_of",
103
+ DEFAULT_DECODER_PAD_TO_MULTIPLE_OF,
104
+ )
105
+ )
106
+ self.decoder_pad_to_multiple_of = self.config.decoder_pad_to_multiple_of
107
+ self.pad_token_id = int(getattr(self.config, "pad_token_id", 0) or 0)
108
+
109
+ self.score_model = T5Gemma2ForScoreClassification.from_pretrained(
110
+ self.model_config.model,
111
+ trust_remote_code=True,
112
+ dtype=self.model_config.dtype,
113
+ )
114
+ self.score_model.config.yes_token_id = self.config.yes_token_id
115
+ self.score_model.config.no_token_id = self.config.no_token_id
116
+ self.score_model.config.num_labels = 1
117
+ self.score_model.config.encoder_chunk_size = self.encoder_chunk_size
118
+ self.score_model.yes_token_id = self.config.yes_token_id
119
+ self.score_model.no_token_id = self.config.no_token_id
120
+ self.score_model.encoder_chunk_size = self.encoder_chunk_size
121
+ self.score_model._validate_score_config()
122
+ self.score_model.eval()
123
+
124
+ pooler_config = self.model_config.pooler_config
125
+ assert pooler_config is not None
126
+ self.pooler = DispatchPooler.for_seq_cls(pooler_config)
127
+
128
+ def get_language_model(self):
129
+ return self
130
+
131
+ def get_num_mm_encoder_tokens(self, num_tokens: int) -> int:
132
+ if self.encoder_chunk_size is None:
133
+ return int(num_tokens)
134
+ return int(math.ceil(num_tokens / int(self.encoder_chunk_size)))
135
+
136
+ def embed_input_ids(self, input_ids: torch.Tensor, *args, **kwargs) -> torch.Tensor:
137
+ return self.score_model.get_input_embeddings()(input_ids)
138
+
139
+ def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
140
+ # The semantic wrapper loads the same checkpoint directly.
141
+ for _ in weights:
142
+ pass
143
+ return set(self.state_dict().keys())
144
+
145
+ def embed_multimodal(self, **kwargs: object) -> list[torch.Tensor]:
146
+ if "encoder_input_ids" not in kwargs:
147
+ raise ValueError(f"Missing {TEXT_MODALITY} encoder_input_ids.")
148
+ rows = _as_token_rows(kwargs["encoder_input_ids"])
149
+ device = next(self.score_model.parameters()).device
150
+ outputs: list[torch.Tensor] = []
151
+ with torch.inference_mode():
152
+ for row in rows:
153
+ input_ids = row.to(device=device, dtype=torch.long).unsqueeze(0)
154
+ attention_mask = torch.ones_like(input_ids)
155
+ raw_encoder_outputs = self.score_model.get_encoder()(
156
+ input_ids=input_ids,
157
+ attention_mask=attention_mask,
158
+ return_dict=True,
159
+ )
160
+ hidden = raw_encoder_outputs.last_hidden_state
161
+ if self.encoder_chunk_size is not None:
162
+ hidden, _ = self.score_model._pool_encoder_chunks(
163
+ hidden,
164
+ attention_mask,
165
+ int(self.encoder_chunk_size),
166
+ )
167
+ item = hidden.squeeze(0).contiguous()
168
+ _debug(f"encoder output shape={tuple(item.shape)}")
169
+ outputs.append(item)
170
+ return outputs
171
+
172
+ def _pad_decoder_rows(
173
+ self,
174
+ rows: list[torch.Tensor],
175
+ device: torch.device,
176
+ ) -> tuple[torch.Tensor, torch.Tensor]:
177
+ max_len = max(int(row.numel()) for row in rows)
178
+ if self.decoder_pad_to_multiple_of > 1:
179
+ multiple = self.decoder_pad_to_multiple_of
180
+ max_len = int(math.ceil(max_len / multiple) * multiple)
181
+ batch = torch.full(
182
+ (len(rows), max_len),
183
+ self.pad_token_id,
184
+ dtype=torch.long,
185
+ device=device,
186
+ )
187
+ mask = torch.zeros_like(batch)
188
+ for index, row in enumerate(rows):
189
+ row = row.to(device=device, dtype=torch.long)
190
+ length = int(row.numel())
191
+ batch[index, :length] = row
192
+ mask[index, :length] = 1
193
+ return batch, mask
194
+
195
+ @staticmethod
196
+ def _pad_encoder_outputs(
197
+ encoder_outputs: list[torch.Tensor],
198
+ device: torch.device,
199
+ ) -> tuple[torch.Tensor, torch.Tensor]:
200
+ max_len = max(int(item.shape[0]) for item in encoder_outputs)
201
+ hidden_size = int(encoder_outputs[0].shape[-1])
202
+ batch = torch.zeros(
203
+ (len(encoder_outputs), max_len, hidden_size),
204
+ dtype=encoder_outputs[0].dtype,
205
+ device=device,
206
+ )
207
+ mask = torch.zeros(
208
+ (len(encoder_outputs), max_len),
209
+ dtype=torch.long,
210
+ device=device,
211
+ )
212
+ for index, item in enumerate(encoder_outputs):
213
+ item = item.to(device=device)
214
+ length = int(item.shape[0])
215
+ batch[index, :length] = item
216
+ mask[index, :length] = 1
217
+ return batch, mask
218
+
219
+ def forward(
220
+ self,
221
+ input_ids: torch.Tensor | None,
222
+ positions: torch.Tensor,
223
+ intermediate_tensors=None,
224
+ inputs_embeds: torch.Tensor | None = None,
225
+ encoder_outputs: list[torch.Tensor] | torch.Tensor | None = None,
226
+ **kwargs,
227
+ ) -> torch.Tensor:
228
+ if input_ids is None:
229
+ raise ValueError("Decoder input_ids are required.")
230
+ decoder_rows, last_indices = _split_by_position_zero(input_ids, positions)
231
+ hidden = input_ids.new_zeros((input_ids.numel(), 1), dtype=torch.float32)
232
+ if not decoder_rows:
233
+ return hidden
234
+ if encoder_outputs is None:
235
+ return hidden
236
+
237
+ encoder_list = (
238
+ [item for item in encoder_outputs]
239
+ if isinstance(encoder_outputs, torch.Tensor)
240
+ else list(encoder_outputs)
241
+ )
242
+ if len(encoder_list) != len(decoder_rows):
243
+ raise ValueError(
244
+ "Mismatched encoder/decoder batch sizes: "
245
+ f"{len(encoder_list)} vs {len(decoder_rows)}."
246
+ )
247
+
248
+ device = next(self.score_model.parameters()).device
249
+ decoder_batch, decoder_mask = self._pad_decoder_rows(decoder_rows, device)
250
+ encoder_batch, encoder_mask = self._pad_encoder_outputs(encoder_list, device)
251
+ with torch.inference_mode():
252
+ outputs = self.score_model(
253
+ encoder_outputs=BaseModelOutput(last_hidden_state=encoder_batch),
254
+ attention_mask=encoder_mask,
255
+ decoder_input_ids=decoder_batch,
256
+ decoder_attention_mask=decoder_mask,
257
+ )
258
+ margins = outputs.logits.squeeze(-1).to(hidden.device, dtype=hidden.dtype)
259
+ _debug(f"margins={margins.float().tolist()}")
260
+ for row_index, last_index in enumerate(last_indices):
261
+ hidden[last_index, 0] = margins[row_index]
262
+ return hidden
263
+
264
+
265
+ __all__ = ["T5Gemma2VllmScoreClassification"]
vllm_support/src/kalm_t5gemma2_vllm_plugin/modeling_score.py ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional, Tuple
4
+
5
+ import torch
6
+ import torch.nn.functional as F
7
+ from transformers.modeling_outputs import BaseModelOutput, SequenceClassifierOutput
8
+ from transformers.models.t5gemma2.modeling_t5gemma2 import (
9
+ T5Gemma2ForConditionalGeneration,
10
+ )
11
+
12
+ from .constants import (
13
+ DEFAULT_ENCODER_CHUNK_SIZE,
14
+ NO_TOKEN_ID,
15
+ YES_TOKEN_ID,
16
+ )
17
+
18
+
19
+ class T5Gemma2ForScoreClassification(T5Gemma2ForConditionalGeneration):
20
+ """Expose the pretrained yes/no decision as one classification logit."""
21
+
22
+ def __init__(self, config):
23
+ super().__init__(config)
24
+ self.num_labels = 1
25
+ self.yes_token_id = int(getattr(config, "yes_token_id", YES_TOKEN_ID))
26
+ self.no_token_id = int(getattr(config, "no_token_id", NO_TOKEN_ID))
27
+ self.encoder_chunk_size = getattr(
28
+ config, "encoder_chunk_size", DEFAULT_ENCODER_CHUNK_SIZE
29
+ )
30
+ if self.encoder_chunk_size is not None:
31
+ self.encoder_chunk_size = int(self.encoder_chunk_size)
32
+ self._validate_score_config()
33
+
34
+ def _validate_score_config(self) -> None:
35
+ vocab_size = int(getattr(self.config, "vocab_size", 0))
36
+ if vocab_size <= 0:
37
+ raise ValueError("config.vocab_size must be a positive integer.")
38
+ for name, token_id in (
39
+ ("yes_token_id", self.yes_token_id),
40
+ ("no_token_id", self.no_token_id),
41
+ ):
42
+ if token_id < 0 or token_id >= vocab_size:
43
+ raise ValueError(
44
+ f"{name}={token_id} is outside vocab_size={vocab_size}."
45
+ )
46
+ if self.encoder_chunk_size is not None and self.encoder_chunk_size <= 0:
47
+ raise ValueError("encoder_chunk_size must be positive or None.")
48
+
49
+ self.config.num_labels = 1
50
+ self.config.yes_token_id = self.yes_token_id
51
+ self.config.no_token_id = self.no_token_id
52
+ self.config.encoder_chunk_size = self.encoder_chunk_size
53
+
54
+ @staticmethod
55
+ def _pool_encoder_chunks(
56
+ hidden_states: torch.Tensor,
57
+ attention_mask: torch.Tensor,
58
+ chunk_size: int,
59
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
60
+ batch_size, sequence_length, hidden_size = hidden_states.shape
61
+ num_chunks = (sequence_length + chunk_size - 1) // chunk_size
62
+ padded_length = num_chunks * chunk_size
63
+ pad_length = padded_length - sequence_length
64
+ if pad_length:
65
+ hidden_states = F.pad(hidden_states, (0, 0, 0, pad_length))
66
+ attention_mask = F.pad(attention_mask, (0, pad_length))
67
+
68
+ hidden_states = hidden_states.view(
69
+ batch_size, num_chunks, chunk_size, hidden_size
70
+ )
71
+ chunk_mask = attention_mask.view(batch_size, num_chunks, chunk_size)
72
+ expanded_mask = chunk_mask.unsqueeze(-1).to(hidden_states.dtype)
73
+ pooled_hidden = (hidden_states * expanded_mask).sum(dim=2)
74
+ pooled_hidden /= chunk_mask.sum(dim=2).clamp(min=1).unsqueeze(-1)
75
+ pooled_mask = (chunk_mask.sum(dim=2) > 0).to(attention_mask.dtype)
76
+ return pooled_hidden, pooled_mask
77
+
78
+ def _forward_with_optional_chunk_pooling(
79
+ self,
80
+ *,
81
+ input_ids: Optional[torch.Tensor],
82
+ attention_mask: Optional[torch.Tensor],
83
+ decoder_input_ids: Optional[torch.Tensor],
84
+ decoder_attention_mask: Optional[torch.Tensor],
85
+ encoder_outputs=None,
86
+ **kwargs,
87
+ ):
88
+ if self.encoder_chunk_size is None or encoder_outputs is not None:
89
+ return super().forward(
90
+ input_ids=input_ids,
91
+ attention_mask=attention_mask,
92
+ decoder_input_ids=decoder_input_ids,
93
+ decoder_attention_mask=decoder_attention_mask,
94
+ encoder_outputs=encoder_outputs,
95
+ return_dict=True,
96
+ **kwargs,
97
+ )
98
+ if input_ids is None:
99
+ raise ValueError("input_ids are required when encoder_outputs is None.")
100
+ if attention_mask is None:
101
+ attention_mask = torch.ones_like(input_ids)
102
+
103
+ raw_encoder_outputs = self.get_encoder()(
104
+ input_ids=input_ids,
105
+ attention_mask=attention_mask,
106
+ return_dict=True,
107
+ )
108
+ pooled_hidden, pooled_mask = self._pool_encoder_chunks(
109
+ raw_encoder_outputs.last_hidden_state,
110
+ attention_mask,
111
+ self.encoder_chunk_size,
112
+ )
113
+ return super().forward(
114
+ encoder_outputs=BaseModelOutput(last_hidden_state=pooled_hidden),
115
+ attention_mask=pooled_mask,
116
+ decoder_input_ids=decoder_input_ids,
117
+ decoder_attention_mask=decoder_attention_mask,
118
+ return_dict=True,
119
+ **kwargs,
120
+ )
121
+
122
+ def forward(
123
+ self,
124
+ input_ids: Optional[torch.Tensor] = None,
125
+ attention_mask: Optional[torch.Tensor] = None,
126
+ decoder_input_ids: Optional[torch.Tensor] = None,
127
+ decoder_attention_mask: Optional[torch.Tensor] = None,
128
+ encoder_outputs=None,
129
+ labels: Optional[torch.Tensor] = None,
130
+ output_attentions: Optional[bool] = None,
131
+ output_hidden_states: Optional[bool] = None,
132
+ return_dict: Optional[bool] = None,
133
+ **kwargs,
134
+ ) -> SequenceClassifierOutput:
135
+ if labels is not None:
136
+ raise NotImplementedError("This wrapper is inference-only.")
137
+ if decoder_input_ids is None:
138
+ raise ValueError("decoder_input_ids are required.")
139
+ if decoder_input_ids.shape[0] == 0:
140
+ raise ValueError("empty batches are not supported.")
141
+ if input_ids is not None and input_ids.shape[0] == 0:
142
+ raise ValueError("empty batches are not supported.")
143
+
144
+ outputs = self._forward_with_optional_chunk_pooling(
145
+ input_ids=input_ids,
146
+ attention_mask=attention_mask,
147
+ decoder_input_ids=decoder_input_ids,
148
+ decoder_attention_mask=decoder_attention_mask,
149
+ encoder_outputs=encoder_outputs,
150
+ output_attentions=output_attentions,
151
+ output_hidden_states=output_hidden_states,
152
+ **kwargs,
153
+ )
154
+ logits = outputs.logits
155
+ if decoder_attention_mask is None:
156
+ sequence_lengths = torch.full(
157
+ (logits.shape[0],),
158
+ logits.shape[1] - 1,
159
+ dtype=torch.long,
160
+ device=logits.device,
161
+ )
162
+ else:
163
+ sequence_lengths = decoder_attention_mask.sum(dim=1).to(torch.long) - 1
164
+ if (sequence_lengths < 0).any():
165
+ raise ValueError("decoder_attention_mask contains an empty sequence.")
166
+
167
+ batch_indices = torch.arange(logits.shape[0], device=logits.device)
168
+ last_logits = logits[batch_indices, sequence_lengths]
169
+ yes_no_logits = torch.stack(
170
+ (
171
+ last_logits[:, self.yes_token_id],
172
+ last_logits[:, self.no_token_id],
173
+ ),
174
+ dim=-1,
175
+ ).float()
176
+ if not torch.isfinite(yes_no_logits).all():
177
+ bad_count = (~torch.isfinite(yes_no_logits).all(dim=-1)).sum().item()
178
+ raise RuntimeError(f"Non-finite yes/no logits for {bad_count} input(s).")
179
+
180
+ margin = yes_no_logits[:, 0] - yes_no_logits[:, 1]
181
+ return SequenceClassifierOutput(
182
+ logits=margin[:, None],
183
+ hidden_states=getattr(outputs, "hidden_states", None),
184
+ attentions=getattr(outputs, "attentions", None),
185
+ )
186
+
187
+
188
+ __all__ = ["T5Gemma2ForScoreClassification"]
189
+
vllm_support/src/kalm_t5gemma2_vllm_plugin/processing.py ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ import hashlib
5
+ from typing import Any
6
+
7
+ import torch
8
+ from transformers.feature_extraction_utils import BatchFeature
9
+ from vllm.inputs import MultiModalDataDict, mm_enc_dec_input, mm_input
10
+ from vllm.multimodal.inputs import MultiModalFieldConfig, PlaceholderRange
11
+ from vllm.multimodal.parse import ModalityDataItems, MultiModalDataItems
12
+ from vllm.multimodal.processing import (
13
+ BaseDummyInputsBuilder,
14
+ BaseProcessingInfo,
15
+ EncDecMultiModalProcessor,
16
+ ProcessorInputs,
17
+ TimingContext,
18
+ )
19
+
20
+ from .constants import TEXT_MODALITY
21
+
22
+
23
+ class TextTokenItems(ModalityDataItems[list[str], str]):
24
+ def __init__(self, data: list[str]) -> None:
25
+ super().__init__(data, TEXT_MODALITY)
26
+
27
+ def get_count(self) -> int:
28
+ return len(self.data)
29
+
30
+ def get(self, index: int) -> str:
31
+ return self.data[index]
32
+
33
+ def get_processor_data(self) -> Mapping[str, object]:
34
+ return {}
35
+
36
+ def get_passthrough_data(self) -> Mapping[str, object]:
37
+ return {}
38
+
39
+
40
+ class TextEncoderProcessingInfo(BaseProcessingInfo):
41
+ def get_supported_mm_limits(self) -> Mapping[str, int | None]:
42
+ return {TEXT_MODALITY: 1}
43
+
44
+ def get_mm_max_tokens_per_item(
45
+ self,
46
+ seq_len: int,
47
+ mm_counts: Mapping[str, int],
48
+ ) -> Mapping[str, int] | None:
49
+ return {TEXT_MODALITY: seq_len}
50
+
51
+ def parse_mm_data(
52
+ self,
53
+ mm_data: MultiModalDataDict,
54
+ *,
55
+ validate: bool = True,
56
+ ) -> MultiModalDataItems:
57
+ text_data = mm_data.get(TEXT_MODALITY)
58
+ if text_data is None:
59
+ items = TextTokenItems([])
60
+ elif isinstance(text_data, str):
61
+ items = TextTokenItems([text_data])
62
+ elif isinstance(text_data, list):
63
+ items = TextTokenItems([str(item) for item in text_data])
64
+ else:
65
+ items = TextTokenItems([str(text_data)])
66
+ if validate:
67
+ self.validate_num_items(TEXT_MODALITY, items.get_count())
68
+ return MultiModalDataItems({TEXT_MODALITY: items})
69
+
70
+
71
+ class TextEncoderDummyInputsBuilder(
72
+ BaseDummyInputsBuilder[TextEncoderProcessingInfo]
73
+ ):
74
+ def get_dummy_text(self, mm_counts: Mapping[str, int]) -> str:
75
+ return "<Document>: dummy"
76
+
77
+ def get_dummy_mm_data(
78
+ self,
79
+ seq_len: int,
80
+ mm_counts: Mapping[str, int],
81
+ mm_options: Mapping[str, Any],
82
+ ) -> MultiModalDataDict:
83
+ count = mm_counts.get(TEXT_MODALITY, 0)
84
+ return {TEXT_MODALITY: ["<Document>: dummy"] * count}
85
+
86
+
87
+ class TextEncoderProcessor(EncDecMultiModalProcessor[TextEncoderProcessingInfo]):
88
+ skip_decoder_start_token = True
89
+
90
+ def create_encoder_prompt(
91
+ self,
92
+ prompt: str | list[int],
93
+ mm_items: MultiModalDataItems,
94
+ ) -> str | list[int]:
95
+ return prompt
96
+
97
+ def _get_mm_fields_config(
98
+ self,
99
+ hf_inputs: BatchFeature,
100
+ hf_processor_mm_kwargs: Mapping[str, object],
101
+ ) -> Mapping[str, MultiModalFieldConfig]:
102
+ return {
103
+ "encoder_input_ids": MultiModalFieldConfig.batched(
104
+ TEXT_MODALITY,
105
+ keep_on_cpu=True,
106
+ )
107
+ }
108
+
109
+ def _get_prompt_updates(self, *args, **kwargs):
110
+ return []
111
+
112
+ def apply(
113
+ self,
114
+ inputs: ProcessorInputs,
115
+ timing_ctx: TimingContext,
116
+ ):
117
+ tokenizer = self.info.get_tokenizer()
118
+ if isinstance(inputs.prompt, str):
119
+ encoder_ids = tokenizer.encode(inputs.prompt, add_special_tokens=False)
120
+ encoder_prompt_text = inputs.prompt
121
+ else:
122
+ encoder_ids = list(inputs.prompt)
123
+ encoder_prompt_text = None
124
+ if not encoder_ids:
125
+ raise ValueError("The text encoder prompt cannot be empty.")
126
+
127
+ tensor = torch.tensor([encoder_ids], dtype=torch.long)
128
+ mm_kwargs = self._build_mm_kwargs(tensor)
129
+ text_items = inputs.mm_data_items.get(TEXT_MODALITY)
130
+ text_for_hash = (
131
+ text_items.get(0)
132
+ if text_items is not None and text_items.get_count() > 0
133
+ else repr(encoder_ids)
134
+ )
135
+ digest = hashlib.sha256(str(text_for_hash).encode("utf-8")).hexdigest()
136
+ mm_hashes = {TEXT_MODALITY: [f"{TEXT_MODALITY}:{digest}"]}
137
+ mm_placeholders = {
138
+ TEXT_MODALITY: [PlaceholderRange(offset=0, length=len(encoder_ids))]
139
+ }
140
+ encoder_inputs = mm_input(
141
+ prompt_token_ids=encoder_ids,
142
+ prompt=encoder_prompt_text,
143
+ mm_kwargs=mm_kwargs,
144
+ mm_hashes=mm_hashes,
145
+ mm_placeholders=mm_placeholders,
146
+ )
147
+ return mm_enc_dec_input(
148
+ encoder_inputs,
149
+ decoder_prompt_token_ids=encoder_ids,
150
+ decoder_prompt=encoder_prompt_text,
151
+ )
152
+
153
+ def _build_mm_kwargs(self, encoder_input_ids: torch.Tensor):
154
+ return self._kwargs_from_batch_feature(
155
+ BatchFeature({"encoder_input_ids": encoder_input_ids})
156
+ )
157
+
158
+ def _kwargs_from_batch_feature(self, batch: BatchFeature):
159
+ from vllm.multimodal.inputs import MultiModalKwargsItems
160
+
161
+ return MultiModalKwargsItems.from_hf_inputs(
162
+ batch,
163
+ self._get_mm_fields_config(batch, {}),
164
+ )
165
+
166
+
167
+ __all__ = [
168
+ "TextEncoderDummyInputsBuilder",
169
+ "TextEncoderProcessingInfo",
170
+ "TextEncoderProcessor",
171
+ "TextTokenItems",
172
+ ]
173
+
vllm_support/src/kalm_t5gemma2_vllm_plugin/register.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from vllm import ModelRegistry
4
+
5
+ from .constants import ARCHITECTURE
6
+
7
+
8
+ def register() -> None:
9
+ ModelRegistry.register_model(
10
+ ARCHITECTURE,
11
+ "kalm_t5gemma2_vllm_plugin.modeling:T5Gemma2VllmScoreClassification",
12
+ )
13
+
14
+
15
+ __all__ = ["register"]
vllm_support/src/kalm_t5gemma2_vllm_plugin/reranker.py ADDED
@@ -0,0 +1,357 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import importlib.metadata
4
+ import math
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Any, Iterable, Optional, Sequence
8
+
9
+ from .constants import (
10
+ ARCHITECTURE,
11
+ DEFAULT_DECODER_PAD_TO_MULTIPLE_OF,
12
+ DEFAULT_DOCUMENT_MAX_LENGTH,
13
+ DEFAULT_ENCODER_CHUNK_SIZE,
14
+ DEFAULT_INSTRUCTION,
15
+ DEFAULT_MAX_MODEL_LEN,
16
+ DEFAULT_QUERY_MAX_LENGTH,
17
+ DEFAULT_SYSTEM_INSTRUCTION,
18
+ MODEL_ID,
19
+ NO_TOKEN_ID,
20
+ PLUGIN_NAME,
21
+ SUPPORTED_ENCODER_CHUNK_SIZES,
22
+ TEXT_MODALITY,
23
+ YES_TOKEN_ID,
24
+ decoder_text,
25
+ encoder_text,
26
+ parse_encoder_chunk_size,
27
+ validate_answer_tokens,
28
+ )
29
+
30
+
31
+ TESTED_VLLM_VERSION = "0.19.1"
32
+
33
+
34
+ def _positive_int(value: int, name: str) -> int:
35
+ parsed = int(value)
36
+ if parsed <= 0:
37
+ raise ValueError(f"{name} must be a positive integer.")
38
+ return parsed
39
+
40
+
41
+ def _sigmoid(value: float) -> float:
42
+ if value >= 0:
43
+ scale = math.exp(-value)
44
+ return 1.0 / (1.0 + scale)
45
+ scale = math.exp(value)
46
+ return scale / (1.0 + scale)
47
+
48
+
49
+ def build_hf_overrides(encoder_chunk_size: int) -> dict[str, object]:
50
+ return {
51
+ "architectures": [ARCHITECTURE],
52
+ "num_labels": 1,
53
+ "yes_token_id": YES_TOKEN_ID,
54
+ "no_token_id": NO_TOKEN_ID,
55
+ "encoder_chunk_size": encoder_chunk_size,
56
+ "decoder_pad_to_multiple_of": DEFAULT_DECODER_PAD_TO_MULTIPLE_OF,
57
+ "problem_type": "regression",
58
+ }
59
+
60
+
61
+ def _enable_plugin() -> None:
62
+ allowed = os.environ.get("VLLM_PLUGINS")
63
+ if allowed is None:
64
+ os.environ["VLLM_PLUGINS"] = PLUGIN_NAME
65
+ return
66
+ names = {item.strip() for item in allowed.split(",") if item.strip()}
67
+ if PLUGIN_NAME not in names:
68
+ raise RuntimeError(
69
+ f"VLLM_PLUGINS={allowed!r} excludes {PLUGIN_NAME!r}. Add the plugin "
70
+ "name or unset VLLM_PLUGINS."
71
+ )
72
+
73
+
74
+ def check_runtime() -> None:
75
+ installed_vllm = importlib.metadata.version("vllm")
76
+ if installed_vllm != TESTED_VLLM_VERSION:
77
+ raise RuntimeError(
78
+ f"This adapter requires vLLM {TESTED_VLLM_VERSION}; "
79
+ f"found {installed_vllm}."
80
+ )
81
+
82
+ discovered = {
83
+ entry.name: entry.value
84
+ for entry in importlib.metadata.entry_points(group="vllm.general_plugins")
85
+ }
86
+ if PLUGIN_NAME not in discovered:
87
+ raise RuntimeError(
88
+ f"vLLM plugin {PLUGIN_NAME!r} is not installed. Install the "
89
+ "vllm_support package before creating the reranker."
90
+ )
91
+
92
+ from vllm.model_executor.models import ModelRegistry
93
+ from vllm.plugins import load_general_plugins
94
+
95
+ load_general_plugins()
96
+ if ARCHITECTURE not in set(ModelRegistry.get_supported_archs()):
97
+ raise RuntimeError(f"{ARCHITECTURE} was not registered in ModelRegistry.")
98
+
99
+
100
+ class KaLMVLLMReranker:
101
+ """Single-GPU vLLM adapter preserving the original KaLM score contract."""
102
+
103
+ def __init__(
104
+ self,
105
+ model: str | Path = MODEL_ID,
106
+ *,
107
+ query_max_length: int = DEFAULT_QUERY_MAX_LENGTH,
108
+ document_max_length: int = DEFAULT_DOCUMENT_MAX_LENGTH,
109
+ encoder_chunk_size: object = DEFAULT_ENCODER_CHUNK_SIZE,
110
+ dtype: str = "bfloat16",
111
+ tensor_parallel_size: int = 1,
112
+ max_model_len: int = DEFAULT_MAX_MODEL_LEN,
113
+ gpu_memory_utilization: float = 0.85,
114
+ batch_size: int = 32,
115
+ instruction: str = DEFAULT_INSTRUCTION,
116
+ system_instruction: str = DEFAULT_SYSTEM_INSTRUCTION,
117
+ skip_runtime_check: bool = False,
118
+ ) -> None:
119
+ self.model = str(model)
120
+ self.query_max_length = _positive_int(
121
+ query_max_length, "query_max_length"
122
+ )
123
+ self.document_max_length = _positive_int(
124
+ document_max_length, "document_max_length"
125
+ )
126
+ self.encoder_chunk_size = parse_encoder_chunk_size(encoder_chunk_size)
127
+ self.dtype = str(dtype)
128
+ self.tensor_parallel_size = _positive_int(
129
+ tensor_parallel_size, "tensor_parallel_size"
130
+ )
131
+ if self.tensor_parallel_size != 1:
132
+ raise ValueError(
133
+ "The published adapter supports tensor_parallel_size=1 only."
134
+ )
135
+ self.max_model_len = _positive_int(max_model_len, "max_model_len")
136
+ self.gpu_memory_utilization = float(gpu_memory_utilization)
137
+ if not 0 < self.gpu_memory_utilization <= 1:
138
+ raise ValueError("gpu_memory_utilization must be in the interval (0, 1].")
139
+ self.batch_size = _positive_int(batch_size, "batch_size")
140
+ if not isinstance(instruction, str) or not isinstance(
141
+ system_instruction, str
142
+ ):
143
+ raise TypeError("instruction and system_instruction must be strings.")
144
+ self.instruction = instruction
145
+ self.system_instruction = system_instruction
146
+
147
+ _enable_plugin()
148
+ if not skip_runtime_check:
149
+ check_runtime()
150
+
151
+ from transformers import AutoTokenizer
152
+
153
+ self.tokenizer = AutoTokenizer.from_pretrained(
154
+ self.model,
155
+ trust_remote_code=True,
156
+ )
157
+ validate_answer_tokens(self.tokenizer)
158
+
159
+ from vllm import LLM
160
+ from vllm.pooling_params import PoolingParams
161
+
162
+ self.pooling_params = PoolingParams(use_activation=False)
163
+ self.llm = LLM(
164
+ model=self.model,
165
+ runner="pooling",
166
+ trust_remote_code=True,
167
+ hf_overrides=build_hf_overrides(self.encoder_chunk_size),
168
+ dtype=self.dtype,
169
+ tensor_parallel_size=1,
170
+ max_model_len=self.max_model_len,
171
+ gpu_memory_utilization=self.gpu_memory_utilization,
172
+ enforce_eager=True,
173
+ limit_mm_per_prompt={TEXT_MODALITY: 1},
174
+ )
175
+
176
+ def close(self) -> None:
177
+ llm = getattr(self, "llm", None)
178
+ if llm is None:
179
+ return
180
+ engine = getattr(llm, "llm_engine", None)
181
+ engine_core = getattr(engine, "engine_core", None)
182
+ shutdown = getattr(engine_core, "shutdown", None)
183
+ if callable(shutdown):
184
+ shutdown()
185
+ self.llm = None
186
+
187
+ def __enter__(self) -> "KaLMVLLMReranker":
188
+ return self
189
+
190
+ def __exit__(self, exc_type, exc_value, traceback) -> None:
191
+ self.close()
192
+
193
+ def _encoder_ids(self, document: str) -> tuple[str, list[int]]:
194
+ text = encoder_text(document)
195
+ token_ids = self.tokenizer(
196
+ text,
197
+ add_special_tokens=False,
198
+ truncation=True,
199
+ max_length=self.document_max_length,
200
+ )["input_ids"]
201
+ if not token_ids:
202
+ raise ValueError("Encoded document prompt is empty.")
203
+ return text, list(token_ids)
204
+
205
+ def _decoder_ids(self, query: str, instruction: str) -> tuple[str, list[int]]:
206
+ text = decoder_text(
207
+ self.tokenizer,
208
+ query,
209
+ instruction=instruction,
210
+ system_instruction=self.system_instruction,
211
+ query_max_length=self.query_max_length,
212
+ )
213
+ token_ids = self.tokenizer.encode(text, add_special_tokens=False)
214
+ if not token_ids:
215
+ raise ValueError("Encoded decoder prompt is empty.")
216
+ return text, list(token_ids)
217
+
218
+ def _prompt(self, query: str, document: str, instruction: str):
219
+ from vllm.inputs import ExplicitEncoderDecoderPrompt, TokensPrompt
220
+
221
+ encoder_prompt, encoder_ids = self._encoder_ids(document)
222
+ decoder_prompt, decoder_ids = self._decoder_ids(query, instruction)
223
+ return ExplicitEncoderDecoderPrompt(
224
+ encoder_prompt=TokensPrompt(
225
+ prompt_token_ids=encoder_ids,
226
+ prompt=encoder_prompt,
227
+ multi_modal_data={TEXT_MODALITY: [encoder_prompt]},
228
+ ),
229
+ decoder_prompt=TokensPrompt(
230
+ prompt_token_ids=decoder_ids,
231
+ prompt=decoder_prompt,
232
+ ),
233
+ )
234
+
235
+ @staticmethod
236
+ def _validate_pairs(
237
+ pairs: Sequence[tuple[str, str]],
238
+ ) -> list[tuple[str, str]]:
239
+ if isinstance(pairs, (str, bytes)) or not isinstance(pairs, Sequence):
240
+ raise TypeError("pairs must be a sequence of (query, document) pairs.")
241
+ validated: list[tuple[str, str]] = []
242
+ for index, pair in enumerate(pairs):
243
+ if (
244
+ isinstance(pair, (str, bytes))
245
+ or not isinstance(pair, Sequence)
246
+ or len(pair) != 2
247
+ ):
248
+ raise ValueError(f"pairs[{index}] must contain exactly two strings.")
249
+ query, document = pair
250
+ if not isinstance(query, str) or not isinstance(document, str):
251
+ raise TypeError(f"pairs[{index}] must contain exactly two strings.")
252
+ validated.append((query, document))
253
+ return validated
254
+
255
+ @staticmethod
256
+ def _margins_from_outputs(outputs: Iterable[Any]) -> list[float]:
257
+ margins: list[float] = []
258
+ for output in outputs:
259
+ values = output.outputs.probs
260
+ if len(values) != 1:
261
+ raise RuntimeError(f"Expected one raw margin, got {values}.")
262
+ margin = float(values[0])
263
+ if not math.isfinite(margin):
264
+ raise RuntimeError(f"vLLM returned a non-finite margin: {margin}.")
265
+ margins.append(margin)
266
+ return margins
267
+
268
+ def predict(
269
+ self,
270
+ pairs: Sequence[tuple[str, str]],
271
+ *,
272
+ instruction: Optional[str] = None,
273
+ return_margin: bool = False,
274
+ ) -> list[float] | list[dict[str, float]]:
275
+ validated_pairs = self._validate_pairs(pairs)
276
+ if not validated_pairs:
277
+ return []
278
+ effective_instruction = self.instruction if instruction is None else instruction
279
+ if not isinstance(effective_instruction, str):
280
+ raise TypeError("instruction must be a string or None.")
281
+
282
+ margins: list[float] = []
283
+ for start in range(0, len(validated_pairs), self.batch_size):
284
+ batch = validated_pairs[start : start + self.batch_size]
285
+ prompts = [
286
+ self._prompt(query, document, effective_instruction)
287
+ for query, document in batch
288
+ ]
289
+ if self.llm is None:
290
+ raise RuntimeError("The reranker has been closed.")
291
+ outputs = self.llm.classify(
292
+ prompts,
293
+ pooling_params=self.pooling_params,
294
+ use_tqdm=False,
295
+ )
296
+ margins.extend(self._margins_from_outputs(outputs))
297
+
298
+ scores = [_sigmoid(margin) for margin in margins]
299
+ if return_margin:
300
+ return [
301
+ {"score": score, "margin": margin}
302
+ for score, margin in zip(scores, margins)
303
+ ]
304
+ return scores
305
+
306
+ def rank(
307
+ self,
308
+ query: str,
309
+ documents: Sequence[str],
310
+ *,
311
+ instruction: Optional[str] = None,
312
+ top_k: Optional[int] = None,
313
+ return_margin: bool = False,
314
+ ) -> list[dict[str, float | int]]:
315
+ if not isinstance(query, str):
316
+ raise TypeError("query must be a string.")
317
+ if isinstance(documents, (str, bytes)) or not isinstance(documents, Sequence):
318
+ raise TypeError("documents must be a sequence of strings.")
319
+ if any(not isinstance(document, str) for document in documents):
320
+ raise TypeError("every document must be a string.")
321
+ if top_k is not None:
322
+ top_k = int(top_k)
323
+ if top_k < 0:
324
+ raise ValueError("top_k must be non-negative or None.")
325
+
326
+ predictions = self.predict(
327
+ [(query, document) for document in documents],
328
+ instruction=instruction,
329
+ return_margin=return_margin,
330
+ )
331
+ rankings: list[dict[str, float | int]] = []
332
+ for corpus_id, prediction in enumerate(predictions):
333
+ if return_margin:
334
+ assert isinstance(prediction, dict)
335
+ item: dict[str, float | int] = {
336
+ "corpus_id": corpus_id,
337
+ "score": prediction["score"],
338
+ "margin": prediction["margin"],
339
+ }
340
+ else:
341
+ assert isinstance(prediction, float)
342
+ item = {"corpus_id": corpus_id, "score": prediction}
343
+ rankings.append(item)
344
+ rankings.sort(key=lambda item: float(item["score"]), reverse=True)
345
+ return rankings if top_k is None else rankings[:top_k]
346
+
347
+
348
+ KaLMVLLMOfflineReranker = KaLMVLLMReranker
349
+
350
+ __all__ = [
351
+ "KaLMVLLMOfflineReranker",
352
+ "KaLMVLLMReranker",
353
+ "SUPPORTED_ENCODER_CHUNK_SIZES",
354
+ "build_hf_overrides",
355
+ "check_runtime",
356
+ "parse_encoder_chunk_size",
357
+ ]
vllm_support/src/kalm_t5gemma2_vllm_plugin/server.py ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import threading
5
+ import time
6
+ from pathlib import Path
7
+ from typing import Any, Optional
8
+
9
+ from fastapi import FastAPI, HTTPException
10
+ from pydantic import BaseModel, Field
11
+
12
+ from .constants import (
13
+ DEFAULT_DOCUMENT_MAX_LENGTH,
14
+ DEFAULT_ENCODER_CHUNK_SIZE,
15
+ DEFAULT_MAX_MODEL_LEN,
16
+ DEFAULT_QUERY_MAX_LENGTH,
17
+ MODEL_ID,
18
+ SUPPORTED_ENCODER_CHUNK_SIZES,
19
+ parse_encoder_chunk_size,
20
+ )
21
+ from .reranker import KaLMVLLMReranker
22
+
23
+
24
+ class RerankRequest(BaseModel):
25
+ query: str
26
+ documents: list[str] = Field(min_length=1)
27
+ instruction: Optional[str] = None
28
+ top_k: Optional[int] = None
29
+ return_margin: bool = False
30
+
31
+
32
+ class ScorePair(BaseModel):
33
+ query: str
34
+ document: str
35
+ id: Optional[Any] = None
36
+
37
+
38
+ class ScoreRequest(BaseModel):
39
+ pairs: list[ScorePair] = Field(min_length=1)
40
+ instruction: Optional[str] = None
41
+ return_margin: bool = False
42
+
43
+
44
+ class OnlineRerankerService:
45
+ def __init__(self, args: argparse.Namespace) -> None:
46
+ self.model = str(args.model)
47
+ self.query_max_length = int(args.query_max_length)
48
+ self.document_max_length = int(args.document_max_length)
49
+ self.encoder_chunk_size = parse_encoder_chunk_size(args.encoder_chunk_size)
50
+ self.max_model_len = int(args.max_model_len)
51
+ self.batch_size = int(args.batch_size)
52
+ self.dtype = str(args.dtype)
53
+ self.gpu_memory_utilization = float(args.gpu_memory_utilization)
54
+ self.started_at = time.time()
55
+ self.lock = threading.Lock()
56
+ self.reranker = KaLMVLLMReranker(
57
+ self.model,
58
+ query_max_length=self.query_max_length,
59
+ document_max_length=self.document_max_length,
60
+ encoder_chunk_size=self.encoder_chunk_size,
61
+ max_model_len=self.max_model_len,
62
+ batch_size=self.batch_size,
63
+ dtype=self.dtype,
64
+ gpu_memory_utilization=self.gpu_memory_utilization,
65
+ tensor_parallel_size=args.tensor_parallel_size,
66
+ )
67
+
68
+ def config(self) -> dict[str, Any]:
69
+ return {
70
+ "model": self.model,
71
+ "query_max_length": self.query_max_length,
72
+ "document_max_length": self.document_max_length,
73
+ "encoder_chunk_size": self.encoder_chunk_size,
74
+ "max_model_len": self.max_model_len,
75
+ "batch_size": self.batch_size,
76
+ "dtype": self.dtype,
77
+ "gpu_memory_utilization": self.gpu_memory_utilization,
78
+ "tensor_parallel_size": 1,
79
+ "supported_encoder_chunk_sizes": sorted(
80
+ SUPPORTED_ENCODER_CHUNK_SIZES
81
+ ),
82
+ }
83
+
84
+ def health(self) -> dict[str, Any]:
85
+ return {
86
+ "status": "ok",
87
+ "uptime_seconds": round(time.time() - self.started_at, 3),
88
+ **self.config(),
89
+ }
90
+
91
+ def close(self) -> None:
92
+ self.reranker.close()
93
+
94
+ def rerank(self, request: RerankRequest) -> dict[str, Any]:
95
+ if request.top_k is not None and request.top_k < 0:
96
+ raise ValueError("top_k must be non-negative or null.")
97
+ with self.lock:
98
+ rankings = self.reranker.rank(
99
+ request.query,
100
+ request.documents,
101
+ instruction=request.instruction,
102
+ top_k=request.top_k,
103
+ return_margin=request.return_margin,
104
+ )
105
+ results: list[dict[str, Any]] = []
106
+ for item in rankings:
107
+ result = {
108
+ "index": int(item["corpus_id"]),
109
+ "score": float(item["score"]),
110
+ }
111
+ if request.return_margin:
112
+ result["margin"] = float(item["margin"])
113
+ results.append(result)
114
+ return {"object": "rerank", "results": results}
115
+
116
+ def score(self, request: ScoreRequest) -> dict[str, Any]:
117
+ pairs = [(item.query, item.document) for item in request.pairs]
118
+ with self.lock:
119
+ predictions = self.reranker.predict(
120
+ pairs,
121
+ instruction=request.instruction,
122
+ return_margin=True,
123
+ )
124
+ results: list[dict[str, Any]] = []
125
+ for index, (pair, prediction) in enumerate(zip(request.pairs, predictions)):
126
+ assert isinstance(prediction, dict)
127
+ result = {
128
+ "index": index,
129
+ "score": float(prediction["score"]),
130
+ }
131
+ if pair.id is not None:
132
+ result["id"] = pair.id
133
+ if request.return_margin:
134
+ result["margin"] = float(prediction["margin"])
135
+ results.append(result)
136
+ return {"object": "score", "results": results}
137
+
138
+
139
+ def build_parser() -> argparse.ArgumentParser:
140
+ parser = argparse.ArgumentParser(
141
+ description="FastAPI server for the KaLM vLLM adapter.",
142
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
143
+ )
144
+ parser.add_argument("--host", default="0.0.0.0")
145
+ parser.add_argument("--port", type=int, default=8000)
146
+ parser.add_argument("--model", default=MODEL_ID)
147
+ parser.add_argument("--query-max-length", type=int, default=DEFAULT_QUERY_MAX_LENGTH)
148
+ parser.add_argument(
149
+ "--document-max-length", type=int, default=DEFAULT_DOCUMENT_MAX_LENGTH
150
+ )
151
+ parser.add_argument(
152
+ "--encoder-chunk-size", default=str(DEFAULT_ENCODER_CHUNK_SIZE)
153
+ )
154
+ parser.add_argument("--max-model-len", type=int, default=DEFAULT_MAX_MODEL_LEN)
155
+ parser.add_argument("--batch-size", type=int, default=32)
156
+ parser.add_argument("--dtype", default="bfloat16")
157
+ parser.add_argument("--gpu-memory-utilization", type=float, default=0.85)
158
+ parser.add_argument("--tensor-parallel-size", type=int, default=1)
159
+ parser.add_argument("--log-level", default="info")
160
+ return parser
161
+
162
+
163
+ def create_app(service: OnlineRerankerService) -> FastAPI:
164
+ app = FastAPI(title="KaLM vLLM Online Reranker", version="0.1.0")
165
+ app.state.service = service
166
+ app.add_event_handler("shutdown", service.close)
167
+
168
+ @app.get("/health")
169
+ def health():
170
+ return app.state.service.health()
171
+
172
+ @app.post("/rerank")
173
+ def rerank(request: RerankRequest):
174
+ try:
175
+ return app.state.service.rerank(request)
176
+ except (TypeError, ValueError) as error:
177
+ raise HTTPException(status_code=400, detail=str(error)) from error
178
+
179
+ @app.post("/score")
180
+ def score(request: ScoreRequest):
181
+ try:
182
+ return app.state.service.score(request)
183
+ except (TypeError, ValueError) as error:
184
+ raise HTTPException(status_code=400, detail=str(error)) from error
185
+
186
+ return app
187
+
188
+
189
+ def main() -> int:
190
+ args = build_parser().parse_args()
191
+ args.encoder_chunk_size = parse_encoder_chunk_size(args.encoder_chunk_size)
192
+ print("=== KaLM vLLM Online Reranker ===", flush=True)
193
+ print(f"model: {args.model}", flush=True)
194
+ print(f"query_max_length: {args.query_max_length}", flush=True)
195
+ print(f"document_max_length: {args.document_max_length}", flush=True)
196
+ print(f"encoder_chunk_size: {args.encoder_chunk_size}", flush=True)
197
+ print(f"max_model_len: {args.max_model_len}", flush=True)
198
+ print(f"listen: http://{args.host}:{args.port}", flush=True)
199
+
200
+ service = OnlineRerankerService(args)
201
+ app = create_app(service)
202
+ import uvicorn
203
+
204
+ uvicorn.run(
205
+ app,
206
+ host=args.host,
207
+ port=args.port,
208
+ log_level=args.log_level,
209
+ workers=1,
210
+ )
211
+ return 0
212
+
213
+
214
+ if __name__ == "__main__":
215
+ raise SystemExit(main())