lukeingawesome commited on
Commit
f115fef
·
verified ·
1 Parent(s): f0005a9

Upload modeling_chest2vec.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. modeling_chest2vec.py +134 -269
modeling_chest2vec.py CHANGED
@@ -1,301 +1,166 @@
1
- """Chest2Vec — LoRA-tuned Qwen3-Embedding model for chest radiology reports.
2
 
3
- Load with:
 
4
 
5
- from transformers import AutoModel
6
- model = AutoModel.from_pretrained("chest2vec/chest2vec_0.6B", trust_remote_code=True)
7
- emb = model.embed_texts(["Frontal chest radiograph. No pneumothorax."]) # [N, H], L2-normalized
 
8
 
9
- Architecture:
10
- 1. Base : Qwen/Qwen3-Embedding-{0.6B,4B} (downloaded at runtime)
11
- 2. Adapter: frozen contrastive LoRA adapter (./contrastive)
12
-
13
- Embeddings use last-token (EOS) pooling with left padding, matching Qwen3-Embedding
14
- and the Stage-2 training setup. FlashAttention-2 is used when CUDA + flash-attn>=2
15
- are available (matching training); otherwise it falls back to SDPA so the model
16
- also loads on CPU.
17
  """
18
- import os
19
- from typing import Dict, List, Optional
20
-
21
  import torch
22
  import torch.nn.functional as F
23
-
24
- from transformers import AutoTokenizer, AutoModel, BitsAndBytesConfig, PreTrainedModel
25
-
26
  from .configuration_chest2vec import Chest2VecConfig
27
 
28
- try:
29
- from peft import PeftModel
30
- _HAS_PEFT = True
31
- except Exception:
32
- PeftModel = None
33
- _HAS_PEFT = False
34
-
35
- try:
36
- from huggingface_hub import snapshot_download
37
- _HAS_HUB = True
38
- except Exception:
39
- snapshot_download = None
40
- _HAS_HUB = False
41
-
42
-
43
- # ----------------------------------------------------------------------------
44
- # Attention backend selection
45
- # ----------------------------------------------------------------------------
46
- def _flash_attn_available() -> bool:
47
- if not torch.cuda.is_available():
48
- return False
49
- try:
50
- import flash_attn # noqa: F401
51
- ver = getattr(flash_attn, "__version__", "0.0.0")
52
- return int(str(ver).split(".")[0]) >= 2
53
- except Exception:
54
- return False
55
-
56
 
57
- def _pick_attn_impl(requested: Optional[str], want_flash: bool) -> str:
58
- import warnings
59
- if requested:
60
- return requested
61
- if want_flash and _flash_attn_available():
62
- return "flash_attention_2"
63
- if want_flash:
64
- warnings.warn(
65
- "Chest2Vec was trained with FlashAttention-2, but it is unavailable "
66
- "(needs CUDA + flash-attn>=2). Falling back to 'sdpa'; embeddings may "
67
- "differ very slightly from the reference implementation.",
68
- RuntimeWarning,
69
- )
70
- return "sdpa"
71
-
72
-
73
- # ----------------------------------------------------------------------------
74
- # Tokenization / pooling helpers (match Qwen3-Embedding + training)
75
- # ----------------------------------------------------------------------------
76
  def build_qwen_query(instruction: str, query: str) -> str:
77
- return f"Instruct: {str(instruction).strip()}\nQuery: {str(query).strip()}"
78
-
79
-
80
- def get_pool_token_id(tok) -> int:
81
- eod_id = tok.convert_tokens_to_ids("<|endoftext|>")
82
- if eod_id is None or eod_id < 0:
83
- eod_id = tok.pad_token_id
84
- return eod_id
85
 
86
 
