Text Ranking
sentence-transformers
Safetensors
Transformers
multilingual
t5gemma2
text2text-generation
reranker
encoder-decoder
FBNL
matryoshka
retrieval
RAG
cosyy commited on
Commit
7b9c7a5
·
verified ·
1 Parent(s): afad80d

Add KaLM reranker implementation files

Browse files
Files changed (2) hide show
  1. kalm_reranker.py +295 -0
  2. kalm_reranker_utils.py +218 -0
kalm_reranker.py ADDED
@@ -0,0 +1,295 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
4
+
5
+ import numpy as np
6
+ import torch
7
+ from transformers import AutoModelForSeq2SeqLM, AutoTokenizer
8
+
9
+ try:
10
+ from .kalm_reranker_utils import (
11
+ DEFAULT_INSTRUCTION,
12
+ DEFAULT_SYSTEM_INSTRUCTION,
13
+ answer_token_id,
14
+ build_decoder_text,
15
+ cast_floating_parameters,
16
+ extract_yes_no_logits,
17
+ forward_reranker_model,
18
+ get_encoder,
19
+ pool_encoder_chunks,
20
+ validate_text_pairs,
21
+ )
22
+ except ImportError: # Support ``from kalm_reranker import KaLMReranker``.
23
+ from kalm_reranker_utils import (
24
+ DEFAULT_INSTRUCTION,
25
+ DEFAULT_SYSTEM_INSTRUCTION,
26
+ answer_token_id,
27
+ build_decoder_text,
28
+ cast_floating_parameters,
29
+ extract_yes_no_logits,
30
+ forward_reranker_model,
31
+ get_encoder,
32
+ pool_encoder_chunks,
33
+ validate_text_pairs,
34
+ )
35
+
36
+
37
+ class KaLMReranker:
38
+ """Score query-document relevance with a KaLM encoder-decoder reranker.
39
+
40
+ The returned score is ``P(yes)`` after applying a two-class softmax to the
41
+ model's ``yes`` and ``no`` logits.
42
+ """
43
+
44
+ def __init__(
45
+ self,
46
+ model_name_or_path: str,
47
+ *,
48
+ device: Optional[Union[str, torch.device]] = None,
49
+ dtype: Optional[Union[str, torch.dtype]] = None,
50
+ batch_size: int = 32,
51
+ query_max_length: int = 512,
52
+ max_length: int = 1024,
53
+ chunk_size: Optional[int] = 4,
54
+ instruction: str = DEFAULT_INSTRUCTION,
55
+ system_instruction: str = DEFAULT_SYSTEM_INSTRUCTION,
56
+ **model_kwargs: Any,
57
+ ) -> None:
58
+ if not isinstance(model_name_or_path, str) or not model_name_or_path:
59
+ raise ValueError("model_name_or_path must be a non-empty string.")
60
+ if batch_size <= 0:
61
+ raise ValueError("batch_size must be positive.")
62
+ if query_max_length <= 0 or max_length <= 0:
63
+ raise ValueError("query_max_length and max_length must be positive.")
64
+ if chunk_size is not None and chunk_size <= 0:
65
+ raise ValueError("chunk_size must be positive or None.")
66
+ if not isinstance(instruction, str) or not isinstance(system_instruction, str):
67
+ raise TypeError("instruction and system_instruction must be strings.")
68
+
69
+ self.device = self._resolve_device(device)
70
+ self.dtype = self._resolve_dtype(dtype, self.device)
71
+ self.batch_size = batch_size
72
+ self.query_max_length = query_max_length
73
+ self.max_length = max_length
74
+ self.chunk_size = chunk_size
75
+ self.instruction = instruction
76
+ self.system_instruction = system_instruction
77
+
78
+ self.tokenizer = AutoTokenizer.from_pretrained(model_name_or_path)
79
+ if self.tokenizer.pad_token_id is None:
80
+ if self.tokenizer.eos_token_id is None:
81
+ raise ValueError(
82
+ "The tokenizer must define a pad token or an EOS token."
83
+ )
84
+ self.tokenizer.pad_token = self.tokenizer.eos_token
85
+ # Final decoder-token indexing assumes right padding, matching training.
86
+ self.tokenizer.padding_side = "right"
87
+
88
+ self.model = AutoModelForSeq2SeqLM.from_pretrained(
89
+ model_name_or_path,
90
+ dtype=self.dtype,
91
+ **model_kwargs,
92
+ )
93
+ cast_floating_parameters(self.model, self.dtype)
94
+ self.model.to(device=self.device)
95
+ self.model.eval()
96
+
97
+ self.yes_token_id = self._answer_token_id("yes")
98
+ self.no_token_id = self._answer_token_id("no")
99
+
100
+ @staticmethod
101
+ def _resolve_device(device: Optional[Union[str, torch.device]]) -> torch.device:
102
+ if device is None:
103
+ device = "cuda" if torch.cuda.is_available() else "cpu"
104
+ resolved = torch.device(device)
105
+ if resolved.type == "cuda" and not torch.cuda.is_available():
106
+ raise RuntimeError("CUDA was requested, but no CUDA device is available.")
107
+ return resolved
108
+
109
+ @staticmethod
110
+ def _resolve_dtype(
111
+ dtype: Optional[Union[str, torch.dtype]], device: torch.device
112
+ ) -> torch.dtype:
113
+ if dtype is None:
114
+ return torch.bfloat16 if device.type == "cuda" else torch.float32
115
+ if isinstance(dtype, torch.dtype):
116
+ return dtype
117
+ if not isinstance(dtype, str):
118
+ raise TypeError(
119
+ "dtype must be a torch.dtype or a string such as 'bfloat16'."
120
+ )
121
+ normalized = dtype.lower().removeprefix("torch.")
122
+ supported = {
123
+ "bfloat16": torch.bfloat16,
124
+ "bf16": torch.bfloat16,
125
+ "float16": torch.float16,
126
+ "fp16": torch.float16,
127
+ "float32": torch.float32,
128
+ "fp32": torch.float32,
129
+ }
130
+ if normalized not in supported:
131
+ raise ValueError(f"Unsupported dtype: {dtype!r}.")
132
+ return supported[normalized]
133
+
134
+ def _answer_token_id(self, answer: str) -> int:
135
+ return answer_token_id(self.tokenizer, answer)
136
+
137
+ def _get_encoder(self):
138
+ return get_encoder(self.model)
139
+
140
+ @staticmethod
141
+ def _pool_encoder_chunks(
142
+ hidden_states: torch.Tensor,
143
+ attention_mask: torch.Tensor,
144
+ chunk_size: int,
145
+ ) -> Tuple[torch.Tensor, torch.Tensor]:
146
+ return pool_encoder_chunks(hidden_states, attention_mask, chunk_size)
147
+
148
+ def _decoder_text(self, query: str, instruction: str) -> str:
149
+ return build_decoder_text(
150
+ self.tokenizer,
151
+ query,
152
+ instruction,
153
+ self.system_instruction,
154
+ self.query_max_length,
155
+ )
156
+
157
+ @staticmethod
158
+ def _validate_pairs(
159
+ pairs: Sequence[Tuple[str, str]],
160
+ ) -> List[Tuple[str, str]]:
161
+ return validate_text_pairs(pairs)
162
+
163
+ @torch.inference_mode()
164
+ def _predict_batch(
165
+ self, pairs: Sequence[Tuple[str, str]], instruction: str
166
+ ) -> List[float]:
167
+ encoder_texts = [f"<Document>: {document}" for _, document in pairs]
168
+ decoder_texts = [self._decoder_text(query, instruction) for query, _ in pairs]
169
+
170
+ encoder_batch = self.tokenizer(
171
+ encoder_texts,
172
+ padding=True,
173
+ truncation=True,
174
+ max_length=self.max_length,
175
+ add_special_tokens=False,
176
+ return_tensors="pt",
177
+ ).to(self.device)
178
+ decoder_batch = self.tokenizer(
179
+ decoder_texts,
180
+ padding=True,
181
+ pad_to_multiple_of=8,
182
+ add_special_tokens=False,
183
+ return_tensors="pt",
184
+ ).to(self.device)
185
+
186
+ outputs = forward_reranker_model(
187
+ self.model,
188
+ input_ids=encoder_batch["input_ids"],
189
+ attention_mask=encoder_batch["attention_mask"],
190
+ decoder_input_ids=decoder_batch["input_ids"],
191
+ decoder_attention_mask=decoder_batch["attention_mask"],
192
+ encoder_chunk_size=self.chunk_size,
193
+ )
194
+ yes_no_logits = extract_yes_no_logits(
195
+ outputs.logits,
196
+ decoder_batch["attention_mask"],
197
+ self.yes_token_id,
198
+ self.no_token_id,
199
+ )
200
+ return torch.softmax(yes_no_logits, dim=-1)[:, 0].cpu().tolist()
201
+
202
+ def predict(
203
+ self,
204
+ pairs: Sequence[Tuple[str, str]],
205
+ *,
206
+ instruction: Optional[str] = None,
207
+ batch_size: Optional[int] = None,
208
+ ) -> List[float]:
209
+ """Return ``P(yes)`` scores in the same order as ``pairs``."""
210
+ validated_pairs = self._validate_pairs(pairs)
211
+ if not validated_pairs:
212
+ return []
213
+ effective_instruction = self.instruction if instruction is None else instruction
214
+ if not isinstance(effective_instruction, str):
215
+ raise TypeError("instruction must be a string or None.")
216
+ effective_batch_size = self.batch_size if batch_size is None else batch_size
217
+ if not isinstance(effective_batch_size, int) or effective_batch_size <= 0:
218
+ raise ValueError("batch_size must be a positive integer.")
219
+
220
+ length_sorted_indices = np.argsort(
221
+ [-(len(query) + len(document)) for query, document in validated_pairs]
222
+ )
223
+ sorted_pairs = [validated_pairs[index] for index in length_sorted_indices]
224
+
225
+ tested_batch_size = effective_batch_size
226
+ first_batch_scores: Optional[List[float]] = None
227
+ while tested_batch_size > 1:
228
+ try:
229
+ first_batch_scores = self._predict_batch(
230
+ sorted_pairs[: min(len(sorted_pairs), tested_batch_size)],
231
+ effective_instruction,
232
+ )
233
+ break
234
+ except torch.cuda.OutOfMemoryError:
235
+ if torch.cuda.is_available():
236
+ torch.cuda.empty_cache()
237
+ tested_batch_size = max(1, tested_batch_size * 3 // 4)
238
+
239
+ if first_batch_scores is None:
240
+ sorted_scores: List[float] = []
241
+ loop_start = 0
242
+ else:
243
+ sorted_scores = list(first_batch_scores)
244
+ loop_start = tested_batch_size
245
+ try:
246
+ for start in range(loop_start, len(sorted_pairs), tested_batch_size):
247
+ sorted_scores.extend(
248
+ self._predict_batch(
249
+ sorted_pairs[start : start + tested_batch_size],
250
+ effective_instruction,
251
+ )
252
+ )
253
+ except torch.cuda.OutOfMemoryError as error:
254
+ if torch.cuda.is_available():
255
+ torch.cuda.empty_cache()
256
+ raise RuntimeError(
257
+ "CUDA ran out of memory during reranking. Retry with a smaller "
258
+ "batch_size or shorter max_length."
259
+ ) from error
260
+ inverse_indices = np.argsort(length_sorted_indices)
261
+ return [sorted_scores[index] for index in inverse_indices]
262
+
263
+ def rank(
264
+ self,
265
+ query: str,
266
+ documents: Sequence[str],
267
+ *,
268
+ instruction: Optional[str] = None,
269
+ top_k: Optional[int] = None,
270
+ batch_size: Optional[int] = None,
271
+ ) -> List[Dict[str, Union[int, float]]]:
272
+ """Rank documents and return ``corpus_id``/``score`` dictionaries."""
273
+ if not isinstance(query, str):
274
+ raise TypeError("query must be a string.")
275
+ if isinstance(documents, (str, bytes)) or not isinstance(documents, Sequence):
276
+ raise TypeError("documents must be a sequence of strings.")
277
+ if any(not isinstance(document, str) for document in documents):
278
+ raise TypeError("every document must be a string.")
279
+ if top_k is not None and (not isinstance(top_k, int) or top_k < 0):
280
+ raise ValueError("top_k must be a non-negative integer or None.")
281
+
282
+ scores = self.predict(
283
+ [(query, document) for document in documents],
284
+ instruction=instruction,
285
+ batch_size=batch_size,
286
+ )
287
+ rankings: List[Dict[str, Union[int, float]]] = [
288
+ {"corpus_id": corpus_id, "score": score}
289
+ for corpus_id, score in enumerate(scores)
290
+ ]
291
+ rankings.sort(key=lambda item: item["score"], reverse=True)
292
+ return rankings if top_k is None else rankings[:top_k]
293
+
294
+
295
+ __all__ = ["KaLMReranker"]
kalm_reranker_utils.py ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Sequence
4
+ from typing import Any
5
+
6
+ import torch
7
+ import torch.nn.functional as F
8
+ from transformers.modeling_outputs import BaseModelOutput
9
+
10
+ DEFAULT_INSTRUCTION = "Given a query, retrieve documents that answer the query."
11
+ DEFAULT_SYSTEM_INSTRUCTION = (
12
+ "Judge whether the Document meets the requirements based on the Query and "
13
+ 'the Instruct provided. Note that the answer can only be "yes" or "no".'
14
+ )
15
+
16
+
17
+ def validate_text_pairs(inputs: Sequence[Sequence[str]]) -> list[tuple[str, str]]:
18
+ """Validate and normalize a batch of ``(query, document)`` pairs."""
19
+ if isinstance(inputs, (str, bytes)) or not isinstance(inputs, Sequence):
20
+ raise TypeError("inputs must be a sequence of (query, document) pairs.")
21
+
22
+ validated: list[tuple[str, str]] = []
23
+ for index, pair in enumerate(inputs):
24
+ if (
25
+ isinstance(pair, (str, bytes))
26
+ or not isinstance(pair, Sequence)
27
+ or len(pair) != 2
28
+ ):
29
+ raise ValueError(f"inputs[{index}] must contain exactly two strings.")
30
+ query, document = pair
31
+ if not isinstance(query, str) or not isinstance(document, str):
32
+ raise TypeError(f"inputs[{index}] must contain exactly two strings.")
33
+ validated.append((query, document))
34
+ return validated
35
+
36
+
37
+ def answer_token_id(tokenizer: Any, answer: str) -> int:
38
+ """Return the single vocabulary token used to score an answer."""
39
+ token_ids = tokenizer(answer, add_special_tokens=False)["input_ids"]
40
+ if len(token_ids) != 1:
41
+ raise ValueError(
42
+ f"The answer {answer!r} must tokenize to exactly one token, "
43
+ f"got {token_ids!r}."
44
+ )
45
+ return token_ids[0]
46
+
47
+
48
+ def build_decoder_text(
49
+ tokenizer: Any,
50
+ query: str,
51
+ instruction: str,
52
+ system_instruction: str,
53
+ query_max_length: int,
54
+ ) -> str:
55
+ """Build the decoder-side instruction/query prompt used during training."""
56
+ query_ids = tokenizer(
57
+ query,
58
+ add_special_tokens=False,
59
+ truncation=True,
60
+ max_length=query_max_length,
61
+ )["input_ids"]
62
+ truncated_query = tokenizer.decode(
63
+ query_ids,
64
+ skip_special_tokens=False,
65
+ clean_up_tokenization_spaces=False,
66
+ )
67
+ return (
68
+ "<bos><start_of_turn>user\n"
69
+ f"{system_instruction}\n\n"
70
+ f"<Instruct>: {instruction}\n"
71
+ f"<Query>: {truncated_query}<end_of_turn>\n"
72
+ "<start_of_turn>model\n\n\n\n"
73
+ )
74
+
75
+
76
+ def get_encoder(model: torch.nn.Module) -> torch.nn.Module:
77
+ if hasattr(model, "get_encoder"):
78
+ return model.get_encoder()
79
+ if hasattr(model, "encoder"):
80
+ return model.encoder
81
+ raise AttributeError(f"Cannot find the encoder on {type(model).__name__}.")
82
+
83
+
84
+ def pool_encoder_chunks(
85
+ hidden_states: torch.Tensor,
86
+ attention_mask: torch.Tensor,
87
+ chunk_size: int,
88
+ ) -> tuple[torch.Tensor, torch.Tensor]:
89
+ """Mean-pool consecutive encoder tokens while respecting padding."""
90
+ if chunk_size <= 0:
91
+ raise ValueError("chunk_size must be positive.")
92
+
93
+ batch_size, sequence_length, hidden_size = hidden_states.shape
94
+ num_chunks = (sequence_length + chunk_size - 1) // chunk_size
95
+ padded_length = num_chunks * chunk_size
96
+ pad_length = padded_length - sequence_length
97
+
98
+ if pad_length:
99
+ hidden_states = F.pad(hidden_states, (0, 0, 0, pad_length))
100
+ attention_mask = F.pad(attention_mask, (0, pad_length))
101
+
102
+ hidden_states = hidden_states.view(batch_size, num_chunks, chunk_size, hidden_size)
103
+ chunk_mask = attention_mask.view(batch_size, num_chunks, chunk_size)
104
+ expanded_mask = chunk_mask.unsqueeze(-1).to(hidden_states.dtype)
105
+ pooled_hidden = (hidden_states * expanded_mask).sum(dim=2)
106
+ pooled_hidden = pooled_hidden / chunk_mask.sum(dim=2).clamp(min=1).unsqueeze(-1)
107
+ pooled_mask = (chunk_mask.sum(dim=2) > 0).to(attention_mask.dtype)
108
+ return pooled_hidden, pooled_mask
109
+
110
+
111
+ def forward_reranker_model(
112
+ model: torch.nn.Module,
113
+ *,
114
+ input_ids: torch.Tensor,
115
+ attention_mask: torch.Tensor,
116
+ decoder_input_ids: torch.Tensor,
117
+ decoder_attention_mask: torch.Tensor,
118
+ encoder_chunk_size: int | None,
119
+ ):
120
+ """Run the encoder-decoder model with optional encoder token compression."""
121
+ if encoder_chunk_size is None:
122
+ return model(
123
+ input_ids=input_ids,
124
+ attention_mask=attention_mask,
125
+ decoder_input_ids=decoder_input_ids,
126
+ decoder_attention_mask=decoder_attention_mask,
127
+ return_dict=True,
128
+ )
129
+
130
+ encoder_outputs = get_encoder(model)(
131
+ input_ids=input_ids,
132
+ attention_mask=attention_mask,
133
+ return_dict=True,
134
+ )
135
+ pooled_hidden, pooled_mask = pool_encoder_chunks(
136
+ encoder_outputs.last_hidden_state,
137
+ attention_mask,
138
+ encoder_chunk_size,
139
+ )
140
+ return model(
141
+ encoder_outputs=BaseModelOutput(last_hidden_state=pooled_hidden),
142
+ attention_mask=pooled_mask,
143
+ decoder_input_ids=decoder_input_ids,
144
+ decoder_attention_mask=decoder_attention_mask,
145
+ return_dict=True,
146
+ )
147
+
148
+
149
+ def extract_yes_no_logits(
150
+ logits: torch.Tensor,
151
+ decoder_attention_mask: torch.Tensor,
152
+ yes_token_id: int,
153
+ no_token_id: int,
154
+ ) -> torch.Tensor:
155
+ """Extract float32 yes/no logits at each sample's final non-padding token."""
156
+ if decoder_attention_mask.ndim != 2:
157
+ raise ValueError("decoder_attention_mask must have shape [batch, sequence].")
158
+ sequence_lengths = decoder_attention_mask.sum(dim=1) - 1
159
+ if (sequence_lengths < 0).any():
160
+ raise ValueError(
161
+ "Every decoder input must contain at least one non-padding token."
162
+ )
163
+
164
+ batch_indices = torch.arange(logits.shape[0], device=logits.device)
165
+ last_logits = logits[batch_indices, sequence_lengths]
166
+ yes_no_logits = torch.stack(
167
+ (last_logits[:, yes_token_id], last_logits[:, no_token_id]), dim=-1
168
+ ).float()
169
+ if not torch.isfinite(yes_no_logits).all():
170
+ bad_count = (~torch.isfinite(yes_no_logits).all(dim=-1)).sum().item()
171
+ raise RuntimeError(
172
+ f"The model produced non-finite yes/no logits for {bad_count} input(s). "
173
+ "Use bfloat16 or float32 instead of float16."
174
+ )
175
+ return yes_no_logits
176
+
177
+
178
+ def normalize_requested_dtype(dtype: Any) -> torch.dtype | None:
179
+ """Normalize a caller-provided dtype without changing the ``auto`` behavior."""
180
+ if dtype is None or dtype == "auto":
181
+ return None
182
+ if isinstance(dtype, torch.dtype):
183
+ return dtype
184
+ if not isinstance(dtype, str):
185
+ return None
186
+ normalized = dtype.lower().removeprefix("torch.")
187
+ return {
188
+ "bfloat16": torch.bfloat16,
189
+ "bf16": torch.bfloat16,
190
+ "float16": torch.float16,
191
+ "fp16": torch.float16,
192
+ "float32": torch.float32,
193
+ "fp32": torch.float32,
194
+ }.get(normalized)
195
+
196
+
197
+ def cast_floating_parameters(model: torch.nn.Module, dtype: torch.dtype | None) -> None:
198
+ """Cast model parameters while preserving checkpoint buffer dtypes."""
199
+ if dtype is None:
200
+ return
201
+ for parameter in model.parameters():
202
+ if parameter.is_floating_point() and parameter.dtype != dtype:
203
+ parameter.data = parameter.data.to(dtype=dtype)
204
+
205
+
206
+ __all__ = [
207
+ "DEFAULT_INSTRUCTION",
208
+ "DEFAULT_SYSTEM_INSTRUCTION",
209
+ "answer_token_id",
210
+ "build_decoder_text",
211
+ "cast_floating_parameters",
212
+ "extract_yes_no_logits",
213
+ "forward_reranker_model",
214
+ "get_encoder",
215
+ "normalize_requested_dtype",
216
+ "pool_encoder_chunks",
217
+ "validate_text_pairs",
218
+ ]