791 lines
29 KiB
Python
791 lines
29 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
"""
|
|
MLX Embedding Model wrapper.
|
|
|
|
This module provides a wrapper around mlx-embeddings for generating
|
|
text embeddings using Apple's MLX framework, with native fallback
|
|
for XLMRoBERTa, BERT, and Qwen2-decoder embedding models.
|
|
"""
|
|
|
|
import gc
|
|
import inspect
|
|
import json
|
|
import logging
|
|
import os
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional, Union
|
|
|
|
import mlx.core as mx
|
|
from mlx.utils import tree_flatten
|
|
|
|
from ..utils.compile_cache import clear_thread_compile_cache
|
|
from ..utils.image import validate_image_data_uri
|
|
from .mlx_embeddings_compat import (
|
|
patch_qwen3_vl_processor_for_torch_free_image_loading,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_DEFAULT_EMBEDDING_MAX_LENGTH = 512
|
|
_TOKENIZER_MAX_LENGTH_SENTINEL = 10**18
|
|
_CONTEXT_LENGTH_ATTRS = (
|
|
"max_position_embeddings",
|
|
"max_seq_len",
|
|
"max_seq_length",
|
|
"seq_length",
|
|
"n_positions",
|
|
)
|
|
_FALSE_ENV_VALUES = {"0", "false", "no", "off"}
|
|
|
|
|
|
@dataclass
|
|
class EmbeddingOutput:
|
|
"""Output from embedding generation."""
|
|
|
|
embeddings: List[List[float]]
|
|
"""List of embedding vectors, one per input text."""
|
|
|
|
total_tokens: int
|
|
"""Total number of tokens in the input."""
|
|
|
|
dimensions: int = 0
|
|
"""Dimension of each embedding vector."""
|
|
|
|
|
|
class MLXEmbeddingModel:
|
|
"""
|
|
Wrapper around mlx-embeddings for generating text embeddings.
|
|
|
|
This class provides a unified interface for loading and running
|
|
embedding models using Apple's MLX framework.
|
|
|
|
Supports:
|
|
- Native XLMRoBERTa embedding (no mlx-embeddings dependency)
|
|
- Native BERT embedding (no mlx-embeddings dependency)
|
|
- Native Qwen2-decoder embedding (last-token + L2; jina-code, gte-Qwen2)
|
|
— mlx-embeddings has no qwen2 module
|
|
- mlx-embeddings fallback for other architectures
|
|
|
|
Example:
|
|
>>> model = MLXEmbeddingModel("mlx-community/all-MiniLM-L6-v2-4bit")
|
|
>>> output = model.embed(["Hello, world!", "How are you?"])
|
|
>>> print(len(output.embeddings)) # 2
|
|
"""
|
|
|
|
def __init__(self, model_name: str, trust_remote_code: bool = False):
|
|
"""
|
|
Initialize the MLX embedding model.
|
|
|
|
Args:
|
|
model_name: HuggingFace model name or local path
|
|
trust_remote_code: Allow execution of custom Python shipped inside
|
|
the model repository. Off by default for security (issue #926).
|
|
"""
|
|
self.model_name = model_name
|
|
self.trust_remote_code = trust_remote_code
|
|
|
|
self.model = None
|
|
self.processor = None
|
|
self._loaded = False
|
|
self._hidden_size: Optional[int] = None
|
|
self._using_native = False
|
|
self._is_compiled = False
|
|
self._compiled_embed = None
|
|
self._remap_input_ids_to_inputs = False
|
|
|
|
def _load_native(self) -> bool:
|
|
"""
|
|
Try to load using native omlx implementations (xlm_roberta, bert).
|
|
|
|
Returns True if native loading succeeded, False otherwise.
|
|
"""
|
|
from transformers import AutoTokenizer
|
|
|
|
model_path = Path(self.model_name)
|
|
config_path = model_path / "config.json"
|
|
if not config_path.exists():
|
|
logger.debug(f"No config.json at {model_path}, native loading skipped")
|
|
return False
|
|
|
|
try:
|
|
with open(config_path) as f:
|
|
config_dict = json.load(f)
|
|
except (json.JSONDecodeError, IOError):
|
|
logger.debug("Failed to read config.json, native loading skipped")
|
|
return False
|
|
|
|
architectures = config_dict.get("architectures", [])
|
|
arch = architectures[0] if architectures else ""
|
|
|
|
native_arch_modules = {
|
|
"XLMRobertaModel": "xlm_roberta",
|
|
"BertModel": "xlm_roberta",
|
|
"BertForMaskedLM": "xlm_roberta",
|
|
"Qwen2ForCausalLM": "qwen2_embedding",
|
|
}
|
|
module_name = native_arch_modules.get(arch)
|
|
if module_name is None:
|
|
logger.debug(
|
|
f"Architecture '{arch}' not natively supported for embedding, "
|
|
"trying mlx-embeddings"
|
|
)
|
|
return False
|
|
|
|
try:
|
|
from importlib import import_module
|
|
|
|
native_module = import_module(f"{__package__}.{module_name}")
|
|
Model = native_module.Model
|
|
ModelArgs = native_module.ModelArgs
|
|
|
|
known_fields = {f.name for f in ModelArgs.__dataclass_fields__.values()}
|
|
model_config = {
|
|
k: v for k, v in config_dict.items() if k in known_fields
|
|
}
|
|
model_config["architectures"] = architectures
|
|
|
|
config = ModelArgs(**model_config)
|
|
model_instance = Model(config)
|
|
|
|
weights = {}
|
|
weight_files = list(model_path.glob("*.safetensors"))
|
|
if not weight_files:
|
|
logger.debug(f"No safetensors files found in {model_path}")
|
|
return False
|
|
|
|
for wf in weight_files:
|
|
weights.update(mx.load(str(wf)))
|
|
|
|
weights = model_instance.sanitize(weights)
|
|
self._validate_native_weights(model_instance, weights)
|
|
model_instance.load_weights(list(weights.items()), strict=False)
|
|
mx.eval(model_instance.parameters())
|
|
# Embedding inference must be deterministic: put the model in eval
|
|
# mode so dropout (p>0 in XLM-RoBERTa/BERT) is disabled. Without this
|
|
# every /v1/embeddings call applies random dropout, producing
|
|
# non-deterministic, corrupted vectors.
|
|
model_instance.train(False)
|
|
|
|
try:
|
|
tokenizer = AutoTokenizer.from_pretrained(
|
|
str(model_path),
|
|
use_fast=False,
|
|
trust_remote_code=self.trust_remote_code,
|
|
)
|
|
except Exception:
|
|
tokenizer = AutoTokenizer.from_pretrained(
|
|
str(model_path),
|
|
trust_remote_code=self.trust_remote_code,
|
|
)
|
|
|
|
self.model = model_instance
|
|
self.processor = tokenizer
|
|
self._hidden_size = config.hidden_size
|
|
self._loaded = True
|
|
self._using_native = True
|
|
self._is_compiled = False
|
|
self._compiled_embed = None
|
|
logger.info(
|
|
f"Embedding model loaded natively: {self.model_name} "
|
|
f"(arch={arch}, hidden_size={config.hidden_size})"
|
|
)
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.debug(f"Native loading failed for {self.model_name}: {e}")
|
|
return False
|
|
|
|
def load(self) -> None:
|
|
"""Load the model and processor/tokenizer."""
|
|
if self._loaded:
|
|
return
|
|
|
|
# 1. Try native loading first (xlm_roberta, bert)
|
|
if self._load_native():
|
|
return
|
|
|
|
# 2. Fallback to mlx-embeddings
|
|
try:
|
|
patch_qwen3_vl_processor_for_torch_free_image_loading()
|
|
from mlx_embeddings import load
|
|
|
|
logger.info(f"Loading embedding model via mlx-embeddings: {self.model_name}")
|
|
|
|
self.model, self.processor = load(
|
|
self.model_name,
|
|
tokenizer_config={"trust_remote_code": self.trust_remote_code},
|
|
)
|
|
|
|
if hasattr(self.model, "config"):
|
|
config = self.model.config
|
|
self._hidden_size = getattr(config, "hidden_size", None)
|
|
if self._hidden_size is None and hasattr(config, "text_config"):
|
|
self._hidden_size = getattr(config.text_config, "hidden_size", None)
|
|
|
|
self._using_native = False
|
|
self._detect_input_key_remapping()
|
|
self._is_compiled = self._try_compile()
|
|
self._loaded = True
|
|
logger.info(
|
|
f"Embedding model loaded successfully: {self.model_name} "
|
|
f"(hidden_size={self._hidden_size}, compiled={self._is_compiled})"
|
|
)
|
|
|
|
except ImportError:
|
|
raise ImportError(
|
|
"mlx-embeddings is required for embedding generation. "
|
|
"Install with: pip install mlx-embeddings"
|
|
)
|
|
except FileNotFoundError:
|
|
raise FileNotFoundError(
|
|
f"No safetensors weight files found for '{self.model_name}'. "
|
|
f"Embedding models require weights in safetensors format. "
|
|
f"If this is a PyTorch model, use an MLX-converted version "
|
|
f"(e.g., from mlx-community on HuggingFace)."
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Failed to load embedding model: {e}")
|
|
raise
|
|
|
|
def _extract_embeddings_array(self, outputs):
|
|
"""Extract embedding tensor from model outputs as a 2D (batch, hidden) array."""
|
|
if hasattr(outputs, "text_embeds") and outputs.text_embeds is not None:
|
|
embeddings = outputs.text_embeds
|
|
elif hasattr(outputs, "pooler_output") and outputs.pooler_output is not None:
|
|
embeddings = outputs.pooler_output
|
|
elif (
|
|
hasattr(outputs, "last_hidden_state")
|
|
and outputs.last_hidden_state is not None
|
|
):
|
|
embeddings = outputs.last_hidden_state
|
|
else:
|
|
raise ValueError(
|
|
"Model output does not contain expected embedding fields "
|
|
"(text_embeds, pooler_output, or last_hidden_state)"
|
|
)
|
|
|
|
# Some models (e.g. ModernBERT loaded as MaskedLM) skip pooling and return
|
|
# per-token features with shape (batch, seq_len, hidden). Mean pool so
|
|
# /v1/embeddings always emits one vector per input.
|
|
if embeddings.ndim == 3:
|
|
embeddings = mx.mean(embeddings, axis=1)
|
|
return embeddings
|
|
|
|
def _validate_native_weights(
|
|
self, model_instance, weights: Dict[str, Any]
|
|
) -> None:
|
|
"""Reject native checkpoints with missing or shape-incompatible core weights."""
|
|
expected_weights = dict(tree_flatten(model_instance.parameters()))
|
|
expected_weight_names = set(expected_weights.keys())
|
|
provided_weight_names = set(weights.keys())
|
|
missing_weight_names = expected_weight_names - provided_weight_names
|
|
|
|
optional_missing_prefixes = ("pooler.",)
|
|
required_missing = sorted(
|
|
name
|
|
for name in missing_weight_names
|
|
if not name.startswith(optional_missing_prefixes)
|
|
)
|
|
if required_missing:
|
|
preview = ", ".join(required_missing[:10])
|
|
suffix = "..." if len(required_missing) > 10 else ""
|
|
raise ValueError(
|
|
"Native embedding checkpoint is missing required weights: "
|
|
f"{preview}{suffix}"
|
|
)
|
|
|
|
shape_mismatches = []
|
|
for name in expected_weight_names & provided_weight_names:
|
|
expected_shape = tuple(expected_weights[name].shape)
|
|
provided_shape = tuple(weights[name].shape)
|
|
if expected_shape != provided_shape:
|
|
shape_mismatches.append((name, expected_shape, provided_shape))
|
|
|
|
if shape_mismatches:
|
|
preview = ", ".join(
|
|
f"{name}: expected {expected_shape}, got {provided_shape}"
|
|
for name, expected_shape, provided_shape in shape_mismatches[:5]
|
|
)
|
|
suffix = "..." if len(shape_mismatches) > 5 else ""
|
|
raise ValueError(
|
|
"Native embedding checkpoint has incompatible weight shapes: "
|
|
f"{preview}{suffix}"
|
|
)
|
|
|
|
def _uses_custom_embedding_inputs(self, processor) -> bool:
|
|
"""Return True when processor exposes a custom embedding input API."""
|
|
for attr_name in ("prepare_embedding_inputs", "prepare_model_inputs"):
|
|
try:
|
|
inspect.getattr_static(processor, attr_name)
|
|
return True
|
|
except AttributeError:
|
|
continue
|
|
return False
|
|
|
|
def _normalize_embedding_inputs(
|
|
self,
|
|
inputs: Union[str, Dict[str, str], List[str], List[Dict[str, str]]],
|
|
) -> List[Dict[str, str]]:
|
|
"""Normalize embedding inputs into item dicts."""
|
|
if not inputs:
|
|
return []
|
|
if isinstance(inputs, str):
|
|
return [{"text": inputs}]
|
|
if isinstance(inputs, dict):
|
|
return [dict(inputs)]
|
|
first = inputs[0]
|
|
if isinstance(first, str):
|
|
return [{"text": text} for text in inputs]
|
|
return [dict(item) for item in inputs]
|
|
|
|
@staticmethod
|
|
def _positive_context_length(value: Any) -> Optional[int]:
|
|
"""Return a usable positive context length from config/tokenizer metadata."""
|
|
if isinstance(value, bool) or not isinstance(value, int):
|
|
return None
|
|
if 0 < value < _TOKENIZER_MAX_LENGTH_SENTINEL:
|
|
return value
|
|
return None
|
|
|
|
@classmethod
|
|
def _get_config_value(cls, config: Any, key: str) -> Optional[int]:
|
|
if config is None:
|
|
return None
|
|
if isinstance(config, dict):
|
|
return cls._positive_context_length(config.get(key))
|
|
return cls._positive_context_length(getattr(config, key, None))
|
|
|
|
@classmethod
|
|
def _context_length_from_config(cls, config: Any) -> Optional[int]:
|
|
"""Read context length from model config objects or dictionaries."""
|
|
for key in _CONTEXT_LENGTH_ATTRS:
|
|
value = cls._get_config_value(config, key)
|
|
if value is not None:
|
|
return value
|
|
|
|
for nested_key in ("text_config", "language_config"):
|
|
nested = None
|
|
if isinstance(config, dict):
|
|
nested = config.get(nested_key)
|
|
elif config is not None:
|
|
nested = getattr(config, nested_key, None)
|
|
for key in _CONTEXT_LENGTH_ATTRS:
|
|
value = cls._get_config_value(nested, key)
|
|
if value is not None:
|
|
return value
|
|
|
|
return None
|
|
|
|
def _resolve_max_length(self, max_length: int | None) -> int:
|
|
"""Resolve embedding token length when callers omit an explicit limit."""
|
|
if max_length is not None:
|
|
value = self._positive_context_length(max_length)
|
|
if value is None:
|
|
raise ValueError("max_length must be a positive integer")
|
|
return value
|
|
|
|
for config in (
|
|
getattr(self.model, "config", None),
|
|
getattr(self.processor, "config", None),
|
|
):
|
|
value = self._context_length_from_config(config)
|
|
if value is not None:
|
|
return value
|
|
|
|
processor = self.processor
|
|
tokenizers = [
|
|
processor,
|
|
getattr(processor, "tokenizer", None),
|
|
getattr(processor, "_tokenizer", None),
|
|
]
|
|
for tokenizer in tokenizers:
|
|
for attr_name in ("model_max_length", "max_length"):
|
|
value = self._positive_context_length(
|
|
getattr(tokenizer, attr_name, None)
|
|
)
|
|
if value is not None:
|
|
return value
|
|
|
|
return _DEFAULT_EMBEDDING_MAX_LENGTH
|
|
|
|
def _prepare_embedding_inputs(
|
|
self,
|
|
processor,
|
|
inputs: Union[List[str], List[Dict[str, str]]],
|
|
max_length: int,
|
|
padding: bool,
|
|
truncation: bool,
|
|
):
|
|
"""
|
|
Prepare inputs for embedding inference.
|
|
|
|
Some embedding processors, such as qwen3_vl in mlx-embeddings, expose
|
|
a higher-level embedding API instead of the tokenizer-style
|
|
``processor(texts, ...)`` path. Reuse that official extension point
|
|
when available to avoid positional-argument mismatches.
|
|
"""
|
|
normalized_inputs = self._normalize_embedding_inputs(inputs)
|
|
|
|
if self._uses_custom_embedding_inputs(processor):
|
|
if hasattr(processor, "prepare_embedding_inputs"):
|
|
return processor.prepare_embedding_inputs(
|
|
normalized_inputs, return_tensors="mlx"
|
|
)
|
|
return processor.prepare_model_inputs(
|
|
normalized_inputs, return_tensors="mlx"
|
|
)
|
|
|
|
if any("image" in item for item in normalized_inputs):
|
|
raise ValueError(
|
|
f"Embedding model '{self.model_name}' does not support image inputs"
|
|
)
|
|
|
|
from mlx_embeddings.utils import prepare_inputs
|
|
|
|
return prepare_inputs(
|
|
processor,
|
|
None,
|
|
[item.get("text", "") for item in normalized_inputs],
|
|
max_length,
|
|
padding,
|
|
truncation,
|
|
None,
|
|
)
|
|
|
|
def _detect_input_key_remapping(self) -> None:
|
|
"""Check if the model accepts `inputs` instead of `input_ids` and cache the result."""
|
|
try:
|
|
params = inspect.signature(self.model.__call__).parameters
|
|
self._remap_input_ids_to_inputs = (
|
|
"input_ids" not in params and "inputs" in params
|
|
)
|
|
except (TypeError, ValueError):
|
|
self._remap_input_ids_to_inputs = False
|
|
|
|
def _adapt_model_inputs_for_call(
|
|
self, model_inputs: Dict[str, Any]
|
|
) -> Dict[str, Any]:
|
|
"""Rename prepared inputs to match the embedding model call signature."""
|
|
adapted_inputs = dict(model_inputs)
|
|
if self._remap_input_ids_to_inputs and "input_ids" in adapted_inputs:
|
|
adapted_inputs["inputs"] = adapted_inputs.pop("input_ids")
|
|
return adapted_inputs
|
|
|
|
def _try_compile(self) -> bool:
|
|
"""
|
|
Compile a primitive-output embedding forward function.
|
|
|
|
Root-cause fix:
|
|
- Compiling model.__call__ directly can return arrays without primitives
|
|
for some embedding/reranker models, causing eval() runtime errors.
|
|
- We compile a narrower function that returns only the final embedding array.
|
|
"""
|
|
compile_env = os.getenv("OMLX_EMBEDDING_COMPILE", "1").strip().lower()
|
|
if compile_env in _FALSE_ENV_VALUES:
|
|
logger.info(
|
|
"mx.compile disabled for %s by OMLX_EMBEDDING_COMPILE",
|
|
self.model_name,
|
|
)
|
|
self._compiled_embed = None
|
|
return False
|
|
|
|
base_model = self.model
|
|
|
|
try:
|
|
def _compiled_embed(inputs):
|
|
outputs = base_model(**self._adapt_model_inputs_for_call(inputs))
|
|
return self._extract_embeddings_array(outputs)
|
|
|
|
self._compiled_embed = mx.compile(_compiled_embed)
|
|
|
|
test_inputs = {"input_ids": mx.zeros((1, 4), dtype=mx.int32)}
|
|
_ = self._compiled_embed(test_inputs)
|
|
|
|
logger.info(
|
|
f"mx.compile enabled for {self.model_name} "
|
|
f"(primitive embedding path)"
|
|
)
|
|
return True
|
|
except Exception as e:
|
|
logger.info(f"mx.compile unavailable for {self.model_name}: {e}")
|
|
self._compiled_embed = None
|
|
return False
|
|
|
|
def close(self) -> None:
|
|
"""Release model, processor, and compiled embedding resources."""
|
|
if not self._loaded and self.model is None and self.processor is None:
|
|
self._compiled_embed = None
|
|
self._is_compiled = False
|
|
return
|
|
|
|
logger.info(
|
|
"Releasing embedding model resources: %s "
|
|
"(compiled=%s, native=%s)",
|
|
self.model_name,
|
|
self._is_compiled,
|
|
self._using_native,
|
|
)
|
|
|
|
self._compiled_embed = None
|
|
self._is_compiled = False
|
|
|
|
self.model = None
|
|
self.processor = None
|
|
self._hidden_size = None
|
|
self._loaded = False
|
|
self._using_native = False
|
|
self._remap_input_ids_to_inputs = False
|
|
|
|
gc.collect()
|
|
mx.synchronize()
|
|
mx.clear_cache()
|
|
clear_thread_compile_cache()
|
|
gc.collect()
|
|
|
|
def embed(
|
|
self,
|
|
inputs: Union[str, List[str], List[Dict[str, str]]],
|
|
max_length: int | None = None,
|
|
padding: bool = True,
|
|
truncation: bool = True,
|
|
) -> EmbeddingOutput:
|
|
"""
|
|
Generate embeddings for input texts.
|
|
|
|
Args:
|
|
texts: List of input texts
|
|
max_length: Maximum token length for each text. If omitted, use
|
|
model/tokenizer metadata when available.
|
|
padding: Whether to pad shorter sequences
|
|
truncation: Whether to truncate longer sequences
|
|
|
|
Returns:
|
|
EmbeddingOutput with embeddings and token count
|
|
"""
|
|
if not self._loaded:
|
|
self.load()
|
|
|
|
max_length = self._resolve_max_length(max_length)
|
|
normalized_inputs = self._normalize_embedding_inputs(inputs)
|
|
for item in normalized_inputs:
|
|
image_ref = item.get("image")
|
|
if isinstance(image_ref, str):
|
|
item["image"] = validate_image_data_uri(
|
|
image_ref,
|
|
field="items[].image",
|
|
)
|
|
input_texts = [item["text"] for item in normalized_inputs if "text" in item]
|
|
has_image_inputs = any("image" in item for item in normalized_inputs)
|
|
|
|
processor = self.processor
|
|
uses_custom_embedding_inputs = self._uses_custom_embedding_inputs(processor)
|
|
if hasattr(processor, "_tokenizer") and not uses_custom_embedding_inputs:
|
|
processor = processor._tokenizer
|
|
|
|
if has_image_inputs and (self._using_native or not uses_custom_embedding_inputs):
|
|
raise ValueError(
|
|
f"Embedding model '{self.model_name}' does not support image inputs"
|
|
)
|
|
|
|
embeddings_array = None
|
|
total_tokens: Optional[int] = None
|
|
|
|
if self._using_native:
|
|
if hasattr(processor, "__call__"):
|
|
encoded = processor(
|
|
input_texts,
|
|
padding=padding,
|
|
truncation=truncation,
|
|
max_length=max_length,
|
|
return_tensors="np",
|
|
)
|
|
input_ids = mx.array(encoded["input_ids"])
|
|
attention_mask = mx.array(encoded["attention_mask"])
|
|
else:
|
|
encoded_ids = []
|
|
masks = []
|
|
for text in input_texts:
|
|
enc = processor.encode(text, add_special_tokens=True)
|
|
ids = list(enc.ids)
|
|
if truncation:
|
|
ids = ids[:max_length]
|
|
encoded_ids.append(ids)
|
|
max_len = max(len(ids) for ids in encoded_ids)
|
|
padded = []
|
|
for ids in encoded_ids:
|
|
pad_len = max_len - len(ids)
|
|
padded.append(ids + [0] * pad_len)
|
|
masks.append([1] * len(ids) + [0] * pad_len)
|
|
input_ids = mx.array(padded)
|
|
attention_mask = mx.array(masks)
|
|
|
|
outputs = self.model(input_ids=input_ids, attention_mask=attention_mask)
|
|
embeddings_array = self._extract_embeddings_array(outputs)
|
|
total_tokens = self._count_prepared_tokens(
|
|
{"attention_mask": attention_mask, "input_ids": input_ids}
|
|
)
|
|
else:
|
|
if self._is_compiled and self._compiled_embed is not None:
|
|
try:
|
|
inputs = self._prepare_embedding_inputs(
|
|
processor,
|
|
normalized_inputs,
|
|
max_length,
|
|
padding,
|
|
truncation,
|
|
)
|
|
if not isinstance(inputs, dict):
|
|
inputs = dict(inputs)
|
|
total_tokens = self._count_prepared_tokens(inputs)
|
|
embeddings_array = self._compiled_embed(inputs)
|
|
except Exception as e:
|
|
logger.warning(
|
|
f"compiled embedding path failed for {self.model_name}: {e}; "
|
|
"disabling compile and falling back to eager generate()"
|
|
)
|
|
self._is_compiled = False
|
|
self._compiled_embed = None
|
|
total_tokens = None
|
|
|
|
if embeddings_array is None:
|
|
if uses_custom_embedding_inputs:
|
|
inputs = self._prepare_embedding_inputs(
|
|
processor,
|
|
normalized_inputs,
|
|
max_length,
|
|
padding,
|
|
truncation,
|
|
)
|
|
if not isinstance(inputs, dict):
|
|
inputs = dict(inputs)
|
|
outputs = self.model(**self._adapt_model_inputs_for_call(inputs))
|
|
total_tokens = self._count_prepared_tokens(inputs)
|
|
else:
|
|
from mlx_embeddings import generate
|
|
|
|
outputs = generate(
|
|
self.model,
|
|
processor,
|
|
input_texts,
|
|
max_length=max_length,
|
|
padding=padding,
|
|
truncation=truncation,
|
|
)
|
|
embeddings_array = self._extract_embeddings_array(outputs)
|
|
|
|
mx.eval(embeddings_array)
|
|
embeddings = embeddings_array.tolist()
|
|
if total_tokens is None:
|
|
total_tokens = self._count_tokens(normalized_inputs)
|
|
dimensions = len(embeddings[0]) if embeddings else 0
|
|
|
|
return EmbeddingOutput(
|
|
embeddings=embeddings,
|
|
total_tokens=total_tokens,
|
|
dimensions=dimensions,
|
|
)
|
|
|
|
def _count_tokens(
|
|
self, inputs: Union[List[str], List[Dict[str, str]]]
|
|
) -> int:
|
|
"""Count total tokens in input texts."""
|
|
total = 0
|
|
processor = self.processor
|
|
|
|
for item in self._normalize_embedding_inputs(inputs):
|
|
text = item.get("text")
|
|
if not text:
|
|
continue
|
|
if hasattr(processor, "encode"):
|
|
tokens = processor.encode(text, add_special_tokens=True)
|
|
if isinstance(tokens, list):
|
|
total += len(tokens)
|
|
elif hasattr(tokens, "shape"):
|
|
total += tokens.shape[-1] if tokens.ndim > 0 else 1
|
|
elif hasattr(tokens, "ids"):
|
|
total += len(tokens.ids)
|
|
else:
|
|
total += len(tokens)
|
|
elif hasattr(processor, "tokenizer"):
|
|
tokens = processor.tokenizer.encode(text, add_special_tokens=True)
|
|
total += len(tokens) if isinstance(tokens, list) else len(list(tokens))
|
|
elif hasattr(processor, "_tokenizer"):
|
|
tokens = processor._tokenizer.encode(text, add_special_tokens=True)
|
|
total += len(tokens) if isinstance(tokens, list) else len(list(tokens))
|
|
else:
|
|
total += len(text.split()) + 2
|
|
|
|
return total
|
|
|
|
def _count_prepared_tokens(self, prepared_inputs: Dict[str, Any]) -> int:
|
|
"""Count tokens from prepared model inputs, including multimodal tokens."""
|
|
attention_mask = prepared_inputs.get("attention_mask")
|
|
if attention_mask is not None:
|
|
try:
|
|
return int(mx.sum(attention_mask).item())
|
|
except (TypeError, ValueError):
|
|
pass
|
|
if isinstance(attention_mask, list):
|
|
return int(sum(sum(row) if isinstance(row, list) else row for row in attention_mask))
|
|
if hasattr(attention_mask, "tolist"):
|
|
values = attention_mask.tolist()
|
|
if values and isinstance(values[0], list):
|
|
return int(sum(sum(row) for row in values))
|
|
return int(sum(values))
|
|
|
|
input_ids = prepared_inputs.get("input_ids")
|
|
if input_ids is None:
|
|
return 0
|
|
if hasattr(input_ids, "shape"):
|
|
if len(input_ids.shape) == 0:
|
|
return 1
|
|
if len(input_ids.shape) == 1:
|
|
return int(input_ids.shape[0])
|
|
return int(input_ids.shape[0] * input_ids.shape[1])
|
|
if isinstance(input_ids, list):
|
|
if input_ids and isinstance(input_ids[0], list):
|
|
return int(sum(len(row) for row in input_ids))
|
|
return int(len(input_ids))
|
|
return 0
|
|
|
|
@property
|
|
def hidden_size(self) -> Optional[int]:
|
|
"""Get the embedding dimension."""
|
|
return self._hidden_size
|
|
|
|
def get_model_info(self) -> dict:
|
|
"""Get information about the loaded model."""
|
|
if not self._loaded:
|
|
return {"loaded": False, "model_name": self.model_name}
|
|
|
|
info = {
|
|
"loaded": True,
|
|
"model_name": self.model_name,
|
|
"hidden_size": self._hidden_size,
|
|
"native_implementation": self._using_native,
|
|
"compiled": self._is_compiled,
|
|
}
|
|
|
|
if hasattr(self.model, "config"):
|
|
config = self.model.config
|
|
info.update(
|
|
{
|
|
"model_type": getattr(config, "model_type", None),
|
|
"vocab_size": getattr(config, "vocab_size", None),
|
|
"max_position_embeddings": getattr(
|
|
config, "max_position_embeddings", None
|
|
),
|
|
}
|
|
)
|
|
|
|
return info
|
|
|
|
def __repr__(self) -> str:
|
|
status = "loaded" if self._loaded else "not loaded"
|
|
impl = "native" if self._using_native else "mlx-embeddings"
|
|
return (
|
|
f"<MLXEmbeddingModel model={self.model_name} "
|
|
f"status={status} impl={impl}>"
|
|
)
|