87
- def encode_with_eos_ids(tok, texts: List[str], max_len: int) -> Dict[str, torch.Tensor]:
88
- """add_special_tokens=False, truncate to max_len-1, append <|endoftext|>, left-pad."""
89
- pad_id = tok.pad_token_id if tok.pad_token_id is not None else tok.eos_token_id
90
- eod_id = get_pool_token_id(tok)
91
- enc = tok(
92
- [str(t) for t in texts],
93
- add_special_tokens=False,
94
- truncation=True,
95
- max_length=max_len - 1,
96
- padding=False,
97
- return_attention_mask=False,
98
- )
99
- input_ids = [ids + [eod_id] for ids in enc["input_ids"]]
100
- attn_mask = [[1] * len(ids) for ids in input_ids]
101
- T = max((len(ids) for ids in input_ids), default=1)
102
- input_ids = [[pad_id] * (T - len(ids)) + ids for ids in input_ids]
103
- attn_mask = [[0] * (T - len(m)) + m for m in attn_mask]
104
- return {
105
- "input_ids": torch.tensor(input_ids, dtype=torch.long),
106
- "attention_mask": torch.tensor(attn_mask, dtype=torch.long),
107
- }
108
 
109
 
110
- def last_token_pool(last_hidden_states: torch.Tensor, attention_mask: torch.Tensor) -> torch.Tensor:
111
- """Left-padding-aware last-token (EOS) pooling."""
112
- left_padding = (attention_mask[:, -1].sum() == attention_mask.shape[0])
113
- if left_padding:
114
- return last_hidden_states[:, -1]
115
  idx = attention_mask.sum(dim=1) - 1
116
- return last_hidden_states[torch.arange(last_hidden_states.size(0), device=last_hidden_states.device), idx]
117
-
118
-
119
- def get_last_hidden_state(model, input_ids, attention_mask):
120
- m = model.module if hasattr(model, "module") else model
121
- position_ids = attention_mask.long().cumsum(-1) - 1
122
- position_ids.masked_fill_(attention_mask == 0, 0)
123
- out = m(input_ids=input_ids, attention_mask=attention_mask, position_ids=position_ids,
124
- use_cache=False, return_dict=True)
125
- if getattr(out, "last_hidden_state", None) is not None:
126
- return out.last_hidden_state
127
- out = m(input_ids=input_ids, attention_mask=attention_mask, position_ids=position_ids,
128
- output_hidden_states=True, use_cache=False, return_dict=True)
129
- return out.hidden_states[-1]
130
 
131
 
132
  class Chest2VecModel(PreTrainedModel):
133
- """LoRA-tuned Qwen3-Embedding model producing L2-normalized report embeddings."""
134
-
135
  config_class = Chest2VecConfig
136
- base_model_prefix = "chest2vec"
137
- # Attention is handled by the inner Qwen3 backbone; advertise support so the
138
- # transformers attn-implementation validator on this wrapper passes.
139
- _supports_sdpa = True
140
- _supports_flash_attn_2 = True
141
- _supports_flash_attn = True
142
- _supports_attention_backend = True
143
 
144
  def __init__(self, config: Chest2VecConfig):
145
  super().__init__(config)
146
- # The base+adapter are assembled in `from_pretrained` (base downloads at runtime).
147
- self.backbone = None
148
- self.tokenizer = None
149
- self._device = torch.device("cpu")
150
- self.register_buffer("_anchor", torch.zeros(1), persistent=False)
151
 
152
  def get_input_embeddings(self):
153
- return None
154
 
155
  def set_input_embeddings(self, value):
