Fixed chunked embedding aggregation with request-id metadata (#46782)
Signed-off-by: Taneem Ibrahim <taneem.ibrahim@gmail.com>
Taneem Ibrahim committed
Jun 27, 2026 at 16:57 UTC
8bf064f8d3408ca89cabc2f071adc696314c867e
3 files changed
+155
-60
tests/entrypoints/pooling/embed/test_io_processor.py
+92
@@ -3,6 +3,7 @@
3
"""Unit tests for EmbedIOProcessor."""
4
5
import pytest
6
+import torch
7
from pydantic import TypeAdapter, ValidationError
8
9
from vllm import PoolingParams
@@ -19,6 +20,7 @@ from vllm.entrypoints.pooling.embed.protocol import (
20
EmbeddingRequest,
21
)
22
from vllm.entrypoints.pooling.typing import PoolingServeContext
23
+from vllm.outputs import PoolingOutput, PoolingRequestOutput
24
25
26
class TestEmbeddingRequestParsing:
@@ -398,6 +400,96 @@ class TestValidateInputType:
400
handler._validate_input_type("z")
401
402
403
+class TestChunkedEmbeddingProcessing:
404
+ """Unit tests for chunked embedding aggregation."""
405
+
406
+ class _FakeModelConfig:
407
+ max_model_len = 3
408
+
409
+ @classmethod
410
+ def _make_handler(cls):
411
+ handler = object.__new__(EmbedIOProcessor)
412
+ handler.model_config = cls._FakeModelConfig()
413
+ return handler
414
+
415
+ @staticmethod
416
+ def _make_context() -> PoolingServeContext[EmbeddingCompletionRequest]:
417
+ request = TypeAdapter(EmbeddingRequest).validate_python(
418
+ {
419
+ "model": "test",
420
+ "input": [[0, 1, 2, 3, 4], [10, 11]],
421
+ }
422
+ )
423
+ assert isinstance(request, EmbeddingCompletionRequest)
424
+ return PoolingServeContext(
425
+ request=request,
426
+ pooling_params=PoolingParams(),
427
+ model_name="test",
428
+ request_id="embd-client-prompt-999-chunk-888",
429
+ engine_inputs=[
430
+ {"prompt_token_ids": [0, 1, 2, 3, 4]},
431
+ {"prompt_token_ids": [10, 11]},
432
+ ],
433
+ )
434
+
435
+ @staticmethod
436
+ def _make_output(
437
+ request_id: str,
438
+ prompt_token_ids: list[int],
439
+ embedding: list[float],
440
+ ) -> PoolingRequestOutput:
441
+ return PoolingRequestOutput(
442
+ request_id=request_id,
443
+ outputs=PoolingOutput(data=torch.tensor(embedding)),
444
+ prompt_token_ids=prompt_token_ids,
445
+ num_cached_tokens=0,
446
+ finished=True,
447
+ )
448
+
449
+ def test_aggregation_uses_metadata_not_request_id_parsing(self):
450
+ handler = self._make_handler()
451
+ ctx = self._make_context()
452
+
453
+ handler._pre_process_chunked(ctx)
454
+
455
+ assert ctx.prompt_request_ids == [
456
+ "embd-client-prompt-999-chunk-888-prompt-0-chunk-0",
457
+ "embd-client-prompt-999-chunk-888-prompt-0-chunk-1",
458
+ "embd-client-prompt-999-chunk-888-prompt-1-chunk-0",
459
+ ]
460
+ assert ctx.chunked_embedding_metadata is not None
461
+ assert [
462
+ (item.prompt_index, item.chunk_index)
463
+ for item in ctx.chunked_embedding_metadata
464
+ ] == [(0, 0), (0, 1), (1, 0)]
465
+
466
+ ctx.final_res_batch = [
467
+ self._make_output(ctx.prompt_request_ids[0], [0, 1, 2], [1.0, 1.0]),
468
+ self._make_output(ctx.prompt_request_ids[1], [3, 4], [4.0, 7.0]),
469
+ self._make_output(ctx.prompt_request_ids[2], [10, 11], [9.0, 9.0]),
470
+ ]
471
+
472
+ handler._post_process_chunked(ctx)
473
+
474
+ assert len(ctx.final_res_batch) == 2
475
+ assert ctx.final_res_batch[0].request_id == (
476
+ "embd-client-prompt-999-chunk-888-prompt-0"
477
+ )
478
+ assert ctx.final_res_batch[0].prompt_token_ids == [0, 1, 2, 3, 4]
479
+ assert torch.allclose(
480
+ ctx.final_res_batch[0].outputs.data,
481
+ torch.tensor([2.2, 3.4]),
482
+ )
483
+ assert ctx.final_res_batch[1].request_id == (
484
+ "embd-client-prompt-999-chunk-888-prompt-1"
485
+ )
486
+ assert ctx.final_res_batch[1].prompt_token_ids == [10, 11]
487
+ assert torch.allclose(
488
+ ctx.final_res_batch[1].outputs.data,
489
+ torch.tensor([9.0, 9.0]),
490
+ )
491
+
492
+
493
class TestPreProcessCohereOnline:
494
"""Unit tests for EmbedIOProcessor._pre_process_cohere_online."""
495
vllm/entrypoints/pooling/embed/io_processor.py
+56
-60
@@ -1,6 +1,7 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
from collections.abc import Sequence
4
+from dataclasses import dataclass
5
from typing import Any, Literal, cast
6
7
import torch
@@ -27,6 +28,7 @@ from vllm.utils.mistral import is_mistral_tokenizer
28
from ..base.io_processor import PoolingIOProcessor
29
from ..scoring.io_processor import JinaRankingIOProcessorMixin
30
from ..typing import (
31
+ ChunkedEmbeddingMetadata,
32
OfflineInputsContext,
33
PoolingChatLikeRequest,
34
PoolingCompletionLikeRequest,
@@ -46,6 +48,12 @@ from .protocol import (
48
logger = init_logger(__name__)
49
50
51
+@dataclass
52
+class _ChunkedPromptAggregator:
53
+ weighted_sum: torch.Tensor | None = None
54
+ total_weight: int = 0
55
+
56
+
57
class EmbedIOProcessor(PoolingIOProcessor):
58
name = "embed"
59
@@ -113,6 +121,7 @@ class EmbedIOProcessor(PoolingIOProcessor):
121
max_model_len = self.model_config.max_model_len
122
chunked_engine_inputs: list[EngineInput] = []
123
prompt_request_ids: list[str] = []
124
+ chunked_embedding_metadata: list[ChunkedEmbeddingMetadata] = []
125
for prompt_idx, engine_input in enumerate(ctx.engine_inputs):
126
token_ids = engine_input.get("prompt_token_ids", None)
127
if token_ids is None:
@@ -132,9 +141,16 @@ class EmbedIOProcessor(PoolingIOProcessor):
141
prompt_request_ids.append(
142
f"{request_id}-prompt-{prompt_idx}-chunk-{chunk_idx}"
143
)
144
+ chunked_embedding_metadata.append(
145
+ ChunkedEmbeddingMetadata(
146
+ prompt_index=prompt_idx,
147
+ chunk_index=chunk_idx,
148
+ )
149
+ )
150
151
ctx.engine_inputs = chunked_engine_inputs
152
ctx.prompt_request_ids = prompt_request_ids
153
+ ctx.chunked_embedding_metadata = chunked_embedding_metadata
154
155
return None
156
@@ -142,66 +158,48 @@ class EmbedIOProcessor(PoolingIOProcessor):
158
# Online aggregation for chunked requests to
159
# minimize memory usage
160
# Track aggregation state for each prompt
145
- prompt_aggregators: dict[int, dict[str, Any]] = {}
146
- short_prompts_results: dict[int, PoolingRequestOutput] = {}
147
- for result_idx, result in enumerate(ctx.final_res_batch):
148
- if "-chunk-" not in result.request_id:
149
- # Non-chunked result - extract prompt_idx from request_id
150
- parts = result.request_id.split("-")
151
- try:
152
- # Last part should be prompt index
153
- prompt_idx = int(parts[-1])
154
- except (ValueError, IndexError):
155
- prompt_idx = result_idx # Fallback to result_idx
156
-
157
- short_prompts_results[prompt_idx] = result
158
- else:
159
- # Extract prompt_idx from chunked request_id
160
- parts = result.request_id.split("-")
161
- try:
162
- prompt_idx = int(parts[parts.index("prompt") + 1])
163
- except (ValueError, IndexError):
164
- # Fallback: extract from result_idx if parsing fails
165
- prompt_idx = result_idx
166
-
167
- # Initialize aggregator for this prompt if needed
168
- if prompt_idx not in prompt_aggregators:
169
- prompt_aggregators[prompt_idx] = {
170
- "weighted_sum": None,
171
- "total_weight": 0,
172
- "chunk_count": 0,
173
- "request_id": result.request_id.split("-chunk-")[0],
174
- }
161
+ if ctx.chunked_embedding_metadata is None:
162
+ raise ValueError("Chunked embedding metadata not available")
163
+ if len(ctx.chunked_embedding_metadata) != len(ctx.final_res_batch):
164
+ raise ValueError(
165
+ "Chunked embedding metadata count does not match result count"
166
+ )
167
176
- aggregator = prompt_aggregators[prompt_idx]
168
+ prompt_aggregators: dict[int, _ChunkedPromptAggregator] = {}
169
+ for result, chunk_metadata in zip(
170
+ ctx.final_res_batch, ctx.chunked_embedding_metadata
171
+ ):
172
+ prompt_idx = chunk_metadata.prompt_index
173
+ aggregator = prompt_aggregators.setdefault(
174
+ prompt_idx, _ChunkedPromptAggregator()
175
+ )
176
178
- # MEAN pooling with online weighted averaging
179
- # Ensure result is PoolingRequestOutput
180
- # for embedding processing
181
- if not isinstance(result, PoolingRequestOutput):
182
- raise ValueError(
183
- f"Expected PoolingRequestOutput for "
184
- f"chunked embedding, got "
185
- f"{type(result).__name__}"
186
- )
187
- if result.prompt_token_ids is None:
188
- raise ValueError(
189
- "prompt_token_ids cannot be None for chunked processing"
190
- )
177
+ # MEAN pooling with online weighted averaging
178
+ # Ensure result is PoolingRequestOutput
179
+ # for embedding processing
180
+ if not isinstance(result, PoolingRequestOutput):
181
+ raise ValueError(
182
+ f"Expected PoolingRequestOutput for "
183
+ f"chunked embedding, got "
184
+ f"{type(result).__name__}"
185
+ )
186
+ if result.prompt_token_ids is None:
187
+ raise ValueError(
188
+ "prompt_token_ids cannot be None for chunked processing"
189
+ )
190
192
- weight = len(result.prompt_token_ids)
193
- embedding_data = result.outputs.data
194
- weighted_embedding = embedding_data.to(dtype=torch.float32) * weight
191
+ weight = len(result.prompt_token_ids)
192
+ embedding_data = result.outputs.data
193
+ weighted_embedding = embedding_data.to(dtype=torch.float32) * weight
194
196
- if aggregator["weighted_sum"] is None:
197
- # First chunk
198
- aggregator["weighted_sum"] = weighted_embedding
199
- else:
200
- # Accumulate
201
- aggregator["weighted_sum"] += weighted_embedding
195
+ if aggregator.weighted_sum is None:
196
+ # First chunk
197
+ aggregator.weighted_sum = weighted_embedding
198
+ else:
199
+ # Accumulate
200
+ aggregator.weighted_sum += weighted_embedding
201
203
- aggregator["total_weight"] += weight
204
- aggregator["chunk_count"] += 1
202
+ aggregator.total_weight += weight
203
204
if ctx.original_engine_inputs is None:
205
raise ValueError("Original engine inputs not available")
@@ -216,8 +214,8 @@ class EmbedIOProcessor(PoolingIOProcessor):
214
# Finalize MEAN aggregation for this chunked prompt
215
aggregator = prompt_aggregators[prompt_idx]
216
219
- weighted_sum = aggregator["weighted_sum"]
220
- total_weight = aggregator["total_weight"]
217
+ weighted_sum = aggregator.weighted_sum
218
+ total_weight = aggregator.total_weight
219
220
if (
221
weighted_sum is not None
@@ -243,7 +241,7 @@ class EmbedIOProcessor(PoolingIOProcessor):
241
242
original_token_ids = cast(list[int], token_ids)
243
pooling_request_output = PoolingRequestOutput(
246
- request_id=aggregator["request_id"],
244
+ request_id=f"{ctx.request_id}-prompt-{prompt_idx}",
245
prompt_token_ids=original_token_ids,
246
outputs=pooling_output_data,
247
num_cached_tokens=0,
@@ -255,8 +253,6 @@ class EmbedIOProcessor(PoolingIOProcessor):
253
raise ValueError(
254
f"Failed to aggregate chunks for prompt {prompt_idx}"
255
)
258
- elif prompt_idx in short_prompts_results:
259
- final_res_batch.append(short_prompts_results[prompt_idx])
256
else:
257
raise ValueError(f"Result not found for prompt {prompt_idx}")
258
vllm/entrypoints/pooling/typing.py
+7
@@ -70,6 +70,12 @@ AnyPoolingResponse: TypeAlias = (
70
PoolingRequestT = TypeVar("PoolingRequestT", bound=AnyPoolingRequest)
71
72
73
+@dataclass(kw_only=True)
74
+class ChunkedEmbeddingMetadata:
75
+ prompt_index: int
76
+ chunk_index: int
77
+
78
+
79
@dataclass(kw_only=True)
80
class PoolingServeContext(Generic[PoolingRequestT]):
81
model_config = ConfigDict(arbitrary_types_allowed=True)
@@ -91,6 +97,7 @@ class PoolingServeContext(Generic[PoolingRequestT]):
97
98
## for Long Text Embedding with Chunked Processing
99
original_engine_inputs: Sequence[EngineInput] | None = None
100
+ chunked_embedding_metadata: list[ChunkedEmbeddingMetadata] | None = None
101
102
## for bi-encoder & late-interaction
103
n_queries: int | None = None