Feature Extraction
sentence-transformers
Safetensors
English
fusion-embedding-connector
embeddings
multimodal
audio
retrieval
matryoshka
qwen3-vl
adapters
custom_code
Instructions to use EximiusLabs/fusion-embedding-2-2b-preview with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- sentence-transformers
How to use EximiusLabs/fusion-embedding-2-2b-preview with sentence-transformers:
from sentence_transformers import SentenceTransformer model = SentenceTransformer("EximiusLabs/fusion-embedding-2-2b-preview", trust_remote_code=True) sentences = [ "The weather is lovely today.", "It's so sunny outside!", "He drove to the stadium." ] embeddings = model.encode(sentences) similarities = model.similarity(embeddings, embeddings) print(similarities.shape) # [3, 3] - Notebooks
- Google Colab
- Kaggle
| """Sentence Transformers module for fusion-embedding-2. | |
| A thin adapter that exposes the released fusion-embedding model through the | |
| Sentence Transformers multimodal ``encode`` API (text, image, audio, video). | |
| All embedding math runs through the ``fusion_embedding`` package's own | |
| ``UnifiedEmbedder`` (the model's native loading path), so vectors produced here | |
| are identical to ``fusion_embedding.UnifiedEmbedder.from_pretrained(...)``: | |
| * text: chat-template instruction, EOS pooling, text-side whitening (fp32); | |
| * image: the frozen base's native vision path (no whitening, no adapters); | |
| * video: the released video preprocessing over the frozen base's video path; | |
| * audio: soxr resampling to 16 kHz, Whisper-style mel, trained resampler, and | |
| the frozen decoder with ONLY the audio adapter gate open. | |
| Every vector is L2-normalized at the full interop dimension (2048). Shorter | |
| Matryoshka rungs: pass ``truncate_dim=<rung>`` together with | |
| ``normalize_embeddings=True`` to ``encode`` (truncate-then-renormalize equals | |
| the native MRL readout). | |
| Requirements (beyond sentence-transformers>=5.5.1): | |
| pip install "fusion-embedding[sense]>=0.3.0" torchvision | |
| The ``sense`` extra pulls the audio decode/resample stack (soundfile, librosa); | |
| transformers itself ships with sentence-transformers. torchvision is required to | |
| load the model at all, not only for video, because the base processor builds a | |
| video processor during construction; this applies to the native loader too. | |
| Embedding an image needs Pillow, which torchvision carries. Embedding a video by | |
| file path additionally requires torchcodec, which in turn needs FFmpeg. | |
| Supported inputs per item (one modality per item; the model has no fused | |
| multi-modality input): | |
| * ``str`` — text, or a local image/audio/video file path (auto-detected); | |
| * ``PIL.Image.Image`` or an HxWxC uint8 array — image; | |
| * ``{"audio": {"array": waveform, "sampling_rate": sr}}`` (or the inner dict | |
| directly) — audio; a bare 1-D array is rejected because the sampling rate | |
| would be unknown; | |
| * a ``[T, C, H, W]`` uint8 frame tensor/array — video. | |
| """ | |
| from __future__ import annotations | |
| import os | |
| from typing import Any, Optional | |
| import torch | |
| try: | |
| from sentence_transformers.base.modality import infer_modality | |
| from sentence_transformers.base.modules.input_module import InputModule | |
| except ImportError as exc: # pragma: no cover - version guard | |
| raise ImportError( | |
| "The fusion-embedding-2 Sentence Transformers integration requires " | |
| "sentence-transformers>=5.5.1 (multimodal encode). " | |
| "Upgrade with: pip install -U sentence-transformers" | |
| ) from exc | |
| try: | |
| from fusion_embedding.config import INSTRUCTION_REGISTRY | |
| from fusion_embedding.model import last_token_pool | |
| from fusion_embedding.unified import UnifiedEmbedder, _chat | |
| except ImportError as exc: # pragma: no cover - dependency guard | |
| raise ImportError( | |
| "The fusion-embedding-2 Sentence Transformers integration needs the " | |
| "fusion-embedding package for the model implementation. Install it " | |
| "with: pip install 'fusion-embedding[sense]>=0.3.0'" | |
| ) from exc | |
| _MIN_ST_VERSION = (5, 5, 1) | |
| def _require_min_st_version() -> None: | |
| """Fail with the real reason on Sentence Transformers older than 5.5.1. | |
| Single-key modality dicts such as {"audio": {"array": ..., "sampling_rate": ...}} | |
| are classified as a tuple by infer_modality before 5.5.1, so the encode call is | |
| rejected by Sentence Transformers itself with a message claiming the modality is | |
| unsupported. The import guard above cannot catch that: sentence_transformers.base | |
| imports cleanly on 5.4.x. | |
| """ | |
| import sentence_transformers | |
| raw = getattr(sentence_transformers, "__version__", "0") | |
| parts = [] | |
| for chunk in raw.split(".")[:3]: | |
| digits = "".join(c for c in chunk if c.isdigit()) | |
| parts.append(int(digits) if digits else 0) | |
| while len(parts) < 3: | |
| parts.append(0) | |
| if tuple(parts) < _MIN_ST_VERSION: | |
| raise ImportError( | |
| "The fusion-embedding-2 Sentence Transformers integration requires " | |
| f"sentence-transformers>=5.5.1, found {raw}. Earlier versions reject " | |
| "single-key modality dicts such as " | |
| '{"audio": {"array": ..., "sampling_rate": ...}} before this module is ' | |
| "reached. Upgrade with: pip install -U 'sentence-transformers>=5.5.1'" | |
| ) | |
| CKPT_FILENAME = "fusion-embedding-2-2b-preview.pt" | |
| class FusionEmbedding2Module(InputModule): | |
| """Single Sentence Transformers module wrapping the full fusion-embedding-2 | |
| encoder (all modalities plus the canonical readout, so no separate Pooling | |
| or Normalize module is needed: ``forward`` emits ``sentence_embedding`` | |
| directly).""" | |
| config_file_name = "sentence_bert_config.json" | |
| config_keys = ["ckpt_filename", "max_seq_length"] | |
| save_in_root = True | |
| def __init__( | |
| self, | |
| model_name_or_path: Optional[str] = None, | |
| ckpt_filename: str = CKPT_FILENAME, | |
| max_seq_length: int = 512, | |
| revision: Optional[str] = None, | |
| token: "bool | str | None" = None, | |
| cache_folder: Optional[str] = None, | |
| local_files_only: bool = False, | |
| model_kwargs: Optional[dict] = None, | |
| embedder: Optional[UnifiedEmbedder] = None, | |
| **kwargs, | |
| ) -> None: | |
| super().__init__() | |
| _require_min_st_version() | |
| self.ckpt_filename = ckpt_filename | |
| self.max_seq_length = max_seq_length | |
| if embedder is None: | |
| if model_name_or_path is None: | |
| raise ValueError("model_name_or_path is required (or pass embedder=)") | |
| model_kwargs = dict(model_kwargs or {}) | |
| dtype = model_kwargs.pop("torch_dtype", model_kwargs.pop("dtype", torch.bfloat16)) | |
| if isinstance(dtype, str): | |
| dtype = getattr(torch, dtype) | |
| device = model_kwargs.pop( | |
| "device", "cuda" if torch.cuda.is_available() else "cpu" | |
| ) | |
| ckpt_path = self.load_file_path( | |
| model_name_or_path, | |
| filename=ckpt_filename, | |
| token=token, | |
| cache_folder=cache_folder, | |
| revision=revision, | |
| local_files_only=local_files_only, | |
| ) | |
| if ckpt_path is None: | |
| raise FileNotFoundError( | |
| f"checkpoint {ckpt_filename!r} not found in {model_name_or_path!r}" | |
| ) | |
| embedder = UnifiedEmbedder.from_pretrained(ckpt_path, device=device, dtype=dtype) | |
| self._emb = embedder | |
| # Wire the video seam the UnifiedEmbedder anticipates: the released video | |
| # preprocessing (fusion_embedding.multimodal) over the frozen base. | |
| self._emb._video_pooler = self._video_pooled | |
| # Register the underlying torch modules so Sentence Transformers device | |
| # management (`model.to(device)`) moves the whole stack. | |
| self.fusion_model = embedder.model | |
| if embedder.full is not None: | |
| self.base = embedder.full | |
| if embedder.tok is not None: | |
| self.tokenizer = embedder.tok | |
| # ------------------------------------------------------------------ loading | |
| def load( | |
| cls, | |
| model_name_or_path: str, | |
| subfolder: str = "", | |
| token: "bool | str | None" = None, | |
| cache_folder: Optional[str] = None, | |
| revision: Optional[str] = None, | |
| local_files_only: bool = False, | |
| trust_remote_code: bool = False, | |
| model_kwargs: Optional[dict] = None, | |
| processor_kwargs: Optional[dict] = None, | |
| config_kwargs: Optional[dict] = None, | |
| backend: str = "torch", | |
| **kwargs, | |
| ) -> "FusionEmbedding2Module": | |
| if backend != "torch": | |
| raise ValueError( | |
| f"fusion-embedding-2 only supports the torch backend, got {backend!r}" | |
| ) | |
| config = cls.load_config( | |
| model_name_or_path, | |
| subfolder=subfolder, | |
| token=token, | |
| cache_folder=cache_folder, | |
| revision=revision, | |
| local_files_only=local_files_only, | |
| ) | |
| config.pop("model_name_or_path", None) | |
| if config_kwargs: | |
| config.update(config_kwargs) | |
| return cls( | |
| model_name_or_path, | |
| revision=revision, | |
| token=token, | |
| cache_folder=cache_folder, | |
| local_files_only=local_files_only, | |
| model_kwargs=model_kwargs, | |
| **config, | |
| ) | |
| # -------------------------------------------------------------- ST contract | |
| def modalities(self) -> list: | |
| return ["text", "image", "audio", "video"] | |
| def get_embedding_dimension(self) -> int: | |
| return int(self._emb.contract.dim) | |
| def save(self, output_path: str, *args, safe_serialization: bool = True, **kwargs) -> None: | |
| # Configuration only: the 2B weights live in the model repository's | |
| # checkpoint file and are not duplicated by Sentence Transformers saves. | |
| self.save_config(output_path) | |
| # ------------------------------------------------------------- input parsing | |
| def preprocess(self, inputs: list, prompt: Optional[str] = None, **kwargs) -> dict: | |
| items = [] | |
| for item in inputs: | |
| modality = infer_modality(item, supported_modalities=self.modalities) | |
| if isinstance(modality, tuple): | |
| raise ValueError( | |
| "fusion-embedding-2 embeds one modality per input item; " | |
| f"got a combined input with {modality}. Encode each modality " | |
| "separately (the shared space makes the vectors comparable)." | |
| ) | |
| if isinstance(item, dict) and set(item.keys()) == {modality}: | |
| item = item[modality] | |
| items.append((modality, self._parse(modality, item))) | |
| return {"fusion_inputs": items, "fusion_prompt": prompt} | |
| def _parse(self, modality: str, item: Any) -> Any: | |
| if modality == "text": | |
| return item | |
| if modality == "image": | |
| return self._parse_image(item) | |
| if modality == "audio": | |
| return self._parse_audio(item) | |
| if modality == "video": | |
| return self._parse_video(item) | |
| raise ValueError(f"unsupported modality {modality!r}") | |
| def _parse_image(item): | |
| import numpy as np | |
| # Guarded so transformers' trust_remote_code import check does not make | |
| # Pillow a load-time requirement: dynamic_module_utils.get_imports skips | |
| # ast.Try blocks, and an unguarded import here is otherwise treated as | |
| # mandatory at construction even for text-only use. | |
| try: | |
| from PIL import Image | |
| except ImportError as exc: # pragma: no cover - optional dependency | |
| raise ImportError( | |
| "embedding an image requires Pillow (pip install pillow)" | |
| ) from exc | |
| if isinstance(item, Image.Image): | |
| return item | |
| if isinstance(item, str): | |
| if item.startswith(("http://", "https://", "data:")): | |
| raise ValueError( | |
| "image URLs / data URIs are not supported; download the file " | |
| "and pass a local path or a PIL image" | |
| ) | |
| return item # local path; decoded by the native path (PIL) | |
| if isinstance(item, torch.Tensor): | |
| item = item.cpu().numpy() | |
| if isinstance(item, np.ndarray): | |
| if item.ndim == 3 and item.shape[0] in (1, 3, 4) and item.shape[-1] not in (1, 3, 4): | |
| item = np.transpose(item, (1, 2, 0)) # CHW -> HWC | |
| if item.ndim != 3 or item.shape[-1] not in (1, 3, 4): | |
| raise ValueError(f"expected an HxWxC image array, got shape {item.shape}") | |
| if item.dtype != np.uint8: | |
| item = np.clip(item, 0, 255).astype(np.uint8) | |
| return Image.fromarray(item.squeeze(-1) if item.shape[-1] == 1 else item) | |
| raise ValueError(f"unsupported image input type {type(item).__name__}") | |
| def _parse_audio(item): | |
| """Return (payload, sampling_rate_or_None); paths carry their own rate.""" | |
| import numpy as np | |
| if isinstance(item, str): | |
| if item.startswith(("http://", "https://")): | |
| raise ValueError( | |
| "audio URLs are not supported; download the file and pass a " | |
| "local path or {'array': ..., 'sampling_rate': ...}" | |
| ) | |
| return (item, None) | |
| if isinstance(item, dict): | |
| if "array" not in item or "sampling_rate" not in item: | |
| raise ValueError( | |
| "audio dicts must have the form " | |
| "{'array': waveform, 'sampling_rate': sr}" | |
| ) | |
| array, sr = item["array"], int(item["sampling_rate"]) | |
| else: | |
| try: # torchcodec AudioDecoder (optional dependency) | |
| from torchcodec.decoders import AudioDecoder | |
| except ImportError: | |
| AudioDecoder = None | |
| if AudioDecoder is not None and isinstance(item, AudioDecoder): | |
| samples = item.get_all_samples() | |
| return (samples.data.mean(dim=0).cpu().numpy(), int(samples.sample_rate)) | |
| raise ValueError( | |
| "a bare audio array has no sampling rate; pass " | |
| "{'audio': {'array': waveform, 'sampling_rate': sr}} instead" | |
| ) | |
| if isinstance(array, torch.Tensor): | |
| array = array.cpu().numpy() | |
| array = np.asarray(array) | |
| if array.ndim == 2 and array.shape[0] < array.shape[1]: | |
| array = array.T # (channels, samples) -> (samples, channels) | |
| if array.ndim > 2: | |
| raise ValueError(f"expected a 1-D or 2-D waveform, got shape {array.shape}") | |
| return (array.astype(np.float32, copy=False), sr) | |
| def _parse_video(item): | |
| import numpy as np | |
| if isinstance(item, str): | |
| if item.startswith(("http://", "https://")): | |
| raise ValueError( | |
| "video URLs are not supported; download the file and pass a " | |
| "local path or a [T, C, H, W] frame tensor" | |
| ) | |
| return item # local path; decoded natively (torchcodec, 1 fps, <=64 frames) | |
| if isinstance(item, dict): | |
| # {"array": frames, "video_metadata": ...}: the released frame-tensor | |
| # path derives its own metadata, so user metadata is not consumed. | |
| item = item["array"] | |
| if isinstance(item, np.ndarray): | |
| item = torch.from_numpy(np.ascontiguousarray(item)) | |
| if isinstance(item, torch.Tensor): | |
| if item.ndim == 5 and item.shape[0] == 1: | |
| item = item.squeeze(0) | |
| if item.ndim == 4 and item.shape[-1] in (1, 3) and item.shape[1] not in (1, 3): | |
| item = item.permute(0, 3, 1, 2) # THWC -> TCHW | |
| if item.ndim != 4: | |
| raise ValueError(f"expected a [T, C, H, W] frame tensor, got shape {list(item.shape)}") | |
| return item | |
| raise ValueError(f"unsupported video input type {type(item).__name__}") | |
| # ------------------------------------------------------------------ forward | |
| def forward(self, features: dict, **kwargs) -> dict: | |
| self._sync_device() | |
| prompt = features.get("fusion_prompt") | |
| vectors = [] | |
| for modality, payload in features["fusion_inputs"]: | |
| if modality == "text": | |
| vectors.append(self._emb.embed_text(payload, instruction=prompt or None)) | |
| elif modality == "image": | |
| vectors.append(self._emb.embed_image(payload)) | |
| elif modality == "audio": | |
| array, sr = payload | |
| vectors.append(self._emb.embed_audio(array, sr=sr)) | |
| elif modality == "video": | |
| vectors.append(self._emb.embed_video(payload)) | |
| else: # pragma: no cover - guarded in preprocess | |
| raise ValueError(f"unsupported modality {modality!r}") | |
| features["sentence_embedding"] = torch.stack(vectors) | |
| return features | |
| def _sync_device(self) -> None: | |
| """Follow Sentence Transformers device moves (`model.to(...)`).""" | |
| param = next(self.parameters(), None) | |
| if param is not None: | |
| self._emb.device = param.device | |
| # ----------------------------------------------------------- native video path | |
| def _video_pooled(self, video, fps, max_frames) -> torch.Tensor: | |
| """The released fusion-embedding video path: reference-exact frame | |
| preprocessing (fusion_embedding.multimodal) -> frozen base's video | |
| forward -> EOS pooling. Runs with every adapter gate closed.""" | |
| from fusion_embedding.config import VIDEO_USER_CONTENT | |
| from fusion_embedding.multimodal import _v_prepare, _v_resize_video | |
| emb = self._emb | |
| gate = getattr(emb.model, "_adapter_gate", None) | |
| if gate is not None and gate.active: | |
| raise RuntimeError("adapter gate is open during a video embed") | |
| if emb.full is None or emb.proc is None: | |
| raise RuntimeError("video embedding needs the real processor + base") | |
| frames, metadata = _v_prepare(video, fps, max_frames) | |
| frames = _v_resize_video(frames) | |
| text = _chat(INSTRUCTION_REGISTRY["doc"], VIDEO_USER_CONTENT) | |
| inputs = emb.proc( | |
| text=[text], | |
| videos=[frames], | |
| video_metadata=[metadata], | |
| do_resize=False, | |
| do_sample_frames=False, | |
| return_tensors="pt", | |
| ).to(emb.device) | |
| hidden = emb.full(**inputs).last_hidden_state | |
| return last_token_pool(hidden, inputs["attention_mask"]) | |