156
- pass
157
-
158
- @classmethod
159
- def from_pretrained(cls, pretrained_model_name_or_path, *model_args, **kwargs):
160
- config = kwargs.pop("config", None)
161
- device = kwargs.pop("device", None)
162
- use_4bit = kwargs.pop("use_4bit", False)
163
- attn_implementation = kwargs.pop("attn_implementation", None)
164
- torch_dtype = kwargs.pop("torch_dtype", None)
165
- token = kwargs.pop("token", None) or kwargs.pop("use_auth_token", None)
166
- cache_dir = kwargs.pop("cache_dir", None)
167
- # remaining HF plumbing kwargs (state_dict, low_cpu_mem_usage, ...) are ignored
168
-
169
- repo_path = pretrained_model_name_or_path
170
- if not os.path.isdir(repo_path):
171
- if not _HAS_HUB:
172
- raise RuntimeError("huggingface_hub is required to load by repo_id.")
173
- repo_path = snapshot_download(repo_path, token=token, cache_dir=cache_dir)
174
-
175
- if config is None:
176
- config = Chest2VecConfig.from_pretrained(repo_path)
177
-
178
- if device is None:
179
- device = "cuda:0" if torch.cuda.is_available() else "cpu"
180
- device_t = torch.device(device)
181
- if torch_dtype is None:
182
- torch_dtype = torch.bfloat16 if device_t.type == "cuda" else torch.float32
183
-
184
- model = cls(config)
185
- model._assemble(repo_path, device=device_t, use_4bit=use_4bit,
186
- attn_implementation=attn_implementation, torch_dtype=torch_dtype, token=token)
187
- return model
188
-
189
- def _assemble(self, repo_path, *, device, use_4bit, attn_implementation, torch_dtype, token=None):
190
- cfg = self.config
191
- if not _HAS_PEFT:
192
- raise RuntimeError("peft is required. Install: pip install peft")
193
-
194
- attn_impl = _pick_attn_impl(attn_implementation, bool(cfg.require_flash_attention_2))
195
-
196
- tokenizer = AutoTokenizer.from_pretrained(
197
- cfg.base_model, padding_side="left", trust_remote_code=True, token=token
198
- )
199
- if tokenizer.pad_token_id is None:
200
- tokenizer.pad_token = tokenizer.eos_token
201
-
202
- base_kwargs = dict(trust_remote_code=True, attn_implementation=attn_impl, token=token)
203
- if use_4bit:
204
- base_kwargs["quantization_config"] = BitsAndBytesConfig(
205
- load_in_4bit=True, bnb_4bit_quant_type="nf4",
206
- bnb_4bit_use_double_quant=True, bnb_4bit_compute_dtype=torch.bfloat16,
207
- )
208
- base_kwargs["device_map"] = {"": str(device)}
209
- else:
210
- base_kwargs["torch_dtype"] = torch_dtype
211
- if device.type == "cuda":
212
- base_kwargs["device_map"] = {"": str(device)}
213
- try:
214
- base = AutoModel.from_pretrained(cfg.base_model, **base_kwargs)
215
- except TypeError as e:
216
- raise RuntimeError("transformers too old for attn_implementation=...; please upgrade.") from e
217
- if device.type != "cuda" and not use_4bit:
218
- base = base.to(device)
219
-
220
- adapter_dir = os.path.join(repo_path, cfg.adapter_subdir)
221
- if not os.path.isfile(os.path.join(adapter_dir, "adapter_config.json")):
222
- raise FileNotFoundError(f"adapter_config.json not found under: {adapter_dir}")
223
- backbone = PeftModel.from_pretrained(base, adapter_dir)
224
- backbone.eval()
225
-
226
- self.backbone = backbone
227
- self.tokenizer = tokenizer
228
- self._device = device
229
- self.eval()
230
 
231
  @property
232
  def device(self):
233
- return self._device
234
-
235
- @torch.inference_mode()
236
- def embed_texts(self, texts: List[str], *, max_len: Optional[int] = None,
237
- batch_size: int = 16, return_cpu_float32: bool = True) -> torch.Tensor:
238
- """Return L2-normalized report embeddings, shape [N, H]."""
239
- if self.backbone is None:
240
- raise RuntimeError("Model not assembled; load via from_pretrained(...).")
241
- max_len = int(max_len or self.config.default_max_len)
242
- device = self._device
243
- if device.type == "cuda":
244
- amp_dtype = torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16
245
- use_amp = True
246
- else:
247
- amp_dtype, use_amp = torch.float32, False
248
-
249
- outs = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
250
  for i in range(0, len(texts), batch_size):
