Integrate with Sentence Transformers, restore training-time tokenization on newer transformers

#1
by tomaarsen HF Staff - opened
README.md CHANGED
@@ -9,6 +9,7 @@ base_model:
9
  pipeline_tag: feature-extraction
10
 
11
  tags:
 
12
  - multimodal-embedding
13
  - text-embedding
14
  - image-embedding
@@ -32,7 +33,7 @@ WeMM-Embedding-2B is a universal multimodal embedding model built on Qwen3.5. It
32
 
33
  ```bash
34
  pip install torch transformers==5.2.0 "qwen-vl-utils[decord]==0.0.14" \
35
- sentence-transformers==5.7.0 "accelerate>=1.1.0"
36
  ```
37
 
38
  ## Transformers
@@ -85,23 +86,43 @@ Use any subset of the content items to encode text, image, or video independentl
85
  ## Sentence Transformers
86
 
87
  ```python
88
- from wemm_sentence_transformers import load_wemm_sentence_transformer
89
 
90
  model_id = "tencent/WeMM-Embedding-2B"
91
- model = load_wemm_sentence_transformer(model_id, device="cuda:0")
92
- inputs = [
93
- "A dog is running on a beach.",
94
- {"image": "/path/to/image.jpg", "text": "Represent this image."},
95
- {"video": "/path/to/video.mp4", "text": "Represent this video."},
96
  ]
97
- embeddings = model.encode(
98
- inputs,
99
- batch_size=1,
100
- truncate_dim=2048,
101
- normalize_embeddings=True,
102
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  ```
104
 
 
 
 
 
 
105
 
106
  ## Matryoshka Embeddings
107
 
