0%

这篇博客主要记录博主在探索meta开源的llama大模型,包括它的数据,training代码以及评测方法。之所以想记录下来,主要是因为llama的论文写的极其的细致,完美践行了开源这个词(感谢meta!),第二个原因是它的文档以及社区都很活跃,使用人群广泛,我们可以借助很多中文社区的复现情况去一探大公司在实现一个大模型时候的考量,以及思考它为什么会这样做。

之前写过一篇关于斯坦福的alpaca的代码的解析,后来看过很多关于微调大模型(supervised finetuning)的代码仓库,大家的实现思路基本上都可以追溯到alpaca的这份代码。

首先我会将所有我参考的资料罗列在前面,方便大家查找: - llama代码仓库 这个仓库是介绍如何下载llama模型 - llama "食谱" 一开始我想在上一个llama仓库中找到相关的train代码,找了半天发现根本没有。后来才发现meta官方将所有finetune(pretrain from scrach)的代码放在这个仓库,适合developer - Llama 2: Open Foundation and Fine-Tuned Chat Models llama2的research paper。强烈建议食用

中文社区的LLama的工作 - Chinese LLaMA Alpaca2

这个仓库同样有配套的文章Efficient and Effective Text Encoding for Chinese LLaMA and Alpaca

这个仓库的工作主要是两个:

  1. 扩充了llama原来的token,也就是中文的那部分
  2. 用新的中文数据在llama上进行了continue pretraining,并且发布了在instruction数据上的微调模型

研究思路很简单,在别人模型上继续预训练,并参照alpaca对预训练的模型进行instruction finetune让其具备follow instructions的能力。我们首先从这个Chinese LLaMA代码仓库看起。

Chinese LLaMA

作者自述做这份工作的原因是原生llama模型的词汇表中仅包含1000+个中文字符,所以首要任务是要扩充llama的词表。他们首先训练了一个中文的tokenizer,然后将其与llama的tokenizer进行融合,融合后的tokenizer拥有49953个token, 那么输入的词汇表数就从32000扩充到了49953。作者的实验还发现用新的融合后的tokenizer去tokenize序列要比旧的tokenizer编码后的序列要短。那很自然的就减少了很多计算量。

image-20231212161342629

在准备好tokenizer之后就到了训练环节,作者在这里没有采用全参数微调而是采用了Lora这种高效微调的方式。其实我看到这里是有疑问的,当然作者也在issue中做了回答:

image-20231212162349967

我个人认为continue pretraining是需要全参数微调的,而且还是在扩充了词表的情况下。

预训练脚本,这个脚本是作者在transformers库的run_clm.py上修改的,至于中文预训练数据部分,作者采用了20G的纯文本数据,并将他们分成了每个block 512个token。我们来看看代码是怎么写的,源代码在run_clm_pt_with_peft,可以先将Chinese-LLaMA-Alpaca-2拉到本地,在文件姐scripts里可以看到training文件夹里有两个训练代码,一个是pretrain的,一个是sft的。我们先看前面这个pretrain的,它的训练任务很好理解,就是用decoder这种模型架构训练一个输入序列的下一个单词。

作者在这个仓库里没有放训练数据,我们先在该仓库里创建一个./data,里面放一些txt格式的数据用于测试,比如一些小说啥的,训练脚本在处理数据时会自动对他们进行读取并chunk成512长度的序列。作者在paper里提到的他们team训练的tokenizer也一并在scripts的tokenizer文件夹内,要跑通train这个代码需要在run_pt.sh内将这些参数都制定好。

先来看load数据以及处理部分的重点代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
files = [file.name for file in path.glob("*.txt")]
for idx, file in enumerate(files):# files为./data文件夹内所有的txt
data_file = os.path.join(path, file)
filename = ''.join(file.split(".")[:-1])
cache_path = os.path.join(data_args.data_cache_dir, filename+f"_{block_size}")
os.makedirs(cache_path, exist_ok=True)
try:
processed_dataset = datasets.load_from_disk(cache_path, keep_in_memory=False) # 首先使用datasets导入
logger.info(f'training datasets-{filename} has been loaded from disk')
except Exception:
cache_dir = os.path.join(data_args.data_cache_dir, filename+f"_text_{block_size}")
os.makedirs(cache_dir, exist_ok=True)
raw_dataset = load_dataset("text", data_files=data_file, cache_dir=cache_dir, keep_in_memory=False)
logger.info(f"{file} has been loaded")
tokenized_dataset = raw_dataset.map(
tokenize_function,
batched=True,
num_proc=data_args.preprocessing_num_workers,
remove_columns="text",
load_from_cache_file=True,
keep_in_memory=False,
cache_file_names = {k: os.path.join(cache_dir, 'tokenized.arrow') for k in raw_dataset},
desc="Running tokenizer on dataset",
)
grouped_datasets = tokenized_dataset.map(
group_texts,
batched=True,
num_proc=data_args.preprocessing_num_workers,
load_from_cache_file=True,
keep_in_memory=False,
cache_file_names = {k: os.path.join(cache_dir, 'grouped.arrow') for k in tokenized_dataset},
desc=f"Grouping texts in chunks of {block_size}",
) #
processed_dataset = grouped_datasets
processed_dataset.save_to_disk(cache_path)
if idx == 0: #
lm_datasets = processed_dataset['train']
else: # 如果有多于2个txt,那么将这些数据叠加起来
assert lm_datasets.features.type == processed_dataset["train"].features.type
lm_datasets = concatenate_datasets([lm_datasets, processed_dataset["train"]])

