Skip to content

OpenAI-Compatible APIs

OpenAI-Compatible API Wrappers

MTEB provides wrappers for connecting to any OpenAI-compatible API server via HTTP for embedding, reranking, and ColBERT-style multi-vector retrieval tasks. These wrappers work with:

  • vLLM servers
  • OpenAI APIs
  • Any other server implementing the OpenAI-compatible /v1/embeddings or /v1/rerank endpoints

This is useful for:

  • Benchmarking remote or production API servers
  • Reusing running server instances across multiple benchmark runs
  • Avoiding repeated model loading overhead
  • Using hosted embedding and reranking APIs

CLI support

The MTEB CLI does not currently support OpenAI-compatible API wrappers. Use the Python API directly as shown in the examples below.

Usage

Note

For vLLM, start a server with:

  • Embedding: vllm serve <model-name> --runner pooling --port 8000
  • Reranking: vllm serve <reranker-model> --runner pooling --port 8001
  • Token-level (ColBERT-style): vllm serve <model-name> --runner pooling --pooler-config.task token_embed --port 8002
import mteb
from mteb.models import OpenAIAPIEncodeWrapper

# Connect to a vLLM server
encoder = OpenAIAPIEncodeWrapper(
    endpoint_url="http://localhost:8000",
    model_name="BAAI/bge-small-en-v1.5",
)

# Or use OpenAI's API
encoder = OpenAIAPIEncodeWrapper(
    endpoint_url="https://api.openai.com/v1",
    model_name="text-embedding-3-small",
    api_key="sk-...",
)

# Evaluate on MTEB tasks
results = mteb.evaluate(
    encoder,
    mteb.get_task("STS12"),
)
print(results)
import mteb
from mteb.models import OpenAIAPIRerankWrapper

# Connect to a vLLM reranking server
reranker = OpenAIAPIRerankWrapper(
    endpoint_url="http://localhost:8001",
    model_name="BAAI/bge-reranker-v2-m3",
)

# Evaluate on MTEB reranking tasks
results = mteb.evaluate(
    reranker,
    mteb.get_task("AskUbuntuDupQuestions"),
)
print(results)
import mteb
from mteb.models import OpenAIAPITokenEmbedWrapper

# Connect to a vLLM server serving a late-interaction (ColBERT-style)
# model. Unlike the two wrappers above, this returns a per-token
# multi-vector embedding for each input instead of one fixed-size
# vector, and scores retrieval candidates with MaxSim rather than
# cosine/dot similarity.
model = OpenAIAPITokenEmbedWrapper(
    endpoint_url="http://localhost:8002",
    model_name="BAAI/bge-m3",
    modalities=["text"],
    # BAAI/bge-m3 has no chat template, so pure-text requests must use
    # the plain `input` field rather than `messages` (see "Multimodal
    # inputs" below).
    use_chat_template=False,
)

# Evaluate on an MTEB retrieval task
results = mteb.evaluate(
    model,
    mteb.get_task("NanoSciFactRetrieval"),
)
print(results)

Multimodal inputs

All three wrappers accept image, audio, and video content alongside text, by sending it to vLLM's Chat Embeddings/Pooling APIs (a messages field, following the vLLM pooling examples) or, for reranking, as {"content": [...]} blocks on /v1/rerank. This requires a vLLM server started with a model that actually supports that modality — for example:

# Multimodal embeddings
vllm serve Qwen/Qwen3-VL-Embedding-2B --runner pooling --max-model-len 8192

# Multimodal reranking (image + video)
vllm serve Qwen/Qwen3-VL-Reranker-2B --runner pooling --max-model-len 4096 \
    --hf_overrides '{"architectures": ["Qwen3VLForSequenceClassification"],"classifier_from_token": ["no", "yes"],"is_original_qwen3_reranker": true}' \
    --chat-template examples/pooling/score/template/qwen3_vl_reranker.jinja

# Multimodal ColBERT-style (image + text)
vllm serve TomoroAI/tomoro-colqwen3-embed-4b --max-model-len 4096
import mteb
from mteb.models import OpenAIAPIEncodeWrapper

encoder = OpenAIAPIEncodeWrapper(
    endpoint_url="http://localhost:8000",
    model_name="Qwen/Qwen3-VL-Embedding-2B",
    modalities=["text", "image"],
)

results = mteb.evaluate(
    encoder,
    mteb.get_task("VisRAGRetArxivQA"),
)
print(results)

OpenAIAPIRerankWrapper supports image and video, but not audio: vLLM's rerank/score content-part schema has no audio variant. OpenAIAPIEncodeWrapper and OpenAIAPITokenEmbedWrapper support all four modalities (text, image, audio, video). Video is re-encoded from decoded frames via torchcodec (pip install mteb[video]); resampling video/audio uses mteb.models.modality_collators.VideoCollator/AudioCollator, and can be tuned via fps, max_frames, num_frames, target_sampling_rate, and max_samples constructor arguments.

use_chat_template

Image/audio/video content is always sent via messages, which vLLM renders through the model's chat template. Non-chat text-encoder models — e.g. BAAI/bge-small-en-v1.5, BAAI/bge-m3 — don't define one and will reject messages requests with a 400 error ("...default chat template is no longer allowed...").

  • OpenAIAPITokenEmbedWrapper defaults to use_chat_template=True (every request, including pure text, goes through messages), since it's vLLM-only.
  • OpenAIAPIEncodeWrapper also defaults to use_chat_template=True, but is commonly pointed at the real OpenAI API or non-chat vLLM models — neither supports messages for embeddings — so set use_chat_template=False for those; pure-text requests then use the plain input field instead.
  • OpenAIAPIRerankWrapper has no such flag: text-only rerank/score requests never use messages in the first place (they use the query/documents string fields), so this only matters for the two wrappers above.

Live testing scripts

scripts/serve_vllm_models.sh and scripts/test_openai_wrappers_live.py in the MTEB repository are ready-to-run companions covering all three wrappers, text-only and multimodal, against real small MTEB tasks:

# terminal 1: start a server for one scenario
scripts/serve_vllm_models.sh text-token-embed

# terminal 2: run the matching scenario
python scripts/test_openai_wrappers_live.py text-token-embed

Run scripts/serve_vllm_models.sh --help for the full list of scenarios (text-embed, multimodal-embed, text-rerank, multimodal-rerank, text-token-embed, multimodal-token-embed).

API Reference

mteb.models.openai_wrappers.OpenAIAPIEncodeWrapper

Bases: OpenAIBaseWrapper, AbsEncoder

OpenAI-compatible API wrapper for MTEB embedding benchmarks.

This wrapper communicates with embedding models served via OpenAI-compatible HTTP APIs using the /v1/embeddings endpoint. When a batch contains image, audio, or video content, it switches to vLLM's Chat Embeddings API (a messages field on the same endpoint) to embed that content together with text, following https://docs.vllm.ai/en/latest/examples/pooling/embed/. This requires a vLLM server started with a multimodal pooling model and, for some models, a matching --chat-template (see vLLM's vision_embedding_online.py example for per-model server flags). Audio is sent as WAV, video as MP4 (re-encoded from decoded frames via torchcodec).

messages is rendered through the model's chat template, so — like OpenAIAPITokenEmbedWrapper — it only works with chat-template-capable (typically VLM-based) models; non-chat text encoders reject it with a 400 ("...default chat template is no longer allowed..."). By default (use_chat_template=True), all batches, including pure text, are sent through messages. Note that messages is a vLLM-only extension — the real OpenAI API and other OpenAI-compatible servers don't support it for embeddings at all — and non-chat text-encoder vLLM models (e.g. BAAI/bge-small-en-v1.5 without a chat template) will reject it; set use_chat_template=False for those, which sends pure-text batches via the plain input field instead (image/audio/video content still requires messages and a chat-capable model regardless of this flag).

Parameters:

Name Type Description Default
endpoint_url str

URL of the OpenAI-compatible server

required
model_name str

Name of the model to use

required
api_key str | None

Optional API key for authentication

None
prompt_dict dict[str, str] | None

A dictionary mapping task names to prompt strings