251
- chunk = [str(t) for t in texts[i:i + batch_size]]
252
- enc = encode_with_eos_ids(self.tokenizer, chunk, max_len)
253
- input_ids = enc["input_ids"].to(device, non_blocking=True)
254
- attention_mask = enc["attention_mask"].to(device, non_blocking=True)
255
- with torch.autocast(device_type=("cuda" if device.type == "cuda" else "cpu"),
256
- dtype=amp_dtype, enabled=use_amp):
257
- h = get_last_hidden_state(self.backbone, input_ids, attention_mask)
258
- emb = F.normalize(last_token_pool(h, attention_mask).float(), p=2, dim=-1)
259
- outs.append(emb.detach())
260
- embeddings = torch.cat(outs, dim=0)
261
- if return_cpu_float32:
262
- embeddings = F.normalize(embeddings.float().cpu(), p=2, dim=-1)
263
- return embeddings
264
-
265
- @torch.inference_mode()
266
- def embed_instruction_query(self, instructions: List[str], queries: List[str], **kw) -> torch.Tensor:
267
- if len(instructions) != len(queries):
268
- raise ValueError("instructions and queries must have the same length.")
269
- return self.embed_texts([build_qwen_query(i, q) for i, q in zip(instructions, queries)], **kw)
270
-
271
- def forward(self, texts: List[str], **kw) -> torch.Tensor: # type: ignore[override]
272
- return self.embed_texts(texts, **kw)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
 
274
  @staticmethod
275
- def cosine_topk(query_emb, cand_emb, k=10, *, device="cuda",
276
- query_batch_size=256, doc_chunk_size=8192):
277
- device_t = torch.device(device if torch.cuda.is_available() else "cpu")
278
- q = F.normalize(query_emb.float(), p=2, dim=-1)
279
- d = F.normalize(cand_emb.float(), p=2, dim=-1)
280
- Nq, _ = q.shape
281
- Nd = d.shape[0]
282
- k = min(int(k), Nd)
283
- top_scores_all = torch.empty((Nq, k), dtype=torch.float32)
284
- top_indices_all = torch.empty((Nq, k), dtype=torch.long)
285
- for qs in range(0, Nq, query_batch_size):
286
- qe = q[qs:qs + query_batch_size].to(device_t, non_blocking=True)
287
- bq = qe.size(0)
288
- top_scores = torch.full((bq, k), -1e9, device=device_t, dtype=torch.float32)
289
- top_indices = torch.full((bq, k), -1, device=device_t, dtype=torch.long)
290
- for ds in range(0, Nd, doc_chunk_size):
291
- de = d[ds:ds + doc_chunk_size].to(device_t, non_blocking=True)
292
- scores = (qe @ de.T).float()
293
- chunk = scores.size(1)
294
- idx_chunk = torch.arange(ds, ds + chunk, device=device_t, dtype=torch.long).unsqueeze(0).expand(bq, -1)
295
- comb_scores = torch.cat([top_scores, scores], dim=1)
296
- comb_idx = torch.cat([top_indices, idx_chunk], dim=1)
297
- new_scores, new_pos = torch.topk(comb_scores, k, dim=1)
298
- top_scores, top_indices = new_scores, comb_idx.gather(1, new_pos)
299
- top_scores_all[qs:qs + bq] = top_scores.cpu()
300
- top_indices_all[qs:qs + bq] = top_indices.cpu()
301
- return top_scores_all, top_indices_all
 
1
+ """Chest2Vec — Qwen3-Embedding model (contrastive LoRA merged in) for chest radiology reports.
2
 
3
+ Self-contained: load with `AutoModel` — no `chest2vec` package, and no download of the base
4
+ Qwen3-Embedding weights (the merged encoder ships in this repo).
5
 
6
+ from transformers import AutoModel, AutoTokenizer
7
+ model = AutoModel.from_pretrained("chest2vec/chest2vec_0.6B", trust_remote_code=True).eval()
8
+ tok = AutoTokenizer.from_pretrained("chest2vec/chest2vec_0.6B", trust_remote_code=True)
9
+ emb = model.embed_texts(["Frontal chest radiograph. No pneumothorax."], tokenizer=tok) # [N,H], L2-normalized
10
 
11
+ Embedding = left-padding-aware last-token (EOS) pooling + L2-norm. Matryoshka: pass `dim=512`/`256`.
 
 
 
 
 
 
 
12
  """
13
+ from typing import List, Optional
 
 
14
  import torch
15
  import torch.nn.functional as F
16
+ from transformers import PreTrainedModel, AutoConfig, AutoModel
17
+ from transformers.modeling_outputs import BaseModelOutputWithPooling
 
18
  from .configuration_chest2vec import Chest2VecConfig
19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  def build_qwen_query(instruction: str, query: str) -> str:
22
+ instruction = str(instruction).strip()
23
+ return f"Instruct: {instruction}\nQuery: {str(query).strip()}" if instruction else str(query).strip()
 
 
 
 
 
 
24
 
