abdoelsayed commited on
Commit
895bd48
·
verified ·
1 Parent(s): 0a2be45

Add UIBK-Embedding-v1 (weights, tokenizer, model code, model card)

Browse files
1_HybridTokens/config.json ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "dim": 768,
3
+ "token_dim": 128,
4
+ "slots": 3,
5
+ "local_weight": 0.9,
6
+ "slot_weight": 0.225,
7
+ "num_latents": 512,
8
+ "num_heads": 8,
9
+ "ff_mult": 4,
10
+ "query_marker_id": 50368,
11
+ "query_local_cap": 64,
12
+ "query_local_ref": 0,
13
+ "query_local_power": 1.0,
14
+ "query_slot_weight": -1.0,
15
+ "doc_slot_weight": -1.0,
16
+ "slot_copies": 1
17
+ }
1_HybridTokens/model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c42363bb43bcfadef8154bc5d13cdc14231242e8b57676c5242590bde2dfbc6f
3
+ size 41000912
README.md CHANGED
@@ -1,3 +1,152 @@
1
  ---
2
  license: apache-2.0
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  license: apache-2.0
3
+ language:
4
+ - en
5
+ library_name: PyLate
6
+ pipeline_tag: sentence-similarity
7
+ base_model: answerdotai/ModernBERT-base
8
+ tags:
9
+ - ColBERT
10
+ - PyLate
11
+ - sentence-transformers
12
+ - late-interaction
13
+ - retrieval
14
+ - feature-extraction
15
+ - ModernBERT
16
  ---