None
use_instructions bool

Whether to use instructions from the prompt_dict

False
instruction_template str | Callable[[str, PromptType | None], str] | None

A template or callable to format instructions

None
apply_instruction_to_documents bool

Whether to apply instructions to documents (passages). Default True.

True
timeout int

Request timeout in seconds (default: 300)

300
max_retries int

Maximum number of retries for failed requests (default: 3)

3
verify_ssl bool

Whether to verify SSL certificates (default: True)

True
max_length int | None

Maximum sequence length for truncation. If None, auto-detected from model metadata.

None
modalities list[Modalities] | None

Modalities supported by the served model. Defaults to ["text", "image", "audio", "video"]; pass a subset (e.g. ["text"]) if the served model is text-only.

None
use_chat_template bool

Whether to send text-only batches through the Chat Embeddings API (messages) like image/audio/video batches. Default True (see class docstring); set False for the real OpenAI API, other non-vLLM OpenAI-compatible servers, or vLLM models with no chat template.

True
fps float | None

Target frames per second for video downsampling (see VideoCollator).

None
max_frames int | None

Safety cap on the number of frames sampled per video.

None
num_frames int | None

If set, sample exactly this many frames per video (fixed-sample mode) instead of FPS-based sampling.

None
target_sampling_rate int | None

Sampling rate (Hz) audio is resampled to before being sent to the server. Defaults to 16000.

None
max_samples int | None

Maximum number of audio samples to keep per item.

None
Source code in mteb/models/openai_wrappers.py
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
class OpenAIAPIEncodeWrapper(OpenAIBaseWrapper, AbsEncoder):
    """OpenAI-compatible API wrapper for MTEB embedding benchmarks.

    This wrapper communicates with embedding models served via OpenAI-compatible
    HTTP APIs using the /v1/embeddings endpoint. When a batch contains image,
    audio, or video content, it switches to vLLM's Chat Embeddings API (a
    `messages` field on the same endpoint) to embed that content together with
    text, following https://docs.vllm.ai/en/latest/examples/pooling/embed/.
    This requires a vLLM server started with a multimodal pooling model and,
    for some models, a matching `--chat-template` (see vLLM's
    `vision_embedding_online.py` example for per-model server flags). Audio is
    sent as WAV, video as MP4 (re-encoded from decoded frames via
    `torchcodec`).

    `messages` is rendered through the model's chat template, so — like
    `OpenAIAPITokenEmbedWrapper` — it only works with chat-template-capable
    (typically VLM-based) models; non-chat text encoders reject it with a 400
    ("...default chat template is no longer allowed..."). By default
    (`use_chat_template=True`), all batches, including pure text, are sent
    through `messages`. Note that `messages` is a vLLM-only extension — the
    real OpenAI API and other OpenAI-compatible servers don't support it for
    embeddings at all — and non-chat text-encoder vLLM models (e.g.
    `BAAI/bge-small-en-v1.5` without a chat template) will reject it; set
    `use_chat_template=False` for those, which sends pure-text batches via
    the plain `input` field instead (image/audio/video content still
    requires `messages` and a chat-capable model regardless of this flag).

    Args:
        endpoint_url: URL of the OpenAI-compatible server
        model_name: Name of the model to use
        api_key: Optional API key for authentication
        prompt_dict: A dictionary mapping task names to prompt strings
        use_instructions: Whether to use instructions from the prompt_dict
        instruction_template: A template or callable to format instructions
        apply_instruction_to_documents: Whether to apply instructions to
            documents (passages). Default True.
        timeout: Request timeout in seconds (default: 300)
        max_retries: Maximum number of retries for failed requests (default: 3)
        verify_ssl: Whether to verify SSL certificates (default: True)
        max_length: Maximum sequence length for truncation. If None,
            auto-detected from model metadata.
        modalities: Modalities supported by the served model. Defaults to
            `["text", "image", "audio", "video"]`; pass a subset (e.g.
            `["text"]`) if the served model is text-only.
        use_chat_template: Whether to send text-only batches through the
            Chat Embeddings API (`messages`) like image/audio/video batches.
            Default True (see class docstring); set False for the real
            OpenAI API, other non-vLLM OpenAI-compatible servers, or vLLM
            models with no chat template.
        fps: Target frames per second for video downsampling (see
            `VideoCollator`).
        max_frames: Safety cap on the number of frames sampled per video.
        num_frames: If set, sample exactly this many frames per video
            (fixed-sample mode) instead of FPS-based sampling.
        target_sampling_rate: Sampling rate (Hz) audio is resampled to before
            being sent to the server. Defaults to 16000.
        max_samples: Maximum number of audio samples to keep per item.
    """

    def __init__(  # noqa: PLR0913
        self,
        endpoint_url: str,
        model_name: str,
        api_key: str | None = None,
        *,
        prompt_dict: dict[str, str] | None = None,
        use_instructions: bool = False,
        instruction_template: (
            str | Callable[[str, PromptType | None], str] | None
        ) = None,
        apply_instruction_to_documents: bool = True,
        timeout: int = 300,
        max_retries: int = 3,
        verify_ssl: bool = True,
        max_length: int | None = None,
        modalities: list[Modalities] | None = None,
        use_chat_template: bool = True,
        fps: float | None = None,
        max_frames: int | None = None,
        num_frames: int | None = None,
        target_sampling_rate: int | None = None,
        max_samples: int | None = None,
    ):
        """Initialize the OpenAI API wrapper for embeddings."""
        # Initialize base class
        super().__init__(
            endpoint_url=endpoint_url,
            model_name=model_name,
            api_key=api_key,
            timeout=timeout,
            max_retries=max_retries,
            verify_ssl=verify_ssl,
            modalities=modalities or ["text", "image", "audio", "video"],
            fps=fps,
            max_frames=max_frames,
            num_frames=num_frames,
            target_sampling_rate=target_sampling_rate,
            max_samples=max_samples,
        )

        # Embedding-specific attributes
        self.prompts_dict = prompt_dict
        self.use_instructions = use_instructions
        self.instruction_template = instruction_template
        self.apply_instruction_to_passages = apply_instruction_to_documents
        self.max_length = max_length
        self.use_chat_template = use_chat_template

        if use_instructions and instruction_template is None:
            raise ValueError(
                "To use instructions, an instruction_template must be provided. "
                "For example, `Instruction: {instruction}`"
            )

        if (
            isinstance(instruction_template, str)
            and "{instruction}" not in instruction_template
        ):
            raise ValueError(
                "Instruction template must contain the string '{instruction}'."
            )

        # Verify server and detect max_length
        self._verify_server()
        self._detect_max_length()

    def _detect_max_length(self) -> None:
        """Auto-detect max_length from model metadata if not provided."""
        if self.max_length is not None:
            return

        try:
            response = requests.get(
                f"{self.endpoint_url}/v1/models",
                timeout=10,
                verify=self.verify_ssl,
            )
            response.raise_for_status()
            models = response.json()

            for model in models.get("data", []):
                if model["id"] != self.model_name:
                    continue
                # vLLM returns max_model_len in model metadata
                max_model_len = model.get("max_model_len")
                if max_model_len:
                    self.max_length = max_model_len
                    logger.info(
                        f"Auto-detected max_length={self.max_length} from model "
                        f"metadata"
                    )
                break
        except Exception as e:
            # If we can't detect max_length, that's fine - will use model default
            logger.debug(f"Could not auto-detect max_length: {e}")

    def _get_embeddings(self, texts: list[str]) -> Array:
        """Get embeddings from the server via OpenAI-compatible API.

        Args:
            texts: List of texts to embed

        Returns:
            Array of embeddings
        """
        payload: dict[str, Any] = {
            "model": self.model_name,
            "input": texts,
            "encoding_format": "float",
        }

        # Add truncation parameter if max_length is set
        # Note: vLLM supports truncate_prompt_tokens, OpenAI uses different params
        if self.max_length:
            payload["truncate_prompt_tokens"] = self.max_length

        result = self._make_request("/v1/embeddings", payload)

        # Extract embeddings, keyed by index since the server may return
        # them out of order.
        embeddings_by_index = {
            item["index"]: item["embedding"] for item in result["data"]
        }

        # Validate all embeddings were returned
        missing_indices = [i for i in range(len(texts)) if i not in embeddings_by_index]
        if missing_indices:
            raise RuntimeError(
                f"Incomplete embeddings from server: expected {len(texts)} "
                f"embeddings, got {len(texts) - len(missing_indices)}. "
                f"Missing indices: {missing_indices[:10]}"
            )

        # Convert to numpy array, restoring the original order
        embeddings = [embeddings_by_index[i] for i in range(len(texts))]
        return np.array(embeddings, dtype=np.float32)

    def _get_multimodal_embeddings(
        self, items: list[MultimodalItem], prompt: str
    ) -> Array:
        """Get embeddings for multimodal items via vLLM's Chat Embeddings API.

        Each item is sent as a single-turn chat message whose content is a
        list of `image_url`/`audio_url`/`video_url`/`text` parts, matching
        the request shape used in
        https://docs.vllm.ai/en/latest/examples/pooling/embed/ (e.g.
        `vision_embedding_online.py`).

        Args:
            items: List of (text, image, audio, video) tuples, at least one
                field of which is set per item.
            prompt: Instruction/prompt prefix to prepend to each item's text.

        Returns:
            Array of embeddings
        """
        messages_batch = []
        for text, image, audio, video in items:
            combined_text = prompt + text if text else prompt
            content = _build_content_parts(
                combined_text, image, audio, video, fps=self.fps
            )
            messages_batch.append([{"role": "user", "content": content}])

        payload: dict[str, Any] = {
            "model": self.model_name,
            "messages": messages_batch,
            "encoding_format": "float",
        }

        if self.max_length:
            payload["truncate_prompt_tokens"] = self.max_length

        result = self._make_request("/v1/embeddings", payload)

        embeddings_by_index = {
            item["index"]: item["embedding"] for item in result["data"]
        }

        missing_indices = [i for i in range(len(items)) if i not in embeddings_by_index]
        if missing_indices:
            raise RuntimeError(
                f"Incomplete embeddings from server: expected {len(items)} "
                f"embeddings, got {len(items) - len(missing_indices)}. "
                f"Missing indices: {missing_indices[:10]}"
            )

        embeddings = [embeddings_by_index[i] for i in range(len(items))]
        return np.array(embeddings, dtype=np.float32)

    def _encode_multimodal(
        self,
        items: list[MultimodalItem],
        *,
        prompt: str,
        batch_size: int,
        show_progress_bar: bool,
    ) -> Array:
        """Encode multimodal items in batches via the Chat Embeddings API."""
        if not items:
            return np.array([], dtype=np.float32).reshape(0, 0)

        all_embeddings = []
        for i in tqdm(
            range(0, len(items), batch_size),
            desc="Encoding multimodal batches",
            disable=not show_progress_bar,
        ):
            batch_items = items[i : i + batch_size]
            batch_embeddings = self._get_multimodal_embeddings(batch_items, prompt)
            all_embeddings.append(batch_embeddings)

        return np.vstack(all_embeddings)

    def encode(
        self,
        inputs: DataLoader[BatchedInput],
        *,
        task_metadata: TaskMetadata,
        hf_split: str,
        hf_subset: str,
        prompt_type: PromptType | None = None,
        batch_size: int = 32,
        show_progress_bar: bool = True,
        **kwargs: Any,
    ) -> Array:
        """Encode the given sentences using the OpenAI-compatible API.

        Args:
            inputs: The sentences to encode
            task_metadata: The metadata of the task
            hf_split: Split of current task
            hf_subset: Subset of current task
            prompt_type: The type of prompt (query or passage)
            batch_size: Batch size for processing (default: 32)
            show_progress_bar: Whether to show progress bar (default: True)
            **kwargs: Additional arguments (precision, etc.)

        Returns:
            The encoded sentences as embeddings
        """
        # Determine prompt to use
        prompt = ""
        if self.use_instructions and self.prompts_dict is not None:
            prompt = self.get_task_instruction(task_metadata, prompt_type)
        elif self.prompts_dict is not None:
            prompt_name = self.get_prompt_name(task_metadata, prompt_type)
            if prompt_name is not None:
                prompt = self.prompts_dict.get(prompt_name, "")

        # Skip instruction for documents if configured
        if (
            self.use_instructions
            and self.apply_instruction_to_passages is False
            and prompt_type == PromptType.document
        ):
            logger.info(f"No instruction used, because prompt type = {prompt_type}")
            prompt = ""
        elif prompt:
            logger.info(
                f"Using instruction: '{prompt}' for task: '{task_metadata.name}' "
                f"prompt type: '{prompt_type}'"
            )

        # Resolve raw "video"/"audio" dataset columns, then collect
        # (text, image, audio, video) tuples from batches, preserving order.
        self._configure_collate_fn(inputs)
        items = _collect_multimodal_items(inputs)

        if self.use_chat_template or any(
            image is not None or audio is not None or video is not None
            for _, image, audio, video in items
        ):
            return self._encode_multimodal(
                items,
                prompt=prompt,
                batch_size=batch_size,
                show_progress_bar=show_progress_bar,
            )

        texts = [prompt + (text or "") for text, _, _, _ in items]

        # Handle empty input
        if not texts:
            return np.array([], dtype=np.float32).reshape(0, 0)

        # Process in batches to avoid overwhelming the server
        all_embeddings = []

        for i in tqdm(
            range(0, len(texts), batch_size),
            desc="Encoding batches",
            disable=not show_progress_bar,
        ):
            batch_texts = texts[i : i + batch_size]
            batch_embeddings = self._get_embeddings(batch_texts)
            all_embeddings.append(batch_embeddings)

        # Concatenate all batches
        embeddings = np.vstack(all_embeddings)
        return embeddings