@@ -109,6 +130,12 @@ embeddings = model.encode(
109
  embedding_256 = torch.nn.functional.normalize(embedding[..., :256], dim=-1)
110
  ```
111
 
 
 
 
 
 
 
112
  Use a dimension listed in `model.config.matryoshka_dimensions`. On MMEB-v2, 256-dimensional embeddings retain 98.7% of the full-dimensional image and video performance.
113
 
114
  ## Serving
 
9
  pipeline_tag: feature-extraction
10
 
11
  tags:
12
+ - sentence-transformers
13
  - multimodal-embedding
14
  - text-embedding
15
  - image-embedding
 
33
 
34
  ```bash
35
  pip install torch transformers==5.2.0 "qwen-vl-utils[decord]==0.0.14" \
36
+ "sentence-transformers>=5.7.0" "accelerate>=1.1.0"
37
  ```
38
 
39
  ## Transformers
 
86
  ## Sentence Transformers
87
 
88
  ```python
89
+ from sentence_transformers import SentenceTransformer
90
 
91
  model_id = "tencent/WeMM-Embedding-2B"
92
+ model = SentenceTransformer(model_id, trust_remote_code=True)
93
+
94
+ queries = [
95
+ "Which Llama 4 model variants are available?",
96
+ "How is mapo tofu prepared?",
97
  ]
98
+ documents = [
99
+ "Mapo tofu is a Sichuan dish of soft tofu simmered in a spicy, numbing sauce of chili bean paste and Sichuan peppercorn.",
100
+ {
101
+ "image": "https://huggingface.co/datasets/sentence-transformers/example-documents/resolve/main/llama4_hgf.png",
102
+ "text": "Represent this image.",
103
+ },
104
+ {
105
+ "video": "https://huggingface.co/datasets/sentence-transformers/example-documents/resolve/main/mapo_tofu.mp4",
106
+ "text": "Represent this video.",
107
+ },
108
+ ]
109
+
110
+ query_embeddings = model.encode_query(queries)
111
+ document_embeddings = model.encode_document(documents)
112
+ print(query_embeddings.shape, document_embeddings.shape)
113
+ # (2, 2048) (3, 2048)
114
+
115
+ similarities = model.similarity(query_embeddings, document_embeddings)
116
+ print(similarities)
117
+ # tensor([[0.0683, 0.4972, 0.0309],
118
+ # [0.7829, 0.1428, 0.5492]])
119
  ```
120
 
121
+ Each input is a string, a URL or path, a `PIL.Image`, or a dict combining `image`,
122
+ `video`, and `text` keys. Put `image` or `video` before `text` so the prompt matches
123
+ the ordering used above. Chat messages such as
124
+ `{"role": "user", "content": [{"type": "image", "image": ...}, {"type": "text", "text": ...}]}`
125
+ are also accepted, which is the way to interleave several images or videos in one input.
126
 
127
  ## Matryoshka Embeddings
128
 
 
130
  embedding_256 = torch.nn.functional.normalize(embedding[..., :256], dim=-1)
131
  ```
132
 
133
+ With Sentence Transformers, pass `truncate_dim` and let it renormalize:
134
+
135
+ ```python
136
+ embeddings_256 = model.encode_document(documents, truncate_dim=256, normalize_embeddings=True)
137
+ ```
138
+
139
  Use a dimension listed in `model.config.matryoshka_dimensions`. On MMEB-v2, 256-dimensional embeddings retain 98.7% of the full-dimensional image and video performance.
140
 
141
  ## Serving
additional_chat_templates/sentence_transformers.jinja ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {#- Embedding template used by Sentence Transformers.
2
+
3
+ Renders the same surface form as chat_template.jinja for text and images. Video
4
+ is the exception: it renders as a bare <|video_pad|>, because the processor
5
+ expands that token into one <seconds><|vision_start|>...<|vision_end|> block per
6
+ frame and, from transformers 5.3, no longer consumes a surrounding pair. The
7
+ wrapped form therefore gains a second, unwanted pair of vision boundaries there.
8
+ This matches embedding_chat_template.jinja, which is bare for the same reason.
9
+
10
+ The tokenizer normalizer drops the role newline before a leading image and the
11
+ newline after the final <|im_end|>, then the post-processor appends <embedding>. #}
12
+ {%- for message in messages %}
13
+ {{- '<|im_start|>' + message.role + '\n' }}
14
+ {%- if message.content is string %}
15
+ {{- message.content }}
16
+ {%- elif message.content is iterable and message.content is not mapping %}
17
+ {%- for item in message.content %}
18
+ {%- if 'image' in item or 'image_url' in item or item.type == 'image' %}
19
+ {{- '<|vision_start|><|image_pad|><|vision_end|>' }}
20
+ {%- elif 'video' in item or item.type == 'video' %}
21
+ {{- '<|video_pad|>' }}
22
+ {%- elif 'text' in item %}
23
+ {{- item.text }}
24
+ {%- else %}
25
+ {{- raise_exception('Unexpected item type in content.') }}
26
+ {%- endif %}
27
+ {%- endfor %}
28
+ {%- elif message.content is not none %}
29
+ {{- raise_exception('Unexpected content type.') }}
30
+ {%- endif %}
31
+ {{- '<|im_end|>\n' }}
32
+ {%- endfor %}
config_sentence_transformers.json ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "__version__": {
3
+ "pytorch": "2.11.0+cu128",
4
+ "sentence_transformers": "5.7.0",
5
+ "transformers": "5.2.0"
6
+ },
7
+ "default_prompt_name": null,
8
+ "model_type": "SentenceTransformer",
9
+ "prompts": {},
10
+ "similarity_fn_name": "cosine"
11
+ }
modeling_st_wemm.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sentence Transformers module for WeMM-Embedding.
2
+
3
+ Reproduces the `transformers` usage from the model card inside a Sentence Transformers
4
+ pipeline: vision inputs are prepared with `qwen_vl_utils.process_vision_info` and the
5
+ embedding is read from `WeMMEmbedding.embedding`, which pools the `<embedding>` position and
6
+ L2-normalizes. Everything else (batching, prompts, truncation, `encode_query` /
7
+ `encode_document`, similarity) comes from the stock `Transformer` module.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import inspect
13
+ from typing import Any
14
+
15
+ from sentence_transformers.models import Transformer
16
+
17
+
18
+ class WeMMTransformer(Transformer):
19
+ """`Transformer` that prepares images and videos the way the model card's snippet does."""
20
+
21
+ def __init__(self, model_name_or_path: str, **kwargs: Any) -> None:
22
+ super().__init__(model_name_or_path, **kwargs)
23
+ vision_config = getattr(self.config, "vision_config", None)
24
+ self.image_patch_size = int(getattr(vision_config, "patch_size", 16))
25
+
26
+ # `embedding` hands its **kwargs to the inner model, so filtering on its own signature
27
+ # would drop `pixel_values`. Filter on the inner model's parameters instead, plus the
28
+ # processor's input names for anything the model only accepts as **kwargs.
29
+ inner_model = getattr(self.model, "model", self.model)
30
+ signature = set(inspect.signature(inner_model.forward).parameters)
31
+ signature |= set(getattr(self.processor, "model_input_names", ()))
32
+ for modality_params in self.modality_config.values():
33
+ method_name = modality_params["method"]
34
+ if method_name != "forward":
35
+ self._method_signature_cache.setdefault(method_name, signature)
36
+
37
+ def _apply_chat_template(
38
+ self,
39
+ messages: list[list[dict[str, Any]]],
40
+ modality_kwargs: dict[str, dict[str, Any]],
41
+ common_kwargs: dict[str, Any],
42
+ chat_template_kwargs: dict[str, Any],
43
+ ) -> dict[str, Any]:
44
+ """Render the chat template and prepare images / videos exactly as the model card does."""
45
+ from qwen_vl_utils import process_vision_info
46
+
47
+ chat_template_kwargs = {"add_generation_prompt": False, **chat_template_kwargs}
48
+ texts = [
49
+ self.processor.apply_chat_template(conversation, tokenize=False, **chat_template_kwargs)
50
+ for conversation in messages
51
+ ]
52
+ images, videos, video_kwargs = process_vision_info(
53
+ [list(conversation) for conversation in messages],
54
+ image_patch_size=self.image_patch_size,
55
+ return_video_kwargs=True,
56
+ return_video_metadata=True,
57
+ )
58
+ if videos is not None:
59
+ videos, video_metadata = (list(part) for part in zip(*videos))
60
+ video_kwargs = {**video_kwargs, "video_metadata": video_metadata}
61
+
62
+ return self.processor(
63
+ text=texts,
64
+ images=images,
65
+ videos=videos,
66
+ text_kwargs=modality_kwargs["text"],
67
+ images_kwargs=modality_kwargs["image"],
68
+ videos_kwargs={**modality_kwargs["video"], **video_kwargs},
69
+ common_kwargs=common_kwargs,
70
+ )
modeling_wemm_embedding.py CHANGED
@@ -5,6 +5,10 @@ from transformers import Qwen3_5ForConditionalGeneration
5
 
6
  class WeMMEmbedding(Qwen3_5ForConditionalGeneration):
7
  def embedding(self, input_ids=None, attention_mask=None, **kwargs):
 
 
 
 
8
  outputs = self.model(
9
  input_ids=input_ids,
10
  attention_mask=attention_mask,
 
5
 
6
  class WeMMEmbedding(Qwen3_5ForConditionalGeneration):
7
  def embedding(self, input_ids=None, attention_mask=None, **kwargs):
8
+ # transformers < 5.15 reuses the rope_deltas cached by the previous multimodal
9
+ # forward for a text-only one, which shifts its position ids.
10
+ self.model.rope_deltas = None
11
+
12
  outputs = self.model(
13
  input_ids=input_ids,
14
  attention_mask=attention_mask,
modules.json ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "idx": 0,
4
+ "name": "0",
5
+ "path": "",
6
+ "type": "modeling_st_wemm.WeMMTransformer"
7
+ }
8
+ ]
sentence_bert_config.json ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "transformer_task": "feature-extraction",
3
+ "modality_config": {
4
+ "text": {
5
+ "method": "embedding",
6
+ "method_output_name": null
7
+ },
8
+ "image": {
9
+ "method": "embedding",
10
+ "method_output_name": null
11
+ },
12
+ "video": {
13
+ "method": "embedding",
14
+ "method_output_name": null
15
+ },
16
+ "image+text": {
17
+ "method": "embedding",
18
+ "method_output_name": null
19
+ },
20
+ "text+video": {
21
+ "method": "embedding",
22
+ "method_output_name": null
23
+ },
24
+ "message": {
25
+ "method": "embedding",
26
+ "method_output_name": null,
27
+ "format": "structured"
28
+ }
29
+ },
30
+ "module_output_name": "sentence_embedding",
31
+ "processing_kwargs": {
32
+ "chat_template": {
33
+ "chat_template": "sentence_transformers"
34
+ }
35
+ },
36
+ "unpad_inputs": false
37
+ }
tokenizer_config.json CHANGED
@@ -24,7 +24,7 @@
24
  "pretokenize_regex": "(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\\r\\n\\p{L}\\p{N}]?[\\p{L}\\p{M}]+|\\p{N}| ?[^\\s\\p{L}\\p{M}\\p{N}]+[\\r\\n]*|\\s*[\\r\\n]+|\\s+(?!\\S)|\\s+",
