Files
wehub-resource-sync 2aaeece67c
Codestyle Check / Lint (push) Has been cancelled
Codestyle Check / Check bypass (push) Has been cancelled
Pipelines-Test / Pipelines-Test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:37:14 +08:00

430 lines
18 KiB
Python

# Copyright (c) 2023 PaddlePaddle Authors. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from dataclasses import dataclass
from typing import Dict, List, Optional, Union
import numpy as np
import paddle
import paddle.distributed as dist
import paddle.nn as nn
from tqdm import tqdm
from ...utils.log import logger
from .. import AutoConfig, AutoModel, AutoTokenizer, PretrainedModel
from ..model_outputs import ModelOutput
@dataclass
class EncoderOutput(ModelOutput):
q_reps: Optional[paddle.Tensor] = None
p_reps: Optional[paddle.Tensor] = None
loss: Optional[paddle.Tensor] = None
scores: Optional[paddle.Tensor] = None
__all__ = ["BiEncoderModel"]
class BiEncoderModel(PretrainedModel):
def __init__(
self,
model_name_or_path: str = None,
corpus_model_name_or_path: str = None,
query_model_name_or_path: str = None,
dtype: str = "float16",
normalized: bool = False,
sentence_pooling_method: str = "cls",
negatives_cross_device: bool = False,
temperature: float = 1.0,
use_inbatch_neg: bool = True,
margin: float = 0.3,
matryoshka_dims: Optional[List[int]] = None,
matryoshka_loss_weights: Optional[List[float]] = None,
query_instruction: Optional[str] = None,
document_instruction: Optional[str] = None,
eval_batch_size: int = 8,
tokenizer=None,
max_seq_length: int = 4096,
model_flag: str = None,
):
super().__init__()
# Load Model
self.model = None
self.model_config = None
self.corpus_model = None
self.query_model = None
if model_name_or_path is not None:
self.model = AutoModel.from_pretrained(model_name_or_path, dtype=dtype, convert_from_torch=True)
self.model_config = AutoConfig.from_pretrained(model_name_or_path)
if corpus_model_name_or_path is not None:
self.corpus_model = AutoModel.from_pretrained(
corpus_model_name_or_path, dtype=dtype, convert_from_torch=True
)
if query_model_name_or_path is not None:
self.query_model = AutoModel.from_pretrained(
query_model_name_or_path, dtype=dtype, convert_from_torch=True
)
if self.corpus_model is None:
self.corpus_model = self.model
if self.query_model is None:
self.query_model = self.model
assert self.corpus_model is not None and self.query_model is not None
self.cross_entropy = nn.CrossEntropyLoss(reduction="mean")
self.normalized = normalized
self.sentence_pooling_method = sentence_pooling_method
self.temperature = temperature
self.use_inbatch_neg = use_inbatch_neg
self.config = self.model_config
self.margin = margin
self.matryoshka_dims = matryoshka_dims
self.query_instruction = query_instruction
self.document_instruction = document_instruction
self.eval_batch_size = eval_batch_size
self.tokenizer = tokenizer
self.max_seq_length = max_seq_length
if self.matryoshka_dims:
self.matryoshka_loss_weights = (
matryoshka_loss_weights if matryoshka_loss_weights else [1] * len(self.matryoshka_dims)
)
else:
self.matryoshka_loss_weights = None
if not normalized:
self.temperature = 1.0
logger.info("reset temperature = 1.0 due to using inner product to compute similarity")
self.negatives_cross_device = negatives_cross_device
if self.negatives_cross_device:
if not dist.is_initialized():
raise ValueError("Distributed training has not been initialized for representation all gather.")
self.process_rank = dist.get_rank()
self.world_size = dist.get_world_size()
self.model_flag = model_flag
def sentence_embedding(self, hidden_state, mask):
if self.sentence_pooling_method == "mean":
s = paddle.sum(hidden_state * mask.unsqueeze(-1).float(), axis=1)
d = mask.sum(axis=1, keepdim=True).float()
return s / d
elif self.sentence_pooling_method == "cls":
return hidden_state[:, 0]
elif self.sentence_pooling_method == "last":
# return hidden_state[:, -1] # this is for padding side is left
sequence_lengths = mask.sum(axis=1)
last_token_indices = sequence_lengths - 1
embeddings = hidden_state[paddle.arange(hidden_state.shape[0]), last_token_indices]
return embeddings
elif self.sentence_pooling_method == "last_8":
last_8_embeddings = hidden_state[paddle.arange(hidden_state.shape[0]), -8:]
embeddings = paddle.mean(last_8_embeddings, axis=1)
return embeddings
else:
raise ValueError(f"Invalid sentence pooling method: {self.sentence_pooling_method}")
def get_model_config(
self,
):
return self.model_config.to_dict()
def encode(self, features, model: AutoModel):
psg_out = model(**features, return_dict=True, output_hidden_states=True)
p_reps = self.sentence_embedding(psg_out.hidden_states[-1], features["attention_mask"])
return p_reps
def compute_similarity(self, q_reps, p_reps):
# q_reps [batch_size, embedding_dim]
# p_reps [batch_size, embedding_dim]
return paddle.matmul(q_reps, p_reps.transpose([1, 0]))
def hard_negative_loss(self, q_reps, p_reps):
scores = self.compute_similarity(q_reps, p_reps)
scores = scores / self.temperature
scores = scores.reshape([q_reps.shape[0], -1])
target = paddle.arange(scores.shape[0], dtype="int64")
target = target * (p_reps.shape[0] // q_reps.shape[0])
loss = self.compute_loss(scores, target)
return scores, loss
def in_batch_negative_loss(self, q_reps, p_reps):
# In batch negatives
scores = self.compute_similarity(q_reps, p_reps)
# Subtract margin from all positive samples cosine_sim()
margin_diag = paddle.full(shape=[q_reps.shape[0]], fill_value=self.margin, dtype=q_reps.dtype)
scores = scores - paddle.diag(margin_diag)
# Scale cosine to ease training converge
scores = scores / self.temperature
target = paddle.arange(0, q_reps.shape[0], dtype="int64")
loss = self.compute_loss(scores, target)
return scores, loss
def forward(
self,
query: Dict[str, paddle.Tensor] = None,
passage: Dict[str, paddle.Tensor] = None,
teacher_score: paddle.Tensor = None,
):
q_reps = self.encode(query, self.query_model)
p_reps = self.encode(passage, self.corpus_model)
# For non-matryoshka loss, we normalize the representations
if not self.matryoshka_dims:
if self.normalized:
q_reps = paddle.nn.functional.normalize(q_reps, axis=-1)
p_reps = paddle.nn.functional.normalize(p_reps, axis=-1)
if self.training:
# Cross device negatives
if self.negatives_cross_device:
q_reps = self._dist_gather_tensor(q_reps)
p_reps = self._dist_gather_tensor(p_reps)
if self.matryoshka_dims:
loss = 0.0
scores = 0.0
for loss_weight, dim in zip(self.matryoshka_loss_weights, self.matryoshka_dims):
reduced_q = q_reps[:, :dim]
reduced_d = p_reps[:, :dim]
if self.normalized:
reduced_q = paddle.nn.functional.normalize(reduced_q, axis=-1)
reduced_d = paddle.nn.functional.normalize(reduced_d, axis=-1)
if self.use_inbatch_neg:
dim_score, dim_loss = self.in_batch_negative_loss(reduced_q, reduced_d)
else:
dim_score, dim_loss = self.hard_negative_loss(reduced_q, reduced_d)
scores += dim_score
loss += loss_weight * dim_loss
elif self.use_inbatch_neg:
scores, loss = self.in_batch_negative_loss(q_reps, p_reps)
else:
scores, loss = self.hard_negative_loss(q_reps, p_reps)
else:
scores = self.compute_similarity(q_reps, p_reps)
loss = None
return EncoderOutput(
loss=loss,
scores=scores,
q_reps=q_reps,
p_reps=p_reps,
)
def compute_loss(self, scores, target):
return self.cross_entropy(scores, target)
def _dist_gather_tensor(self, t: Optional[paddle.Tensor]):
if t is None:
return None
all_tensors = [paddle.empty_like(t) for _ in range(self.world_size)]
dist.all_gather(all_tensors, t)
all_tensors[self.process_rank] = t
all_tensors = paddle.concat(all_tensors, axis=0)
return all_tensors
def save_pretrained(self, output_dir: str, **kwargs):
state_dict = self.model.state_dict()
state_dict = type(state_dict)({k: v.clone().cpu() for k, v in state_dict.items()})
self.model.save_pretrained(output_dir, state_dict=state_dict)
@paddle.no_grad()
def encode_sentences(
self, sentences: List[str], model: AutoModel, tokenizer: AutoTokenizer, titles: List[str] = None, **kwargs
) -> np.ndarray:
model.eval()
all_embeddings = []
for start_index in tqdm(range(0, len(sentences), self.eval_batch_size), desc="Batches"):
sentences_batch = sentences[start_index : start_index + self.eval_batch_size]
if titles:
titles_batch = titles[start_index : start_index + self.eval_batch_size]
assert len(sentences_batch) == len(titles_batch)
inputs = tokenizer(
titles_batch,
sentences_batch,
padding=True,
truncation=True,
return_tensors="pd",
max_length=self.max_seq_length,
return_attention_mask=True,
)
else:
inputs = tokenizer(
sentences_batch,
padding=True,
truncation=True,
return_tensors="pd",
max_length=self.max_seq_length,
return_attention_mask=True,
)
outputs = model(
**inputs, # 注意 bert 类型有 token_type_ids
return_dict=True,
output_hidden_states=True,
)
last_hidden_state = outputs.hidden_states[-1]
if self.sentence_pooling_method == "last":
if tokenizer.padding_side == "right":
sequence_lengths = inputs.attention_mask.sum(axis=1)
last_token_indices = sequence_lengths - 1
embeddings = last_hidden_state[paddle.arange(last_hidden_state.shape[0]), last_token_indices]
elif tokenizer.padding_side == "left":
embeddings = last_hidden_state[:, -1]
else:
raise NotImplementedError(f"Padding side {tokenizer.padding_side} not supported.")
elif self.sentence_pooling_method == "cls":
embeddings = last_hidden_state[:, 0]
elif self.sentence_pooling_method == "mean":
inputs.attention_mask = paddle.cast(
inputs.attention_mask, dtype="float32"
) # float cannot * int64, maybe paddle's bug
s = paddle.sum(last_hidden_state * inputs.attention_mask.unsqueeze(-1), axis=1)
d = inputs.attention_mask.sum(axis=1, keepdim=True)
embeddings = s / d
elif self.sentence_pooling_method == "last_8":
last_8_embeddings = last_hidden_state[:, -8:, :]
embeddings = paddle.mean(last_8_embeddings, axis=1)
else:
raise NotImplementedError(f"Pooling method {self.pooling_method} not supported.")
if self.normalized:
embeddings = paddle.nn.functional.normalize(embeddings, p=2, axis=-1)
all_embeddings.append(embeddings.cpu().numpy().astype("float32"))
return np.concatenate(all_embeddings, axis=0)
def encode_queries(self, queries: List[str], **kwargs) -> np.ndarray:
"""
This function will be used to encode queries for retrieval task
if there is a instruction for queries, we will add it to the query text
"""
if self.query_instruction is not None:
input_texts = [f"{self.query_instruction}{query}" for query in queries]
else:
input_texts = queries
if self.model_flag == "llara":
input_texts = self.preprocess_sentences_for_llara(input_texts, query_or_doc="query")
if self.model_flag == "bge-en-icl":
input_texts = self.preprocess_sentences_for_bge_en_icl(input_texts, query_or_doc="query")
if self.model_flag == "qwen3":
input_texts = self.preprocess_sentences_for_qwen3(input_texts, query_or_doc="query")
encode_results = self.encode_sentences(sentences=input_texts, model=self.query_model, tokenizer=self.tokenizer)
return encode_results
def encode_corpus(self, corpus: List[Union[Dict[str, str], str]], **kwargs) -> np.ndarray:
"""
This function will be used to encode corpus for retrieval task
if there is a instruction for docs, we will add it to the doc text
"""
if isinstance(corpus[0], dict):
if self.document_instruction is not None:
input_texts = [
"{}{} {}".format(self.document_instruction, doc.get("title", ""), doc["text"]).strip()
for doc in corpus
]
else:
input_texts = ["{} {}".format(doc.get("title", ""), doc["text"]).strip() for doc in corpus]
else:
if self.document_instruction is not None:
input_texts = [f"{self.document_instruction}{doc}" for doc in corpus]
else:
input_texts = corpus
input_titles = None
if self.model_flag == "llara":
input_texts = self.preprocess_sentences_for_llara(input_texts, query_or_doc="doc")
if "RocketQA" in self.model_flag:
if isinstance(corpus[0], dict):
input_texts = [doc["text"] for doc in corpus]
input_titles = [doc.get("title", "") for doc in corpus]
if self.model_flag == "qwen3":
input_texts = self.preprocess_sentences_for_qwen3(input_texts, query_or_doc="doc")
encode_results = self.encode_sentences(
sentences=input_texts, titles=input_titles, model=self.corpus_model, tokenizer=self.tokenizer
)
return encode_results
def preprocess_sentences_for_bge_en_icl(self, sentences: List[str], query_or_doc: str, **kwargs) -> List[str]:
if query_or_doc == "query":
query_suffix = "\n<response> "
else:
raise ValueError(f"Invalid query_or_doc: {query_or_doc}")
input_texts = []
for query in sentences:
new_query = f"{query}{query_suffix}"
input_length = len(self.tokenizer(new_query)["input_ids"])
if input_length > self.max_seq_length:
cur_len = 0
add_len = 1
while add_len < len(query):
add_len *= 2
while add_len > 1:
add_len //= 2
assert isinstance(cur_len, int) and isinstance(
add_len, int
), f"cur_len={cur_len} add_len={add_len}"
new_query = f"{query[:cur_len+add_len]}{query_suffix}"
input_length = len(self.tokenizer(new_query)["input_ids"])
if input_length <= self.max_seq_length:
cur_len += add_len
new_query = f"{query[:cur_len]}{query_suffix}"
input_texts.append(new_query)
return input_texts
def preprocess_sentences_for_llara(self, sentences: List[str], query_or_doc: str, **kwargs) -> List[str]:
prefix = '"'
if query_or_doc == "query":
suffix = '", predict the following passage within eight words: <s9><s10><s11><s12><s13><s14><s15><s16>'
elif query_or_doc == "doc":
suffix = '", summarize the above passage within eight words: <s1><s2><s3><s4><s5><s6><s7><s8>'
else:
raise ValueError(f"Invalid query_or_doc: {query_or_doc}")
sentences_after_process = []
for sentence in sentences:
inputs = self.tokenizer(
sentence,
return_tensors=None,
max_length=self.max_seq_length - 20,
truncation=True,
add_special_tokens=False,
)
sentences_after_process.append(self.tokenizer.decode(inputs["input_ids"], skip_special_tokens=True))
sentences_after_process = [prefix + " " + sentence + " " + suffix for sentence in sentences_after_process]
return sentences_after_process
def preprocess_sentences_for_qwen3(self, sentences: List[str], query_or_doc: str, **kwargs) -> List[str]:
sentences = [f"{sentence}<|endoftext|>" for sentence in sentences]
return sentences