__init__(endpoint_url, model_name, api_key=None, *, prompt_dict=None, use_instructions=False, instruction_template=None, apply_instruction_to_documents=True, timeout=300, max_retries=3, verify_ssl=True, max_length=None, modalities=None, use_chat_template=True, fps=None, max_frames=None, num_frames=None, target_sampling_rate=None, max_samples=None)

Initialize the OpenAI API wrapper for embeddings.

Source code in mteb/models/openai_wrappers.py
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
def __init__(  # noqa: PLR0913
    self,
    endpoint_url: str,
    model_name: str,
    api_key: str | None = None,
    *,
    prompt_dict: dict[str, str] | None = None,
    use_instructions: bool = False,
    instruction_template: (
        str | Callable[[str, PromptType | None], str] | None
    ) = None,
    apply_instruction_to_documents: bool = True,
    timeout: int = 300,
    max_retries: int = 3,
    verify_ssl: bool = True,
    max_length: int | None = None,
    modalities: list[Modalities] | None = None,
    use_chat_template: bool = True,
    fps: float | None = None,
    max_frames: int | None = None,
    num_frames: int | None = None,
    target_sampling_rate: int | None = None,
    max_samples: int | None = None,
):
    """Initialize the OpenAI API wrapper for embeddings."""
    # Initialize base class
    super().__init__(
        endpoint_url=endpoint_url,
        model_name=model_name,
        api_key=api_key,
        timeout=timeout,
        max_retries=max_retries,
        verify_ssl=verify_ssl,
        modalities=modalities or ["text", "image", "audio", "video"],
        fps=fps,
        max_frames=max_frames,
        num_frames=num_frames,
        target_sampling_rate=target_sampling_rate,
        max_samples=max_samples,
    )

    # Embedding-specific attributes
    self.prompts_dict = prompt_dict
    self.use_instructions = use_instructions
    self.instruction_template = instruction_template
    self.apply_instruction_to_passages = apply_instruction_to_documents
    self.max_length = max_length
    self.use_chat_template = use_chat_template

    if use_instructions and instruction_template is None:
        raise ValueError(
            "To use instructions, an instruction_template must be provided. "
            "For example, `Instruction: {instruction}`"
        )

    if (
        isinstance(instruction_template, str)
        and "{instruction}" not in instruction_template
    ):
        raise ValueError(
            "Instruction template must contain the string '{instruction}'."
        )

    # Verify server and detect max_length
    self._verify_server()
    self._detect_max_length()