25
  "processor_class": "Qwen3VLProcessor",
26
  "split_special_tokens": false,
27
- "tokenizer_class": "Qwen2TokenizerFast",
28
  "unk_token": null,
29
  "video_token": "<|video_pad|>",
30
  "vision_bos_token": "<|vision_start|>",
 
24
  "pretokenize_regex": "(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\\r\\n\\p{L}\\p{N}]?[\\p{L}\\p{M}]+|\\p{N}| ?[^\\s\\p{L}\\p{M}\\p{N}]+[\\r\\n]*|\\s*[\\r\\n]+|\\s+(?!\\S)|\\s+",
25
  "processor_class": "Qwen3VLProcessor",
26
  "split_special_tokens": false,
27
+ "tokenizer_class": "PreTrainedTokenizerFast",
28
  "unk_token": null,
29
  "video_token": "<|video_pad|>",
30
  "vision_bos_token": "<|vision_start|>",
wemm_sentence_transformers.py DELETED
@@ -1,198 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Sentence-Transformers adapter for WeMM-Embedding."""
3
-
4
- from __future__ import annotations
5
-
6
- from pathlib import Path
7
- from typing import Any
8
-
9
- import torch
10
- from qwen_vl_utils import process_vision_info
11
- from sentence_transformers import SentenceTransformer
12
- from sentence_transformers.sentence_transformer.modules import InputModule
13
- from transformers import AutoModel, AutoProcessor
14
-
15
-
16
- def _as_messages(
17
- value: Any,
18
- prompt: str | None = None,
19
- ) -> list[dict[str, Any]]:
20
- if isinstance(value, str):
21
- text = f"{prompt or ''}{value}"
22
- return [{"role": "user", "content": [{"type": "text", "text": text}]}]
23
-
24
- if isinstance(value, list):
25
- if not value or not all(
26
- isinstance(message, dict) and "role" in message for message in value
27
- ):
28
- raise TypeError("A list input must be one chat-style conversation.")
29
- messages = [dict(message) for message in value]
30
- if prompt:
31
- messages.insert(0, {"role": "user", "content": prompt})
32
- return messages
33
-
34
- if not isinstance(value, dict):
35
- raise TypeError(
36
- "Input must be text, a chat conversation, or a multimodal dict."
37
- )
38
- if "messages" in value:
39
- return _as_messages(value["messages"], prompt=prompt)
40
-
41
- content: list[dict[str, Any]] = []
42
- for modality in ("image", "video"):
43
- if value.get(modality) is not None:
44
- content.append({"type": modality, modality: value[modality]})
45
- if value.get("text") is not None:
46
- content.append(
47
- {"type": "text", "text": f"{prompt or ''}{value['text']}"}
48
- )
49
- elif prompt:
50
- content.append({"type": "text", "text": prompt})
51
- if not content:
52
- raise ValueError("Input must contain text, image, or video.")
53
- return [{"role": "user", "content": content}]
54
-
55
-
56
- class WeMMInputModule(InputModule):
57
- config_keys = ["model_name_or_path"]
58
- save_in_root = False
59
-
60
- def __init__(
61
- self,
62
- model_name_or_path: str,
63
- *,
64
- device: str | torch.device | None = None,
65
- **_: Any,
66
- ) -> None:
67
- super().__init__()
68
- self.model_name_or_path = str(model_name_or_path)
69
- self.processor = AutoProcessor.from_pretrained(
70
- self.model_name_or_path,
71
- trust_remote_code=True,
72
- )
73
- self.tokenizer = self.processor.tokenizer
74
- self.tokenizer.padding_side = "right"
75
- self.auto_model = AutoModel.from_pretrained(
76
- self.model_name_or_path,
77
- trust_remote_code=True,
78
- dtype=torch.bfloat16,
79
- device_map=device,
80
- attn_implementation="sdpa",
81
- experts_implementation="eager",
82
- ).eval()
83
- self.embedding_token_id = self.tokenizer.convert_tokens_to_ids(
84
- "<embedding>"
85
- )
86
- text_config = getattr(
87
- self.auto_model.config,
88
- "text_config",
89
- self.auto_model.config,
90
- )
91
- self.embedding_dimension = int(text_config.hidden_size)
92
-
93
- @property
94
- def modalities(self):
95
- return [
96
- "text",
97
- "image",
98
- "video",
99
- "message",
100
- ("image", "text"),
101
- ("text", "video"),
102
- ]
103
-
104
- @property
105
- def max_seq_length(self) -> int:
106
- return int(self.tokenizer.model_max_length)
107
-
108
- def preprocess(
109
- self,
110
- inputs: list[Any],
111
- prompt: str | None = None,
112
- **_: Any,
113
- ) -> dict[str, torch.Tensor | Any]:
114
- conversations = [
115
- _as_messages(value, prompt=prompt)
116
- for value in inputs
117
- ]
118
- prompts = [
119
- self.processor.apply_chat_template(
120
- messages,
121
- tokenize=False,
122
- add_generation_prompt=False,
123
- )
124
- for messages in conversations
125
- ]
126
- images, videos, video_kwargs = process_vision_info(
127
- conversations,
128
- image_patch_size=16,
129
- return_video_kwargs=True,
130
- return_video_metadata=True,
131
- )
132
- if videos is not None:
133
- videos, video_metadata = zip(*videos)
134
- videos, video_metadata = list(videos), list(video_metadata)
135
- else:
136
- video_metadata = None
137
-
138
- features = self.processor(
139
- text=prompts,
140
- images=images,
141
- videos=videos,
142
- video_metadata=video_metadata,
143
- padding=True,
144
- return_tensors="pt",
145
- **video_kwargs,
146
- )
147
- input_ids = features["input_ids"]
148
- positions = features["attention_mask"].sum(dim=1) - 1
149
- batch = torch.arange(input_ids.shape[0])
150
- terminal_ids = input_ids[batch, positions]
151
- if not torch.all(terminal_ids == self.embedding_token_id):
152
- raise RuntimeError("Each input must end with <embedding>.")
153
- features["modality"] = "message"
154
- return dict(features)
155
-
156
- def forward(
157
- self,
158
- features: dict[str, torch.Tensor | Any],
159
- **_: Any,
160
- ) -> dict[str, torch.Tensor | Any]:
161
- model_inputs = {
162
- key: value
163
- for key, value in features.items()
164
- if key != "modality"
165
- }
166
- inner = getattr(self.auto_model, "model", None)
167
- if inner is not None and hasattr(inner, "rope_deltas"):
168
- inner.rope_deltas = None
169
- features["sentence_embedding"] = self.auto_model.embedding(
170
- **model_inputs
171
- ).float()
172
- return features
173
-
174
- def get_embedding_dimension(self) -> int:
175
- return self.embedding_dimension
176
-
177
- def save(
178
- self,
179
- output_path: str,
180
- *args: Any,
181
- safe_serialization: bool = True,
182
- **kwargs: Any,
183
- ) -> None:
184
- del args, safe_serialization, kwargs
185
- self.save_config(output_path)
186
-
187
-
188
- def load_wemm_sentence_transformer(
189
- model_name_or_path: str | Path,
190
- *,
191
- device: str | torch.device = "cuda:0",
192
- ) -> SentenceTransformer:
193
- return SentenceTransformer(
194
- modules=[WeMMInputModule(str(model_name_or_path), device=device)],
195
- device=str(device),
196
- similarity_fn_name="cosine",
197
- )
198
-