25
 
26
+ def _build_encoder(encoder_config: dict, attn_implementation: str = "sdpa"):
27
+ ecfg = dict(encoder_config)
28
+ for k in ("architectures", "auto_map", "transformers_version", "_name_or_path", "torch_dtype"):
29
+ ecfg.pop(k, None)
30
+ model_type = ecfg.pop("model_type", "qwen3")
31
+ cfg = AutoConfig.for_model(model_type, **ecfg)
32
+ cfg.torch_dtype = "float32"
33
+ try:
34
+ return AutoModel.from_config(cfg, attn_implementation=attn_implementation)
35
+ except TypeError:
36
+ return AutoModel.from_config(cfg)
 
 
 
 
 
 
 
 
 
 
37
 
38
 
39
+ def _last_token_pool(h: torch.Tensor, attention_mask: torch.Tensor) -> torch.Tensor:
40
+ left = (attention_mask[:, -1].sum() == attention_mask.shape[0])
41
+ if left:
42
+ return h[:, -1]
 
43
  idx = attention_mask.sum(dim=1) - 1
44
+ return h[torch.arange(h.size(0), device=h.device), idx]
 
 
 
 
 
 
 
 
 
 
 
 
 
45
 
46
 
47
  class Chest2VecModel(PreTrainedModel):
 
 
48
  config_class = Chest2VecConfig
49
+ base_model_prefix = "model"
 
 
 
 
 
 
50
 
51
  def __init__(self, config: Chest2VecConfig):
52
  super().__init__(config)
53
+ self.model = _build_encoder(config.encoder_config, getattr(config, "attn_implementation", "sdpa"))
54
+ self._tokenizer = None
55
+ self.post_init()
 
 
56
 
57
  def get_input_embeddings(self):
58
+ return self.model.get_input_embeddings()
59
 
60
  def set_input_embeddings(self, value):
61
+ self.model.set_input_embeddings(value)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
 
63
  @property
64
  def device(self):
65
+ return next(self.parameters()).device
66
+
67
+ # ---- low-level encoder forward (token tensors -> pooled, L2-normalized embedding) ----
68
+ def encode(self, input_ids, attention_mask, position_ids=None, normalize=True):
69
+ if position_ids is None and attention_mask is not None:
70
+ position_ids = attention_mask.long().cumsum(-1) - 1
71
+ position_ids.masked_fill_(attention_mask == 0, 0)
72
+ out = self.model(input_ids=input_ids, attention_mask=attention_mask,
73
+ position_ids=position_ids, use_cache=False, return_dict=True)
74
+ h = out.last_hidden_state if hasattr(out, "last_hidden_state") else out.hidden_states[-1]
75
+ emb = _last_token_pool(h, attention_mask).float()
76
+ if normalize:
77
+ emb = F.normalize(emb, p=2, dim=-1)
78
+ return BaseModelOutputWithPooling(last_hidden_state=h, pooler_output=emb)
79
+
80
+ def _get_tokenizer(self, tokenizer=None):
81
+ if tokenizer is not None:
82
+ return tokenizer
83
+ if self._tokenizer is None:
84
+ from transformers import AutoTokenizer
85
+ src = self.config._name_or_path or self.config.base_model
86
+ self._tokenizer = AutoTokenizer.from_pretrained(src, padding_side="left", trust_remote_code=True)
87
+ if self._tokenizer.pad_token_id is None:
88
+ self._tokenizer.pad_token = self._tokenizer.eos_token
89
+ return self._tokenizer
90
+
91
+ def _encode_ids(self, tok, texts: List[str], max_len: int):
92
+ pad_id = tok.pad_token_id if tok.pad_token_id is not None else tok.eos_token_id
93
+ eod_id = tok.convert_tokens_to_ids("<|endoftext|>")
94
+ if eod_id is None or eod_id < 0:
95
+ eod_id = pad_id
96
+ enc = tok([str(t) for t in texts], add_special_tokens=False, truncation=True,
97
+ max_length=max_len - 1, padding=False, return_attention_mask=False)
98
+ ids = [x + [eod_id] for x in enc["input_ids"]]
99
+ T = max((len(x) for x in ids), default=1)
100
+ input_ids = [[pad_id] * (T - len(x)) + x for x in ids]
101
+ attn = [[0] * (T - len(x)) + [1] * len(x) for x in ids]
102
+ return torch.tensor(input_ids, dtype=torch.long), torch.tensor(attn, dtype=torch.long)
103
+
104
+ @torch.no_grad()
105
+ def _embed_formatted(self, texts, tokenizer, max_len, batch_size, return_cpu, dim):
106
+ if isinstance(texts, str):
107
+ texts = [texts]
108
+ if dim is not None and dim > self.config.hidden_size:
109
+ raise ValueError(f"dim {dim} > embedding dim {self.config.hidden_size}")
110
+ tok = self._get_tokenizer(tokenizer)
111
+ max_len = max_len or self.config.default_max_len
112
+ dev = self.device
113
+ self.eval()
114
+ out = []
115
  for i in range(0, len(texts), batch_size):