encode(inputs, *, task_metadata, hf_split, hf_subset, prompt_type=None, batch_size=32, show_progress_bar=True, **kwargs)

Encode the given sentences using the OpenAI-compatible API.

Parameters:

Name Type Description Default
inputs DataLoader[BatchedInput]

The sentences to encode

required
task_metadata TaskMetadata

The metadata of the task

required
hf_split str

Split of current task

required
hf_subset str

Subset of current task

required
prompt_type PromptType | None

The type of prompt (query or passage)

None
batch_size int

Batch size for processing (default: 32)

32
show_progress_bar bool

Whether to show progress bar (default: True)

True
**kwargs Any

Additional arguments (precision, etc.)

{}

Returns:

Type Description
Array

The encoded sentences as embeddings

Source code in mteb/models/openai_wrappers.py
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
def encode(
    self,
    inputs: DataLoader[BatchedInput],
    *,
    task_metadata: TaskMetadata,
    hf_split: str,
    hf_subset: str,
    prompt_type: PromptType | None = None,
    batch_size: int = 32,
    show_progress_bar: bool = True,
    **kwargs: Any,
) -> Array:
    """Encode the given sentences using the OpenAI-compatible API.

    Args:
        inputs: The sentences to encode
        task_metadata: The metadata of the task
        hf_split: Split of current task
        hf_subset: Subset of current task
        prompt_type: The type of prompt (query or passage)
        batch_size: Batch size for processing (default: 32)
        show_progress_bar: Whether to show progress bar (default: True)
        **kwargs: Additional arguments (precision, etc.)

    Returns:
        The encoded sentences as embeddings
    """
    # Determine prompt to use
    prompt = ""
    if self.use_instructions and self.prompts_dict is not None:
        prompt = self.get_task_instruction(task_metadata, prompt_type)
    elif self.prompts_dict is not None:
        prompt_name = self.get_prompt_name(task_metadata, prompt_type)
        if prompt_name is not None:
            prompt = self.prompts_dict.get(prompt_name, "")

    # Skip instruction for documents if configured
    if (
        self.use_instructions
        and self.apply_instruction_to_passages is False
        and prompt_type == PromptType.document
    ):
        logger.info(f"No instruction used, because prompt type = {prompt_type}")
        prompt = ""
    elif prompt:
        logger.info(
            f"Using instruction: '{prompt}' for task: '{task_metadata.name}' "
            f"prompt type: '{prompt_type}'"
        )

    # Resolve raw "video"/"audio" dataset columns, then collect
    # (text, image, audio, video) tuples from batches, preserving order.
    self._configure_collate_fn(inputs)
    items = _collect_multimodal_items(inputs)

    if self.use_chat_template or any(
        image is not None or audio is not None or video is not None
        for _, image, audio, video in items
    ):
        return self._encode_multimodal(
            items,
            prompt=prompt,
            batch_size=batch_size,
            show_progress_bar=show_progress_bar,
        )

    texts = [prompt + (text or "") for text, _, _, _ in items]

    # Handle empty input
    if not texts:
        return np.array([], dtype=np.float32).reshape(0, 0)

    # Process in batches to avoid overwhelming the server
    all_embeddings = []

    for i in tqdm(
        range(0, len(texts), batch_size),
        desc="Encoding batches",
        disable=not show_progress_bar,
    ):
        batch_texts = texts[i : i + batch_size]
        batch_embeddings = self._get_embeddings(batch_texts)
        all_embeddings.append(batch_embeddings)

    # Concatenate all batches
    embeddings = np.vstack(all_embeddings)
    return embeddings

mteb.models.openai_wrappers.OpenAIAPIRerankWrapper

Bases: OpenAIBaseWrapper

OpenAI-compatible API wrapper for MTEB reranking benchmarks.

This wrapper communicates with reranking models served via OpenAI-compatible HTTP APIs using the /v1/rerank endpoint. Queries or documents that carry an image or video are sent as {"content": [...]} blocks containing image_url/video_url/text parts, matching the multimodal rerank request shape documented in https://docs.vllm.ai/en/latest/examples/pooling/score/ (e.g. vision_rerank_api_online.py). This requires a vLLM server started with a vision-language pooling/reranker model. Audio is not supported here: vLLM's rerank/score content-part schema (ScoreContentPartParam) has no audio variant, unlike the Chat Embeddings/Pooling APIs used by OpenAIAPIEncodeWrapper/OpenAIAPITokenEmbedWrapper.

Parameters:

Name Type Description Default
endpoint_url str

URL of the OpenAI-compatible server

required
model_name str

Name of the reranking model to use

required
api_key str | None

Optional API key for authentication

None
timeout int

Request timeout in seconds (default: 300)

300
max_retries int

Maximum number of retries for failed requests (default: 3)

3
verify_ssl bool

Whether to verify SSL certificates (default: True)

True
modalities list[Modalities] | None

Modalities supported by the served model. Defaults to ["text", "image", "video"] (no audio; see above); pass a subset (e.g. ["text"]) if the served model is text-only.

None
fps float | None

Target frames per second for video downsampling (see VideoCollator); also used as the encoded output frame rate.

None
max_frames int | None

Safety cap on the number of frames sampled per video.

None
num_frames int | None

If set, sample exactly this many frames per video (fixed-sample mode) instead of FPS-based sampling.

