Repository navigation
feat(sglang): expose cross-encoder reranking through /v1/rerank #14032
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
pvijayakrish
merged 10 commits into
ai-dynamo:main
from
xianlubird:feat/sglang-cross-encoder-rerank
Sep 16, 2026
Merged
Changes from 1 commit
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
af2eb01
feat(sglang): expose cross-encoder reranking through /v1/rerank
xianlubird 92b6abc
fix(sglang): address rerank CI failures
xianlubird a0ebce9
fix(sglang): isolate reranking in a dedicated worker
xianlubird 4d70801
chore(sglang): merge main and resolve rerank discovery conflict
xianlubird 15aa12d
test(sglang): adapt pooling mocks to resolved server args
xianlubird ebb68fb
chore(sglang): merge main to include vLLM test collection fix
xianlubird 3c4e793
Merge main into feat/sglang-cross-encoder-rerank
xianlubird 175d073
chore: merge main into SGLang rerank PR
xianlubird 15fb5a2
chore(sglang): merge main and resolve worker imports
xianlubird 152c271
chore(sglang): merge main into cross-encoder rerank
xianlubird File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
6 changes: 6 additions & 0 deletions
6
components/src/dynamo/sglang/request_handlers/rerank/__init__.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| from dynamo.sglang.request_handlers.rerank.rerank_handler import RerankWorkerHandler | ||
|
|
||
| __all__ = ["RerankWorkerHandler"] |
124 changes: 124 additions & 0 deletions
124
components/src/dynamo/sglang/request_handlers/rerank/rerank_handler.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,124 @@ | ||
| # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| import heapq | ||
| from collections.abc import AsyncGenerator | ||
| from typing import Any | ||
|
|
||
| import sglang as sgl | ||
| from sglang.srt.managers.io_struct import EmbeddingReqInput | ||
|
|
||
| from dynamo._core import Context | ||
| from dynamo.sglang.protocol import RerankRequest | ||
|
|
||
|
|
||
| def _looks_like_decoder_reranker(tokenizer_manager: Any) -> bool: | ||
| """Detect decoder-only Qwen3 rerankers that need SGLang's generation path.""" | ||
| tokenizer = getattr(tokenizer_manager, "tokenizer", None) | ||
| chat_template = getattr(tokenizer, "chat_template", "") | ||
| template = chat_template.lower() if isinstance(chat_template, str) else "" | ||
| model_config = getattr(tokenizer_manager, "model_config", None) | ||
| model_path = str(getattr(model_config, "model_path", "")).lower() | ||
| has_yes_no_template = ( | ||
| "answer can only be" in template and "yes" in template and "no" in template | ||
| ) | ||
| return ( | ||
| has_yes_no_template | ||
| or "qwen3-reranker" in model_path | ||
| or "qwen3-vl-reranker" in model_path | ||
| ) | ||
|
|
||
|
|
||
| class RerankWorkerHandler: | ||
| """Runs stable text-only cross-encoder reranking through SGLang pooling.""" | ||
|
|
||
| def __init__(self, engine: sgl.Engine, *, enable_trace: bool) -> None: | ||
| self.engine = engine | ||
| self.enable_trace = enable_trace | ||
|
|
||
| @staticmethod | ||
| def _validate(request: RerankRequest) -> None: | ||
| if not request.query.strip(): | ||
| raise ValueError("Query cannot be empty or whitespace only") | ||
| if not request.documents: | ||
| raise ValueError("Documents cannot be empty") | ||
| if any(not document.strip() for document in request.documents): | ||
| raise ValueError("Each document cannot be empty or whitespace only") | ||
| if request.top_n is not None and request.top_n < 1: | ||
| raise ValueError("Parameter 'top_n' must be larger than 0") | ||
|
|
||
| @staticmethod | ||
| def _score(item: Any, index: int) -> float: | ||
| if not isinstance(item, dict) or "embedding" not in item: | ||
| raise ValueError(f"Missing embedding score for rerank at index {index}") | ||
| score = item["embedding"] | ||
| if isinstance(score, list): | ||
| if not score or not isinstance(score[0], (int, float)): | ||
| raise ValueError( | ||
| f"Invalid embedding score for rerank at index {index}: {score!r}" | ||
| ) | ||
| score = score[0] | ||
| if not isinstance(score, (int, float)): | ||
| raise ValueError( | ||
| f"Invalid embedding score for rerank at index {index}: {score!r}" | ||
| ) | ||
| return float(score) | ||
|
|
||
| async def generate( | ||
| self, request: dict[str, Any], context: Context | ||
| ) -> AsyncGenerator[list[dict[str, Any]], None]: | ||
| rerank_request = RerankRequest(**request) | ||
| self._validate(rerank_request) | ||
|
|
||
| tokenizer_manager = self.engine.tokenizer_manager | ||
| if _looks_like_decoder_reranker(tokenizer_manager): | ||
| raise ValueError( | ||
| "Dynamo's SGLang /v1/rerank integration currently supports " | ||
| "cross-encoder rerankers only; decoder-only Qwen3 and Qwen3-VL " | ||
| "rerankers are not supported" | ||
| ) | ||
|
|
||
| pairs = [ | ||
| [rerank_request.query, document] for document in rerank_request.documents | ||
| ] | ||
| trace_id = context.trace_id | ||
| request_ids = ( | ||
| [f"{trace_id}-{index}" for index in range(len(pairs))] | ||
| if trace_id is not None | ||
| else None | ||
| ) | ||
| internal_request = EmbeddingReqInput( | ||
| text=pairs, | ||
| is_cross_encoder_request=True, | ||
| external_trace_header=( | ||
| context.trace_headers() if self.enable_trace else None | ||
| ), | ||
| rid=request_ids, | ||
| ) | ||
| responses = tokenizer_manager.generate_request(internal_request, None) | ||
| result = await anext(responses) | ||
| if not isinstance(result, list): | ||
| result = [result] | ||
| if len(result) != len(rerank_request.documents): | ||
| raise ValueError( | ||
| "SGLang returned an unexpected number of rerank scores: " | ||
| f"expected {len(rerank_request.documents)}, got {len(result)}" | ||
| ) | ||
|
|
||
| ranked = [] | ||
| for index, item in enumerate(result): | ||
| response = {"score": self._score(item, index), "index": index} | ||
| if rerank_request.return_documents: | ||
| response["document"] = rerank_request.documents[index] | ||
| meta_info = item.get("meta_info") if isinstance(item, dict) else None | ||
| if meta_info is not None: | ||
| response["meta_info"] = meta_info | ||
| ranked.append(response) | ||
|
|
||
| if rerank_request.top_n is not None: | ||
| ranked = heapq.nlargest( | ||
| rerank_request.top_n, ranked, key=lambda item: item["score"] | ||
| ) | ||
| else: | ||
| ranked.sort(key=lambda item: item["score"], reverse=True) | ||
| yield ranked |
175 changes: 175 additions & 0 deletions
175
components/src/dynamo/sglang/tests/test_sglang_rerank_handler.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,175 @@ | ||
| # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| """Unit tests for SGLang cross-encoder reranking.""" | ||
|
|
||
| from types import SimpleNamespace | ||
| from typing import Any | ||
|
|
||
| import pytest | ||
|
|
||
| pytest.importorskip( | ||
| "sglang.srt.managers.io_struct", reason="sglang not installed in this container" | ||
| ) | ||
|
|
||
| from dynamo.sglang.request_handlers.embedding import ( # noqa: E402 | ||
| EmbeddingWorkerHandler, | ||
| ) | ||
| from dynamo.sglang.request_handlers.rerank import RerankWorkerHandler # noqa: E402 | ||
|
|
||
| pytestmark = [ | ||
| pytest.mark.unit, | ||
| pytest.mark.sglang, | ||
| pytest.mark.core, | ||
| pytest.mark.gpu_0, | ||
| pytest.mark.profiled_vram_gib(0), | ||
| pytest.mark.pre_merge, | ||
| ] | ||
|
|
||
|
|
||
| class _TokenizerManager: | ||
| def __init__( | ||
| self, | ||
| scores: list[float], | ||
| *, | ||
| chat_template: str = "", | ||
| model_path: str = "BAAI/bge-reranker-v2-m3", | ||
| ) -> None: | ||
| self.scores = scores | ||
| self.requests: list[tuple[Any, Any]] = [] | ||
| self.tokenizer = SimpleNamespace(chat_template=chat_template) | ||
| self.model_config = SimpleNamespace(model_path=model_path) | ||
|
|
||
| async def generate_request(self, request: Any, raw_request: Any): | ||
| request.normalize_batch_and_arguments() | ||
| self.requests.append((request, raw_request)) | ||
| yield [ | ||
| { | ||
| "embedding": [score], | ||
| "meta_info": {"prompt_tokens": index + 1}, | ||
| } | ||
| for index, score in enumerate(self.scores) | ||
| ] | ||
|
|
||
|
|
||
| class _Engine: | ||
| def __init__(self, tokenizer_manager: _TokenizerManager) -> None: | ||
| self.tokenizer_manager = tokenizer_manager | ||
|
|
||
|
|
||
| class _Context: | ||
| trace_id = "rerank-trace" | ||
|
|
||
| def trace_headers(self) -> dict[str, str]: | ||
| return {"traceparent": "00-test"} | ||
|
|
||
|
|
||
| def _handler( | ||
| scores: list[float], | ||
| *, | ||
| chat_template: str = "", | ||
| model_path: str = "BAAI/bge-reranker-v2-m3", | ||
| ) -> tuple[RerankWorkerHandler, _TokenizerManager]: | ||
| manager = _TokenizerManager( | ||
| scores, chat_template=chat_template, model_path=model_path | ||
| ) | ||
| return RerankWorkerHandler(_Engine(manager), enable_trace=True), manager | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_builds_pairs_sorts_scores_and_applies_top_n(): | ||
| handler, manager = _handler([0.2, 0.9, 0.5]) | ||
| outputs = [ | ||
| output | ||
| async for output in handler.generate( | ||
| { | ||
| "model": "reranker", | ||
| "query": "query", | ||
| "documents": ["zero", "one", "two"], | ||
| "top_n": 2, | ||
| "return_documents": True, | ||
| }, | ||
| _Context(), | ||
| ) | ||
| ] | ||
|
|
||
| assert [item["index"] for item in outputs[0]] == [1, 2] | ||
| assert [item["document"] for item in outputs[0]] == ["one", "two"] | ||
| [(request, raw_request)] = manager.requests | ||
| assert raw_request is None | ||
| assert request.text == [["query", "zero"], ["query", "one"], ["query", "two"]] | ||
| assert request.is_cross_encoder_request is True | ||
| assert request.rid == ["rerank-trace-0", "rerank-trace-1", "rerank-trace-2"] | ||
| assert request.external_trace_header == {"traceparent": "00-test"} | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_omits_documents_when_not_requested(): | ||
| handler, _ = _handler([0.1, 0.8]) | ||
| [output] = [ | ||
| output | ||
| async for output in handler.generate( | ||
| { | ||
| "model": "reranker", | ||
| "query": "query", | ||
| "documents": ["a", "b"], | ||
| "return_documents": False, | ||
| }, | ||
| _Context(), | ||
| ) | ||
| ] | ||
| assert [item["index"] for item in output] == [1, 0] | ||
| assert all("document" not in item for item in output) | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| @pytest.mark.parametrize( | ||
| "request", | ||
| [ | ||
| {"model": "m", "query": " ", "documents": ["doc"]}, | ||
| {"model": "m", "query": "query", "documents": []}, | ||
| {"model": "m", "query": "query", "documents": [""]}, | ||
| {"model": "m", "query": "query", "documents": ["doc"], "top_n": 0}, | ||
| ], | ||
| ) | ||
| async def test_rejects_invalid_requests_before_inference(request): | ||
| handler, manager = _handler([0.5]) | ||
| with pytest.raises(ValueError): | ||
| _ = [output async for output in handler.generate(request, _Context())] | ||
| assert manager.requests == [] | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_rejects_decoder_only_qwen3_reranker(): | ||
| handler, manager = _handler( | ||
| [0.5], | ||
| chat_template='The answer can only be "yes" or "no"', | ||
| model_path="Qwen/Qwen3-Reranker-0.6B", | ||
| ) | ||
| with pytest.raises(ValueError, match="cross-encoder rerankers only"): | ||
| _ = [ | ||
| output | ||
| async for output in handler.generate( | ||
| {"model": "m", "query": "query", "documents": ["doc"]}, | ||
| _Context(), | ||
| ) | ||
| ] | ||
| assert manager.requests == [] | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_embedding_endpoint_dispatches_rerank_shape(): | ||
| class _RerankHandler: | ||
| async def generate(self, request, context): | ||
| yield [{"score": 0.7, "index": 0}] | ||
|
|
||
| handler = EmbeddingWorkerHandler.__new__(EmbeddingWorkerHandler) | ||
| handler.rerank_handler = _RerankHandler() | ||
| output = [ | ||
| item | ||
| async for item in handler.generate( | ||
| {"model": "m", "query": "query", "documents": ["doc"]}, | ||
| _Context(), | ||
| ) | ||
| ] | ||
| assert output == [[{"score": 0.7, "index": 0}]] |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.