# SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project from collections.abc import Iterable from typing import cast import torch from vllm import PromptType, TextPrompt from vllm.config import ModelConfig from vllm.exceptions import VLLMValidationError from vllm.inputs import MultiModalDataDict, MultiModalUUIDDict from vllm.renderers.chat_utils import ( BaseMultiModalItemTracker, ChatCompletionContentPartParam, ChatCompletionContentPartTextParam, ConversationMessage, MultiModalItemTracker, parse_chat_message_content_parts, ) from .typing import ( ScoreContentPartParam, ScoreData, ScoreInput, ScoringData, ) def get_num_special_tokens_for_pair(tokenizer) -> int: """Get number of special tokens added for a text pair encoding.""" method = getattr(tokenizer, "num_special_tokens_to_add", None) if method is not None: try: return method(pair=True) except TypeError: pass # Fallback: compute by tokenizing empty strings empty_encoding = tokenizer("", text_pair="", add_special_tokens=True) return len(empty_encoding["input_ids"]) def truncate_text_to_tokens( text: str, tokenizer, max_tokens: int, ) -> str: """Truncate text to a maximum number of content tokens. Uses offset_mapping to slice the original text at the exact character boundary, avoiding lossy encode→decode round-trips that can shift the token count due to BPE merge boundary changes. """ if max_tokens <= 0: return "" while text: encoding = tokenizer( text, add_special_tokens=False, return_offsets_mapping=True, ) offsets = encoding["offset_mapping"] if len(encoding["input_ids"]) <= max_tokens: break char_end = min(offsets[max_tokens - 1][1], offsets[max_tokens][0]) if char_end >= len(text): char_end = len(text) - 1 text = text[:char_end] return text def compute_maxsim_score(q_emb: torch.Tensor, d_emb: torch.Tensor) -> torch.Tensor: """Compute ColBERT MaxSim score. Args: q_emb: Query token embeddings [query_len, dim] d_emb: Document token embeddings [doc_len, dim] Returns: MaxSim score (sum over query tokens of max similarity to any doc token) """ # compute in float32 for numerical stability # [query_len, doc_len] token_scores = torch.matmul(q_emb.float(), d_emb.float().T) # Max over document tokens, sum over query tokens return token_scores.amax(dim=-1).sum() def _validate_mm_score_input( data: list[ScoreInput], is_multimodal_model: bool, architecture: str, ) -> list[ScoreData]: out: list[ScoreData] = [] for d in data: if isinstance(d, str): out.append(d) else: if not is_multimodal_model: raise VLLMValidationError( f"MultiModalParam is not supported for {architecture}" ) content = cast(list[ScoreContentPartParam], d.get("content", [])) out.append(content) return out def _validate_score_input_lens( data_1: list[ScoreData], data_2: list[ScoreData], ): len_1 = len(data_1) len_2 = len(data_2) if len_1 > 1 and len_1 != len_2: raise VLLMValidationError("Input lengths must be either 1:1, 1:N or N:N") if len_1 == 0: raise VLLMValidationError("At least one text element must be given") if len_2 == 0: raise VLLMValidationError("At least one text_pair element must be given") def validate_score_input( data_1: ScoreInput | list[ScoreInput], data_2: ScoreInput | list[ScoreInput], is_multimodal_model: bool, architecture: str, ) -> ScoringData: if not isinstance(data_1, list): data_1 = [data_1] if not isinstance(data_2, list): data_2 = [data_2] score_input_1 = _validate_mm_score_input(data_1, is_multimodal_model, architecture) score_input_2 = _validate_mm_score_input(data_2, is_multimodal_model, architecture) _validate_score_input_lens(score_input_1, score_input_2) return ScoringData(data_1=score_input_1, data_2=score_input_2) def score_data_to_prompts( data_list: list[ScoreData], role: str, model_config: ModelConfig, ) -> list[PromptType]: """Convert a list of ScoreData into PromptType objects. For plain text inputs, returns the string directly. For multimodal inputs (list of content parts), parses them into a :class:`TextPrompt` with attached ``multi_modal_data`` / ``multi_modal_uuids``. This is used by late-interaction scoring where each query/document is encoded independently. """ prompts: list[PromptType] = [] for data in data_list: if isinstance(data, str): prompts.append(data) else: text, mm_data, mm_uuids = parse_score_data_single(data, role, model_config) prompt: TextPrompt = TextPrompt(prompt=text) if mm_data is not None: prompt["multi_modal_data"] = mm_data if mm_uuids is not None: prompt["multi_modal_uuids"] = mm_uuids prompts.append(prompt) return prompts def _ensure_str(content: list[ConversationMessage]) -> str: """Extract a single string prompt from parsed conversation content.""" assert len(content) == 1 prompt = content[0]["content"] if prompt is not None and isinstance(prompt, str): return cast(str, prompt) raise ValueError(f"Only string content is supported, but got {content}.") def _parse_score_content( role: str, data: ScoreData, mm_tracker: BaseMultiModalItemTracker, ) -> list[ConversationMessage]: parts: Iterable[ChatCompletionContentPartParam] if isinstance(data, str): parts = [ChatCompletionContentPartTextParam(type="text", text=data)] else: parts = cast(Iterable[ChatCompletionContentPartParam], data) mm_parser = mm_tracker.create_parser() parse_res = parse_chat_message_content_parts( role=role, parts=parts, mm_tracker=mm_tracker, wrap_dicts=False, interleave_strings=False, multimodal_content_part_separator="", ) if parse_res: return parse_res mm_placeholder_storage = mm_parser.mm_placeholder_storage() if ( len(mm_placeholder_storage) != 1 or len(next(iter(mm_placeholder_storage.values()))) != 1 ): raise ValueError("Only one multi-modal item is supported") return next(iter(mm_placeholder_storage.values()))[0] def parse_score_data_single( data: ScoreData, role: str, model_config: ModelConfig, ) -> tuple[str, MultiModalDataDict | None, MultiModalUUIDDict | None]: """Parse **one** ScoreData into a text prompt and its own multi-modal data. Unlike :func:`parse_score_data`, each call creates an **independent** :class:`MultiModalItemTracker` so multi-modal items are kept separate. This is the correct behaviour for late-interaction scoring, where query and document are encoded independently. """ mm_tracker = MultiModalItemTracker(model_config) content = _parse_score_content(role, data, mm_tracker) prompt = _ensure_str(content) mm_items, mm_uuids = mm_tracker.resolve_items() return prompt, mm_items, mm_uuids def parse_score_data( data_1: ScoreData, data_2: ScoreData, model_config: ModelConfig, ) -> tuple[str, str, MultiModalDataDict | None, MultiModalUUIDDict | None]: """Parse a query-document pair into text prompts and shared multi-modal data. Uses a **single** :class:`MultiModalItemTracker` so that multi-modal items from both inputs are merged into one ``mm_data`` dict. This is the correct behaviour for cross-encoder scoring, where query and document are concatenated into a single model prompt. """ mm_tracker = MultiModalItemTracker(model_config) content_1 = _parse_score_content("query", data_1, mm_tracker) content_2 = _parse_score_content("document", data_2, mm_tracker) prompt_1 = _ensure_str(content_1) prompt_2 = _ensure_str(content_2) mm_items, mm_uuids = mm_tracker.resolve_items() return prompt_1, prompt_2, mm_items, mm_uuids def compress_token_type_ids(token_type_ids: list[int]) -> tuple[int, int]: """Return the half-open range containing token type 1. Right padding uses the tokenizer's padding token type, which is commonly zero. Therefore, a padded query-document pair can contain zeros followed by ones followed by trailing zeros. """ first_one = len(token_type_ids) first_zero_after_one = len(token_type_ids) err_msg = ( "Token type ids are expected to be a sequence" " of zeros followed by ones and optional trailing zeros" ) for i, type_id in enumerate(token_type_ids): if type_id not in (0, 1): raise ValueError(err_msg) if type_id == 1: if first_zero_after_one < len(token_type_ids): raise ValueError(err_msg) if first_one == len(token_type_ids): first_one = i elif first_one < len(token_type_ids) and first_zero_after_one == len( token_type_ids ): first_zero_after_one = i return first_one, first_zero_after_one