None
Source code in mteb/models/openai_wrappers.py
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
class OpenAIAPIRerankWrapper(OpenAIBaseWrapper):
    """OpenAI-compatible API wrapper for MTEB reranking benchmarks.

    This wrapper communicates with reranking models served via OpenAI-compatible
    HTTP APIs using the /v1/rerank endpoint. Queries or documents that carry
    an image or video are sent as `{"content": [...]}` blocks containing
    `image_url`/`video_url`/`text` parts, matching the multimodal rerank
    request shape documented in
    https://docs.vllm.ai/en/latest/examples/pooling/score/ (e.g.
    `vision_rerank_api_online.py`). This requires a vLLM server started with a
    vision-language pooling/reranker model. Audio is *not* supported here:
    vLLM's rerank/score content-part schema (`ScoreContentPartParam`) has no
    audio variant, unlike the Chat Embeddings/Pooling APIs used by
    `OpenAIAPIEncodeWrapper`/`OpenAIAPITokenEmbedWrapper`.

    Args:
        endpoint_url: URL of the OpenAI-compatible server
        model_name: Name of the reranking model to use
        api_key: Optional API key for authentication
        timeout: Request timeout in seconds (default: 300)
        max_retries: Maximum number of retries for failed requests (default: 3)
        verify_ssl: Whether to verify SSL certificates (default: True)
        modalities: Modalities supported by the served model. Defaults to
            `["text", "image", "video"]` (no audio; see above); pass a
            subset (e.g. `["text"]`) if the served model is text-only.
        fps: Target frames per second for video downsampling (see
            `VideoCollator`); also used as the encoded output frame rate.
        max_frames: Safety cap on the number of frames sampled per video.
        num_frames: If set, sample exactly this many frames per video
            (fixed-sample mode) instead of FPS-based sampling.
    """

    def __init__(  # noqa: PLR0913
        self,
        endpoint_url: str,
        model_name: str,
        api_key: str | None = None,
        *,
        timeout: int = 300,
        max_retries: int = 3,
        verify_ssl: bool = True,
        modalities: list[Modalities] | None = None,
        fps: float | None = None,
        max_frames: int | None = None,
        num_frames: int | None = None,
    ):
        """Initialize the OpenAI Rerank wrapper."""
        # Initialize base class
        super().__init__(
            endpoint_url=endpoint_url,
            model_name=model_name,
            api_key=api_key,
            timeout=timeout,
            max_retries=max_retries,
            verify_ssl=verify_ssl,
            modalities=modalities or ["text", "image", "video"],
            fps=fps,
            max_frames=max_frames,
            num_frames=num_frames,
        )

        # Verify server is reachable
        self._verify_server()

    def _rerank(
        self,
        query: str | dict[str, Any],
        documents: list[str | dict[str, Any]],
        top_k: int | None = None,
    ) -> Array:
        """Get relevance scores for query-document pairs.

        Args:
            query: The query. Either a plain string, or a
                `{"content": [...]}` dict for multimodal (image and/or text)
                queries, built by `_to_score_input`.
            documents: List of documents to rank. Each document is either a
                plain string or a `{"content": [...]}` multimodal dict.
            top_k: Optional number of top results to return

        Returns:
            Array of relevance scores in the same order as input documents
        """
        payload: dict[str, Any] = {
            "model": self.model_name,
            "query": query,
            "documents": documents,
        }

        if top_k is not None:
            payload["top_n"] = top_k

        result = self._make_request("/v1/rerank", payload)

        # Extract scores in original document order
        # The API returns documents sorted by relevance, so we need to reorder
        scores = [0.0] * len(documents)
        for item in result["results"]:
            original_index = item["index"]
            scores[original_index] = item["relevance_score"]

        return np.array(scores, dtype=np.float32)

    @staticmethod
    def _to_score_input(
        text: str | None,
        image: Image.Image | None,
        video: torch.Tensor | None,
        *,
        fps: float | None,
    ) -> str | dict[str, Any]:
        """Build a vLLM rerank/score input.

        Returns a plain string, or a multimodal `{"content": [...]}` dict
        when an image and/or video is present.
        """
        if image is None and video is None:
            return text or ""

        content = _build_content_parts(text, image, None, video, fps=fps)
        return {"content": content}

    def predict(
        self,
        inputs1: DataLoader[BatchedInput],
        inputs2: DataLoader[BatchedInput],
        *,
        task_metadata: TaskMetadata,
        hf_split: str,
        hf_subset: str,
        prompt_type: PromptType | None = None,
        batch_size: int = 32,
        show_progress_bar: bool = True,
        top_k: int | None = None,
        **kwargs: Any,
    ) -> Array:
        """Predict relevance scores for query-document pairs.

        Args:
            inputs1: Queries (first input)
            inputs2: Documents (second input)
            task_metadata: The metadata of the task
            hf_split: Split of current task
            hf_subset: Subset of current task
            prompt_type: The type of prompt
            batch_size: Batch size for processing (default: 32)
            show_progress_bar: Whether to show progress bar (default: True)
            top_k: Optional number of top results to return per query
            **kwargs: Additional arguments

        Returns:
            Relevance scores for each query-document pair
        """
        # Collect all queries and documents (text and, optionally,
        # images/video); resolve raw "video" columns first.
        self._configure_collate_fn(inputs1)
        self._configure_collate_fn(inputs2)
        query_items = _collect_multimodal_items(inputs1)
        document_items = _collect_multimodal_items(inputs2)

        # Handle empty input
        if not query_items and not document_items:
            return np.array([], dtype=np.float32)

        # Expect equal-length queries and documents
        if len(query_items) != len(document_items):
            raise ValueError(
                f"Expected equal number of queries and documents, got "
                f"{len(query_items)} queries and {len(document_items)} documents"
            )

        # Pairwise scoring - one query per document
        all_scores = []
        for i in tqdm(
            range(0, len(query_items), batch_size),
            desc="Reranking batches",
            disable=not show_progress_bar,
        ):
            batch_queries = query_items[i : i + batch_size]
            batch_docs = document_items[i : i + batch_size]

            # Score each pair individually
            batch_scores = []
            for (query_text, query_image, _, query_video), (
                doc_text,
                doc_image,
                _,
                doc_video,
            ) in zip(batch_queries, batch_docs, strict=True):
                query = self._to_score_input(
                    query_text, query_image, query_video, fps=self.fps
                )
                document = self._to_score_input(
                    doc_text, doc_image, doc_video, fps=self.fps
                )
                score = self._rerank(query, [document], top_k)[0]
                batch_scores.append(score)

            all_scores.extend(batch_scores)

        scores = np.array(all_scores, dtype=np.float32)
        return scores

__init__(endpoint_url, model_name, api_key=None, *, timeout=300, max_retries=3, verify_ssl=True, modalities=None, fps=None, max_frames=None, num_frames=None)

Initialize the OpenAI Rerank wrapper.

Source code in mteb/models/openai_wrappers.py
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
def __init__(  # noqa: PLR0913
    self,
    endpoint_url: str,
    model_name: str,
    api_key: str | None = None,
    *,
    timeout: int = 300,
    max_retries: int = 3,
    verify_ssl: bool = True,
    modalities: list[Modalities] | None = None,
    fps: float | None = None,
    max_frames: int | None = None,
    num_frames: int | None = None,
):
    """Initialize the OpenAI Rerank wrapper."""
    # Initialize base class
    super().__init__(
        endpoint_url=endpoint_url,
        model_name=model_name,
        api_key=api_key,
        timeout=timeout,
        max_retries=max_retries,
        verify_ssl=verify_ssl,
        modalities=modalities or ["text", "image", "video"],
        fps=fps,
        max_frames=max_frames,
        num_frames=num_frames,
    )

    # Verify server is reachable
    self._verify_server()

predict(inputs1, inputs2, *, task_metadata, hf_split, hf_subset, prompt_type=None, batch_size=32, show_progress_bar=True, top_k=None, **kwargs)

Predict relevance scores for query-document pairs.

Parameters:

Name Type Description Default
inputs1 DataLoader[BatchedInput]

Queries (first input)

required
inputs2 DataLoader[BatchedInput]

Documents (second input)

required
task_metadata TaskMetadata

The metadata of the task

required
hf_split str

Split of current task

required
hf_subset str

Subset of current task

required
prompt_type PromptType | None

The type of prompt

None
batch_size int

Batch size for processing (default: 32)

32
show_progress_bar bool

Whether to show progress bar (default: True)

True
top_k int | None

Optional number of top results to return per query

None
**kwargs Any

Additional arguments

{}

Returns:

Type Description
Array

Relevance scores for each query-document pair

Source code in mteb/models/openai_wrappers.py
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
def predict(
    self,
    inputs1: DataLoader[BatchedInput],
    inputs2: DataLoader[BatchedInput],
    *,
    task_metadata: TaskMetadata,
    hf_split: str,
    hf_subset: str,
    prompt_type: PromptType | None = None,
    batch_size: int = 32,
    show_progress_bar: bool = True,
    top_k: int | None = None,
    **kwargs: Any,
) -> Array:
    """Predict relevance scores for query-document pairs.

    Args:
        inputs1: Queries (first input)
        inputs2: Documents (second input)
        task_metadata: The metadata of the task
        hf_split: Split of current task
        hf_subset: Subset of current task
        prompt_type: The type of prompt
        batch_size: Batch size for processing (default: 32)
        show_progress_bar: Whether to show progress bar (default: True)
        top_k: Optional number of top results to return per query
        **kwargs: Additional arguments

    Returns:
        Relevance scores for each query-document pair
    """
    # Collect all queries and documents (text and, optionally,
    # images/video); resolve raw "video" columns first.
    self._configure_collate_fn(inputs1)
    self._configure_collate_fn(inputs2)
    query_items = _collect_multimodal_items(inputs1)
    document_items = _collect_multimodal_items(inputs2)

    # Handle empty input
    if not query_items and not document_items:
        return np.array([], dtype=np.float32)

    # Expect equal-length queries and documents
    if len(query_items) != len(document_items):
        raise ValueError(
            f"Expected equal number of queries and documents, got "
            f"{len(query_items)} queries and {len(document_items)} documents"
        )

    # Pairwise scoring - one query per document
    all_scores = []
    for i in tqdm(
        range(0, len(query_items), batch_size),
        desc="Reranking batches",
        disable=not show_progress_bar,
    ):
        batch_queries = query_items[i : i + batch_size]
        batch_docs = document_items[i : i + batch_size]

        # Score each pair individually
        batch_scores = []
        for (query_text, query_image, _, query_video), (
            doc_text,
            doc_image,
            _,
            doc_video,
        ) in zip(batch_queries, batch_docs, strict=True):
            query = self._to_score_input(
                query_text, query_image, query_video, fps=self.fps
            )
            document = self._to_score_input(
                doc_text, doc_image, doc_video, fps=self.fps
            )
            score = self._rerank(query, [document], top_k)[0]
            batch_scores.append(score)

        all_scores.extend(batch_scores)

    scores = np.array(all_scores, dtype=np.float32)
    return scores