内有两个帮助函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
def tokenize_function(examples):
with CaptureLogger(tok_logger) as cl:
output = tokenizer(examples["text"]) # 仅仅做了tokenize这一个动作,而且会在每一个序列的结尾都加上EOS,由于设置了tokenizer.add_eos_token = True
# clm input could be much much longer than block_size
if "Token indices sequence length is longer than the" in cl.out:
tok_logger.warning(
"^^^^^^^^^^^^^^^^ Please ignore the warning above - this long input will be chunked into smaller bits"
" before being passed to the model."
)
return output

# Main data processing function that will concatenate all texts from our dataset and generate chunks of block_size.
def group_texts(examples): # 在这个函数里程序将tokenize之后的input_ids和attention_mask进行chunk,保证每个chunk大小都是block_size的
# Concatenate all texts.
concatenated_examples = {k: list(chain(*examples[k])) for k in examples.keys()}
total_length = len(concatenated_examples[list(examples.keys())[0]])
# We drop the small remainder, we could add padding if the model supported it instead of this drop, you can
# customize this part to your needs.
if total_length >= block_size:
total_length = (total_length // block_size) * block_size
# Split by chunks of max_len.
result = {
k: [t[i : i + block_size] for i in range(0, total_length, block_size)]
for k, t in concatenated_examples.items()
}
result["labels"] = result["input_ids"].copy() # 这里labels设置成和input_ids一模一样
return result

可以看到基本采用transformer的库来实现的数据的导入以及process,总体来说使用datasets还是比较方便的。

再来看如何做的lora train:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
from peft import LoraConfig, TaskType, get_peft_model, PeftModel, get_peft_model_state_dict
if not training_args.full_finetuning: # 默认模型是全参数微调
if training_args.peft_path is not None:
logger.info("Peft from pre-trained model")
model = PeftModel.from_pretrained(model, training_args.peft_path, device_map=device_map)
else:
logger.info("Init new peft model")
target_modules = training_args.trainable.split(',')
modules_to_save = training_args.modules_to_save
if modules_to_save is not None:
modules_to_save = modules_to_save.split(',')
lora_rank = training_args.lora_rank
lora_dropout = training_args.lora_dropout
lora_alpha = training_args.lora_alpha
logger.info(f"target_modules: {target_modules}")
logger.info(f"lora_rank: {lora_rank}")
peft_config = LoraConfig(
task_type=TaskType.CAUSAL_LM,
target_modules=target_modules,
inference_mode=False,
r=lora_rank, lora_alpha=lora_alpha,
lora_dropout=lora_dropout,
modules_to_save=modules_to_save) # LoraConfig是PEFT这个包内的
model = get_peft_model(model, peft_config)
model.print_trainable_parameters()

该仓库的instruction finetune的代码和alpaca的思路一样,很多写法都一模一样。不过因为作者在做pretrain的时候用的是lora的形式,所以在sft的时候也需要在这个基础模型上进行微调。作者在run_clm_sft_with_peft.py中是类似于pt脚本中的写法:

1
2
3
if training_args.peft_path is not None:
logger.info("Peft from pre-trained model")
model = PeftModel.from_pretrained(model, training_args.peft_path, device_map=device_map)

这里的peft_path是需要在train的时候传入参数的,也就是我们在pretrain时候通过call_back函数保存的lora参数, 模型组装好之后训练。博主认为这时候是所有参数一起调整了,包含lora部分以及llama2基础模型部分。

一点题外话:在阅读Chinese LLaMA这份代码的时候发现了其中一个作者崔一鸣的博客,内有一个关于大模型的纵览介绍挺适合初学者熟悉大模型的相关技术,也适合面试的盆友回顾以及对自己还没掌握透的知识进行查漏补缺的。[Methods and Practices for Large Pre-trained Language Models](https://ymcui.com/talk/20230826_baai_llm_tutorial.pdf)

建议配合stateofgpt食用

LLaMA

拓展补充介绍

LLaMA的1和2版本在模型架构上大多数相似,其中三个关键技术使羊驼模型区别于其他模型,这里摘一下llama2 research paper中的描述:

image-20231213162604171

RMSNorm

在介绍RMSNorm之前补充一下Batch Normalization以及Layer Normalization

参考:

上面图片中,每一行属于一个batch的数据,不用管这个batch内的数据是2维的还是1维的。

Batch Normalization

for each dimension of the input, all data points in the batch are gathered and normalized with the same mean and standard deviation

BN的所有计算都在一个batch以内,也就是我们用到的数据只是这个batch内的数据,不会涉及到其他batch的数据

image-20231214093939577

上面的伪代码中的x可以是一个向量,如果是向量的情况下涉及到的x的相加都是向量的运算。值得注意的是在卷积层里,dimension指的是channel维度的

image-20231214094523587

也就是不同channel计算出的μ和σ是不同的。

the input data is normalized separately for each channel in a convolutional layer.

而在全连接层,dimension就是指feature维度。

Layer Normalization

with LayerNorm, we normalize each data point separately. Moreover, each data point’s mean and variance are shared over all hidden units (i.e. neurons) of the layer

跟batch没关系,在layer层面去计算均值和方差。比如在全连接层,输入是125个神经元的话,就对这些神经元进行归一化。也就是数据中的每一个data points都是独立进行归一化的,和其他data points无关。那么对于卷积层来说的话就有两种计算方式:参考

image-20231214100917596

看pytorch的doc多采取前一种全部一股脑求平均和方差的方式。

RMSNorm

RMSNorm的research paper写着一部分写的特别清楚,推荐查看原文Root Mean Square Layer Normalization

image-20231214103540564

RMSNorm去除了LN的求平均数的过程,并且将LN中的除以方差变成了除以root mean square。来看llama中的代码实现:llama/llama/model.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def _norm(self, x):
"""
Apply the RMSNorm normalization to the input tensor.

Args:
x (torch.Tensor): The input tensor.

Returns:
torch.Tensor: The normalized tensor.

"""
return x * torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + self.eps) # eps防止除以0


SwiGLU

阅读知乎这篇博客大模型基础|激活函数|从ReLU 到SwiGLU

Rotary Embedding, RoPE

Attention is All you need中的position embedding

首先回顾下在Attention is all you need原文paper中对于位置编码的公式:

image-20231215135557606

我一开始理解这两个公式的时候很困难,后来查了一些资料,发现很多人也在这里由一些困惑,包括tensorflow官方的实现方式位置编码,tensorflow的官方给出的代码是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def get_angles(pos, i, d_model):
angle_rates = 1 / np.power(10000, (2 * (i//2)) / np.float32(d_model))
return pos * angle_rates
def positional_encoding(position, d_model):
angle_rads = get_angles(np.arange(position)[:, np.newaxis],
np.arange(d_model)[np.newaxis, :],
d_model)

# 将 sin 应用于数组中的偶数索引(indices);2i
angle_rads[:, 0::2] = np.sin(angle_rads[:, 0::2]) # 不懂双冒号切片的可参考: https://stackoverflow.com/questions/3453085/what-is-double-colon-in-python-when-subscripting-sequences

# 将 cos 应用于数组中的奇数索引;2i+1
angle_rads[:, 1::2] = np.cos(angle_rads[:, 1::2])

pos_encoding = angle_rads[np.newaxis, ...]

return tf.cast(pos_encoding, dtype=tf.float32)

get_angles方法里10000的指数系数中tensorflow的实现多加了一个i//2。这里我非常困惑,后来发现stackflow上也有同样的发问:

推荐阅读一下A Gentle Introduction to Positional Encoding in Transformer Models, Part 1。该作者的实现方式更符合人类的理解方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
import matplotlib.pyplot as plt

def getPositionEncoding(seq_len, d, n=10000):
P = np.zeros((seq_len, d))
for k in range(seq_len):
for i in np.arange(int(d/2)): # 这里只循环d//2次
denominator = np.power(n, 2*i/d)
P[k, 2*i] = np.sin(k/denominator)
P[k, 2*i+1] = np.cos(k/denominator)
return P

P = getPositionEncoding(seq_len=4, d=4, n=100)
print(P)

那么该怎么理解paper中的公式以及tensorflow//2的这个实现呢。就拿某一个sequence中的token来举例子,如果我们想要编码的向量长度是20,也就是d=20。那么tensorflow的做法是首先创建一个长度为20的向量,然后依次求其中的值。

1
2
3
4
5
该token的position encoding所有应该求值得index
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]
angle_rates = 1 / np.power(10000, (2 * (i//2)) / np.float32(d_model)) 这句话
10000的指数(2 * (i//2))是
[0, 0, 2, 2, 4, 4, 6, 6, 8, 8, 10, 10, 12, 12, 14, 14, 16, 16, 18, 18]

所以对照paper中的公式表达的意思就是:

在向量的偶数index位置,比如0,2...等,公式里的2i就等于它的index

在向量的奇数index位置,比如1,3...等,公式里的10000的指数也就是2i的位置应该取这个奇数的前一个偶数值。

那么我们来看看tensorflow的这份代码就对上了:

10000的指数部分出现的值为: [0, 0, 2, 2, 4, 4, 6, 6, 8, 8, 10, 10, 12, 12, 14, 14, 16, 16, 18, 18]

所以paper里的这个公式要将2i当作一个整体来看。

RoPE(rotary Position Embedding)

image-20231215152735803

RoPE进一步改进了绝对位置编码,是一种在transformer attention中的Q和K上添加相对位置信息的方法

image-20231215152803294

首先作者将隐藏层的向量每两个维度编成一组,看成2维的向量;然后对于特定位置m的x1,x2,将他们旋转mθ角度,用新的x1,x2值替换老的值加入到query和key中。

Grouped-Query Attention (GQA)

GQA是llama2相较于llama1新采用的技术,它是一种提升推理速度的方法,主要针对多头注意力机制进行改进,与KV Cache搭配使用

之前写过一篇关于stanford alpaca的代码的分析,最近在kaggle上看到一个检测某段长文本是否是AI生成的任务LLM - Detect AI Generated Text,自己也在尝试做这个任务的时候,发现斯坦福的这份代码真是常看常新。对于数据的准备部分,有很多选择,比如在创建Dataset的时候就把所有的字符串数据tokenize好,在get_item()的函数返回时就返回input_ids,也可以是像斯坦福的这份代码一样,先把数据读取进来然后再用DataCollator处理(padding)。

我之前没有发现斯坦福这份代码这么写的真正原因,直到我自己来处理这种不定长的序列输入时才发现这样写的绝妙,因为我们都知道矩阵是每一行都需要是同样的size,所以斯坦福的写法在数据处理前期一直在用list,而不是batch。

先来说说transformer的对于序列分类的官方教程的写法传送门

transformer的这个教程直接使用的是自己的数据集,已经规整为datasets了,首先它对数据集使用map函数做了截断的处理:

1
2
3
4
5
6
7
8
from transformers import AutoTokenizer

tokenizer = AutoTokenizer.from_pretrained("distilbert-base-uncased")

def preprocess_function(examples):
return tokenizer(examples["text"], truncation=True)

tokenized_imdb = imdb.map(preprocess_function, batched=True)

然后重点来了,注意在上面的preprocess_function中并没有对序列进行padding,只是对过长的序列做了截断。接着作者使用了datacollatorwithpadding,给出的理由是:

It's more efficient to dynamically pad the sentences to the longest length in a batch during collation, instead of padding the whole dataset to the maximum length.

我们可以用官方的文档中看到对于datacollator的定义

Data collators are objects that will form a batch by using a list of dataset elements as input. These elements are of the same type as the elements of train_dataset or eval_dataset

也就是data collators的输入是一个list,list里的每一个元素跟train_dataset中的元素是一样的。在data collator中你可以做一些processing,如padding,random masking。我们接下来可以看到斯坦福的羊驼代码就是将padding的步骤放到了data collator内。

1
2
3
from transformers import DataCollatorWithPadding

data_collator = DataCollatorWithPadding(tokenizer=tokenizer)

在这里的教程里,作者使用了DataCollatorWithPadding,它会动态地pad inputs。我看到文档里还有class transformers.DataCollatorForTokenClassification这个类,maybe可以处理不定长输入,留作后续探索。

transformer的这个教程还是过于简单了,在实际的case中情况会复杂一点。接下来我们看斯坦福的羊驼咋处理不定长sequence的。

首先它先把Dataset定义好:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
def preprocess(
sources: Sequence[str],
targets: Sequence[str],
tokenizer: transformers.PreTrainedTokenizer,
) -> Dict:
"""Preprocess the data by tokenizing."""
examples = [s + t for s, t in zip(sources, targets)]
examples_tokenized, sources_tokenized = [_tokenize_fn(strings, tokenizer) for strings in (examples, sources)]
input_ids = examples_tokenized["input_ids"]
labels = copy.deepcopy(input_ids)
for label, source_len in zip(labels, sources_tokenized["input_ids_lens"]):
label[:source_len] = IGNORE_INDEX
return dict(input_ids=input_ids, labels=labels)

class SupervisedDataset(Dataset):
"""Dataset for supervised fine-tuning."""

def __init__(self, data_path: str, tokenizer: transformers.PreTrainedTokenizer):
super(SupervisedDataset, self).__init__()
logging.warning("Loading data...")
list_data_dict = utils.jload(data_path)

logging.warning("Formatting inputs...")
prompt_input, prompt_no_input = PROMPT_DICT["prompt_input"], PROMPT_DICT["prompt_no_input"]
sources = [
prompt_input.format_map(example) if example.get("input", "") != "" else prompt_no_input.format_map(example)
for example in list_data_dict
]
targets = [f"{example['output']}{tokenizer.eos_token}" for example in list_data_dict] # 将output之后加上[EOS]

logging.warning("Tokenizing inputs... This may take some time...")
data_dict = preprocess(sources, targets, tokenizer)

self.input_ids = data_dict["input_ids"]
self.labels = data_dict["labels"]

def __len__(self):
return len(self.input_ids)

def __getitem__(self, i) -> Dict[str, torch.Tensor]:
return dict(input_ids=self.input_ids[i], labels=self.labels[i])

玄机在:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def _tokenize_fn(strings: Sequence[str], tokenizer: transformers.PreTrainedTokenizer) -> Dict:
"""Tokenize a list of strings."""
tokenized_list = [
tokenizer(
text,
return_tensors="pt",
padding="longest",
max_length=tokenizer.model_max_length,
truncation=True,
) # 这里做了循环,也就是对strings这个list里的每一个sequence单独做的tokenize,然后把这些不等长的input_ids一同放到一个list里。之所以用list,是因为list里可以存储不等长的list。一直到这一步都没有做padding
for text in strings
]
input_ids = labels = [tokenized.input_ids[0] for tokenized in tokenized_list]
input_ids_lens = labels_lens = [
tokenized.input_ids.ne(tokenizer.pad_token_id).sum().item() for tokenized in tokenized_list
]
return dict(
input_ids=input_ids,
labels=labels,
input_ids_lens=input_ids_lens,
labels_lens=labels_lens,
)

当我们调用SupervisedDataset实例化数据后我们来看看数据长什么样子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
train_dataset中有两个key,一个Input_ids,一个labels
input_ids中的值长这样:
tensor([ 2, 45943, 16, 41, 15741, 14, 7448, 10, 3685, 4,
21062, 10, 1263, 14, 16574, 25830, 5, 2069, 4, 50118,
50118, 48134, 41241, 35, 50118, 31033, 130, 4965, 13, 4959,
2245, 4, 50118, 50118, 48134, 19121, 35, 134, 4, 43800,
10, 9320, 5626, 8, 146, 686, 7, 680, 2710, 9,
12849, 8, 8942, 4, 1437, 50118, 176, 4, 30450, 4595,
7, 489, 110, 809, 2171, 8, 670, 4, 1437, 50118,
246, 4, 2315, 615, 3581, 8, 3014, 10, 4292, 3581,
3078, 4, 2])
labels中的值长这样:
tensor([ -100, -100, -100, -100, -100, -100, -100, -100, -100, -100,
-100, -100, -100, -100, -100, -100, -100, -100, -100, -100,
-100, -100, -100, -100, -100, -100, -100, -100, -100, -100,
-100, -100, -100, -100, -100, -100, -100, 134, 4, 43800,
10, 9320, 5626, 8, 146, 686, 7, 680, 2710, 9,
12849, 8, 8942, 4, 1437, 50118, 176, 4, 30450, 4595,
7, 489, 110, 809, 2171, 8, 670, 4, 1437, 50118,
246, 4, 2315, 615, 3581, 8, 3014, 10, 4292, 3581,
3078, 4, 2])

而且input_ids中每一个值的长度都是不同的,这是因为没有做padding的结果,仅仅是将所有的过长的sequence截断了。

羊驼的代码将所有的padding细节都放到了collator里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@dataclass
class DataCollatorForSupervisedDataset(object):
"""Collate examples for supervised fine-tuning."""

tokenizer: transformers.PreTrainedTokenizer

def __call__(self, instances: Sequence[Dict]) -> Dict[str, torch.Tensor]:
input_ids, labels = tuple([instance[key] for instance in instances] for key in ("input_ids", "labels"))
input_ids = torch.nn.utils.rnn.pad_sequence(
input_ids, batch_first=True, padding_value=self.tokenizer.pad_token_id
)
labels = torch.nn.utils.rnn.pad_sequence(labels, batch_first=True, padding_value=IGNORE_INDEX)
return dict(
input_ids=input_ids,
labels=labels,
attention_mask=input_ids.ne(self.tokenizer.pad_token_id), # see https://pytorch.org/docs/stable/generated/torch.ne.html#torch.ne
)

这里作者写了一个自己的类,继承自object。这里没有继承transformer的DefaultDataCollator,暂时不清楚用意,但我觉得应该也可以。这个类实现了一个__call__方法,接受的是一个Sequence(可迭代对象),对象中是字典(input_ids, labels),我们上面在创建数据集的时候getitem每次返回一个dict,这个dict里有input_id和label。现在的collator接受的是这个字典的list,也就是有很多个数据(batch_size),我们对这个batch里的数据统一进行padding,这样就实现了在batch内部去pad,避免将所有的字符串都pad成最长的字符长度。

最近吴恩达出了一个小课程,传送门: Building and Evaluating Advanced RAG ApplicationsB站也有人搬运了,有中英文字幕。最近也正好在做RAG相关的项目,看到这个课程里有一些新的东西,权当在这篇博客里总结记录。

另外还推荐阅读一篇综述Trends in Integration of Knowledge and Large Language Models: A Survey and Taxonomy of Methods, Benchmarks, and Applications, 该综述的第三章详细介绍了retrieval augmentation的方法。我这篇博客会首先理顺一些理论,然后再介绍吴恩达课程里的知识(个人认为吴大佬出的关于LLM的一系列shot course可食用性不够高,比如上面说的这个RAG相关的课怎么看都觉得是在推广LlamaIndex这个框架,对于原理一句话带过,很多细节不清楚)。

3.2节提到的两个工作值得注意:

  1. Query2doc

Query2doc prompts the LLMs to generate a pseudo-document by employing a few-shot prompting paradigm. Subsequently, the original query is expanded by incorporating the pseudo-document. The retriever module uses this new query to retrieve a list of relevant documents.

  1. Rewrite-Retrieve-Read

Different with Query2doc,they adopt a trainable language model to perform the rewriting step

在抽取的context的使用上,我们一般的认知是加入到prompt里,告诉LLM根据这个context回答某个query,这篇综述在3.2节还概括介绍了另外两种使用knowledge的方式:

image-20231205145030752

我个人认为第二种方式实操性差一点,第三种和第一种应该是大家会普遍采取的方式,第二种需要更多精细的prompt设计。


以下为课程相关的 ,传送门: Building and Evaluating Advanced RAG Applications. 课程笔记参考Frameworks in Focus: ‘Building and Evaluating Advanced RAG’ with TruLens and LlamaIndex Insights

构建 Construction

简单的RAG构建的资料太多太多了,最简易的RAG构建可以参考Simple RAG for GitHub issues using Hugging Face Zephyr and LangChain.

RAG中两个最核心的模块: Retrieval 和 Generation (Read),内部都有很多可以enhance的地方。这里列举一些可以查阅的资料,内整理了一些对于RAG的enhancement的点:

img

上面这张图来自于langchain的cookbook,蓝色部分是作者认为all possibilities for system enhancement。我这里只对一些我关注的技术做整理和探索。

Chunking

有一堆文档,如何将这些文档切分成“完美的”chunk。

我比较关注的是对PDF格式的文件的处理,比较有参考价值的资料:5 Levels Of Text Splitting,内介绍的level1和level2的切分方式都是现在比较常见的。

对于PDF中的图片,也有博客进行了探索

对于PDF中table的处理,一个可行的方式是用Unstuctured这个library抽取出HTML格式的table,然后用LLM将其summary一下,那么对于retriveal的时候,是将summary的vector和query的vector去进行比对的,如果match上了,就会把原生的HTML的表格表示输入给LLM去生成最终的答案。做法详见Semi-structured RAG

对于PDF中图片的处理,也是对image先用LLM总结描述一下。其实5 Levels Of Text Splitting里面介绍的方法都是可以的,但我觉得实操会有一定的难度。因为PDF中的images是会被单独放到一个文件夹里的,前后夹的文本其实是丢失了,这样不可避免的就会丢失一定的语义信息。表格其实还好,但是很多时候贴了一张图片之后,后面的文字基本上是相关联的。这时候需要把图片的信息和后面的文字结合起来就需要知道每一个图片所在pdf的位置,我目前看到的资料还没有很好的解决这个问题。

评估 Evaluation

该课程建议从三个维度来评测一个RAG Application的好坏:

image-20231205105234142
  • 问题和回答的相关性
  • 根据问题抽取出来的context和问题的相关性
  • 回答和context的相关性

该课程主要目的是宣传自己的框架Trulens(目前该框架在github有1.8k star,热度不咋高),如果想了解Evaluation的全景知识建议看一下Retrieval-Augmented Generation for Large Language Models: A Survey

上面的review中很重要的两个总结:

  1. 上面所说的三个quality score如何计算?可以看到仍然是我们熟悉的一些metrics
image-20240228142327120
  1. 现有的可用评估框架有哪些?
image-20240508133011839

我们上面提到的课程里使用的就是该表格中列出的TruLens. 上面这张表格总结的还不是特别全面,而且没有datasets的整理,24年新出的文章CRUD-RAG: A Comprehensive Chinese Benchmark for Retrieval-Augmented Generation of Large Language Models 中对这部分做了整理:

image-20240228152011016

这里做一下update,在作者写这篇文章时,综述Retrieval-Augmented Generation for Large Language Models: A Survey还未对评估的数据集做整理,但最近一期3月的论文更新中已经有了这部分的内容。主要增添了对于每一个评测任务的数据集的整理。

其中[7]就是RGB,它数据的生成是利用一系列收集到的news report,然后利用LLM来基于这些report生成relevant events,questions和answers。[38]是ARES,利用flan-t5来生成的一系列合成query和answer。其中比较重要的一列是是否有金标准,也就是上图中的倒数第二列。 13,12以及38分别是TruLens-Eval,RAGAS和ARES,这三个是不需要金标准的,不过代价是需要用到Chatgpt来做自动评估呀,这些可都是白花花的银子。使用Trulens-Eval都是需要配置openai的API的。

LangChain Benchmark

对于想要快速去搭建一个评估RAG的框架的人来说,最好是有现成的可以直接用的评估体系,省去自己搜集数据以及编写各种计算metrics的麻烦。langchain提供了这么一个benchmark包,介绍传送门,截止到24年3月,该库已经包含了三个开源数据集,两个是上面介绍的python文档和pdf的QA问答数据集,还有一个是正在开发中的基于PPT的问答数据集:

image-20240314142718109

这份langchain官方教程里用了好多新的tool,其中一个就是Smith, 在notebook中clone的所有数据集都可以在这个平台上看到,有点像console。LangChain Docs Q&A的数据长这样:

1
2
3
4
{
"question": "How can I parallelize calls in LangChain?",
"answer": "To make parallel calls from a LangChain object, use the 'batch()' (or asynchronous 'abatch()') method. You can also use a `RunnableParallel` object."
}

langchain-benchmark总体而言还处于初期,对于retrival的task也只有三个数据集做支撑,定制化的程度不是特别高。具体可以参考langchain-benchmark官方教程

今天在看huggingface官网文档的时候又看到官方出了新的evaluation的guidebook,这份代码里写的相当详细,不再是一个普通的RAG评估流程,还介绍了评估数据集的生成方式,最重要的是还做了数据集的filtering,这份教程对于企业内部生成自己的评估数据集是有很大的参考价值的。

CRUD

现在我们花点篇幅来详细说一下CRUD这个中文评估benchmark。作者的出发点在于评估一个RAG的应用,要区别于评估一个LLM模型,下面这句话是作者从四个维度来评估RAG的出发点:

Lewis et al. [25] argue that the core of RAG systems is their interactive way of combining LLMs with external knowledge sources

RAG和LLM的交互方式,也就是RAG帮助LLM做了哪些东西让LLM能更好的回答问题,作者觉得是这四个方面:Create,Read,Update和Delete.

image-20240314135617343

Read很常见,RAG会从知识库中搜集更多的信息来供LLM回答问题,Update主要是为了解决LLM无法回答具有时效性的问题,或者当时训练模型时没有加入的信息,Delete这点其实在我看来有点牵强。Read和Update这两点确实是评估一个RAG很关键的方面。

做RAG的评估,最重要的两点就是:

  1. 数据集的准备,作者打算从上面四个维度去衡量一个RAG的好坏,那就得准备相应的数据集,这部分的工作是我们平时自己做测评的重点
  2. 测评metrics的选择,除了我们熟知的BLEU,ROUGE,还有bert判分。其中还有作者基于QuestEval创造的RAGQuestEval评分。这个metrics还挺有意思的。这里放在这里详细介绍下:

首先基于ground truth sentence生成一系列的问题,生成问题的prompt设计是这样的:

你是一位新闻编辑,现在,你被提供了一篇新闻,请先从新闻中抽取出你认为重要的所有关键信息(通常为一个关键词,包含文章中的所有实体和名词性短语),然后,根据关键信息设计几个问题,考验大家能否正确回答问题。用json的形式返回答案。以下是个例子。

新闻:2014年,全国新增并网光伏发电容量1060万千瓦,约占全球新增容量的四分之一。其中,全国新增光伏电站855万千瓦,分布式205万千瓦。据统计,2014年中国光伏发电量达到了250亿千瓦时,同比增⻓超过 200%。

{json_response}

现在你需要为这篇新闻设计问题,尽量涵盖大多数关键信息,请尽量让答案可以用两三个词回答,答案不能太长,key_info包含文章中的所有实体和名词性短语,question与key_info一一对应,数量一致,输出用json的格式:

{news}

注意这里先让LLM抽取文章中的所有实体和名词性短语作为关键信息,question是根据这些关键信息生成的。问题生成完之后分别用reference sentence和ground truth sentence作为context,去让LLM回答上面生成的问题。如果遇到无法回答的问题就让LLM答“无法回答”. 最后一步针对回答的结果计算precision 和 recall。

该文章作者在数据的处理方面,选择去爬取网上最新的news,然后用这8000个新闻建立了三个task的数据集:open-domain multi-document summarization(考察RAG的delete能力),text-continuation(考察RAG的Generation能力),question-answering(read能力)和hallucination modification(考察RAG的Update能力)。

其实仔细看上面review中的总结,CRUD这篇文章里提到的应该考察RAG的“哪些能力”还是不够全面的,而且我个人认为CRUD里面仅仅是以end-to-end的方式计算generated anwser和gound truth之间的差距也是不太可取的,它没有涉及到RAG里面很重要的一个环节:retrieval。更全面的方式应该是计算三种quality scores(具体参考review的介绍):

  • context relevance: query 和 context 的关系
  • faithfulness(groundness):answer 和 context 的关系

This measures the factual consistency of the generated answer againest the given context

主要用于检测LLM的幻觉。这里博客 对trulens的计算方式做了详细介绍,注意它里面的prompt的设计。Ragas框架对于faithfulness的计算查看Faithfulness,也是用chatgpt来把answer中的statement拆开然后分别去与召回的context做对照,可以查看ragas框架计算faithfulness的代码.

My spicy comment: trulens和ragas两者还挺类似的,就是ragas除了计算faithfulness,还多了好几个metrics,如context precision, context recall, context entity recall。其实就是把context relevance这个metric拆分地更细了。不仅如此,ragas把answer relevance也拆的更细了,它包含了answer correctness, answer relevance和answer similarity. 相比较而言,ragas在笔者写这篇文章的时候,star数是要比trulens多的,前者4.8k,后者1.8k。而且issues明显要多于trulens,直觉上看应该是ragas用的人比较多。

在整理这部分metrics的时候,也搜了一下大家都在用什么样的框架来评估自己的RAG,看到reddit上也有人有这样的疑问Why is everyone using RAGAS for RAG evaluation? For me it looks very unreliable, 我觉得其中一个回答比较贴合当下对于RAG评估的一个现状:

There is no proper techincal report, paper, or any experiment that ragas metric is useful and effective to evaluate LLM performance. That's why I do not choose ragas at my AutoRAG tool. I use metrics like G-eval or sem score that has proper experiment and result that shows such metrics are effective. I think evaluating LLM generation performance is not easy problem and do not have silver bullet. All we can do is doing lots of experiment and mixing various metrics for reliable result. In this term, ragas can be a opiton... (If i am missing ragas experiment or benchmark result, let me know)

https://www.reddit.com/r/LangChain/comments/1bijg75/comment/kvoj1q8/?utm_source=share&utm_medium=web3x&utm_name=web3xcss&utm_term=1&utm_content=share_button

  • answer relevance: answer 和 query 的关系

至于计算出上面这三个方面的数值,有多种方式。有用LLM的,比如Trulens就是用的chatgpt,也可以用claude,参考见基于大语言模型知识问答应用落地实践 – 使用 TruLens 做自动化 RAG 项目评估测试。也有直接计算相似度的,比如我们熟悉的bert score,rouge-L。review在这里也进行了整理:

image-20240508132450374

Customization模式

其实在具体的业务场景下,如果已经搭建了一套RAG系统,如何来评估这个RAG系统的好坏,更合理的方式还是需要用自己的数据来测评,如果只是用一些公开的benchmark,如上面提到的langchain benchmark,还是CRUD提出的以新闻为数据的benchmark,都有一点不那么让人信服,毕竟你费劲巴拉地搭建一个RAG的chatbot,还是要在自己的具体的业务场景表现好,客户才会买账吧。

但更多情况下,业务场景下往往是缺少金标数据集的,这时候就需要去针对自己的业务场景去生成一些“合成”数据集。我们可能基于的就是一堆的业务文档,这些文档有的是PDF,有的可能是word,也会有PPT,如果根据这些文档去生成自己的评测数据集,这样基于这个评测数据集我们再去“调整”我们RAG中的各个能影响RAG performance的环节:embedding模型选择哪个,LLM选择哪个?chunking应该如何优化等等?加了rewrite和rerank等techniques之后有没有让RAG的效果变好,这里的变好仅仅是指在我们自己的业务数据上变好,而不是在其他开源的benchmark上,这样才具有一定的说服力。

参考博客RAG Evaluation, 文章介绍了一种根据documents生成synthetic evaluation dataset的办法,里面还加了一些tricks:如何用一个critique agents去筛选QA。不过该篇文章evaluation环节仅仅计算了answer和query的关系(faithfulness),它给出的理由是:

Out of the different RAG evaluation metrics, we choose to focus only on faithfulness since it the best end-to-end metric of our system’s performance.

RAG 中的PainPoints

参考:

上面视频对应的博客

image-20240725140423246
image-20240725140443402