quadembed 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,24 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ .claude/
5
+ scratch/
6
+
7
+ # large binary checkpoints and cached datasets -- not meant for git,
8
+ # the trained model is published separately on Hugging Face:
9
+ # https://huggingface.co/Mithil-21/quadembed-nano
10
+ checkpoints/
11
+ checkpoints_round*_backup/
12
+ data_cache/
13
+ *.pt
14
+
15
+ # staging folder for the Hugging Face upload, mirrors published content
16
+ hf_repo/
17
+
18
+ # python build artifacts
19
+ package/dist/
20
+ *.egg-info/
21
+
22
+ # personal blog draft and its working assets -- not meant for the public repo
23
+ medium-blog-draft.md
24
+ blog_assets/
@@ -0,0 +1,45 @@
1
+ Creative Commons Attribution-NonCommercial 4.0 International (CC BY-NC 4.0)
2
+
3
+ Copyright (c) 2026 Mithil Maske
4
+
5
+ This package and its associated model weights are licensed under the Creative
6
+ Commons Attribution-NonCommercial 4.0 International License.
7
+
8
+ You are free to:
9
+
10
+ Share - copy and redistribute the material in any medium or format
11
+ Adapt - remix, transform, and build upon the material
12
+
13
+ Under the following terms:
14
+
15
+ Attribution - You must give appropriate credit, provide a link to the
16
+ license, and indicate if changes were made.
17
+
18
+ NonCommercial - You may not use the material for commercial purposes.
19
+
20
+ Full license text: https://creativecommons.org/licenses/by-nc/4.0/legalcode
21
+
22
+
23
+ WHY NON-COMMERCIAL
24
+ ------------------
25
+
26
+ QuadEmbed is built on top of three frozen pretrained encoders. One of them,
27
+ jinaai/jina-embeddings-v5-text-nano, is itself licensed CC BY-NC 4.0, and it
28
+ defines the embedding space that the trained projectors map into. That
29
+ non-commercial restriction therefore carries through to this work.
30
+
31
+ The other two frozen encoders are permissively licensed:
32
+ - google/siglip2-base-patch16-naflex (Apache-2.0)
33
+ - openai/whisper-large-v3 (Apache-2.0)
34
+
35
+ If you need commercial use, you would need a separate license for the text
36
+ encoder from Jina AI, or to retrain the projectors against a permissively
37
+ licensed text encoder instead.
38
+
39
+
40
+ ATTRIBUTION
41
+ -----------
42
+
43
+ The architecture reproduced here (GELATO) is described in Jina AI's paper on
44
+ jina-embeddings-v5-omni, arXiv:2605.08384. This project is an independent
45
+ reproduction and is not affiliated with or endorsed by Jina AI.
@@ -0,0 +1,157 @@
1
+ Metadata-Version: 2.5
2
+ Name: quadembed
3
+ Version: 0.1.0
4
+ Summary: Multimodal embeddings for text, image, audio, and video in one shared vector space, trained on a single consumer GPU
5
+ Project-URL: Homepage, https://github.com/mithilai/QuadEmbed
6
+ Project-URL: Repository, https://github.com/mithilai/QuadEmbed
7
+ Project-URL: Model Weights, https://huggingface.co/Mithil-21/quadembed-nano
8
+ Project-URL: Write-up, https://medium.com/@mithilmaske/i-built-a-multimodal-embedding-model-from-scratch-on-an-rtx-4060-text-image-audio-and-video-ab1fef04f1cd
9
+ Author: Mithil Maske
10
+ License: CC-BY-NC-4.0
11
+ License-File: LICENSE
12
+ Keywords: audio,clip,contrastive-learning,embeddings,multimodal,retrieval,semantic-search,text-to-image,video
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Intended Audience :: Science/Research
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Multimedia :: Sound/Audio :: Analysis
21
+ Classifier: Topic :: Multimedia :: Video
22
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
23
+ Requires-Python: >=3.10
24
+ Requires-Dist: huggingface-hub>=0.24
25
+ Requires-Dist: numpy>=1.24
26
+ Requires-Dist: pillow>=9.0
27
+ Requires-Dist: torch>=2.1
28
+ Requires-Dist: transformers>=4.45
29
+ Provides-Extra: all
30
+ Requires-Dist: librosa>=0.10; extra == 'all'
31
+ Requires-Dist: opencv-python>=4.8; extra == 'all'
32
+ Requires-Dist: soundfile>=0.12; extra == 'all'
33
+ Provides-Extra: audio
34
+ Requires-Dist: librosa>=0.10; extra == 'audio'
35
+ Requires-Dist: soundfile>=0.12; extra == 'audio'
36
+ Provides-Extra: video
37
+ Requires-Dist: opencv-python>=4.8; extra == 'video'
38
+ Description-Content-Type: text/markdown
39
+
40
+ # QuadEmbed
41
+
42
+ Multimodal embeddings for **text, image, audio, and video** in one shared
43
+ 768-dimensional vector space. Compare any modality against any other with a
44
+ dot product.
45
+
46
+ Trained end-to-end on a single RTX 4060 laptop GPU (8GB VRAM) by freezing
47
+ three pretrained encoders and training only two small projection heads,
48
+ reproducing the GELATO architecture behind Jina AI's jina-embeddings-v5-omni.
49
+
50
+ > ⚠️ **Non-commercial license.** QuadEmbed builds on
51
+ > `jina-embeddings-v5-text-nano`, which is CC-BY-NC-4.0. That restriction
52
+ > carries through to this package and its weights. Research and educational
53
+ > use is fine; commercial use is not, without a separate license from Jina AI.
54
+
55
+ ## Install
56
+
57
+ ```bash
58
+ pip install quadembed # text + image + audio
59
+ pip install quadembed[video] # adds video support (OpenCV)
60
+ pip install quadembed[all] # everything, plus audio file loading helpers
61
+ ```
62
+
63
+ ## Quickstart
64
+
65
+ ```python
66
+ from quadembed import QuadEmbed
67
+ from PIL import Image
68
+
69
+ model = QuadEmbed.from_pretrained() # downloads weights from the Hub on first run
70
+
71
+ texts = ["a dog running on the beach", "a bowl of ramen noodles"]
72
+ text_embeds = model.embed_text(texts)
73
+ image_embeds = model.embed_image([Image.open("photo.jpg").convert("RGB")])
74
+
75
+ # embeddings are L2-normalized, so this is cosine similarity
76
+ similarity = text_embeds @ image_embeds.T
77
+ for text, score in zip(texts, similarity[:, 0].tolist()):
78
+ print(f"{score:.3f} {text}")
79
+ ```
80
+
81
+ ### Audio
82
+
83
+ Pass mono float32 arrays at 16 kHz (what the frozen Whisper encoder expects):
84
+
85
+ ```python
86
+ import soundfile as sf
87
+
88
+ audio, sr = sf.read("clip.wav")
89
+ audio_embeds = model.embed_audio([audio.astype("float32")], sampling_rate=sr)
90
+ similarity = model.embed_text(["a dog barking"]) @ audio_embeds.T
91
+ ```
92
+
93
+ ### Video
94
+
95
+ ```python
96
+ model = QuadEmbed.from_pretrained(modalities=("text", "video"))
97
+ video_embeds = model.embed_video_file("clip.mp4", num_frames=4)
98
+ similarity = model.embed_text(["a person skateboarding"]) @ video_embeds.T
99
+ ```
100
+
101
+ ### Loading only what you need
102
+
103
+ Each encoder costs memory and download time. Load a subset:
104
+
105
+ ```python
106
+ model = QuadEmbed.from_pretrained(modalities=("text", "vision")) # skip audio
107
+ model = QuadEmbed.from_pretrained(device="cpu") # force CPU
108
+ ```
109
+
110
+ ## How it works
111
+
112
+ Three frozen encoders, two trained projectors:
113
+
114
+ | Role | Model (frozen) | Trainable on top |
115
+ |---|---|---|
116
+ | Text (anchor) | `jinaai/jina-embeddings-v5-text-nano` (239M) | nothing, it defines the space |
117
+ | Vision | `google/siglip2-base-patch16-naflex` | vision projector (2.36M params) |
118
+ | Audio | `openai/whisper-large-v3` encoder | audio projector (0.98M params) |
119
+
120
+ Only ~3.3M parameters were ever trained, against nearly a billion frozen ones.
121
+ Video needs no encoder or projector of its own: frames are sampled, run
122
+ through the vision path, and mean-pooled over time.
123
+
124
+ Training used bidirectional in-batch InfoNCE (temperature 0.02) plus
125
+ Matryoshka representation learning over prefix dims {32, 64, 128, 256, 768},
126
+ so truncated embeddings remain usable if you need a smaller index.
127
+
128
+ ## Measured performance
129
+
130
+ Cross-modal retrieval recall@k on held-out splits, text-query direction:
131
+
132
+ | Modality | R@1 | R@5 | R@10 | n |
133
+ |---|---|---|---|---|
134
+ | Image | 13.7% | 68.6% | 81.1% | 1024 |
135
+ | Audio | 67% | 97% | 100% | 33 |
136
+ | Video | 40% | 86% | 94% | 50 |
137
+
138
+ Random-chance R@1 on the 1024-candidate image eval is ~0.1%.
139
+
140
+ These are honest small-scale numbers. Vision was trained on ~172k
141
+ image-caption pairs, orders of magnitude less than production embedding
142
+ models, and image R@1 is the weakest metric as a result. R@5 and R@10 are
143
+ considerably stronger, so this is more useful for candidate retrieval and
144
+ reranking than for exact top-1 matching.
145
+
146
+ ## Links
147
+
148
+ - **Source and training code:** [github.com/mithilai/QuadEmbed](https://github.com/mithilai/QuadEmbed)
149
+ - **Model weights:** [huggingface.co/Mithil-21/quadembed-nano](https://huggingface.co/Mithil-21/quadembed-nano)
150
+ - **Full write-up:** [I Built a Multimodal Embedding Model From Scratch on an RTX 4060](https://medium.com/@mithilmaske/i-built-a-multimodal-embedding-model-from-scratch-on-an-rtx-4060-text-image-audio-and-video-ab1fef04f1cd)
151
+
152
+ ## Credit
153
+
154
+ QuadEmbed reproduces the architecture described in Jina AI's GELATO paper
155
+ ([arXiv:2605.08384](https://arxiv.org/abs/2605.08384)). All credit for the
156
+ original architecture and training recipe belongs there. This is an
157
+ independent reproduction, not affiliated with or endorsed by Jina AI.
@@ -0,0 +1,118 @@
1
+ # QuadEmbed
2
+
3
+ Multimodal embeddings for **text, image, audio, and video** in one shared
4
+ 768-dimensional vector space. Compare any modality against any other with a
5
+ dot product.
6
+
7
+ Trained end-to-end on a single RTX 4060 laptop GPU (8GB VRAM) by freezing
8
+ three pretrained encoders and training only two small projection heads,
9
+ reproducing the GELATO architecture behind Jina AI's jina-embeddings-v5-omni.
10
+
11
+ > ⚠️ **Non-commercial license.** QuadEmbed builds on
12
+ > `jina-embeddings-v5-text-nano`, which is CC-BY-NC-4.0. That restriction
13
+ > carries through to this package and its weights. Research and educational
14
+ > use is fine; commercial use is not, without a separate license from Jina AI.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ pip install quadembed # text + image + audio
20
+ pip install quadembed[video] # adds video support (OpenCV)
21
+ pip install quadembed[all] # everything, plus audio file loading helpers
22
+ ```
23
+
24
+ ## Quickstart
25
+
26
+ ```python
27
+ from quadembed import QuadEmbed
28
+ from PIL import Image
29
+
30
+ model = QuadEmbed.from_pretrained() # downloads weights from the Hub on first run
31
+
32
+ texts = ["a dog running on the beach", "a bowl of ramen noodles"]
33
+ text_embeds = model.embed_text(texts)
34
+ image_embeds = model.embed_image([Image.open("photo.jpg").convert("RGB")])
35
+
36
+ # embeddings are L2-normalized, so this is cosine similarity
37
+ similarity = text_embeds @ image_embeds.T
38
+ for text, score in zip(texts, similarity[:, 0].tolist()):
39
+ print(f"{score:.3f} {text}")
40
+ ```
41
+
42
+ ### Audio
43
+
44
+ Pass mono float32 arrays at 16 kHz (what the frozen Whisper encoder expects):
45
+
46
+ ```python
47
+ import soundfile as sf
48
+
49
+ audio, sr = sf.read("clip.wav")
50
+ audio_embeds = model.embed_audio([audio.astype("float32")], sampling_rate=sr)
51
+ similarity = model.embed_text(["a dog barking"]) @ audio_embeds.T
52
+ ```
53
+
54
+ ### Video
55
+
56
+ ```python
57
+ model = QuadEmbed.from_pretrained(modalities=("text", "video"))
58
+ video_embeds = model.embed_video_file("clip.mp4", num_frames=4)
59
+ similarity = model.embed_text(["a person skateboarding"]) @ video_embeds.T
60
+ ```
61
+
62
+ ### Loading only what you need
63
+
64
+ Each encoder costs memory and download time. Load a subset:
65
+
66
+ ```python
67
+ model = QuadEmbed.from_pretrained(modalities=("text", "vision")) # skip audio
68
+ model = QuadEmbed.from_pretrained(device="cpu") # force CPU
69
+ ```
70
+
71
+ ## How it works
72
+
73
+ Three frozen encoders, two trained projectors:
74
+
75
+ | Role | Model (frozen) | Trainable on top |
76
+ |---|---|---|
77
+ | Text (anchor) | `jinaai/jina-embeddings-v5-text-nano` (239M) | nothing, it defines the space |
78
+ | Vision | `google/siglip2-base-patch16-naflex` | vision projector (2.36M params) |
79
+ | Audio | `openai/whisper-large-v3` encoder | audio projector (0.98M params) |
80
+
81
+ Only ~3.3M parameters were ever trained, against nearly a billion frozen ones.
82
+ Video needs no encoder or projector of its own: frames are sampled, run
83
+ through the vision path, and mean-pooled over time.
84
+
85
+ Training used bidirectional in-batch InfoNCE (temperature 0.02) plus
86
+ Matryoshka representation learning over prefix dims {32, 64, 128, 256, 768},
87
+ so truncated embeddings remain usable if you need a smaller index.
88
+
89
+ ## Measured performance
90
+
91
+ Cross-modal retrieval recall@k on held-out splits, text-query direction:
92
+
93
+ | Modality | R@1 | R@5 | R@10 | n |
94
+ |---|---|---|---|---|
95
+ | Image | 13.7% | 68.6% | 81.1% | 1024 |
96
+ | Audio | 67% | 97% | 100% | 33 |
97
+ | Video | 40% | 86% | 94% | 50 |
98
+
99
+ Random-chance R@1 on the 1024-candidate image eval is ~0.1%.
100
+
101
+ These are honest small-scale numbers. Vision was trained on ~172k
102
+ image-caption pairs, orders of magnitude less than production embedding
103
+ models, and image R@1 is the weakest metric as a result. R@5 and R@10 are
104
+ considerably stronger, so this is more useful for candidate retrieval and
105
+ reranking than for exact top-1 matching.
106
+
107
+ ## Links
108
+
109
+ - **Source and training code:** [github.com/mithilai/QuadEmbed](https://github.com/mithilai/QuadEmbed)
110
+ - **Model weights:** [huggingface.co/Mithil-21/quadembed-nano](https://huggingface.co/Mithil-21/quadembed-nano)
111
+ - **Full write-up:** [I Built a Multimodal Embedding Model From Scratch on an RTX 4060](https://medium.com/@mithilmaske/i-built-a-multimodal-embedding-model-from-scratch-on-an-rtx-4060-text-image-audio-and-video-ab1fef04f1cd)
112
+
113
+ ## Credit
114
+
115
+ QuadEmbed reproduces the architecture described in Jina AI's GELATO paper
116
+ ([arXiv:2605.08384](https://arxiv.org/abs/2605.08384)). All credit for the
117
+ original architecture and training recipe belongs there. This is an
118
+ independent reproduction, not affiliated with or endorsed by Jina AI.
@@ -0,0 +1,59 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "quadembed"
7
+ version = "0.1.0"
8
+ description = "Multimodal embeddings for text, image, audio, and video in one shared vector space, trained on a single consumer GPU"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "CC-BY-NC-4.0" }
12
+ authors = [{ name = "Mithil Maske" }]
13
+ keywords = [
14
+ "embeddings",
15
+ "multimodal",
16
+ "retrieval",
17
+ "contrastive-learning",
18
+ "clip",
19
+ "text-to-image",
20
+ "audio",
21
+ "video",
22
+ "semantic-search",
23
+ ]
24
+ classifiers = [
25
+ "Development Status :: 4 - Beta",
26
+ "Intended Audience :: Science/Research",
27
+ "Intended Audience :: Developers",
28
+ "Programming Language :: Python :: 3",
29
+ "Programming Language :: Python :: 3.10",
30
+ "Programming Language :: Python :: 3.11",
31
+ "Programming Language :: Python :: 3.12",
32
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
33
+ "Topic :: Multimedia :: Sound/Audio :: Analysis",
34
+ "Topic :: Multimedia :: Video",
35
+ ]
36
+ dependencies = [
37
+ "torch>=2.1",
38
+ "transformers>=4.45",
39
+ "huggingface-hub>=0.24",
40
+ "pillow>=9.0",
41
+ "numpy>=1.24",
42
+ ]
43
+
44
+ [project.optional-dependencies]
45
+ video = ["opencv-python>=4.8"]
46
+ audio = ["soundfile>=0.12", "librosa>=0.10"]
47
+ all = ["opencv-python>=4.8", "soundfile>=0.12", "librosa>=0.10"]
48
+
49
+ [project.urls]
50
+ Homepage = "https://github.com/mithilai/QuadEmbed"
51
+ Repository = "https://github.com/mithilai/QuadEmbed"
52
+ "Model Weights" = "https://huggingface.co/Mithil-21/quadembed-nano"
53
+ "Write-up" = "https://medium.com/@mithilmaske/i-built-a-multimodal-embedding-model-from-scratch-on-an-rtx-4060-text-image-audio-and-video-ab1fef04f1cd"
54
+
55
+ [tool.hatch.build.targets.wheel]
56
+ packages = ["quadembed"]
57
+
58
+ [tool.hatch.build.targets.sdist]
59
+ include = ["quadembed", "README.md", "LICENSE"]
@@ -0,0 +1,168 @@
1
+ """QuadEmbed: a local multimodal embedding model covering text, image, audio,
2
+ and video in one shared 768-dim space.
3
+
4
+ Reproduces the GELATO architecture (jina-embeddings-v5-omni): three frozen
5
+ pretrained encoders plus two small trained projectors. Only the projectors
6
+ were trained; every encoder is used as published.
7
+
8
+ from quadembed import QuadEmbed
9
+
10
+ model = QuadEmbed.from_pretrained() # pulls weights from the Hub
11
+ text = model.embed_text(["a dog on the beach"])
12
+ image = model.embed_image([Image.open("photo.jpg")])
13
+ print(text @ image.T) # cosine similarity
14
+ """
15
+ import os
16
+
17
+ import torch
18
+ import torch.nn.functional as F
19
+
20
+ from .encoders import AudioEncoder, TextEncoder, VisionEncoder
21
+ from .losses import bidirectional_infonce, matryoshka_infonce
22
+ from .projectors import AudioProjector, VisionProjector, embed_video_from_frames
23
+ from .video import embed_video, sample_frames
24
+
25
+ __version__ = "0.1.0"
26
+
27
+ DEFAULT_REPO_ID = "Mithil-21/quadembed-nano"
28
+
29
+ __all__ = [
30
+ "QuadEmbed",
31
+ "TextEncoder",
32
+ "VisionEncoder",
33
+ "AudioEncoder",
34
+ "VisionProjector",
35
+ "AudioProjector",
36
+ "matryoshka_infonce",
37
+ "bidirectional_infonce",
38
+ "embed_video",
39
+ "embed_video_from_frames",
40
+ "sample_frames",
41
+ "__version__",
42
+ ]
43
+
44
+ _CHECKPOINTS = {
45
+ "vision": "checkpoints/vision_projector.pt",
46
+ "audio": "checkpoints/audio_projector.pt",
47
+ "video": "checkpoints/video_projector.pt",
48
+ }
49
+
50
+
51
+ def _default_device() -> str:
52
+ return "cuda" if torch.cuda.is_available() else "cpu"
53
+
54
+
55
+ class QuadEmbed:
56
+ """Three frozen encoders plus the trained projectors, with one `embed_*`
57
+ method per modality.
58
+
59
+ Every method returns an L2-normalized ``[batch, 768]`` tensor in the same
60
+ shared space, so any two outputs from any two modalities can be compared
61
+ with a plain matrix product (which is then cosine similarity).
62
+
63
+ Loading all three encoders takes a few GB of VRAM and a minute or two on
64
+ first run while the frozen backbones download from the Hub. Pass
65
+ ``modalities=`` to load only what you need.
66
+ """
67
+
68
+ def __init__(self, device: str | None = None, modalities=("text", "vision", "audio")):
69
+ self.device = device or _default_device()
70
+ self.modalities = tuple(modalities)
71
+
72
+ self.text_encoder = TextEncoder(self.device) if "text" in self.modalities else None
73
+
74
+ needs_vision = "vision" in self.modalities or "video" in self.modalities
75
+ self.vision_encoder = VisionEncoder(self.device) if needs_vision else None
76
+ self.audio_encoder = AudioEncoder(self.device) if "audio" in self.modalities else None
77
+
78
+ self.vision_projector = (
79
+ VisionProjector(patch_dim=768, out_dim=768).to(self.device).eval()
80
+ if "vision" in self.modalities else None
81
+ )
82
+ self.audio_projector = (
83
+ AudioProjector(in_dim=1280, out_dim=768).to(self.device).eval()
84
+ if "audio" in self.modalities else None
85
+ )
86
+ self.video_projector = (
87
+ VisionProjector(patch_dim=768, out_dim=768).to(self.device).eval()
88
+ if "video" in self.modalities else None
89
+ )
90
+
91
+ @classmethod
92
+ def from_pretrained(
93
+ cls,
94
+ repo_id_or_path: str = DEFAULT_REPO_ID,
95
+ device: str | None = None,
96
+ modalities=("text", "vision", "audio"),
97
+ revision: str | None = None,
98
+ ) -> "QuadEmbed":
99
+ """Load trained projector weights.
100
+
101
+ ``repo_id_or_path`` is either a Hugging Face repo id (the default
102
+ pulls the published checkpoints) or a local directory containing a
103
+ ``checkpoints/`` folder in the same layout.
104
+ """
105
+ model = cls(device=device, modalities=modalities)
106
+
107
+ targets = [
108
+ ("vision", model.vision_projector),
109
+ ("audio", model.audio_projector),
110
+ ("video", model.video_projector),
111
+ ]
112
+ for name, projector in targets:
113
+ if projector is None:
114
+ continue
115
+ path = model._resolve(repo_id_or_path, _CHECKPOINTS[name], revision)
116
+ projector.load_state_dict(torch.load(path, map_location=model.device))
117
+ return model
118
+
119
+ @staticmethod
120
+ def _resolve(repo_id_or_path: str, filename: str, revision: str | None) -> str:
121
+ local = os.path.join(repo_id_or_path, filename)
122
+ if os.path.isfile(local):
123
+ return local
124
+ if os.path.isdir(repo_id_or_path):
125
+ raise FileNotFoundError(f"{local} not found in local directory {repo_id_or_path}")
126
+
127
+ from huggingface_hub import hf_hub_download
128
+
129
+ return hf_hub_download(repo_id=repo_id_or_path, filename=filename, revision=revision)
130
+
131
+ def _check(self, attr: str, modality: str):
132
+ value = getattr(self, attr)
133
+ if value is None:
134
+ raise RuntimeError(
135
+ f"'{modality}' was not loaded. Construct with "
136
+ f"modalities=(..., '{modality}') to enable it."
137
+ )
138
+ return value
139
+
140
+ @torch.no_grad()
141
+ def embed_text(self, texts: list[str]) -> torch.Tensor:
142
+ encoder = self._check("text_encoder", "text")
143
+ return F.normalize(encoder.embed(texts).float(), dim=-1)
144
+
145
+ @torch.no_grad()
146
+ def embed_image(self, images: list) -> torch.Tensor:
147
+ encoder = self._check("vision_encoder", "vision")
148
+ projector = self._check("vision_projector", "vision")
149
+ patch_tokens, mask, spatial_shapes = encoder.patch_tokens(images)
150
+ return F.normalize(projector(patch_tokens.float(), mask, spatial_shapes), dim=-1)
151
+
152
+ @torch.no_grad()
153
+ def embed_audio(self, arrays: list, sampling_rate: int = 16000) -> torch.Tensor:
154
+ """``arrays``: list of mono float32 numpy arrays at ``sampling_rate``
155
+ (16 kHz is what the frozen Whisper encoder expects)."""
156
+ encoder = self._check("audio_encoder", "audio")
157
+ projector = self._check("audio_projector", "audio")
158
+ frame_tokens = encoder.frame_tokens(arrays, sampling_rate=sampling_rate)
159
+ return F.normalize(projector(frame_tokens.float()), dim=-1)
160
+
161
+ @torch.no_grad()
162
+ def embed_video_file(self, video_path: str, num_frames: int = 4) -> torch.Tensor:
163
+ """Samples ``num_frames`` frames and mean-pools their embeddings.
164
+ Needs the video extra: ``pip install quadembed[video]``."""
165
+ encoder = self._check("vision_encoder", "video")
166
+ projector = self._check("video_projector", "video")
167
+ out = embed_video(encoder, projector, video_path, self.device, num_frames=num_frames)
168
+ return F.normalize(out.unsqueeze(0), dim=-1)
@@ -0,0 +1,103 @@
1
+ """Frozen encoder loaders.
2
+
3
+ Substitutions vs. the GELATO paper's exact checkpoints, since the paper's
4
+ adapted encoders (Qwen3.5's vision tower, Qwen2.5-Omni's audio tower) aren't
5
+ distributed as standalone checkpoints, and the paper itself says they were
6
+ *adapted from* these two source models -- so we use the source models
7
+ directly, which the paper's own dimension numbers (1280 audio, patch-merge
8
+ producing 3072 for nano) line up with:
9
+ text : jinaai/jina-embeddings-v5-text-nano (exact match, publicly released)
10
+ vision: google/siglip2-base-patch16-naflex (source SigLIP2 GELATO adapted)
11
+ audio : openai/whisper-large-v3 encoder (source Whisper GELATO adapted)
12
+ """
13
+ import torch
14
+ import torch.nn as nn
15
+ from transformers import (
16
+ AutoModel,
17
+ AutoTokenizer,
18
+ AutoImageProcessor,
19
+ WhisperModel,
20
+ WhisperFeatureExtractor,
21
+ )
22
+
23
+ try:
24
+ from transformers import Siglip2VisionModel as _VisionModelClass
25
+ except ImportError:
26
+ from transformers import AutoModel as _VisionModelClass
27
+
28
+ TEXT_MODEL_ID = "jinaai/jina-embeddings-v5-text-nano"
29
+ VISION_MODEL_ID = "google/siglip2-base-patch16-naflex"
30
+ AUDIO_MODEL_ID = "openai/whisper-large-v3"
31
+
32
+
33
+ def _freeze(module: nn.Module) -> nn.Module:
34
+ module.eval()
35
+ for p in module.parameters():
36
+ p.requires_grad_(False)
37
+ return module
38
+
39
+
40
+ def mean_pool(last_hidden_state: torch.Tensor, attention_mask: torch.Tensor) -> torch.Tensor:
41
+ mask = attention_mask.unsqueeze(-1).to(last_hidden_state.dtype)
42
+ summed = (last_hidden_state * mask).sum(dim=1)
43
+ counts = mask.sum(dim=1).clamp(min=1e-9)
44
+ return summed / counts
45
+
46
+
47
+ class TextEncoder:
48
+ def __init__(self, device: str, dtype=torch.bfloat16, max_length: int = 512):
49
+ self.tokenizer = AutoTokenizer.from_pretrained(TEXT_MODEL_ID, trust_remote_code=True)
50
+ self.model = _freeze(
51
+ AutoModel.from_pretrained(TEXT_MODEL_ID, trust_remote_code=True, dtype=dtype).to(device)
52
+ )
53
+ self.device = device
54
+ self.max_length = max_length
55
+
56
+ @torch.no_grad()
57
+ def embed(self, texts: list[str]) -> torch.Tensor:
58
+ inputs = self.tokenizer(
59
+ texts, padding=True, truncation=True, max_length=self.max_length, return_tensors="pt"
60
+ ).to(self.device)
61
+ out = self.model(**inputs)
62
+ return mean_pool(out.last_hidden_state, inputs["attention_mask"])
63
+
64
+
65
+ class VisionEncoder:
66
+ def __init__(self, device: str, dtype=torch.bfloat16):
67
+ self.processor = AutoImageProcessor.from_pretrained(VISION_MODEL_ID)
68
+ self.model = _freeze(_VisionModelClass.from_pretrained(VISION_MODEL_ID, dtype=dtype).to(device))
69
+ self.device = device
70
+
71
+ @torch.no_grad()
72
+ def patch_tokens(self, images: list) -> tuple[torch.Tensor, torch.Tensor | None, torch.Tensor | None]:
73
+ """Returns (patch_tokens [B,N,D], attention_mask [B,N] or None,
74
+ spatial_shapes [B,2] or None -- per-image (H_patches, W_patches)
75
+ before NaFlex pads every image in the batch to the same N, needed
76
+ for a real spatial 2x2 merge instead of grouping arbitrary tokens."""
77
+ inputs = self.processor(images=images, return_tensors="pt").to(self.device)
78
+ vision_module = getattr(self.model, "vision_model", self.model)
79
+ out = vision_module(**inputs)
80
+ mask = inputs.get("pixel_attention_mask")
81
+ spatial_shapes = inputs.get("spatial_shapes")
82
+ return out.last_hidden_state, mask, spatial_shapes
83
+
84
+
85
+ class AudioEncoder:
86
+ def __init__(self, device: str, dtype=torch.bfloat16):
87
+ self.feature_extractor = WhisperFeatureExtractor.from_pretrained(AUDIO_MODEL_ID)
88
+ full = WhisperModel.from_pretrained(AUDIO_MODEL_ID, dtype=dtype)
89
+ self.model = full.encoder
90
+ del full.decoder
91
+ del full
92
+ self.model = _freeze(self.model.to(device))
93
+ self.device = device
94
+ self.dtype = dtype
95
+
96
+ @torch.no_grad()
97
+ def frame_tokens(self, audio_arrays: list, sampling_rate: int = 16000) -> torch.Tensor:
98
+ inputs = self.feature_extractor(
99
+ audio_arrays, sampling_rate=sampling_rate, return_tensors="pt"
100
+ ).to(self.device)
101
+ features = inputs["input_features"].to(self.dtype)
102
+ out = self.model(features)
103
+ return out.last_hidden_state
@@ -0,0 +1,30 @@
1
+ """Bidirectional in-batch InfoNCE + Matryoshka loss, matching GELATO's Stage
2
+ 1/2 objective (temperature 0.02, prefix dims for the nano joint space)."""
3
+ import torch
4
+ import torch.nn.functional as F
5
+
6
+ NANO_MATRYOSHKA_DIMS = (32, 64, 128, 256, 768)
7
+
8
+
9
+ def bidirectional_infonce(a: torch.Tensor, b: torch.Tensor, temperature: float = 0.02) -> torch.Tensor:
10
+ """a, b: [B, D] paired embeddings (e.g. text, image), same batch order = positives."""
11
+ a = F.normalize(a, dim=-1)
12
+ b = F.normalize(b, dim=-1)
13
+ logits = a @ b.T / temperature # [B, B]
14
+ labels = torch.arange(a.shape[0], device=a.device)
15
+ loss_a2b = F.cross_entropy(logits, labels)
16
+ loss_b2a = F.cross_entropy(logits.T, labels)
17
+ return (loss_a2b + loss_b2a) / 2
18
+
19
+
20
+ def matryoshka_infonce(a: torch.Tensor, b: torch.Tensor, dims=NANO_MATRYOSHKA_DIMS, temperature: float = 0.02) -> torch.Tensor:
21
+ """Sum of bidirectional InfoNCE over truncated embedding prefixes, so
22
+ every prefix length is independently a usable (Matryoshka-truncatable)
23
+ embedding, not just the full 768-dim vector."""
24
+ losses = []
25
+ full_dim = a.shape[-1]
26
+ for d in dims:
27
+ if d > full_dim:
28
+ continue
29
+ losses.append(bidirectional_infonce(a[..., :d], b[..., :d], temperature))
30
+ return torch.stack(losses).mean()
@@ -0,0 +1,111 @@
1
+ """Trainable projectors that map frozen vision/audio encoder outputs into the
2
+ frozen text encoder's embedding space -- the only parameters GELATO trains.
3
+
4
+ Everything upstream (SigLIP2, Whisper encoder, jina-v5-text-nano) stays frozen;
5
+ only VisionProjector.fc_vision_2 and AudioProjector.fc_audio get gradients.
6
+ """
7
+ import torch
8
+ import torch.nn as nn
9
+
10
+
11
+ class VisionProjector(nn.Module):
12
+ """LayerNorm -> real 2x2 spatial merge -> trainable linear, matching
13
+ GELATO's "LayerNorm, 2x2 spatial merge, fc_vision_2" description
14
+ (nano: 3072->768).
15
+
16
+ Uses NaFlex's per-image `spatial_shapes` (H_patches, W_patches) to group
17
+ each image's own patch grid into genuine 2x2 spatial blocks before
18
+ concatenating channel-wise -- earlier versions approximated this with
19
+ sequential 4-token grouping (mixing patches from unrelated rows), which
20
+ is architecturally wrong and is kept here only as a fallback for
21
+ callers that don't have spatial_shapes available.
22
+ """
23
+
24
+ def __init__(self, patch_dim: int, out_dim: int, merge: int = 4, hidden_dim: int | None = None):
25
+ super().__init__()
26
+ self.merge = merge
27
+ self.norm = nn.LayerNorm(patch_dim, elementwise_affine=False)
28
+ if hidden_dim:
29
+ # not part of GELATO's own recipe (which uses a single linear
30
+ # fc_vision_2) -- an experiment to test whether a linear
31
+ # projector's capacity, not data, is capping R@1. See README's
32
+ # "round 6" for the result.
33
+ self.fc_vision_2 = nn.Sequential(
34
+ nn.Linear(patch_dim * merge, hidden_dim), nn.GELU(), nn.Linear(hidden_dim, out_dim)
35
+ )
36
+ else:
37
+ self.fc_vision_2 = nn.Linear(patch_dim * merge, out_dim)
38
+ self.modality_delim = nn.Parameter(torch.zeros(out_dim))
39
+
40
+ def _spatial_merge_one(self, tokens: torch.Tensor, h: int, w: int) -> torch.Tensor:
41
+ # tokens: [N, D] for one image, first h*w entries are the valid grid
42
+ d = tokens.shape[-1]
43
+ h2, w2 = h - (h % 2), w - (w % 2) # drop a trailing odd row/col rather than fabricate one
44
+ grid = tokens[: h * w].view(h, w, d)[:h2, :w2]
45
+ blocks = grid.reshape(h2 // 2, 2, w2 // 2, 2, d).permute(0, 2, 1, 3, 4).reshape(-1, 4 * d)
46
+ return blocks
47
+
48
+ def forward(
49
+ self,
50
+ patch_tokens: torch.Tensor,
51
+ attention_mask: torch.Tensor | None = None,
52
+ spatial_shapes: torch.Tensor | None = None,
53
+ ) -> torch.Tensor:
54
+ # patch_tokens: [B, N, patch_dim]
55
+ b, n, d = patch_tokens.shape
56
+ patch_tokens = self.norm(patch_tokens)
57
+
58
+ if spatial_shapes is not None:
59
+ pooled = []
60
+ for i in range(b):
61
+ h, w = int(spatial_shapes[i, 0]), int(spatial_shapes[i, 1])
62
+ blocks = self._spatial_merge_one(patch_tokens[i], h, w)
63
+ projected = self.fc_vision_2(blocks) # [num_blocks, out_dim]
64
+ pooled.append(projected.mean(dim=0))
65
+ pooled = torch.stack(pooled, dim=0)
66
+ else:
67
+ pad = (-n) % self.merge
68
+ pt = patch_tokens
69
+ if pad:
70
+ pt = nn.functional.pad(pt, (0, 0, 0, pad))
71
+ if attention_mask is not None:
72
+ attention_mask = nn.functional.pad(attention_mask, (0, pad))
73
+ merged = pt.reshape(b, pt.shape[1] // self.merge, d * self.merge)
74
+ projected = self.fc_vision_2(merged) # [B, N/merge, out_dim]
75
+ if attention_mask is not None:
76
+ merged_mask = attention_mask.reshape(b, -1, self.merge).amax(dim=-1).to(projected.dtype)
77
+ weights = merged_mask.unsqueeze(-1)
78
+ pooled = (projected * weights).sum(dim=1) / weights.sum(dim=1).clamp(min=1e-6)
79
+ else:
80
+ pooled = projected.mean(dim=1)
81
+
82
+ return pooled + self.modality_delim
83
+
84
+
85
+ class AudioProjector(nn.Module):
86
+ """Trainable linear (1280->768 for nano), matching GELATO's fc_audio."""
87
+
88
+ def __init__(self, in_dim: int, out_dim: int):
89
+ super().__init__()
90
+ self.fc_audio = nn.Linear(in_dim, out_dim)
91
+ self.modality_delim = nn.Parameter(torch.zeros(out_dim))
92
+
93
+ def forward(self, frame_tokens: torch.Tensor, attention_mask: torch.Tensor | None = None) -> torch.Tensor:
94
+ # frame_tokens: [B, T, in_dim]
95
+ projected = self.fc_audio(frame_tokens)
96
+ if attention_mask is not None:
97
+ weights = attention_mask.unsqueeze(-1).to(projected.dtype)
98
+ pooled = (projected * weights).sum(dim=1) / weights.sum(dim=1).clamp(min=1e-6)
99
+ else:
100
+ pooled = projected.mean(dim=1)
101
+ return pooled + self.modality_delim
102
+
103
+
104
+ def embed_video_from_frames(vision_projector: VisionProjector, per_frame_patch_tokens: list[torch.Tensor]) -> torch.Tensor:
105
+ """Video = mean of per-frame vision-projector embeddings.
106
+
107
+ No dedicated video encoder, matching GELATO: sample frames, run each
108
+ through the (shared, already-trained) vision projector, pool over time.
109
+ """
110
+ frame_embeds = torch.stack([vision_projector(tokens) for tokens in per_frame_patch_tokens], dim=1) # [B, F, out_dim]
111
+ return frame_embeds.mean(dim=1)
@@ -0,0 +1,47 @@
1
+ """Frame sampling + video embedding. No video encoder exists here -- a video
2
+ is just N sampled frames run through the already-trained VisionProjector and
3
+ mean-pooled, exactly as GELATO does."""
4
+ import numpy as np
5
+ import torch
6
+ from PIL import Image
7
+
8
+
9
+ def _require_cv2():
10
+ try:
11
+ import cv2
12
+ except ImportError as e: # pragma: no cover
13
+ raise ImportError(
14
+ "Video support needs OpenCV. Install it with:\n"
15
+ " pip install quadembed[video]"
16
+ ) from e
17
+ return cv2
18
+
19
+
20
+ def sample_frames(video_path: str, num_frames: int = 4) -> list:
21
+ cv2 = _require_cv2()
22
+ cap = cv2.VideoCapture(video_path)
23
+ total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
24
+ if total <= 0:
25
+ cap.release()
26
+ raise ValueError(f"no frames read from {video_path}")
27
+ indices = np.linspace(0, total - 1, num=min(num_frames, total), dtype=int)
28
+ frames = []
29
+ for idx in indices:
30
+ cap.set(cv2.CAP_PROP_POS_FRAMES, int(idx))
31
+ ok, frame_bgr = cap.read()
32
+ if not ok:
33
+ continue
34
+ frame_rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB)
35
+ frames.append(Image.fromarray(frame_rgb))
36
+ cap.release()
37
+ if not frames:
38
+ raise ValueError(f"no frames decoded from {video_path}")
39
+ return frames
40
+
41
+
42
+ @torch.no_grad()
43
+ def embed_video(vision_encoder, vision_projector, video_path: str, device: str, num_frames: int = 4) -> torch.Tensor:
44
+ frames = sample_frames(video_path, num_frames=num_frames)
45
+ patch_tokens, mask, spatial_shapes = vision_encoder.patch_tokens(frames) # [num_frames, N, 768]
46
+ frame_embeds = vision_projector(patch_tokens.float(), mask, spatial_shapes) # [num_frames, 768]
47
+ return frame_embeds.mean(dim=0)