mteb.models.openai_wrappers.OpenAIAPITokenEmbedWrapper

Bases: OpenAIBaseWrapper

OpenAI-compatible API wrapper for ColBERT-style multi-vector retrieval models.

Served via vLLM's Pooling API using late (token) interaction.

Unlike OpenAIAPIEncodeWrapper, which returns a single fixed-size vector per input, this wrapper requests per-token embeddings (shape (num_tokens, dim)) from vLLM's /pooling endpoint, following https://docs.vllm.ai/en/latest/examples/pooling/token_embed/. It implements SearchProtocol directly: index() encodes and keeps the corpus' multi-vector embeddings in memory, and search() scores queries against them (or against top_ranked candidates, for reranking tasks) via brute-force MaxSim (late interaction) — no ANN index or extra dependency (e.g. PyLate) is used, so this scales linearly with corpus size rather than using an approximate index.

The server must be started with a pooling model whose pooler task is token_embed, e.g. for a text-only ColBERT model:

vllm serve BAAI/bge-m3 --pooler-config.task token_embed

or, for a multimodal (image + text) late interaction model:

vllm serve TomoroAI/tomoro-colqwen3-embed-4b --max-model-len 4096

Image, audio, and video items are sent one request at a time via the Chat Pooling API (a messages field on /pooling), following colqwen3_token_embed_online.py; unlike /v1/embeddings, vLLM's /pooling endpoint does not support batching multiple chat conversations into a single request. messages is rendered through the model's chat template, so it only works with chat-template-capable (typically VLM-based) pooling models — non-chat text encoders like BAAI/bge-m3 reject it with a 400 ("...default chat template is no longer allowed..."). By default (use_chat_template=True) all items, including pure text, are sent through messages; set use_chat_template=False for text-only models without a chat template, which instead batches text via the plain input field (image/audio/video items still require messages and a chat-capable model regardless of this flag). Audio is sent as WAV, video as MP4 (re-encoded from decoded frames; requires the av package).

Parameters:

Name Type Description Default
endpoint_url str

URL of the OpenAI-compatible server

required
model_name str

Name of the model to use

required
api_key str | None

Optional API key for authentication

None
prompt_dict dict[str, str] | None

A dictionary mapping task names to prompt strings

None
timeout int

Request timeout in seconds (default: 300)

300
max_retries int

Maximum number of retries for failed requests (default: 3)

3
verify_ssl bool

Whether to verify SSL certificates (default: True)

True
modalities list[Modalities] | None

Modalities supported by the served model. Defaults to ["text", "image", "audio", "video"]; pass a subset (e.g. ["text"]) if the served model is text-only.

None
use_chat_template bool

Whether to send text-only items through the Chat Pooling API (messages) like image/audio/video items. Default True. Set False for text-only pooling models that don't define a chat template (e.g. BAAI/bge-m3), so text is instead batched via the plain input field.

True
fps float | None

Target frames per second for video downsampling (see VideoCollator).

None
max_frames int | None

Safety cap on the number of frames sampled per video.

None
num_frames int | None

If set, sample exactly this many frames per video (fixed-sample mode) instead of FPS-based sampling.

None
target_sampling_rate int | None

Sampling rate (Hz) audio is resampled to before being sent to the server. Defaults to 16000.

None
max_samples int | None

Maximum number of audio samples to keep per item.

