# 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]) -> int: """Return position of the first 1 or the length of the list if not found. """ first_one = len(token_type_ids) err_msg = ( "Token type ids are expected to be a sequence" " of zeros followed by a sequence of ones" ) for i, type_id in enumerate(token_type_ids): if type_id == 0 and first_one < i: raise ValueError(err_msg) elif type_id == 1 and first_one > i: first_one = i elif type_id > 1: raise ValueError(err_msg) return first_one