Spaces:
Running on Zero
Running on Zero
| # V-SPLADE | |
| # Copyright (c) 2026-present NAVER Corp. | |
| # Apache-2.0 | |
| """ | |
| Encoder Layer — backbone model that produces hidden states. | |
| V_SPLADE uses the VBert (BiModernVBERT) encoder as its sole backbone. | |
| The encoder exposes a unified API: | |
| encode_passage(inputs) -> (hidden_states, attention_mask) | |
| encode_text(inputs) -> (hidden_states, attention_mask) | |
| """ | |
| import torch | |
| import torch.nn as nn | |
| import torch.nn.functional as F | |
| from enum import Enum | |
| from typing import Optional, Tuple | |
| class DecoupledEmbedding(nn.Embedding): | |
| """Embedding with a separate trainable additional vocabulary. | |
| Matches the V-SPLADE weight layout where tok_embeddings has both a | |
| main ``weight`` and an ``additional_embedding.weight`` for extra tokens. | |
| """ | |
| def __init__( | |
| self, | |
| num_embeddings, | |
| num_additional_embeddings, | |
| embedding_dim, | |
| partially_freeze=False, | |
| device=None, | |
| dtype=None, | |
| padding_idx=None, | |
| **kwargs, | |
| ): | |
| if padding_idx is not None and padding_idx > num_embeddings: | |
| raise ValueError(f"padding_idx must be within num_embeddings. Got {padding_idx} and {num_embeddings}") | |
| super().__init__( | |
| num_embeddings=num_embeddings, | |
| embedding_dim=embedding_dim, | |
| device=device, | |
| dtype=dtype, | |
| padding_idx=padding_idx, | |
| **kwargs, | |
| ) | |
| self.num_embeddings = num_embeddings | |
| self.num_additional_embeddings = num_additional_embeddings | |
| self.partially_freeze = partially_freeze | |
| if partially_freeze: | |
| self.weight.requires_grad_(False) | |
| if self.num_additional_embeddings > 0: | |
| self.additional_embedding = nn.Embedding( | |
| num_embeddings=num_additional_embeddings, | |
| embedding_dim=embedding_dim, | |
| device=device, | |
| dtype=dtype, | |
| ) | |
| def forward(self, input_ids): | |
| if self.num_additional_embeddings == 0: | |
| return super().forward(input_ids) | |
| input_ids = input_ids.clone() | |
| additional_vocab_indices = torch.where(input_ids >= self.num_embeddings) | |
| input_ids_additional_vocab = input_ids[additional_vocab_indices] | |
| additional_embeddings = self.additional_embedding(input_ids_additional_vocab - self.num_embeddings) | |
| input_ids[additional_vocab_indices] = 0 | |
| full_vector = F.embedding(input_ids, self.weight) | |
| full_vector[additional_vocab_indices] = additional_embeddings | |
| return full_vector | |
| class EncoderType(Enum): | |
| VBERT = "vbert" | |
| # -------------------------------------------------------------- | |
| # Abstract Base | |
| # -------------------------------------------------------------- | |
| class BaseEncoder(nn.Module): | |
| """Abstract encoder base. | |
| Unified API: | |
| encode_passage(inputs) -> (hidden_states, attention_mask) | |
| encode_text(inputs) -> (hidden_states, attention_mask) | |
| """ | |
| vocab_size: int = 0 | |
| hidden_size: int = 0 | |
| def encode_passage(self, **kwargs) -> Tuple[torch.Tensor, torch.Tensor]: | |
| raise NotImplementedError | |
| def encode_text(self, **kwargs) -> Tuple[torch.Tensor, torch.Tensor]: | |
| raise NotImplementedError | |
| def gradient_checkpointing_enable(self, gradient_checkpointing_kwargs=None): | |
| pass | |
| def get_text_embeddings(self) -> Optional[nn.Embedding]: | |
| """Return the text embedding layer (for query-encoder initialization).""" | |
| return None | |
| # -------------------------------------------------------------- | |
| # MLM head used by VBert | |
| # -------------------------------------------------------------- | |
| class ModernVBertMLMHead(nn.Module): | |
| """MLM head: dense(768->768) -> GELU -> LayerNorm(768) -> decoder(768->50368).""" | |
| def __init__(self, hidden_size: int = 768, vocab_size: int = 50368): | |
| super().__init__() | |
| self.dense = nn.Linear(hidden_size, hidden_size) | |
| self.norm = nn.LayerNorm(hidden_size) | |
| self.decoder = nn.Linear(hidden_size, vocab_size) | |
| def forward(self, hidden_states: torch.Tensor) -> torch.Tensor: | |
| h = self.dense(hidden_states) | |
| h = F.gelu(h) | |
| h = self.norm(h) | |
| h = self.decoder(h) | |
| return h | |
| def from_safetensors(cls, safetensors_path: str, **kwargs): | |
| from safetensors import safe_open | |
| head = cls(**kwargs) | |
| with safe_open(safetensors_path, framework="pt") as f: | |
| head.dense.weight.data.copy_(f.get_tensor("lm_head.head.dense.weight")) | |
| head.norm.weight.data.copy_(f.get_tensor("lm_head.head.norm.weight")) | |
| head.decoder.weight.data.copy_(f.get_tensor("lm_head.decoder.weight")) | |
| head.decoder.bias.data.copy_(f.get_tensor("lm_head.decoder.bias")) | |
| return head | |
| # -------------------------------------------------------------- | |
| # VBert Encoder (BiModernVBERT) | |
| # -------------------------------------------------------------- | |
| class VBertEncoder(BaseEncoder): | |
| """BiModernVBERT encoder + external MLM head, with optional LoRA.""" | |
| def __init__( | |
| self, | |
| model_name: str = "ModernVBERT/bimodernvbert", | |
| lm_head_model: str = "ModernVBERT/ModernVBERT", | |
| lm_head_lora_r: int = 32, | |
| encoder_lora_r: int = 32, | |
| lm_head_full: bool = False, | |
| **kwargs, | |
| ): | |
| super().__init__() | |
| from peft import LoraConfig, get_peft_model | |
| from models.convert import ensure_compatible_backbone | |
| # 0. Auto-convert the backbone if it uses the upstream ModernVBERT layout. | |
| # Compatible backbones (local or Hub) pass through unchanged; the raw | |
| # upstream checkpoint is downloaded + converted once (cached) so that | |
| # from_scratch training works directly from the Hub id. | |
| model_name = ensure_compatible_backbone(model_name) | |
| lm_head_model = ensure_compatible_backbone(lm_head_model) if lm_head_model else model_name | |
| # 1. Load encoder backbone. | |
| model_cls = self._resolve_model_cls(model_name) | |
| self.encoder = model_cls.from_pretrained(model_name, dtype=torch.bfloat16) | |
| # Disable compiled_mlp - FX tracing in gradient_checkpointing traces | |
| # both branches of the if/else in ModernBertEncoderLayer.forward(), | |
| # hitting compiled_mlp even when reference_compile is None/False. | |
| def _set_reference_compile_false(module): | |
| if hasattr(module, "config") and hasattr(module.config, "reference_compile"): | |
| module.config.reference_compile = False | |
| for m in self.encoder.modules(): | |
| _set_reference_compile_false(m) | |
| # 2. Merge any existing LoRA adapters into base weights. | |
| has_lora = any("lora" in k for k in self.encoder.state_dict().keys()) | |
| if has_lora: | |
| from peft.tuners.lora.layer import Linear as LoraLinear | |
| for _, mod in self.encoder.named_modules(): | |
| if isinstance(mod, LoraLinear) and hasattr(mod, "merge"): | |
| mod.merge() | |
| # 3. Apply a fresh full LoRA on encoder (all layers: attn + mlp). | |
| if encoder_lora_r > 0: | |
| self.encoder.model.text_model = get_peft_model( | |
| self.encoder.model.text_model, | |
| LoraConfig( | |
| r=encoder_lora_r, lora_alpha=encoder_lora_r, | |
| target_modules=["Wqkv", "Wo", "Wi"], | |
| bias="none", | |
| ), | |
| ) | |
| # 4. Load MLM head - from same model dir or separate model. | |
| import os as _os | |
| encoder_sf = _os.path.join(model_name, "model.safetensors") | |
| has_lm_head_in_encoder = False | |
| if _os.path.isfile(encoder_sf): | |
| from safetensors import safe_open as _safe_open | |
| with _safe_open(encoder_sf, framework="pt") as _f: | |
| has_lm_head_in_encoder = any("lm_head" in k for k in _f.keys()) | |
| if has_lm_head_in_encoder: | |
| self.mlm_head = ModernVBertMLMHead.from_safetensors( | |
| encoder_sf, hidden_size=768, vocab_size=50368, | |
| ).to(torch.bfloat16) | |
| else: | |
| safetensors_path = self._find_safetensors(lm_head_model) | |
| self.mlm_head = ModernVBertMLMHead.from_safetensors( | |
| safetensors_path, hidden_size=768, vocab_size=50368, | |
| ).to(torch.bfloat16) | |
| # 5. Apply LoRA to MLM head (dense + decoder). | |
| if lm_head_lora_r > 0 and not lm_head_full: | |
| self.mlm_head = get_peft_model(self.mlm_head, LoraConfig( | |
| r=lm_head_lora_r, lora_alpha=lm_head_lora_r, | |
| target_modules=["dense", "decoder"], bias="none", | |
| )) | |
| self.vocab_size = 50368 | |
| self.hidden_size = 768 | |
| # Freeze base weights, keep LoRA trainable. | |
| for name, param in self.named_parameters(): | |
| if "lora" in name.lower(): | |
| param.requires_grad = True | |
| else: | |
| param.requires_grad = False | |
| # Optional full-parameter tuning for the MLM head (no LoRA). | |
| if lm_head_full: | |
| for param in self.mlm_head.parameters(): | |
| param.requires_grad = True | |
| def from_hf_export(cls, hf_dir: str, dtype: torch.dtype = torch.bfloat16) -> "VBertEncoder": | |
| """Build an empty VBertEncoder shell from a V-SPLADE HF export. | |
| Constructs `BiModernVBert(config)` + `ModernVBertMLMHead(...)` with | |
| randomly-initialized weights — the caller is expected to populate them | |
| via :func:`models.load_hf_export`. Used by `build_model(mode='inference_only')`. | |
| """ | |
| from colpali_engine.models import BiModernVBert | |
| instance = cls.__new__(cls) | |
| nn.Module.__init__(instance) | |
| config = BiModernVBert.config_class.from_pretrained(hf_dir) | |
| instance.encoder = BiModernVBert(config).to(dtype=dtype) | |
| # Replace text model embeddings with DecoupledEmbedding to match the | |
| # V-SPLADE weight layout (tok_embeddings.weight + additional_embedding.weight). | |
| # The native ModernBertModel uses a plain nn.Embedding with the FULL vocab | |
| # (50408). V-SPLADE splits this into main (50368) + additional (40). | |
| text_model = instance.encoder.model.text_model | |
| old_emb = text_model.get_input_embeddings() | |
| additional_vocab = getattr(config, "additional_vocab_size", 40) | |
| main_vocab = old_emb.num_embeddings - additional_vocab # 50408 - 40 = 50368 | |
| new_emb = DecoupledEmbedding( | |
| num_embeddings=main_vocab, | |
| num_additional_embeddings=additional_vocab, | |
| embedding_dim=old_emb.embedding_dim, | |
| padding_idx=old_emb.padding_idx if old_emb.padding_idx is not None and old_emb.padding_idx < main_vocab else None, | |
| ).to(dtype=dtype) | |
| text_model.set_input_embeddings(new_emb) | |
| # hidden_size may be at top-level (custom config) or under text_config | |
| # (native transformers 5.x ModernVBertConfig). | |
| hidden_size = getattr(config, "hidden_size", None) | |
| if hidden_size is None and hasattr(config, "text_config"): | |
| hidden_size = config.text_config.hidden_size | |
| instance.mlm_head = ModernVBertMLMHead( | |
| hidden_size=hidden_size, vocab_size=50368, | |
| ).to(dtype=dtype) | |
| instance.vocab_size = 50368 | |
| instance.hidden_size = hidden_size | |
| # No grad needed at inference; trainer-side flags are not touched. | |
| for p in instance.parameters(): | |
| p.requires_grad = False | |
| return instance | |
| def _resolve_model_cls(model_name: str): | |
| import json, os | |
| from colpali_engine.models import BiModernVBert | |
| config_path = os.path.join(model_name, "config.json") | |
| adapter_config_path = os.path.join(model_name, "adapter_config.json") | |
| if os.path.isfile(adapter_config_path): | |
| with open(adapter_config_path) as f: | |
| adapter_cfg = json.load(f) | |
| base_path = adapter_cfg.get("base_model_name_or_path", "") | |
| base_config = os.path.join(base_path, "config.json") | |
| if os.path.isfile(base_config): | |
| config_path = base_config | |
| if os.path.isfile(config_path): | |
| with open(config_path) as f: | |
| cfg = json.load(f) | |
| archs = cfg.get("architectures", []) | |
| # V_SPLADE only uses the bidirectional encoder variant. | |
| if "BiModernVBert" in archs: | |
| return BiModernVBert | |
| return BiModernVBert | |
| def _find_safetensors(model_name: str) -> str: | |
| import os | |
| local = os.path.join(model_name, "model.safetensors") | |
| if os.path.isfile(local): | |
| return local | |
| from huggingface_hub import hf_hub_download | |
| return hf_hub_download(model_name, "model.safetensors") | |
| def gradient_checkpointing_enable(self, gradient_checkpointing_kwargs=None): | |
| kwargs = gradient_checkpointing_kwargs or {"use_reentrant": False} | |
| text_model = self.encoder.model.text_model | |
| if hasattr(text_model, "gradient_checkpointing_enable"): | |
| text_model.gradient_checkpointing_enable(gradient_checkpointing_kwargs=kwargs) | |
| def _get_hidden_states( | |
| self, | |
| input_ids: torch.Tensor, | |
| attention_mask: torch.Tensor, | |
| pixel_values: Optional[torch.Tensor] = None, | |
| pixel_attention_mask: Optional[torch.Tensor] = None, | |
| ) -> torch.Tensor: | |
| kw = dict(input_ids=input_ids, attention_mask=attention_mask) | |
| if pixel_values is not None: | |
| kw["pixel_values"] = pixel_values | |
| if pixel_attention_mask is not None: | |
| kw["pixel_attention_mask"] = pixel_attention_mask | |
| outputs = self.encoder.model(**kw) | |
| return outputs[0] | |
| def encode_passage(self, **kwargs) -> Tuple[torch.Tensor, torch.Tensor]: | |
| hidden = self._get_hidden_states( | |
| kwargs["input_ids"], kwargs["attention_mask"], | |
| kwargs.get("pixel_values"), kwargs.get("pixel_attention_mask"), | |
| ) | |
| return hidden, kwargs["attention_mask"] | |
| def encode_text(self, **kwargs) -> Tuple[torch.Tensor, torch.Tensor]: | |
| hidden = self._get_hidden_states(kwargs["input_ids"], kwargs["attention_mask"]) | |
| return hidden, kwargs["attention_mask"] | |
| def get_lm_head(self): | |
| return self.mlm_head | |
| def get_text_embeddings(self) -> Optional[nn.Module]: | |
| return self.encoder.model.text_model.get_input_embeddings() | |
| def image_token_id(self) -> int: | |
| return 50407 # BiModernVBERT <image> token | |
| # -------------------------------------------------------------- | |
| # Factory | |
| # -------------------------------------------------------------- | |
| def build_encoder(encoder_type: str, **kwargs) -> BaseEncoder: | |
| """Build encoder by type string. V_SPLADE only ships the vbert backbone.""" | |
| if encoder_type == "vbert": | |
| return VBertEncoder(**kwargs) | |
| raise ValueError(f"Unknown encoder_type: {encoder_type}. Choose: vbert") | |