None
Source code in mteb/models/openai_wrappers.py
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
class OpenAIAPITokenEmbedWrapper(OpenAIBaseWrapper):
    """OpenAI-compatible API wrapper for ColBERT-style multi-vector retrieval models.

    Served via vLLM's Pooling API using late (token) interaction.

    Unlike `OpenAIAPIEncodeWrapper`, which returns a single fixed-size vector
    per input, this wrapper requests per-token embeddings (shape
    `(num_tokens, dim)`) from vLLM's `/pooling` endpoint, following
    https://docs.vllm.ai/en/latest/examples/pooling/token_embed/. It
    implements `SearchProtocol` directly: `index()` encodes and keeps the
    corpus' multi-vector embeddings in memory, and `search()` scores queries
    against them (or against `top_ranked` candidates, for reranking tasks)
    via brute-force MaxSim (late interaction) — no ANN index or extra
    dependency (e.g. PyLate) is used, so this scales linearly with corpus
    size rather than using an approximate index.

    The server must be started with a pooling model whose pooler task is
    `token_embed`, e.g. for a text-only ColBERT model:

        vllm serve BAAI/bge-m3 --pooler-config.task token_embed

    or, for a multimodal (image + text) late interaction model:

        vllm serve TomoroAI/tomoro-colqwen3-embed-4b --max-model-len 4096

    Image, audio, and video items are sent one request at a time via the Chat
    Pooling API (a `messages` field on `/pooling`), following
    `colqwen3_token_embed_online.py`; unlike `/v1/embeddings`, vLLM's
    `/pooling` endpoint does not support batching multiple chat conversations
    into a single request. `messages` is rendered through the model's chat
    template, so it only works with chat-template-capable (typically
    VLM-based) pooling models — non-chat text encoders like `BAAI/bge-m3`
    reject it with a 400 ("...default chat template is no longer
    allowed..."). By default (`use_chat_template=True`) all items, including
    pure text, are sent through `messages`; set `use_chat_template=False` for
    text-only models without a chat template, which instead batches text via
    the plain `input` field (image/audio/video items still require
    `messages` and a chat-capable model regardless of this flag). Audio is
    sent as WAV, video as MP4 (re-encoded from decoded frames; requires the
    `av` package).

    Args:
        endpoint_url: URL of the OpenAI-compatible server
        model_name: Name of the model to use
        api_key: Optional API key for authentication
        prompt_dict: A dictionary mapping task names to prompt strings
        timeout: Request timeout in seconds (default: 300)
        max_retries: Maximum number of retries for failed requests (default: 3)
        verify_ssl: Whether to verify SSL certificates (default: True)
        modalities: Modalities supported by the served model. Defaults to
            `["text", "image", "audio", "video"]`; pass a subset (e.g.
            `["text"]`) if the served model is text-only.
        use_chat_template: Whether to send text-only items through the Chat
            Pooling API (`messages`) like image/audio/video items. Default
            True. Set False for text-only pooling models that don't define a
            chat template (e.g. `BAAI/bge-m3`), so text is instead batched
            via the plain `input` field.
        fps: Target frames per second for video downsampling (see
            `VideoCollator`).
        max_frames: Safety cap on the number of frames sampled per video.
        num_frames: If set, sample exactly this many frames per video
            (fixed-sample mode) instead of FPS-based sampling.
        target_sampling_rate: Sampling rate (Hz) audio is resampled to before
            being sent to the server. Defaults to 16000.
        max_samples: Maximum number of audio samples to keep per item.
    """

    def __init__(  # noqa: PLR0913
        self,
        endpoint_url: str,
        model_name: str,
        api_key: str | None = None,
        *,
        prompt_dict: dict[str, str] | None = None,
        timeout: int = 300,
        max_retries: int = 3,
        verify_ssl: bool = True,
        modalities: list[Modalities] | None = None,
        use_chat_template: bool = True,
        fps: float | None = None,
        max_frames: int | None = None,
        num_frames: int | None = None,
        target_sampling_rate: int | None = None,
        max_samples: int | None = None,
    ):
        """Initialize the OpenAI API wrapper for token-level (ColBERT-style) embeddings."""
        super().__init__(
            endpoint_url=endpoint_url,
            model_name=model_name,
            api_key=api_key,
            timeout=timeout,
            max_retries=max_retries,
            verify_ssl=verify_ssl,
            modalities=modalities or ["text", "image", "audio", "video"],
            fps=fps,
            max_frames=max_frames,
            num_frames=num_frames,
            target_sampling_rate=target_sampling_rate,
            max_samples=max_samples,
        )
        self.mteb_model_meta = self.mteb_model_meta.model_copy(  # type: ignore[union-attr]
            update={"similarity_fn_name": ScoringFunction.MAX_SIM}
        )

        self.prompt_dict = prompt_dict
        self.use_chat_template = use_chat_template
        self._corpus_ids: list[str] | None = None
        self._corpus_embeddings: list[NDArray[np.floating]] | None = None

        # Verify server is reachable
        self._verify_server()

    def _pooling_request(self, payload: dict[str, Any]) -> list[NDArray[np.floating]]:
        """Send a request to vLLM's /pooling endpoint.

        Returns per-item multi-vector (token-level) embeddings.
        """
        result = self._make_request("/pooling", payload)

        embeddings_by_index = {
            item["index"]: np.array(item["data"], dtype=np.float32)
            for item in result["data"]
        }

        expected = len(result["data"])
        missing_indices = [i for i in range(expected) if i not in embeddings_by_index]
        if missing_indices:
            raise RuntimeError(
                f"Incomplete pooling output from server: expected "
                f"{expected} items, missing indices: {missing_indices[:10]}"
            )

        return [embeddings_by_index[i] for i in range(expected)]

    def _encode_texts(self, texts: list[str]) -> list[NDArray[np.floating]]:
        """Get multi-vector embeddings for a batch of texts via /pooling.

        Uses the plain `input` field (`PoolingCompletionRequest`), which
        works for any pooling model. Pure-text items must go through this
        path rather than `_encode_item`'s Chat Pooling API: `messages` is
        rendered through the model's chat template, and most text-only
        pooling models (e.g. BAAI/bge-m3) don't define one, so vLLM rejects
        it with a 400 ("...default chat template is no longer allowed...").
        """
        payload: dict[str, Any] = {"model": self.model_name, "input": texts}
        return self._pooling_request(payload)

    def _encode_item(
        self,
        text: str | None,
        image: Image.Image | None,
        audio: AudioInputItem | None,
        video: torch.Tensor | None,
    ) -> NDArray[np.floating]:
        """Get a multi-vector embedding for a single image/audio/video item.

        Uses the Chat Pooling API (`messages`), required for non-text
        content and only supported by chat-template-capable (typically
        VLM-based) pooling models. vLLM's `/pooling` endpoint only accepts a
        single conversation per request, so these items cannot be batched.
        """
        content = _build_content_parts(text, image, audio, video, fps=self.fps)

        payload: dict[str, Any] = {
            "model": self.model_name,
            "messages": [{"role": "user", "content": content}],
        }
        return self._pooling_request(payload)[0]

    def _encode(
        self,
        inputs: DataLoader[BatchedInput],
        *,
        task_metadata: TaskMetadata,
        hf_split: str,
        hf_subset: str,
        prompt_type: PromptType | None = None,
        batch_size: int = 32,
        show_progress_bar: bool = True,
        **kwargs: Any,
    ) -> list[NDArray[np.floating]]:
        """Encode inputs as multi-vector (per-token) embeddings.

        Args:
            inputs: The sentences/images/audio/video to encode
            task_metadata: The metadata of the task
            hf_split: Split of current task
            hf_subset: Subset of current task
            prompt_type: The type of prompt (query or document)
            batch_size: Batch size used for batching pure-text requests when
                `use_chat_template=False` (default: 32). Image/audio/video
                items, and all items when `use_chat_template=True`, are
                always sent one request at a time (see class docstring).
            show_progress_bar: Whether to show progress bar (default: True)
            **kwargs: Additional arguments (unused)

        Returns:
            A list of per-item multi-vector embeddings, each of shape
            `(num_tokens, dim)`.
        """
        prompt = get_prompt(self.prompt_dict, task_metadata, prompt_type) or ""
        if prompt:
            logger.info(
                f"Using prompt: '{prompt}' for task: '{task_metadata.name}' "
                f"prompt type: '{prompt_type}'"
            )

        self._configure_collate_fn(inputs)
        items = _collect_multimodal_items(inputs)

        if self.use_chat_template:
            embeddings = []
            for text, image, audio, video in tqdm(
                items, desc="Pooling items", disable=not show_progress_bar
            ):
                combined_text = (prompt + text) if text else prompt
                embeddings.append(
                    self._encode_item(combined_text or None, image, audio, video)
                )
            return embeddings

        # use_chat_template=False: batch pure-text items via the plain
        # `input` field; image/audio/video items still need `messages`.
        text_indices = [
            i
            for i, (_, image, audio, video) in enumerate(items)
            if image is None and audio is None and video is None
        ]
        other_indices = [i for i in range(len(items)) if i not in set(text_indices)]

        embeddings_by_index: dict[int, NDArray[np.floating]] = {}

        for start in tqdm(
            range(0, len(text_indices), batch_size),
            desc="Pooling text batches",
            disable=not show_progress_bar,
        ):
            batch_idx = text_indices[start : start + batch_size]
            batch_texts = [prompt + (items[i][0] or "") for i in batch_idx]
            batch_embeddings = self._encode_texts(batch_texts)
            for idx, embedding in zip(batch_idx, batch_embeddings, strict=True):
                embeddings_by_index[idx] = embedding

        for i in tqdm(
            other_indices,
            desc="Pooling multimodal items",
            disable=not show_progress_bar,
        ):
            text, image, audio, video = items[i]
            combined_text = (prompt + text) if text else prompt
            embeddings_by_index[i] = self._encode_item(
                combined_text or None, image, audio, video
            )

        return [embeddings_by_index[i] for i in range(len(items))]

    def index(
        self,
        corpus: CorpusDatasetType,
        *,
        task_metadata: TaskMetadata,
        hf_split: str,
        hf_subset: str,
        encode_kwargs: EncodeKwargs,
        num_proc: int | None,
    ) -> None:
        """Encode the corpus into multi-vector embeddings and keep them in memory.

        Args:
            corpus: Corpus dataset to index.
            task_metadata: Metadata of the task.
            hf_split: Split of current task.
            hf_subset: Subset of current task.
            encode_kwargs: Additional arguments to pass to `_encode`.
            num_proc: Number of processes to use for dataloading.
        """
        documents_loader = create_dataloader(
            corpus,
            task_metadata=task_metadata,
            prompt_type=PromptType.document,
            batch_size=encode_kwargs.get("batch_size", 32),
            num_proc=num_proc,
        )
        self._corpus_ids = [str(doc_id) for doc_id in corpus["id"]]
        self._corpus_embeddings = self._encode(
            documents_loader,
            task_metadata=task_metadata,
            hf_split=hf_split,
            hf_subset=hf_subset,
            prompt_type=PromptType.document,
            **encode_kwargs,
        )

    def search(
        self,
        queries: QueryDatasetType,
        *,
        task_metadata: TaskMetadata,
        hf_split: str,
        hf_subset: str,
        top_k: int,
        encode_kwargs: EncodeKwargs,
        top_ranked: TopRankedDocumentsType | None = None,
        num_proc: int | None,
    ) -> RetrievalOutputType:
        """Score queries against the indexed corpus using brute-force MaxSim.

        Args:
            queries: Queries to search with.
            task_metadata: Metadata of the task.
            hf_split: Split of current task.
            hf_subset: Subset of current task.
            top_k: Number of top documents to return per query.
            encode_kwargs: Additional arguments to pass to `_encode`.
            top_ranked: If given (reranking tasks), restricts scoring to
                these candidate document IDs per query instead of the full
                indexed corpus.
            num_proc: Number of processes to use for dataloading.

        Returns:
            Mapping of query ID to a mapping of document ID to relevance score.
        """
        if self._corpus_ids is None or self._corpus_embeddings is None:
            raise ValueError("Index is not built. Call index() before search().")

        queries_loader = create_dataloader(
            queries,
            task_metadata=task_metadata,
            prompt_type=PromptType.query,
            batch_size=encode_kwargs.get("batch_size", 32),
            num_proc=num_proc,
        )
        query_embeddings = self._encode(
            queries_loader,
            task_metadata=task_metadata,
            hf_split=hf_split,
            hf_subset=hf_subset,
            prompt_type=PromptType.query,
            **encode_kwargs,
        )
        query_ids = [row["id"] for row in queries]

        doc_id_to_idx = {doc_id: idx for idx, doc_id in enumerate(self._corpus_ids)}

        results: RetrievalOutputType = {}
        for query_id, query_embedding in zip(query_ids, query_embeddings, strict=True):
            if top_ranked is not None:
                candidate_ids = [
                    doc_id
                    for doc_id in top_ranked.get(query_id, [])
                    if doc_id in doc_id_to_idx
                ]
            else:
                candidate_ids = self._corpus_ids

            candidate_embeddings = [
                self._corpus_embeddings[doc_id_to_idx[doc_id]]
                for doc_id in candidate_ids
            ]
            scores = _max_sim_scores(query_embedding, candidate_embeddings)

            top_items = heapq.nlargest(
                top_k, zip(candidate_ids, scores, strict=True), key=lambda item: item[1]
            )
            results[query_id] = dict(top_items)

        return results