17
+
18
+ # UIBK-Embedding-v1
19
+
20
+ **UIBK-Embedding-v1** is an English retrieval model that holds a **late-interaction (ColBERT) retriever and a dense
21
+ retriever in one 179M-parameter encoder**, and stores both in one standard ColBERT index.
22
+
23
+ - One encoder pass per text. Output: a list of 128-dimensional vectors, one per token plus 3 vectors that carry the
24
+ dense sentence embedding.
25
+ - Plain MaxSim over these vectors equals **token MaxSim + 0.75 x dense cosine**. No special index or scorer is needed:
26
+ the model loads as a [PyLate](https://github.com/lightonai/pylate) ColBERT model and works with PLAID.
27
+ - 179M parameters (ModernBERT-base trunk of 18 shared layers, 4 own layers per head), 2048-token documents,
28
+ 256-token queries.
29
+
30
+ ## Results
31
+
32
+ nDCG@10, [MTEB](https://github.com/embeddings-benchmark/mteb) 2.21.6 with PyLate and a PLAID index (default settings).
33
+
34
+ | | UIBK-Embedding-v1 | LateOn (149M) |
35
+ |---|---|---|
36
+ | **BEIR (15 datasets), average** | **59.74** | 57.22 |
37
+ | **NanoBEIR (13 datasets), average** | **70.55** | - |
38
+
39
+ | BEIR dataset | UIBK-Embedding-v1 | LateOn |
40
+ |---|---|---|
41
+ | ArguAna | 56.78 | 50.52 |
42
+ | CQADupStack | 48.61 | 47.36 |
43
+ | ClimateFEVER | 44.29 | 39.67 |
44
+ | DBPedia | 49.41 | 45.99 |
45
+ | FEVER | 93.23 | 92.02 |
46
+ | FiQA | 53.61 | 53.12 |
47
+ | HotpotQA | 81.60 | 79.98 |
48
+ | MSMARCO | 46.71 | 45.67 |
49
+ | NFCorpus | 38.91 | 37.79 |
50
+ | NQ | 67.66 | 63.91 |
51
+ | Quora | 89.90 | 89.67 |
52
+ | SCIDOCS | 23.50 | 21.90 |
53
+ | SciFact | 77.98 | 76.61 |
54
+ | TREC-COVID | 83.73 | 83.60 |
55
+ | Touche-2020 | 40.21 | 30.52 |
56
+
57
+ LateOn's numbers are those of its model card.
58
+
59
+ | NanoBEIR dataset | UIBK-Embedding-v1 |
60
+ |---|---|
61
+ | NanoArguAna | 59.84 |
62
+ | NanoClimateFever | 51.65 |
63
+ | NanoDBPedia | 69.20 |
64
+ | NanoFEVER | 96.57 |
65
+ | NanoFiQA2018 | 64.34 |
66
+ | NanoHotpotQA | 92.72 |
67
+ | NanoMSMARCO | 68.99 |
68
+ | NanoNFCorpus | 39.22 |
69
+ | NanoNQ | 82.14 |
70
+ | NanoQuora | 96.99 |
71
+ | NanoSCIDOCS | 45.40 |
72
+ | NanoSciFact | 83.93 |
73
+ | NanoTouche2020 | 66.14 |
74
+
75
+ ## Usage
76
+
77
+ The model needs `trust_remote_code=True` (two small modules in this repository define the two-top encoder and the
78
+ output layer) and **the prompts `query` / `document` must be passed** when encoding.
79
+
80
+ ```bash
81
+ pip install -U pylate "transformers>=5.3" "sentence-transformers>=5.3"
82
+ ```
83
+
84
+ ```python
85
+ from pylate import indexes, models, retrieve
86
+
87
+ model = models.ColBERT("DataScience-UIBK/uibk-embedding-v1", trust_remote_code=True)
88
+
89
+ documents = [
90
+ "ColBERT scores a document by matching every query token with its most similar document token.",
91
+ "Comprehensive smoke-free laws were followed by falls in hospital admissions for heart attacks.",
92
+ ]
93
+ documents_embeddings = model.encode(documents, is_query=False, prompt_name="document")
94
+
95
+ index = indexes.PLAID(index_folder="pylate-index", index_name="index", override=True)
96
+ index.add_documents(documents_ids=["d1", "d2"], documents_embeddings=documents_embeddings)
97
+
98
+ queries_embeddings = model.encode(["how does late interaction score documents"], is_query=True, prompt_name="query")
99
+ results = retrieve.ColBERT(index=index).retrieve(queries_embeddings=queries_embeddings, k=10)
100
+ ```
101
+
102
+ Re-ranking a list of candidates without an index:
103
+
104
+ ```python
105
+ from pylate import rank
106
+
107
+ reranked = rank.rerank(
108
+ documents_ids=[["d1", "d2"]],
109
+ queries_embeddings=queries_embeddings,
110
+ documents_embeddings=[documents_embeddings],
111
+ )
112
+ ```
113
+
114
+ ## How it works
115
+
116
+ ```
117
+ text ("query: ..." / "document: ...", with a [Q] / [D] marker)
118
+ -> shared trunk: ModernBERT embeddings + layers 0-17
119
+ -> token top (layers 18-21) -> linear 768 -> 128 per token = token vectors
120
+ -> dense top (own layers 18-21) -> latent-attention pooling -> 768-dim sentence vector
121
+ -> rotated, cut into 3 slices of 127 dims = 3 "slot" vectors
122
+ ```
123
+
124
+ Every output vector has 128 dimensions and unit norm. Dimension 0 is a tag with opposite signs for token vectors and
125
+ slot vectors, so in MaxSim a token only ever matches a token and a slot only its own slot. One MaxSim pass therefore
126
+ computes `token MaxSim + 0.75 x dense cosine` (up to a constant per query). The token head rewards exact term and
127
+ phrase matches; the dense head rewards overall meaning; their sum is better than either.
128
+
129
+ ## Training
130
+
131
+ - **Backbone**: ModernBERT-base, contrastive pre-training on LightOn's public pre-training collection.
132
+ - **Fine-tuning** (1 epoch): contrastive losses on both heads with hard negatives, distillation from a cross-encoder
133
+ (`mixedbread-ai/mxbai-rerank-large-v2`) on the dense head and on the fused score, and feature anchors to our earlier
134
+ single-head models.
135
+ - **Data**: LightOn's public English fine-tuning set (MS MARCO, NQ, HotpotQA, FEVER, FiQA, SQuAD v2, TriviaQA, MIRACL)
136
+ plus about 560k mined pairs from the pre-training collection (Quora, StackExchange, S2ORC, Reddit). Training
137
+ queries that equal a BEIR test query were removed.
138
+ - **Teachers**, for transparency: the cross-encoder above; one of the earlier single-head models was itself trained
139
+ with a ranking loss to a fine-tune of `dunzhang/stella_en_400M_v5`.
140
+
141
+ The model is **not zero-shot on the BEIR datasets whose training splits are in the data** (MS MARCO, NQ, HotpotQA,
142
+ FEVER, FiQA), like other models trained on this collection.
143
+
144
+ ## Limitations
145
+
146
+ - English only.
147
+ - Multi-vector output: a ColBERT index is larger than a single-vector index.
148
+ - Needs `trust_remote_code=True`, `transformers >= 5.3`, and the prompts above.
149
+
150
+ ## Citation
151
+
152
+ To be added.
branched_transformer.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """UIBK-Embedding-v1 encoder: ModernBERT with a shared trunk and two tops.
2
+
3
+ (This file ships with the model and is loaded from the model repository: `trust_remote_code=True`.)
4
+
5
+ The embeddings and the first `shared_layers` transformer layers are shared; each retrieval head has its own copy of the
6
+ remaining layers and final norm, so that the dense head and the token (ColBERT) head make different errors:
7
+
8
+ token_embeddings = final_norm(layers[K:](trunk)) the wrapped model's own top: the ColBERT branch
9
+ dense_states = dense_norm(dense_layers(trunk)) the second top: read by the dense pooling
10
+
11
+ `auto_model` is a complete ModernBertModel (trunk + ColBERT top), so PyLate and the tokenizer handling see an ordinary
12
+ Transformer module; the dense top lives in dense_layers / dense_norm and is saved to dense_branch.safetensors.
13
+ 18 shared layers and 4 own layers per branch: 149.0M + 20.1M parameters (+ 9.9M in the output layer).
14
+ Requires transformers >= 5.3: the forward pass follows ModernBertModel.forward of that version.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import copy
20
+ import os
21
+
22
+ import torch
23
+ from safetensors.torch import load_file, save_file
24
+ from sentence_transformers.models import Transformer
25
+ from torch import nn
26
+ from transformers.masking_utils import create_bidirectional_mask, create_bidirectional_sliding_window_mask
27
+
28
+ BRANCH_FILE = "dense_branch.safetensors"
29
+
30
+
31
+ class BranchedTransformer(Transformer):
32
+ config_keys: list[str] = ["max_seq_length", "do_lower_case", "shared_layers"]
33
+
34
+ def __init__(self, model_name_or_path: str, *args, shared_layers: int = 18, **kwargs) -> None:
35
+ super().__init__(model_name_or_path, *args, **kwargs)
36
+ self.shared_layers = shared_layers
37
+ model = self.auto_model
38
+ self.dense_layers = nn.ModuleList(copy.deepcopy(layer) for layer in model.layers[shared_layers:])
39
+ self.dense_norm = copy.deepcopy(model.final_norm)
40
+ for module in self.dense_layers.modules(): # one config object: the attention implementation is switched on it
41
+ if hasattr(module, "config"):
42
+ module.config = model.config
43
+ path = os.path.join(model_name_or_path, BRANCH_FILE)
44
+ if os.path.exists(path):
45
+ self.load_dense_branch(load_file(path))
46
+
47
+ def load_dense_branch(self, state: dict[str, torch.Tensor]) -> None:
48
+ """`state`: dense_layers.* / dense_norm.* tensors, or a full encoder state dict (its layers from K on are used)."""
49
+ if not any(k.startswith("dense_layers.") for k in state):
50
+ k0 = self.shared_layers
51
+ renamed = {}
52
+ for name, tensor in state.items():
53
+ if name.startswith("final_norm."):
54
+ renamed["dense_norm." + name[len("final_norm."):]] = tensor
55
+ elif name.startswith("layers.") and int(name.split(".")[1]) >= k0:
56
+ parts = name.split(".")
57
+ renamed[".".join(["dense_layers", str(int(parts[1]) - k0), *parts[2:]])] = tensor
58
+ state = renamed
59
+ own = {k: v for k, v in self.state_dict().items() if k.startswith(("dense_layers.", "dense_norm."))}
60
+ if set(own) != set(state):
61
+ raise ValueError(f"dense branch weights do not match: {sorted(set(own) ^ set(state))[:4]}")
62
+ self.load_state_dict(state, strict=False)
63
+
64
+ def branched(self, input_ids: torch.Tensor, attention_mask: torch.Tensor | None = None,
65
+ position_ids: torch.Tensor | None = None, **kwargs) -> tuple[torch.Tensor, torch.Tensor]:
66
+ """ModernBertModel.forward (transformers 5.3) with the layer loop split after the trunk."""
67
+ model = self.auto_model
68
+ kwargs.pop("return_dict", None)
69
+ if position_ids is None:
70
+ position_ids = torch.arange(input_ids.shape[1], device=input_ids.device).unsqueeze(0)
71
+ hidden = model.embeddings(input_ids=input_ids, inputs_embeds=None)
72
+ masks = attention_mask
73
+ if not isinstance(masks, dict):
74
+ mask_kwargs = {"config": model.config, "inputs_embeds": hidden, "attention_mask": attention_mask}
75
+ masks = {"full_attention": create_bidirectional_mask(**mask_kwargs),
76
+ "sliding_attention": create_bidirectional_sliding_window_mask(**mask_kwargs)}
77
+ positions = {kind: model.rotary_emb(hidden, position_ids, kind) for kind in model.config.layer_types}
78
+
79
+ def run(layers, states):
80
+ for layer in layers:
81
+ states = layer(states, attention_mask=masks[layer.attention_type],
82
+ position_embeddings=positions[layer.attention_type], **kwargs)
83
+ return states
84
+
85
+ trunk = run(model.layers[: self.shared_layers], hidden)
86
+ tokens = model.final_norm(run(model.layers[self.shared_layers:], trunk))
87
+ dense = self.dense_norm(run(self.dense_layers, trunk))
88
+ return tokens, dense
89
+
90
+ def forward(self, features: dict[str, torch.Tensor], **kwargs) -> dict[str, torch.Tensor]:
91
+ inputs = {k: features[k] for k in ("input_ids", "attention_mask", "position_ids") if k in features}
92
+ features["token_embeddings"], features["dense_states"] = self.branched(**inputs, **kwargs)
93
+ return features
94
+
95
+ def save(self, output_path: str, safe_serialization: bool = True, **kwargs) -> None:
96
+ super().save(output_path, safe_serialization=safe_serialization, **kwargs)
97
+ own = {k: v.detach().cpu().contiguous() for k, v in self.state_dict().items()
98
+ if k.startswith(("dense_layers.", "dense_norm."))}
99
+ save_file(own, os.path.join(output_path, BRANCH_FILE), metadata={"format": "pt"})
config.json ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "architectures": [
3
+ "ModernBertModel"
4
+ ],
5
+ "attention_bias": false,
6
+ "attention_dropout": 0.0,
7
+ "bos_token_id": 50281,
8
+ "classifier_activation": "gelu",
9
+ "classifier_bias": false,
10
+ "classifier_dropout": 0.0,
11
+ "classifier_pooling": "mean",
12
+ "cls_token_id": 50281,
13
+ "decoder_bias": true,
14
+ "deterministic_flash_attn": false,
15
+ "dtype": "float32",
16
+ "embedding_dropout": 0.0,
17
+ "eos_token_id": 50282,
18
+ "global_attn_every_n_layers": 3,
19
+ "gradient_checkpointing": false,
20
+ "hidden_activation": "gelu",
21
+ "hidden_size": 768,
22
+ "initializer_cutoff_factor": 2.0,
23
+ "initializer_range": 0.02,
24
+ "intermediate_size": 1152,
25
+ "layer_norm_eps": 1e-05,
26
+ "layer_types": [
27
+ "full_attention",
28
+ "sliding_attention",
29
+ "sliding_attention",
30
+ "full_attention",
31
+ "sliding_attention",
32
+ "sliding_attention",
33
+ "full_attention",
34
+ "sliding_attention",
35
+ "sliding_attention",
36
+ "full_attention",
37
+ "sliding_attention",
38
+ "sliding_attention",
39
+ "full_attention",
40
+ "sliding_attention",
41
+ "sliding_attention",
42
+ "full_attention",
43
+ "sliding_attention",
44
+ "sliding_attention",
45
+ "full_attention",
46
+ "sliding_attention",
47
+ "sliding_attention",
48
+ "full_attention"
49
+ ],
50
+ "local_attention": 128,
51
+ "max_position_embeddings": 8192,
52
+ "mlp_bias": false,
53
+ "mlp_dropout": 0.0,
54
+ "model_type": "modernbert",
55
+ "norm_bias": false,
56
+ "norm_eps": 1e-05,
57
+ "num_attention_heads": 12,
58
+ "num_hidden_layers": 22,
59
+ "pad_token_id": 50283,
60
+ "position_embedding_type": "absolute",
61
+ "rope_parameters": {
62
+ "full_attention": {
63
+ "rope_theta": 160000.0,
64
+ "rope_type": "default"
65
+ },
66
+ "sliding_attention": {
67
+ "rope_theta": 10000.0,
68
+ "rope_type": "default"
69
+ }
70
+ },
71
+ "sep_token_id": 50282,
72
+ "sparse_pred_ignore_index": -100,
73
+ "sparse_prediction": false,
74
+ "tie_word_embeddings": true,
75
+ "transformers_version": "5.3.0",
76
+ "vocab_size": 50370
77
+ }
config_sentence_transformers.json ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model_type": "ColBERT",
3
+ "__version__": {
4
+ "sentence_transformers": "5.3.0",
5
+ "transformers": "5.3.0",
6
+ "pytorch": "2.9.0+cu128"
7
+ },
8
+ "prompts": {
9
+ "query": "query: ",
10
+ "document": "document: "
11
+ },
12
+ "default_prompt_name": null,
13
+ "similarity_fn_name": "MaxSim",
14
+ "query_prefix": "[Q] ",
15
+ "document_prefix": "[D] ",
16
+ "query_length": 256,
17
+ "document_length": 2048,
18
+ "attend_to_expansion_tokens": false,
19
+ "skiplist_words": [
20
+ "!",
21
+ "\"",
22
+ "#",
23
+ "$",
24
+ "%",
25
+ "&",
26
+ "'",
27
+ "(",
28
+ ")",
29
+ "*",
30
+ "+",
31
+ ",",
32
+ "-",
33
+ ".",
34
+ "/",
35
+ ":",
36
+ ";",
37
+ "<",
38
+ "=",
39
+ ">",
40
+ "?",
41
+ "@",
42
+ "[",
43
+ "\\",
44
+ "]",
45
+ "^",
46
+ "_",
47
+ "`",
48
+ "{",
49
+ "|",
50
+ "}",
51
+ "~"
52
+ ],
53
+ "do_query_expansion": false,
54
+ "hybrid_head": {
55
+ "dim": 768,
56
+ "token_dim": 128,
57
+ "slots": 3,
58
+ "local_weight": 0.9,
59
+ "slot_weight": 0.225,
60
+ "num_latents": 512,
61
+ "num_heads": 8,
62
+ "ff_mult": 4,
63
+ "query_marker_id": 50368,
64
+ "query_local_cap": 64,
65
+ "query_local_ref": 0,
66
+ "query_local_power": 1.0,
67
+ "query_slot_weight": -1.0,
68
+ "doc_slot_weight": -1.0,
69
+ "slot_copies": 1,
70
+ "sha256": "c42363bb43bcfadef8154bc5d13cdc14231242e8b57676c5242590bde2dfbc6f",
71
+ "code_sha256": "0f0c540e201da7150b2dbc2447914d2f68a4e6e1d66280ab8843e829a62db80d",
72
+ "branch_sha256": "1e924278f9c2ec9c7dc326892a19ade45feb72cf2a780bef77006e0bc07ccac6",
73
+ "branch_code_sha256": "38023f3eaaeac0e7da5a92d19059a3ebbb5e644841c13ff3aa05adf3e6d8d9e1"
74
+ }
75
+ }
dense_branch.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1e924278f9c2ec9c7dc326892a19ade45feb72cf2a780bef77006e0bc07ccac6
3
+ size 80246240
hybrid_tokens.py ADDED
@@ -0,0 +1,224 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """UIBK-Embedding-v1 output layer: a dense retriever and a late-interaction (ColBERT) retriever in one list of vectors.
2
+
3
+ (This file ships with the model and is loaded from the model repository: `trust_remote_code=True`.)
4
+
5
+ One encoder pass gives the token states H. From them:
6
+ local tokens u_t = normalize(W H_t) the ColBERT projection (768 -> 128), rotated down to 127 dims
7
+ dense vector g = mean_t LatentAttention(H)_t latent-attention pooling of the dense branch's states
8
+ The 128-dim output vectors (unit norm) are
9
+ a local token [ +sqrt(1 - b2) ; sqrt(b2) * u_t ] b2 = local_weight
10
+ dense slot s [ -sqrt(1 - d2) ; sqrt(d2) * g_s ] d2 = slot_weight; g rotated to `slots` slices of 127
11
+ dims, each slice normalised
12
+ and the slots overwrite the first `slots` token positions of every query and document ([CLS], the [Q] / [D] marker,
13
+ the prompt word), which are never punctuation, so PyLate keeps them. Dimension 0 is a tag: it adds a constant to pairs
14
+ of the same kind and subtracts one from mixed pairs, so a vector never picks a partner of the other kind:
15
+ local x local = (1 - b2) + b2 * cos(u, u')
16
+ slot s x slot s = (1 - d2) + d2 * cos(g_s, g'_s)
17
+ slot x local = -sqrt((1 - b2)(1 - d2)) + sqrt(b2 d2) * (g_s . u) (unrelated spaces: the last term is ~0)
18
+ so plain MaxSim(q, d) = const(q) + b2 * MaxSim_local(q, d) + d2 * sum_s cos(g_s, g'_s): the fixed-weight sum of the
19
+ token score and the dense score, MaxSim_local + lam * (mean slice cosine) with lam = slots * d2 / b2 (0.75 in the
20
+ released configuration). Any ColBERT index and scorer works unchanged.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import torch
26
+ import torch.nn.functional as F
27
+ from pylate.models import Dense
28
+ from torch import nn
29
+
30
+
31
+ class GEGLU(nn.Module):
32
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
33
+ x, gates = x.chunk(2, dim=-1)
34
+ return x * F.gelu(gates)
35
+
36
+
37
+ def orthonormal(rows: int, cols: int, seed: int) -> torch.Tensor:
38
+ """(rows, cols) matrix with orthonormal columns, the same for a given seed on every machine."""
39
+ q, _ = torch.linalg.qr(torch.randn(rows, rows, generator=torch.Generator().manual_seed(seed), dtype=torch.float64))
40
+ return q[:, :cols].float()
41
+
42
+
43
+ class HybridTokens(Dense):
44
+ config_keys: list[str] = ["dim", "token_dim", "slots", "local_weight", "slot_weight", "num_latents", "num_heads",
45
+ "ff_mult", "query_marker_id", "query_local_cap", "query_local_ref", "query_local_power",
46
+ "query_slot_weight", "doc_slot_weight", "slot_copies"]
47
+ refine_rows = 32768 # token rows per call of the pooling block (memory only)
48
+
49
+ def __init__(self, dim: int = 768, token_dim: int = 128, slots: int = 3, local_weight: float = 0.8,
50
+ slot_weight: float = 0.8, num_latents: int = 512, num_heads: int = 8, ff_mult: int = 4,
51
+ query_marker_id: int = -1, query_local_cap: int = 0, query_local_ref: int = 0,
52
+ query_local_power: float = 1.0, query_slot_weight: float = -1.0,
53
+ doc_slot_weight: float = -1.0, slot_copies: int = 1) -> None:
54
+ super().__init__(in_features=dim, out_features=token_dim, bias=False) # self.linear: the ColBERT projection
55
+ self.dim, self.token_dim, self.slots = dim, token_dim, slots
56
+ self.local_weight, self.slot_weight = local_weight, slot_weight
57
+ # Long queries: the dense vector pools the whole query, but only its first `query_local_cap` tokens take part
58
+ # in the token-level MaxSim (a ColBERT reads 32-64 query tokens): the later query tokens are masked out of the
59
+ # output, so PyLate's encode drops them. A query is recognised by its [Q] marker at position 1.
60
+ self.query_marker_id, self.query_local_cap = query_marker_id, query_local_cap
61
+ # Optional, off in the released model (query_local_ref = 0): with r > 0, a query with n > r local tokens gets
62
+ # the local weight b2 * f^2, f = (r / n) ** query_local_power, which scales its token score by f.
63
+ self.query_local_ref, self.query_local_power = query_local_ref, query_local_power
64
+ # Optional, off in the released model (both -1 = slot_weight on both sides): separate slot weights for queries
65
+ # (q2) and documents (p2); slot x slot = const + sqrt(q2 p2) cos, lam = slots * sqrt(q2 p2) / b2.
66
+ self.query_slot_weight, self.doc_slot_weight = query_slot_weight, doc_slot_weight
67
+ # Optional, off in the released model (slot_copies = 1): with k > 1 (padded batches) every text carries its
68
+ # dense vector k times, copy c rotated by its own fixed orthogonal matrix before slicing; the extra slot
69
+ # vectors are appended as new columns after the token columns (mask 1, the id of position 0).
70
+ self.slot_copies = slot_copies
71
+ self._copy_rotations: dict[int, torch.Tensor] = {}
72
+ self.num_latents, self.num_heads, self.ff_mult = num_latents, num_heads, ff_mult
73
+ content = token_dim - 1
74
+ self.register_buffer("local_rotation", orthonormal(token_dim, content, seed=1))
75
+ self.register_buffer("dense_rotation", orthonormal(dim, slots * content, seed=0))
76
+ # the dense pooling block (names as in LatentAttentionPooling)
77
+ self.latents = nn.Parameter(torch.randn(num_latents, dim) * dim ** -0.5)
78
+ self.norm_x = nn.LayerNorm(dim)
79
+ self.norm_latents = nn.LayerNorm(dim)
80
+ self.to_q = nn.Linear(dim, dim, bias=False)
81
+ self.to_kv = nn.Linear(dim, 2 * dim, bias=False)
82
+ self.to_out = nn.Linear(dim, dim)
83
+ self.norm_ff = nn.LayerNorm(dim)
84
+ self.ff = nn.Sequential(nn.Linear(dim, 2 * ff_mult * dim), GEGLU(), nn.Linear(ff_mult * dim, dim))
85
+
86
+ def refine(self, x: torch.Tensor) -> torch.Tensor:
87
+ """The latent-attention block of our dense models on token states (..., T, dim), token by token."""
88
+ b, t, d = x.shape
89
+ h, dh = self.num_heads, d // self.num_heads
90
+ q = self.to_q(self.norm_x(x)).view(b, t, h, dh).transpose(1, 2)
91
+ k, v = self.to_kv(self.norm_latents(self.latents)).chunk(2, dim=-1)
92
+ k = k.view(self.num_latents, h, dh).transpose(0, 1).unsqueeze(0).expand(b, -1, -1, -1).to(q.dtype)
93
+ v = v.view(self.num_latents, h, dh).transpose(0, 1).unsqueeze(0).expand(b, -1, -1, -1).to(q.dtype)
94
+ attn = F.scaled_dot_product_attention(q, k, v)
95
+ x = x + self.to_out(attn.transpose(1, 2).reshape(b, t, d))
96
+ return x + self.ff(self.norm_ff(x))
97
+
98
+ def layout(self, features: dict[str, torch.Tensor]):
99
+ """Token rows (1, rows, dim) of a padded or packed batch, with each row's sequence, position and realness."""
100
+ states = features["token_embeddings"]
101
+ device = states.device
102
+ if "cu_seq_lens_q" in features: # packed batch (training): (1, rows, dim), sequences back to back
103
+ bounds = features["cu_seq_lens_q"].long()
104
+ lengths = bounds[1:] - bounds[:-1]
105
+ n_seq = lengths.numel()
106
+ seq = torch.repeat_interleave(torch.arange(n_seq, device=device), lengths)
107
+ pos = features["position_ids"][0]
108
+ if "kv_index" in features: # query expansion: the [MASK] rows are not part of the pooled vector
109
+ real = torch.zeros(seq.numel(), device=device).index_fill_(0, features["kv_index"], 1.0)
110
+ else:
111
+ real = torch.ones(seq.numel(), device=device)
112
+ else: # padded batch (B, T, dim)
113
+ n_seq, t = states.shape[:2]
114
+ seq = torch.arange(n_seq, device=device).repeat_interleave(t)
115
+ pos = torch.arange(t, device=device).repeat(n_seq)
116
+ real = features["attention_mask"].reshape(-1).float()
117
+ return states.reshape(1, -1, states.size(-1)), seq, pos, real, n_seq
118
+
119
+ def dense_vectors(self, features: dict[str, torch.Tensor]) -> torch.Tensor:
120
+ """The dense models' sentence embedding of every sequence, normalised: (sequences, dim), float32."""
121
+ rows, seq, _, real, n_seq = self.layout(features)
122
+ if "dense_states" in features: # two-top encoder (branched_transformer.py): the dense branch's states
123
+ rows = features["dense_states"].reshape(1, -1, rows.size(-1))
124
+ # The block works token by token, so only the rows that enter the pooled vector are refined, in chunks: the
125
+ # same result with bounded memory for long documents in large batches.
126
+ keep = real > 0
127
+ if not bool(keep.all()):
128
+ idx = keep.nonzero(as_tuple=True)[0]
129
+ rows, seq, real = rows[:, idx], seq[idx], real[idx]
130
+ refined = torch.cat([self.refine(chunk)[0].float() for chunk in rows.split(self.refine_rows, dim=1)])
131
+ refined = refined * real.unsqueeze(-1)
132
+ pooled = torch.zeros(n_seq, refined.size(-1), device=rows.device).index_add_(0, seq, refined)
133
+ pooled = pooled / torch.zeros(n_seq, device=rows.device).index_add_(0, seq, real).clamp(min=1.0).unsqueeze(-1)
134
+ return F.normalize(pooled, dim=-1)
135
+
136
+ def forward(self, features: dict[str, torch.Tensor]) -> dict[str, torch.Tensor]:
137
+ states = features["token_embeddings"]
138
+ rows, seq, pos, real, n_seq = self.layout(features)
139
+ content = self.token_dim - 1
140
+ is_query = None
141
+ asymmetric = self.query_slot_weight >= 0 and self.doc_slot_weight >= 0
142
+ if self.query_local_cap > 0 or self.query_local_ref > 0 or asymmetric:
143
+ ids = features["input_ids"].reshape(-1)
144
+ marker = pos == 1
145
+ is_query = torch.zeros(n_seq, dtype=torch.bool, device=rows.device)
146
+ is_query[seq[marker]] = ids[marker] == self.query_marker_id
147
+
148
+ local = F.normalize(self.linear(rows[0]), dim=-1) @ self.local_rotation.to(rows.dtype)
149
+ local = F.normalize(local, dim=-1)
150
+ if self.query_local_ref > 0:
151
+ counted = real * (pos >= self.slots)
152
+ if self.query_local_cap > 0:
153
+ counted = counted * (pos < self.query_local_cap)
154
+ n = torch.zeros(n_seq, device=rows.device).index_add_(0, seq, counted.float()).clamp(min=1.0)
155
+ f = (self.query_local_ref / n).clamp(max=1.0) ** self.query_local_power
156
+ f = torch.where(is_query, f, torch.ones_like(f))
157
+ weight = (self.local_weight * f[seq] ** 2).unsqueeze(-1) # per row
158
+ out = torch.cat([(1.0 - weight) ** 0.5, local.float() * weight ** 0.5], dim=-1).to(local.dtype)
159
+ else:
160
+ local = local * self.local_weight ** 0.5
161
+ out = torch.cat([local.new_full((local.size(0), 1), (1.0 - self.local_weight) ** 0.5), local], dim=-1)
162
+
163
+ dense = self.dense_vectors(features) @ self.dense_rotation.float()
164
+ dense = F.normalize(dense.view(n_seq, self.slots, content), dim=-1)
165
+ unit_slices = dense # (sequences, slots, content), float32, each slice of unit norm
166
+ if not asymmetric:
167
+ dense = dense.to(out.dtype) * self.slot_weight ** 0.5
168
+ if self.query_local_cap > 0:
169
+ capped = is_query[seq] & (pos >= self.query_local_cap)
170
+ if "attention_mask" in features: # padded batch: pylate keeps the query tokens whose mask is 1
171
+ keep = ~capped.view(features["attention_mask"].shape)
172
+ features["attention_mask"] = features["attention_mask"] * keep.to(features["attention_mask"].dtype)
173
+ slot = pos < self.slots
174
+ if asymmetric:
175
+ weight = torch.where(is_query[seq[slot]], self.query_slot_weight, self.doc_slot_weight)
176
+ weight = weight.to(torch.float32).unsqueeze(-1)
177
+ out[slot] = torch.cat([-(1.0 - weight) ** 0.5, dense[seq[slot], pos[slot]] * weight ** 0.5],
178
+ dim=-1).to(out.dtype)
179
+ else:
180
+ tag = dense.new_full((int(slot.sum()), 1), -((1.0 - self.slot_weight) ** 0.5))
181
+ out[slot] = torch.cat([tag, dense[seq[slot], pos[slot]]], dim=-1)
182
+ features["token_embeddings"] = out.view(*states.shape[:-1], self.token_dim)
183
+ if self.slot_copies > 1 and "cu_seq_lens_q" not in features and "attention_mask" in features:
184
+ extra = []
185
+ for c in range(1, self.slot_copies):
186
+ if c not in self._copy_rotations:
187
+ self._copy_rotations[c] = orthonormal(self.slots * content, self.slots * content, seed=1000 + c)
188
+ rotation = self._copy_rotations[c].to(unit_slices.device)
189
+ copy = F.normalize((unit_slices.reshape(n_seq, -1) @ rotation).view(n_seq, self.slots, content), dim=-1)
190
+ if asymmetric:
191
+ weight = torch.where(is_query, self.query_slot_weight, self.doc_slot_weight)
192
+ weight = weight.to(torch.float32).view(n_seq, 1, 1).expand(n_seq, self.slots, 1)
193
+ else:
194
+ weight = copy.new_full((n_seq, self.slots, 1), self.slot_weight)
195
+ extra.append(torch.cat([-(1.0 - weight) ** 0.5, copy * weight ** 0.5], dim=-1))
196
+ extra = torch.cat(extra, dim=1).to(out.dtype) # (sequences, slots x (k - 1), token_dim)
197
+ n_extra = extra.size(1)
198
+ features["token_embeddings"] = torch.cat([features["token_embeddings"], extra], dim=1)
199
+ mask, ids = features["attention_mask"], features["input_ids"]
200
+ features["attention_mask"] = torch.cat([mask, mask.new_ones(n_seq, n_extra)], dim=1)
201
+ features["input_ids"] = torch.cat([ids, ids[:, :1].expand(n_seq, n_extra)], dim=1)
202
+ if "token_type_ids" in features:
203
+ types = features["token_type_ids"]
204
+ features["token_type_ids"] = torch.cat([types, types.new_zeros(n_seq, n_extra)], dim=1)
205
+ return features
206
+
207
+ def get_config_dict(self) -> dict:
208
+ return {k: getattr(self, k) for k in self.config_keys}
209
+
210
+ def save(self, output_path: str, *args, safe_serialization: bool = True, **kwargs) -> None:
211
+ self.save_config(output_path)
212
+ self.save_torch_weights(output_path, safe_serialization=safe_serialization)
213
+
214
+ @classmethod
215
+ def load(cls, model_name_or_path: str, subfolder: str = "", token=None, cache_folder=None, revision=None,
216
+ local_files_only: bool = False, **kwargs):
217
+ hub = {"subfolder": subfolder, "token": token, "cache_folder": cache_folder, "revision": revision,
218
+ "local_files_only": local_files_only}
219
+ model = cls(**cls.load_config(model_name_or_path=model_name_or_path, **hub))
220
+ return cls.load_torch_weights(model_name_or_path=model_name_or_path, model=model, **hub)
221
+
222
+ def __repr__(self) -> str:
223
+ n = sum(p.numel() for p in self.parameters())
224
+ return f"HybridTokens({self.get_config_dict()}, params={n / 1e6:.1f}M)"
model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:8a66be0525cff10c0b1ba931336c49f1637fc4791a34a7d63eca3ee2dae67636
3
+ size 596076280
modules.json ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "idx": 0,
4
+ "name": "0",
5
+ "path": "",
6
+ "type": "branched_transformer.BranchedTransformer"
7
+ },
8
+ {
9
+ "idx": 1,
10
+ "name": "1",
11
+ "path": "1_HybridTokens",
12
+ "type": "hybrid_tokens.HybridTokens"
13
+ }
14
+ ]
sentence_bert_config.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ {
2
+ "max_seq_length": 299,
3
+ "do_lower_case": false,
4
+ "shared_layers": 18
5
+ }
tokenizer.json ADDED
The diff for this file is too large to render. See raw diff
 
tokenizer_config.json ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "backend": "tokenizers",
3
+ "clean_up_tokenization_spaces": true,
4
+ "cls_token": "[CLS]",
5
+ "is_local": true,
6
+ "mask_token": "[MASK]",
7
+ "max_length": 512,
8
+ "model_input_names": [
9
+ "input_ids",
10
+ "attention_mask"
11
+ ],
12
+ "model_max_length": 299,
13
+ "pad_to_multiple_of": null,
14
+ "pad_token": "[MASK]",
15
+ "pad_token_type_id": 0,
16
+ "padding_side": "right",
17
+ "sep_token": "[SEP]",
18
+ "stride": 0,
19
+ "tokenizer_class": "TokenizersBackend",
20
+ "truncation_side": "right",
21
+ "truncation_strategy": "longest_first",
22
+ "unk_token": "[UNK]"
23
+ }