main
py 462 lines 17.2 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """ColBERT late interaction model for retrieval and reranking.
4
5 ColBERT uses per-token embeddings and late interaction (MaxSim) scoring
6 instead of single-vector representations or cross-encoder concatenation.
7
8 This module provides:
9
10 - :class:`ColBERTMixin` — mixin that adds ColBERT late-interaction support
11 to any embedding model.
12 - :class:`ColBERTModel` — ColBERT with BERT backbone (original architecture).
13 - :class:`ColBERTModernBertModel` — ColBERT with ModernBERT backbone.
14 - :class:`ColBERTJinaRobertaModel` — ColBERT with Jina XLM-RoBERTa backbone.
15
16 Reference: https://arxiv.org/abs/2004.12832
17 """
18
19 from collections.abc import Iterable
20
21 import torch
22 from torch import nn
23
24 from vllm.config import PoolerConfig, VllmConfig
25 from vllm.model_executor.layers.pooler import Pooler
26 from vllm.model_executor.layers.pooler.tokwise import pooler_for_token_embed
27 from vllm.model_executor.models.utils import AutoWeightsLoader, WeightsMapper
28
29 from .bert import BertEmbeddingModel, BertModel
30 from .interfaces import HasInnerState, IsHybrid, SupportsLateInteraction
31 from .interfaces_base import default_pooling_type
32 from .lfm2 import Lfm2ForCausalLM, Lfm2Model
33
34
35 class ColBERTMixin(nn.Module, SupportsLateInteraction):
36 """Mixin that adds ColBERT late interaction support to any embedding model.
37
38 ColBERT (Contextualized Late Interaction over BERT) uses per-token
39 embeddings with a linear projection layer. This mixin provides:
40
41 - ColBERT linear projection initialisation / lazy creation
42 - Weight loading helpers for the projection layer
43 - A builder for the token-embedding pooler
44
45 **Integration:**
46
47 1. Inherit from both ``ColBERTMixin`` and ``nn.Module``.
48 2. In ``__init__``: call ``super().__init__()``, then
49 :meth:`_init_colbert_components`, then create ``self.model``
50 (the backbone) and ``self.pooler`` via :meth:`_build_colbert_pooler`.
51 3. In ``load_weights``: use :meth:`_load_colbert_weights` to separate
52 the ColBERT projection weight, then delegate the rest to the backbone.
53 """
54
55 # Set during _init_colbert_components
56 colbert_dim: int | None
57 colbert_linear: nn.Linear | None
58 hidden_size: int
59 head_dtype: torch.dtype
60
61 # ------------------------------------------------------------------ init
62
63 def _init_colbert_components(
64 self,
65 hidden_size: int,
66 colbert_dim: int | None,
67 head_dtype: torch.dtype,
68 ) -> None:
69 """Initialise ColBERT projection layer.
70
71 Args:
72 hidden_size: Hidden dimension of the encoder backbone.
73 colbert_dim: Output dimension for ColBERT embeddings. If
74 ``None``, will be inferred from weights during loading (or
75 auto-loaded from sentence-transformers Dense layers).
76 head_dtype: Data type for the projection layer.
77
78 """
79 self.hidden_size = hidden_size
80 self.colbert_dim = colbert_dim
81 self.head_dtype = head_dtype
82
83 if colbert_dim is not None:
84 self.colbert_linear = self._build_colbert_linear()
85 else:
86 self.colbert_linear = None
87
88 def _build_colbert_linear(self) -> nn.Linear:
89 """Build the ColBERT linear projection layer."""
90 if self.colbert_dim is None:
91 raise ValueError("colbert_dim must be set before building the linear layer")
92 return nn.Linear(
93 self.hidden_size,
94 self.colbert_dim,
95 bias=False,
96 dtype=self.head_dtype,
97 )
98
99 # ---------------------------------------------------------------- pooler
100
101 def _build_colbert_pooler(self, pooler_config: PoolerConfig) -> Pooler:
102 """Build pooler for ColBERT token embeddings.
103
104 When ``colbert_linear`` is set, it is used as the projector.
105 Otherwise ``pooler_for_token_embed`` falls back to auto-loading
106 sentence-transformers Dense layers (``1_Dense/`` etc.).
107 """
108 return pooler_for_token_embed(
109 pooler_config,
110 projector=self.colbert_linear,
111 )
112
113 # --------------------------------------------------------- config helper
114
115 @classmethod
116 def get_colbert_dim_from_config(cls, hf_config) -> int | None:
117 """Extract ColBERT dimension from a HuggingFace config.
118
119 Checks ``colbert_dim``, ``dim`` and ``projection_dim`` in that order.
120 """
121 return (
122 getattr(hf_config, "colbert_dim", None)
123 or getattr(hf_config, "dim", None)
124 or getattr(hf_config, "projection_dim", None)
125 )
126
127 # -------------------------------------------------------- weight loading
128
129 def _load_colbert_weights(
130 self,
131 weights: Iterable[tuple[str, torch.Tensor]],
132 colbert_weight_names: tuple[str, ...] = (
133 "linear.weight",
134 "colbert_linear.weight",
135 ),
136 ) -> tuple[list[tuple[str, torch.Tensor]], set[str]]:
137 """Separate and load ColBERT projection weights.
138
139 Scans *weights* for entries whose name ends with one of
140 *colbert_weight_names*. The matching weight is loaded into
141 ``self.colbert_linear`` (creating it first if ``colbert_dim`` was
142 not known at init time).
143
144 Args:
145 weights: Iterable of ``(name, tensor)`` weight pairs.
146 colbert_weight_names: Suffixes that identify the ColBERT linear
147 weight.
148
149 Returns:
150 ``(remaining_weights, loaded_names)`` — the weights that were
151 **not** consumed and the set of names that were loaded.
152
153 """
154 weights_list = list(weights)
155 other_weights: list[tuple[str, torch.Tensor]] = []
156 colbert_weight: tuple[str, torch.Tensor] | None = None
157
158 for name, weight in weights_list:
159 if any(name.endswith(cw) for cw in colbert_weight_names):
160 colbert_weight = (name, weight)
161 else:
162 other_weights.append((name, weight))
163
164 loaded: set[str] = set()
165 if colbert_weight is not None:
166 _name, weight = colbert_weight
167 if weight.dim() == 2:
168 # Infer colbert_dim from weight shape if not set
169 if self.colbert_dim is None:
170 self.colbert_dim = weight.shape[0]
171 self.colbert_linear = self._build_colbert_linear()
172 # Update the pooler's projector
173 if hasattr(self, "pooler") and hasattr(self.pooler, "head"):
174 self.pooler.head.projector = self.colbert_linear
175
176 assert self.colbert_linear is not None
177 # Move to same device as model
178 if hasattr(self, "model"):
179 device = next(self.model.parameters()).device
180 self.colbert_linear.to(device)
181
182 weight = weight.to(self.colbert_linear.weight.device)
183 self.colbert_linear.weight.data.copy_(weight)
184 loaded.add("pooler.head.projector.weight")
185
186 return other_weights, loaded
187
188
189 # -----------------------------------------------------------------------
190 # Concrete model: ColBERT + BERT backbone (original architecture)
191 # -----------------------------------------------------------------------
192
193
194 @default_pooling_type(seq_pooling_type="CLS", tok_pooling_type="ALL")
195 class ColBERTModel(ColBERTMixin, BertEmbeddingModel):
196 """ColBERT late interaction model with BERT backbone.
197
198 Supports the ``token_embed`` task (per-token embeddings for late
199 interaction). MaxSim scoring is computed externally.
200 """
201
202 def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
203 config = vllm_config.model_config.hf_config
204
205 # Must run before super().__init__ because _build_pooler reads these.
206 colbert_dim = self.get_colbert_dim_from_config(config)
207 self._init_colbert_components(
208 hidden_size=config.hidden_size,
209 colbert_dim=colbert_dim,
210 head_dtype=vllm_config.model_config.head_dtype,
211 )
212
213 super().__init__(vllm_config=vllm_config, prefix=prefix)
214
215 def _build_model(self, vllm_config: VllmConfig, prefix: str = "") -> BertModel:
216 return BertModel(vllm_config=vllm_config, prefix=prefix)
217
218 def _build_pooler(self, pooler_config: PoolerConfig) -> Pooler:
219 return self._build_colbert_pooler(pooler_config)
220
221 def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]):
222 other_weights, colbert_loaded = self._load_colbert_weights(weights)
223 # Force "bert." to become "model."
224 mapper = WeightsMapper(orig_to_new_prefix={"bert.": "model."})
225 loader = AutoWeightsLoader(self)
226 loaded = loader.load_weights(other_weights, mapper=mapper)
227 return loaded | colbert_loaded
228
229
230 # -----------------------------------------------------------------------
231 # Concrete model: ColBERT + ModernBERT backbone
232 # -----------------------------------------------------------------------
233
234 from .modernbert import ModernBertModel # noqa: E402
235
236
237 @default_pooling_type(seq_pooling_type="CLS", tok_pooling_type="ALL")
238 class ColBERTModernBertModel(ColBERTMixin, nn.Module):
239 """ColBERT late interaction model with ModernBERT backbone.
240
241 For ``lightonai/GTE-ModernColBERT-v1`` and similar models.
242 The projection is auto-loaded from sentence-transformers ``1_Dense/``
243 when not present in the main checkpoint.
244 """
245
246 is_pooling_model = True
247
248 def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
249 super().__init__()
250 config = vllm_config.model_config.hf_config
251
252 colbert_dim = self.get_colbert_dim_from_config(config)
253 self._init_colbert_components(
254 hidden_size=config.hidden_size,
255 colbert_dim=colbert_dim,
256 head_dtype=vllm_config.model_config.head_dtype,
257 )
258
259 self.model = ModernBertModel(
260 vllm_config=vllm_config,
261 prefix=prefix,
262 )
263
264 pooler_config = vllm_config.model_config.pooler_config
265 assert pooler_config is not None
266 self.pooler = self._build_colbert_pooler(pooler_config)
267
268 def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor:
269 return self.model.embed_input_ids(input_ids)
270
271 def forward(
272 self,
273 input_ids: torch.Tensor,
274 positions: torch.Tensor,
275 intermediate_tensors=None,
276 inputs_embeds: torch.Tensor | None = None,
277 ) -> torch.Tensor:
278 return self.model(
279 input_ids=input_ids,
280 positions=positions,
281 inputs_embeds=inputs_embeds,
282 intermediate_tensors=intermediate_tensors,
283 )
284
285 def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]):
286 other_weights, colbert_loaded = self._load_colbert_weights(weights)
287
288 loaded_model = self.model.load_weights(other_weights)
289 loaded = {f"model.{name}" for name in loaded_model} | colbert_loaded
290
291 # When the ST projector is loaded via `_build_colbert_pooler`, the weights
292 # might come from `colbert_loaded` or the pooler automatically falls back to
293 # load from `/1_Dense` etc.
294 # We need to mark its params as loaded so the weight validator doesn't complain
295 # when they are loaded via fallback.
296 if hasattr(self.pooler, "head"):
297 head = self.pooler.head
298 projector = getattr(head, "projector", None)
299 if projector is not None and isinstance(projector, nn.Module):
300 for name, _ in projector.named_parameters():
301 loaded.add(f"pooler.head.projector.{name}")
302
303 return loaded
304
305
306 # -----------------------------------------------------------------------
307 # Concrete model: ColBERT + Jina XLM-RoBERTa backbone
308 # -----------------------------------------------------------------------
309
310 from .bert_with_rope import JinaRobertaModel # noqa: E402
311
312
313 @default_pooling_type(seq_pooling_type="CLS", tok_pooling_type="ALL")
314 class ColBERTJinaRobertaModel(ColBERTMixin, nn.Module):
315 """ColBERT late interaction model with Jina XLM-RoBERTa backbone.
316
317 For ``jinaai/jina-colbert-v2`` and similar models.
318 """
319
320 is_pooling_model = True
321
322 def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
323 super().__init__()
324 config = vllm_config.model_config.hf_config
325
326 colbert_dim = self.get_colbert_dim_from_config(config)
327 self._init_colbert_components(
328 hidden_size=config.hidden_size,
329 colbert_dim=colbert_dim,
330 head_dtype=vllm_config.model_config.head_dtype,
331 )
332
333 self.model = JinaRobertaModel(
334 vllm_config=vllm_config,
335 prefix=prefix,
336 )
337
338 pooler_config = vllm_config.model_config.pooler_config
339 assert pooler_config is not None
340 self.pooler = self._build_colbert_pooler(pooler_config)
341
342 def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor:
343 return self.model.embed_input_ids(input_ids)
344
345 def forward(
346 self,
347 input_ids: torch.Tensor,
348 positions: torch.Tensor,
349 intermediate_tensors=None,
350 inputs_embeds: torch.Tensor | None = None,
351 ) -> torch.Tensor:
352 return self.model(
353 input_ids=input_ids,
354 positions=positions,
355 inputs_embeds=inputs_embeds,
356 intermediate_tensors=intermediate_tensors,
357 )
358
359 def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]):
360 other_weights, colbert_loaded = self._load_colbert_weights(weights)
361
362 # HF pooler weights are not used in ColBERT
363 mapper = WeightsMapper(
364 orig_to_new_prefix={"roberta.": "model.", "model.pooler.": None}
365 )
366
367 loader = AutoWeightsLoader(self)
368 loaded = loader.load_weights(other_weights, mapper=mapper)
369 return loaded | colbert_loaded
370
371
372 # -----------------------------------------------------------------------
373 # Concrete model: ColBERT + LFM2 backbone
374 # -----------------------------------------------------------------------
375
376
377 @default_pooling_type(seq_pooling_type="CLS", tok_pooling_type="ALL")
378 class ColBERTLfm2Model(ColBERTMixin, nn.Module, HasInnerState, IsHybrid):
379 """ColBERT late interaction model with LFM2 backbone.
380
381 For ``LiquidAI/LFM2-ColBERT-350M`` and similar models.
382
383 The projection is auto-loaded from sentence-transformers ``1_Dense/``
384 when not present in the main checkpoint.
385 """
386
387 is_pooling_model = True
388 # LFM2 is a hybrid model (attention + SSM layers); these flags ensure
389 # HybridAttentionMambaModelConfig.verify_and_update_config runs so that
390 # mamba_block_size and related cache settings are correctly initialised.
391 is_hybrid = True
392 has_inner_state = True
393
394 @classmethod
395 def get_mamba_state_shape_from_config(cls, vllm_config: VllmConfig):
396 return Lfm2ForCausalLM.get_mamba_state_shape_from_config(vllm_config)
397
398 @classmethod
399 def get_mamba_state_dtype_from_config(cls, vllm_config: VllmConfig):
400 return Lfm2ForCausalLM.get_mamba_state_dtype_from_config(vllm_config)
401
402 @classmethod
403 def get_mamba_state_copy_func(cls):
404 return Lfm2ForCausalLM.get_mamba_state_copy_func()
405
406 def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
407 super().__init__()
408 config = vllm_config.model_config.hf_config
409
410 colbert_dim = self.get_colbert_dim_from_config(config)
411 self._init_colbert_components(
412 hidden_size=config.hidden_size,
413 colbert_dim=colbert_dim,
414 head_dtype=vllm_config.model_config.head_dtype,
415 )
416
417 self.model = Lfm2Model(
418 vllm_config=vllm_config,
419 prefix=prefix,
420 )
421
422 pooler_config = vllm_config.model_config.pooler_config
423 assert pooler_config is not None
424 self.pooler = self._build_colbert_pooler(pooler_config)
425
426 def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor:
427 return self.model.embed_input_ids(input_ids)
428
429 def forward(
430 self,
431 input_ids: torch.Tensor,
432 positions: torch.Tensor,
433 intermediate_tensors=None,
434 inputs_embeds: torch.Tensor | None = None,
435 ) -> torch.Tensor:
436 return self.model(
437 input_ids=input_ids,
438 positions=positions,
439 inputs_embeds=inputs_embeds,
440 intermediate_tensors=intermediate_tensors,
441 )
442
443 def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]):
444 other_weights, colbert_loaded = self._load_colbert_weights(weights)
445
446 loaded_model = self.model.load_weights(other_weights)
447
448 loaded = {f"model.{name}" for name in loaded_model} | colbert_loaded
449
450 # When the ST projector is loaded via `_build_colbert_pooler`, the weights
451 # might come from `colbert_loaded` or the pooler automatically falls back to
452 # load from `/1_Dense` etc.
453 # We need to mark its params as loaded so the weight validator doesn't complain
454 # when they are loaded via fallback.
455 if hasattr(self.pooler, "head"):
456 head = self.pooler.head
457 projector = getattr(head, "projector", None)
458 if projector is not None and isinstance(projector, nn.Module):
459 for name, _ in projector.named_parameters():
460 loaded.add(f"pooler.head.projector.{name}")
461
462 return loaded