__init__(endpoint_url, model_name, api_key=None, *, prompt_dict=None, timeout=300, max_retries=3, verify_ssl=True, modalities=None, use_chat_template=True, fps=None, max_frames=None, num_frames=None, target_sampling_rate=None, max_samples=None)

Initialize the OpenAI API wrapper for token-level (ColBERT-style) embeddings.

Source code in mteb/models/openai_wrappers.py
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
def __init__(  # noqa: PLR0913
    self,
    endpoint_url: str,
    model_name: str,
    api_key: str | None = None,
    *,
    prompt_dict: dict[str, str] | None = None,
    timeout: int = 300,
    max_retries: int = 3,
    verify_ssl: bool = True,
    modalities: list[Modalities] | None = None,
    use_chat_template: bool = True,
    fps: float | None = None,
    max_frames: int | None = None,
    num_frames: int | None = None,
    target_sampling_rate: int | None = None,
    max_samples: int | None = None,
):
    """Initialize the OpenAI API wrapper for token-level (ColBERT-style) embeddings."""
    super().__init__(
        endpoint_url=endpoint_url,
        model_name=model_name,
        api_key=api_key,
        timeout=timeout,
        max_retries=max_retries,
        verify_ssl=verify_ssl,
        modalities=modalities or ["text", "image", "audio", "video"],
        fps=fps,
        max_frames=max_frames,
        num_frames=num_frames,
        target_sampling_rate=target_sampling_rate,
        max_samples=max_samples,
    )
    self.mteb_model_meta = self.mteb_model_meta.model_copy(  # type: ignore[union-attr]
        update={"similarity_fn_name": ScoringFunction.MAX_SIM}
    )

    self.prompt_dict = prompt_dict
    self.use_chat_template = use_chat_template
    self._corpus_ids: list[str] | None = None
    self._corpus_embeddings: list[NDArray[np.floating]] | None = None

    # Verify server is reachable
    self._verify_server()

index(corpus, *, task_metadata, hf_split, hf_subset, encode_kwargs, num_proc)

Encode the corpus into multi-vector embeddings and keep them in memory.

Parameters:

Name Type Description Default
corpus CorpusDatasetType

Corpus dataset to index.

required
task_metadata TaskMetadata

Metadata of the task.

required
hf_split str

Split of current task.

required
hf_subset str

Subset of current task.

required
encode_kwargs EncodeKwargs

Additional arguments to pass to _encode.

required
num_proc int | None

Number of processes to use for dataloading.

required
Source code in mteb/models/openai_wrappers.py
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
def index(
    self,
    corpus: CorpusDatasetType,
    *,
    task_metadata: TaskMetadata,
    hf_split: str,
    hf_subset: str,
    encode_kwargs: EncodeKwargs,
    num_proc: int | None,
) -> None:
    """Encode the corpus into multi-vector embeddings and keep them in memory.

    Args:
        corpus: Corpus dataset to index.
        task_metadata: Metadata of the task.
        hf_split: Split of current task.
        hf_subset: Subset of current task.
        encode_kwargs: Additional arguments to pass to `_encode`.
        num_proc: Number of processes to use for dataloading.
    """
    documents_loader = create_dataloader(
        corpus,
        task_metadata=task_metadata,
        prompt_type=PromptType.document,
        batch_size=encode_kwargs.get("batch_size", 32),
        num_proc=num_proc,
    )
    self._corpus_ids = [str(doc_id) for doc_id in corpus["id"]]
    self._corpus_embeddings = self._encode(
        documents_loader,
        task_metadata=task_metadata,
        hf_split=hf_split,
        hf_subset=hf_subset,
        prompt_type=PromptType.document,
        **encode_kwargs,
    )

search(queries, *, task_metadata, hf_split, hf_subset, top_k, encode_kwargs, top_ranked=None, num_proc)

Score queries against the indexed corpus using brute-force MaxSim.

Parameters:

Name Type Description Default
queries QueryDatasetType

Queries to search with.

required
task_metadata TaskMetadata

Metadata of the task.

required
hf_split str

Split of current task.

required
hf_subset str

Subset of current task.

required
top_k int

Number of top documents to return per query.

required
encode_kwargs EncodeKwargs

Additional arguments to pass to _encode.

required
top_ranked TopRankedDocumentsType | None

If given (reranking tasks), restricts scoring to these candidate document IDs per query instead of the full indexed corpus.

None
num_proc int | None

Number of processes to use for dataloading.

required

Returns:

Type Description
RetrievalOutputType

Mapping of query ID to a mapping of document ID to relevance score.

Source code in mteb/models/openai_wrappers.py
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
def search(
    self,
    queries: QueryDatasetType,
    *,
    task_metadata: TaskMetadata,
    hf_split: str,
    hf_subset: str,
    top_k: int,
    encode_kwargs: EncodeKwargs,
    top_ranked: TopRankedDocumentsType | None = None,
    num_proc: int | None,
) -> RetrievalOutputType:
    """Score queries against the indexed corpus using brute-force MaxSim.

    Args:
        queries: Queries to search with.
        task_metadata: Metadata of the task.
        hf_split: Split of current task.
        hf_subset: Subset of current task.
        top_k: Number of top documents to return per query.
        encode_kwargs: Additional arguments to pass to `_encode`.
        top_ranked: If given (reranking tasks), restricts scoring to
            these candidate document IDs per query instead of the full
            indexed corpus.
        num_proc: Number of processes to use for dataloading.

    Returns:
        Mapping of query ID to a mapping of document ID to relevance score.
    """
    if self._corpus_ids is None or self._corpus_embeddings is None:
        raise ValueError("Index is not built. Call index() before search().")

    queries_loader = create_dataloader(
        queries,
        task_metadata=task_metadata,
        prompt_type=PromptType.query,
        batch_size=encode_kwargs.get("batch_size", 32),
        num_proc=num_proc,
    )
    query_embeddings = self._encode(
        queries_loader,
        task_metadata=task_metadata,
        hf_split=hf_split,
        hf_subset=hf_subset,
        prompt_type=PromptType.query,
        **encode_kwargs,
    )
    query_ids = [row["id"] for row in queries]

    doc_id_to_idx = {doc_id: idx for idx, doc_id in enumerate(self._corpus_ids)}

    results: RetrievalOutputType = {}
    for query_id, query_embedding in zip(query_ids, query_embeddings, strict=True):
        if top_ranked is not None:
            candidate_ids = [
                doc_id
                for doc_id in top_ranked.get(query_id, [])
                if doc_id in doc_id_to_idx
            ]
        else:
            candidate_ids = self._corpus_ids

        candidate_embeddings = [
            self._corpus_embeddings[doc_id_to_idx[doc_id]]
            for doc_id in candidate_ids
        ]
        scores = _max_sim_scores(query_embedding, candidate_embeddings)

        top_items = heapq.nlargest(
            top_k, zip(candidate_ids, scores, strict=True), key=lambda item: item[1]
        )
        results[query_id] = dict(top_items)

    return results