Feature Extraction
sentence-transformers
Safetensors
Transformers
nanovdr_doc
visual-document-retrieval
document-ai
nanovdr
knowledge-distillation
retrieval
custom_code
Instructions to use nanovdr/NanoVDR-D-HiRes-Qwen3VL8B-4096 with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- sentence-transformers
How to use nanovdr/NanoVDR-D-HiRes-Qwen3VL8B-4096 with sentence-transformers:
from sentence_transformers import SentenceTransformer model = SentenceTransformer("nanovdr/NanoVDR-D-HiRes-Qwen3VL8B-4096", 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] - Transformers
How to use nanovdr/NanoVDR-D-HiRes-Qwen3VL8B-4096 with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("feature-extraction", model="nanovdr/NanoVDR-D-HiRes-Qwen3VL8B-4096", trust_remote_code=True)# pip install -U transformers accelerate # Load model directly from transformers import AutoModel model = AutoModel.from_pretrained("nanovdr/NanoVDR-D-HiRes-Qwen3VL8B-4096", trust_remote_code=True, device_map="auto") - Notebooks
- Google Colab
- Kaggle
Download modeling_nanovdr_doc.py from nanovdr/NanoVDR-D-HiRes-Qwen3VL8B-4096: direct link, hf CLI and curl.
- Browser
- Download file 10.7 kB
-
https://huggingface.co/nanovdr/NanoVDR-D-HiRes-Qwen3VL8B-4096/resolve/main/modeling_nanovdr_doc.py
- Command line
-
hf download hf://nanovdr/NanoVDR-D-HiRes-Qwen3VL8B-4096/modeling_nanovdr_doc.py
-
curl -L -o modeling_nanovdr_doc.py https://huggingface.co/nanovdr/NanoVDR-D-HiRes-Qwen3VL8B-4096/resolve/main/modeling_nanovdr_doc.py
10.7 kB
| """NanoVDR document tower — standalone modeling file for the Hub. | |
| A page image is tiled, every tile is encoded by a vision transformer, the patch | |
| tokens of all tiles are concatenated into one sequence, a bidirectional text | |
| backbone re-encodes that sequence (no text tokens involved), and a mean pool | |
| plus linear projection produces one L2-normalised vector in the teacher's | |
| embedding space. | |
| Retrieval is a dot product against a query vector from the matching NanoVDR | |
| query tower, or against the teacher itself. | |
| Tiling is *not* here. It lives in ``NanoVDRDocImageProcessor``, which ships | |
| alongside this file, because it is preprocessing and because having one home for | |
| it is what makes ``processor(images=pages)`` produce exactly the tensors this | |
| model was trained on. | |
| This file is self-contained: it is the only source of truth for the forward | |
| pass and does not import from the training repository or from the processor. | |
| """ | |
| from __future__ import annotations | |
| import math | |
| import warnings | |
| from dataclasses import dataclass | |
| from typing import Optional, Sequence, Union | |
| import torch | |
| import torch.nn as nn | |
| import torch.nn.functional as F | |
| from transformers import AutoConfig, AutoModel, PretrainedConfig, PreTrainedModel | |
| from transformers.modeling_outputs import ModelOutput | |
| IMAGE_SIZE = 448 | |
| class NanoVDRDocConfig(PretrainedConfig): | |
| model_type = "nanovdr_doc" | |
| def __init__( | |
| self, | |
| visual_encoder_name: str = "OpenGVLab/InternViT-300M-448px-V2_5", | |
| text_backbone_name: str = "answerdotai/ModernBERT-base", | |
| embed_dim: int = 4096, | |
| pool_type: str = "mean", | |
| pixel_shuffle_r: int = 1, | |
| tile_min_num: int = 1, | |
| tile_max_num: int = 6, | |
| tile_max_total: int = 7, | |
| tile_use_thumbnail: bool = True, | |
| image_size: int = IMAGE_SIZE, | |
| use_flash_attn: bool = False, | |
| text_attn_implementation: str = "sdpa", | |
| **kwargs, | |
| ): | |
| self.visual_encoder_name = visual_encoder_name | |
| self.text_backbone_name = text_backbone_name | |
| self.embed_dim = embed_dim | |
| self.pool_type = pool_type | |
| self.pixel_shuffle_r = pixel_shuffle_r | |
| self.tile_min_num = tile_min_num | |
| self.tile_max_num = tile_max_num | |
| self.tile_max_total = tile_max_total | |
| self.tile_use_thumbnail = tile_use_thumbnail | |
| self.image_size = image_size | |
| # InternViT ships a native flash-attention path and ModernBERT picks | |
| # flash-attention-2 when the package is importable. Both need Ampere or | |
| # newer, so the defaults here stay portable: plain attention for the | |
| # vision tower and sdpa for the text tower (sdpa dispatches to a fused | |
| # kernel on capable hardware anyway). Set use_flash_attn=True and | |
| # text_attn_implementation="flash_attention_2" on Ampere+ for speed. | |
| self.use_flash_attn = use_flash_attn | |
| self.text_attn_implementation = text_attn_implementation | |
| super().__init__(**kwargs) | |
| class NanoVDRDocOutput(ModelOutput): | |
| embedding: Optional[torch.FloatTensor] = None | |
| # -------------------------------------------------------------------------- | |
| # Model | |
| # -------------------------------------------------------------------------- | |
| class NanoVDRDocModel(PreTrainedModel): | |
| config_class = NanoVDRDocConfig | |
| base_model_prefix = "nanovdr_doc" | |
| _no_split_modules = ["InternVisionEncoderLayer", "ModernBertEncoderLayer"] | |
| def __init__(self, config: NanoVDRDocConfig): | |
| super().__init__(config) | |
| self.cfg = config | |
| vis_cfg = AutoConfig.from_pretrained(config.visual_encoder_name, trust_remote_code=True) | |
| if hasattr(vis_cfg, "use_flash_attn"): | |
| vis_cfg.use_flash_attn = config.use_flash_attn | |
| visual = AutoModel.from_config(vis_cfg, trust_remote_code=True) | |
| # Some releases wrap the tower; take the vision half when they do. | |
| self.visual_encoder = getattr(visual, "vision_model", visual) | |
| self.vis_dim = getattr(vis_cfg, "hidden_size", None) or vis_cfg.vision_config.hidden_size | |
| txt_cfg = AutoConfig.from_pretrained(config.text_backbone_name) | |
| if hasattr(txt_cfg, "reference_compile"): | |
| txt_cfg.reference_compile = False | |
| self.text_backbone = AutoModel.from_config( | |
| txt_cfg, attn_implementation=config.text_attn_implementation | |
| ) | |
| text_dim = txt_cfg.hidden_size | |
| r = int(config.pixel_shuffle_r) | |
| self.connect = nn.Linear(self.vis_dim * r * r, text_dim) | |
| self.proj = nn.Linear(text_dim, config.embed_dim) | |
| if config.pool_type == "eos": | |
| self.eos_token = nn.Parameter(torch.randn(1, 1, text_dim) * 0.02) | |
| self.post_init() | |
| # -- internals --------------------------------------------------------- | |
| def _pixel_shuffle(self, patches: torch.Tensor) -> torch.Tensor: | |
| B, N, D = patches.shape | |
| hw = int(round(math.sqrt(N))) | |
| r = int(self.cfg.pixel_shuffle_r) | |
| if hw * hw != N or hw % r != 0: | |
| return patches | |
| x = patches.reshape(B, hw, hw, D).reshape(B, hw // r, r, hw // r, r, D) | |
| x = x.permute(0, 1, 3, 2, 4, 5).contiguous() | |
| return x.reshape(B, (hw // r) ** 2, r * r * D) | |
| # -- forward ----------------------------------------------------------- | |
| def forward( | |
| self, | |
| pixel_values: torch.Tensor, | |
| tile_mask: Optional[torch.Tensor] = None, | |
| return_dict: bool = True, | |
| **kwargs, | |
| ): | |
| """Encode pages into L2-normalised embeddings. | |
| Parameters | |
| ---------- | |
| pixel_values : (B, T, 3, H, W) or (B, 3, H, W) | |
| Tiled pages, zero-padded to ``T`` tiles, or a single view per page. | |
| tile_mask : (B, T) bool | |
| True for real tiles. Required when ``pixel_values`` is 5-D. | |
| A 4-D input is accepted but is not how the released towers were | |
| trained. Downscaling a whole page to one 448px view destroys the small | |
| text that document retrieval turns on, and the resulting embeddings are | |
| substantially worse while looking perfectly well-formed, so this case | |
| warns rather than failing silently. | |
| """ | |
| tiled = pixel_values.dim() == 5 | |
| if tiled: | |
| B, T, C, H, W = pixel_values.shape | |
| pv = pixel_values.reshape(B * T, C, H, W) | |
| if tile_mask is None: | |
| raise ValueError("tile_mask is required for tiled (5-D) pixel_values.") | |
| else: | |
| B, T = pixel_values.size(0), 1 | |
| pv = pixel_values | |
| warnings.warn( | |
| "NanoVDRDocModel received a single 448px view per page (4-D pixel_values) " | |
| "instead of tiles. Retrieval quality drops markedly. Use " | |
| "NanoVDRDocImageProcessor, which emits pixel_values and tile_mask together: " | |
| "processor = AutoImageProcessor.from_pretrained(<repo>, trust_remote_code=True)", | |
| UserWarning, | |
| stacklevel=2, | |
| ) | |
| vis_dtype = next(self.visual_encoder.parameters()).dtype | |
| vis_out = self.visual_encoder(pixel_values=pv.to(vis_dtype)) | |
| patches = vis_out.last_hidden_state if hasattr(vis_out, "last_hidden_state") else vis_out[0] | |
| patches = patches.float() | |
| if patches.dim() == 4: # (B, C, H, W) spatial output | |
| patches = F.adaptive_avg_pool2d(patches, (16, 16)).flatten(2).transpose(1, 2) | |
| if int(self.cfg.pixel_shuffle_r) > 1: | |
| patches = self._pixel_shuffle(patches) | |
| n_per_tile, vis_dim = patches.size(1), patches.size(2) | |
| if tiled: | |
| patches = patches.reshape(B, T * n_per_tile, vis_dim) | |
| pseudo = self.connect(patches) | |
| max_pos = self.text_backbone.config.max_position_embeddings | |
| if tiled: | |
| attn = tile_mask.unsqueeze(-1).expand(B, T, n_per_tile).reshape(B, T * n_per_tile) | |
| attn = attn.to(pseudo.device).long() | |
| else: | |
| attn = torch.ones(B, pseudo.size(1), device=pseudo.device, dtype=torch.long) | |
| if self.cfg.pool_type == "eos": | |
| if pseudo.size(1) > max_pos - 1: | |
| pseudo, attn = pseudo[:, : max_pos - 1], attn[:, : max_pos - 1] | |
| eos = self.eos_token.expand(B, 1, -1).to(pseudo.dtype) | |
| pseudo = torch.cat([pseudo, eos], dim=1) | |
| attn = torch.cat([attn, torch.ones(B, 1, device=attn.device, dtype=torch.long)], dim=1) | |
| elif pseudo.size(1) > max_pos: | |
| pseudo, attn = pseudo[:, :max_pos], attn[:, :max_pos] | |
| text_out = self.text_backbone(inputs_embeds=pseudo, attention_mask=attn) | |
| if self.cfg.pool_type == "eos": | |
| pooled = text_out.last_hidden_state[:, -1, :] | |
| else: | |
| m = attn.unsqueeze(-1).float() | |
| pooled = (text_out.last_hidden_state * m).sum(1) / m.sum(1).clamp(min=1) | |
| emb = F.normalize(self.proj(pooled), p=2, dim=-1) | |
| return NanoVDRDocOutput(embedding=emb) if return_dict else (emb,) | |
| # -- convenience ------------------------------------------------------- | |
| def encode( | |
| self, | |
| images: Union["Image.Image", Sequence["Image.Image"]], # noqa: F821 | |
| processor, | |
| batch_size: int = 8, | |
| device: Optional[Union[str, torch.device]] = None, | |
| ) -> torch.Tensor: | |
| """Batch, preprocess and encode PIL pages. Returns (N, embed_dim) on CPU. | |
| ``processor`` must be a ``NanoVDRDocImageProcessor``; a stock image | |
| processor emits one view per page and no tile mask, which this model | |
| would accept and quietly underperform on. | |
| """ | |
| from PIL import Image # local import; PIL is not a hard dependency of import | |
| if isinstance(images, Image.Image): | |
| images = [images] | |
| if not getattr(processor, "do_tile", False): | |
| raise ValueError( | |
| f"{type(processor).__name__} does not tile. The document tower needs " | |
| "NanoVDRDocImageProcessor, which ships with every NanoVDR document " | |
| "checkpoint: AutoImageProcessor.from_pretrained(<repo>, trust_remote_code=True)." | |
| ) | |
| device = device or next(self.parameters()).device | |
| out = [] | |
| self.eval() | |
| for start in range(0, len(images), batch_size): | |
| batch = processor(images=images[start : start + batch_size], return_tensors="pt") | |
| emb = self(**{k: v.to(device) for k, v in batch.items()}).embedding | |
| out.append(emb.float().cpu()) | |
| return torch.cat(out, dim=0) | |
| AutoConfig.register("nanovdr_doc", NanoVDRDocConfig) | |
| AutoModel.register(NanoVDRDocConfig, NanoVDRDocModel) | |