116
+ ii, am = self._encode_ids(tok, texts[i:i + batch_size], max_len)
117
+ emb = self.encode(ii.to(dev), am.to(dev), normalize=False).pooler_output
118
+ if dim is not None:
119
+ emb = emb[:, :dim]
120
+ emb = F.normalize(emb, p=2, dim=-1)
121
+ out.append(emb.cpu() if return_cpu else emb)
122
+ return torch.cat(out, dim=0)
123
+
124
+ # ---- public API ----
125
+ def embed_texts(self, texts, *, tokenizer=None, max_len: Optional[int] = None,
126
+ batch_size: int = 16, return_cpu: bool = True, dim: Optional[int] = None):
127
+ """Embed reports/documents (no instruction). Returns [N, dim] L2-normalized."""
128
+ return self._embed_formatted(texts, tokenizer, max_len, batch_size, return_cpu, dim)
129
+
130
+ def embed_instruction_query(self, instructions, queries, *, tokenizer=None,
131
+ max_len: Optional[int] = None, batch_size: int = 16,
132
+ return_cpu: bool = True, dim: Optional[int] = None):
133
+ """Embed instruction-conditioned queries. `instructions` may be one string or a list."""
134
+ if isinstance(queries, str):
135
+ queries = [queries]
136
+ if isinstance(instructions, str):
137
+ instructions = [instructions] * len(queries)
138
+ texts = [build_qwen_query(i, q) for i, q in zip(instructions, queries)]
139
+ return self._embed_formatted(texts, tokenizer, max_len, batch_size, return_cpu, dim)
140
+
141
+ def embed(self, texts, *, instruction: Optional[str] = None, tokenizer=None,
142
+ max_len: Optional[int] = None, batch_size: int = 16, return_cpu: bool = True,
143
+ dim: Optional[int] = None):
144
+ """Convenience: with `instruction`, embed as instruction-conditioned queries; else plain."""
145
+ if instruction:
146
+ return self.embed_instruction_query(instruction, texts, tokenizer=tokenizer,
147
+ max_len=max_len, batch_size=batch_size,
148
+ return_cpu=return_cpu, dim=dim)
149
+ return self.embed_texts(texts, tokenizer=tokenizer, max_len=max_len,
150
+ batch_size=batch_size, return_cpu=return_cpu, dim=dim)
151
+
152
+ def forward(self, texts=None, *, input_ids=None, attention_mask=None, position_ids=None,
153
+ normalize=True, **kwargs):
154
+ if input_ids is not None:
155
+ return self.encode(input_ids, attention_mask, position_ids, normalize=normalize)
156
+ if texts is not None:
157
+ return BaseModelOutputWithPooling(pooler_output=self.embed_texts(texts, return_cpu=False))
158
+ raise ValueError("Provide either `texts` or (`input_ids`, `attention_mask`).")
159
 
160
  @staticmethod
161
+ def cosine_topk(query_emb, cand_emb, k=10):
162
+ """Top-k most similar candidates per query (embeddings assumed L2-normalized)."""
163
+ sims = query_emb @ cand_emb.T
164
+ k = min(k, cand_emb.shape[0])
165
+ vals, idx = torch.topk(sims, k, dim=-1)
166
+ return vals, idx