使用Qdrant和Few-Shot Learning对OpenAI模型进行检索增强生成(RAG)的微调
本笔记的目的是通过一个全面的示例,演示如何对OpenAI模型进行检索增强生成(RAG)的微调。
我们还将集成Qdrant和Few-Shot Learning来提升模型的性能并减少幻觉。这可以作为一个实用指南,供对利用OpenAI模型解决特定用例感兴趣的ML从业者、数据科学家和AI工程师参考。🤩
为什么你应该阅读这篇博客?
您想学习如何: - 为特定用例微调OpenAI模型 - 使用Qdrant来提升您的RAG模型的性能 - 使用微调来提高您的RAG模型的正确性并减少幻觉
首先,我们选择了一个数据集,其中我们保证检索是完美的。我们选择了SQuAD数据集的一个子集,这是关于维基百科文章的问题和答案的集合。我们还包括了答案不在上下文中的样本,以演示RAG如何处理这种情况。
目录
- 设置环境
零样本学习部分
- 数据准备:SQuADv2数据集
- 使用基础gpt-3.5-turbo-0613模型进行回答
- 微调并使用微调模型进行回答
- 评估:模型表现如何?
少样本学习部分
-
使用Qdrant改进RAG提示
-
使用Qdrant微调OpenAI模型
-
评估
-
结论
- 综合结果
- 观察
术语、定义和参考资料
检索增强生成(RAG)是什么? “检索增强生成(RAG)”一词来源于Facebook AI的Lewis等人最近的一篇论文。这个想法是使用一个预训练的语言模型(LM)生成文本,但使用一个单独的检索系统来查找相关文档,以对LM进行条件设定。
Qdrant是什么? Qdrant是一个开源的向量搜索引擎,允许您在大型数据集中搜索相似的向量。它是用Rust构建的,我们将使用Python客户端与其交互。这是RAG中的检索部分。
什么是少样本学习? 少样本学习是一种机器学习类型,其中模型通过在少量数据上进行训练或微调来“改进”。在这种情况下,我们将使用它来在SQuAD数据集的少量示例上微调RAG模型。这是RAG中的增强部分。
什么是零样本学习? 零样本学习是一种机器学习类型,其中模型通过在没有任何特定数据集信息的情况下进行训练或微调来“改进”。
什么是微调? 微调是一种机器学习类型,其中模型通过在少量数据上进行训练或微调来“改进”。在这种情况下,我们将使用它来在SQuAD数据集的少量示例上微调RAG模型。LM是RAG中生成部分的关键。
1. 设置环境
安装和导入依赖项
!pip install pandas openai tqdm tenacity scikit-learn tiktoken python-dotenv seaborn --upgrade --quiet
import json
import os
import time
import pandas as pd
from openai import OpenAI
import tiktoken
import seaborn as sns
from tenacity import retry, wait_exponential
from tqdm import tqdm
from collections import defaultdict
import numpy as np
import matplotlib.pyplot as plt
import numpy as np
from sklearn.metrics import confusion_matrix
import warnings
warnings.filterwarnings('ignore')
tqdm.pandas()
client = OpenAI(api_key=os.environ.get("OPENAI_API_KEY", "<your OpenAI API key if not set as env var>"))
设置您的密钥
在这里获取您的OpenAI密钥,在创建免费集群后在这里获取您的Qdrant密钥。
os.environ["QDRANT_URL"] = "https://xxx.cloud.qdrant.io:6333"
os.environ["QDRANT_API_KEY"] = "xxx"
第二部分 数据准备:SQuADv2数据子集
为了演示目的,我们将从SQuADv2数据集的训练集和验证集中提取一小部分数据。该数据集包含了问题和上下文,其中答案不在上下文中,以帮助我们评估LLM如何处理这种情况。
我们将从JSON文件中读取数据,并创建一个包含以下列的数据框:question
、context
、answer
、is_impossible
。
下载数据
# !mkdir -p local_cache
# !wget https://rajpurkar.github.io/SQuAD-explorer/dataset/train-v2.0.json -O local_cache/train.json
# !wget https://rajpurkar.github.io/SQuAD-explorer/dataset/dev-v2.0.json -O local_cache/dev.json
读取JSON到DataFrame
def json_to_dataframe_with_titles(json_data):
qas = []
context = []
is_impossible = []
answers = []
titles = []
for article in json_data['data']:
title = article['title']
for paragraph in article['paragraphs']:
for qa in paragraph['qas']:
qas.append(qa['question'].strip())
context.append(paragraph['context'])
is_impossible.append(qa['is_impossible'])
ans_list = []
for ans in qa['answers']:
ans_list.append(ans['text'])
answers.append(ans_list)
titles.append(title)
df = pd.DataFrame({'title': titles, 'question': qas, 'context': context, 'is_impossible': is_impossible, 'answers': answers})
return df
def get_diverse_sample(df, sample_size=100, random_state=42):
"""
从每个标题中进行抽样,获取数据框的多样化样本。
"""
sample_df = df.groupby(['title', 'is_impossible']).apply(lambda x: x.sample(min(len(x), max(1, sample_size // 50)), random_state=random_state)).reset_index(drop=True)
if len(sample_df) < sample_size:
remaining_sample_size = sample_size - len(sample_df)
remaining_df = df.drop(sample_df.index).sample(remaining_sample_size, random_state=random_state)
sample_df = pd.concat([sample_df, remaining_df]).sample(frac=1, random_state=random_state).reset_index(drop=True)
return sample_df.sample(min(sample_size, len(sample_df)), random_state=random_state).reset_index(drop=True)
train_df = json_to_dataframe_with_titles(json.load(open('local_cache/train.json')))
val_df = json_to_dataframe_with_titles(json.load(open('local_cache/dev.json')))
df = get_diverse_sample(val_df, sample_size=100, random_state=42)
3. 使用基础gpt-3.5-turbo-0613模型进行回答
3.1 无样本提示
让我们从使用基础gpt-3.5-turbo-0613模型来回答问题开始。这个提示是问题和上下文的简单连接,中间用分隔符标记:\n\n
。我们的提示中有一个简单的指示部分:
根据上下文回答以下问题。只能从上下文中回答。如果你不知道答案,说 ‘我不知道’。
还有其他可能的提示,但这是一个很好的起点。我们将使用这个提示来回答验证集中的问题。
# 获取提示消息的函数
def get_prompt(row):
return [
{"role": "system", "content": "You are a helpful assistant."},
{
"role": "user",
"content": f"""Answer the following Question based on the Context only. Only answer from the Context. If you don't know the answer, say 'I don't know'.
Question: {row.question}\n\n
Context: {row.context}\n\n
Answer:\n""",
},
]
3.2 使用零提示回答
接下来,您需要一些可重复使用的函数,这些函数会调用OpenAI
API并返回答案。您将使用API的ChatCompletion.create
端点,该端点接受一个提示并返回完成的文本。
# 以坚韧不拔的精神进行多次重试
@retry(wait=wait_exponential(multiplier=1, min=2, max=6))
def api_call(messages, model):
return client.chat.completions.create(
model=model,
messages=messages,
stop=["\n\n"],
max_tokens=100,
temperature=0.0,
)
# 主要功能是回答问题
def answer_question(row, prompt_func=get_prompt, model="gpt-3.5-turbo"):
messages = prompt_func(row)
response = api_call(messages, model)
return response.choices[0].message.content
⏰ 运行时间:约3分钟,🛜 需要互联网连接
# 使用 `progress_apply` 结合 `tqdm` 来显示进度条
df["generated_answer"] = df.progress_apply(answer_question, axis=1)
df.to_json("local_cache/100_val.json", orient="records", lines=True)
df = pd.read_json("local_cache/100_val.json", orient="records", lines=True)
df
title | question | context | is_impossible | answers | |
---|---|---|---|---|---|
0 | Scottish_Parliament | What consequence of establishing the Scottish ... | A procedural consequence of the establishment ... | False | [able to vote on domestic legislation that app... |
1 | Imperialism | Imperialism is less often associated with whic... | The principles of imperialism are often genera... | True | [] |
2 | Economic_inequality | What issues can't prevent women from working o... | When a person’s capabilities are lowered, they... | True | [] |
3 | Southern_California | What county are Los Angeles, Orange, San Diego... | Its counties of Los Angeles, Orange, San Diego... | True | [] |
4 | French_and_Indian_War | When was the deportation of Canadians? | Britain gained control of French Canada and Ac... | True | [] |
... | ... | ... | ... | ... | ... |
95 | Geology | In the layered Earth model, what is the inner ... | Seismologists can use the arrival times of sei... | True | [] |
96 | Prime_number | What type of value would the Basel function ha... | The zeta function is closely related to prime ... | True | [] |
97 | Fresno,_California | What does the San Joaquin Valley Railroad cros... | Passenger rail service is provided by Amtrak S... | True | [] |
98 | Victoria_(Australia) | What party rules in Melbourne's inner regions? | The centre-left Australian Labor Party (ALP), ... | False | [The Greens, Australian Greens, Greens] |
99 | Immune_system | The speed of the killing response of the human... | In humans, this response is activated by compl... | False | [signal amplification, signal amplification, s... |
100 rows × 5 columns
4. 微调和使用微调模型进行回答
有关完整的微调过程,请参考OpenAI Fine-Tuning文档。
4.1 准备微调数据
我们需要为微调准备数据。我们将使用与之前相同数据集的训练集中的一些样本,但我们将在上下文中添加答案。这将帮助模型学习从上下文中检索答案。
我们的指令提示与之前相同,系统提示也是如此。
def dataframe_to_jsonl(df):
def create_jsonl_entry(row):
answer = row["answers"][0] if row["answers"] else "I don't know"
messages = [
{"role": "system", "content": "You are a helpful assistant."},
{
"role": "user",
"content": f"""Answer the following Question based on the Context only. Only answer from the Context. If you don't know the answer, say 'I don't know'.
Question: {row.question}\n\n
Context: {row.context}\n\n
Answer:\n""",
},
{"role": "assistant", "content": answer},
]
return json.dumps({"messages": messages})
jsonl_output = df.apply(create_jsonl_entry, axis=1)
return "\n".join(jsonl_output)
train_sample = get_diverse_sample(train_df, sample_size=100, random_state=42)
with open("local_cache/100_train.jsonl", "w") as f:
f.write(dataframe_to_jsonl(train_sample))
提示:💡 验证微调数据
您可以查看这个食谱以获取有关如何准备微调数据的更多详细信息。
4.2 微调OpenAI模型
如果您是OpenAI模型微调的新手,请参考如何微调Chat模型笔记本。您也可以参考OpenAI微调文档以获取更多详细信息。
class OpenAIFineTuner:
"""
微调OpenAI模型的类
"""
def __init__(self, training_file_path, model_name, suffix):
self.training_file_path = training_file_path
self.model_name = model_name
self.suffix = suffix
self.file_object = None
self.fine_tuning_job = None
self.model_id = None
def create_openai_file(self):
self.file_object = client.files.create(
file=open(self.training_file_path, "r"),
purpose="fine-tune",
)
def wait_for_file_processing(self, sleep_time=20):
while self.file_object.status != 'processed':
time.sleep(sleep_time)
self.file_object.refresh()
print("File Status: ", self.file_object.status)
def create_fine_tuning_job(self):
self.fine_tuning_job = client.fine_tuning.jobs.create(
training_file=self.file_object["id"],
model=self.model_name,
suffix=self.suffix,
)
def wait_for_fine_tuning(self, sleep_time=45):
while self.fine_tuning_job.status != 'succeeded':
time.sleep(sleep_time)
self.fine_tuning_job.refresh()
print("Job Status: ", self.fine_tuning_job.status)
def retrieve_fine_tuned_model(self):
self.model_id = client.fine_tuning.jobs.retrieve(self.fine_tuning_job["id"]).fine_tuned_model
return self.model_id
def fine_tune_model(self):
self.create_openai_file()
self.wait_for_file_processing()
self.create_fine_tuning_job()
self.wait_for_fine_tuning()
return self.retrieve_fine_tuned_model()
fine_tuner = OpenAIFineTuner(
training_file_path="local_cache/100_train.jsonl",
model_name="gpt-3.5-turbo",
suffix="100trn20230907"
)
⏰ 运行时间:约10-20分钟,🛜 需要互联网连接
model_id = fine_tuner.fine_tune_model()
model_id
4.2.1 尝试使用微调模型
让我们在与之前相同的验证集上尝试使用微调模型。您将使用与之前相同的提示,但是这次将使用微调模型而不是基础模型。在这样做之前,您可以简单地调用一下,以了解微调模型的表现如何。
completion = client.chat.completions.create(
model=model_id,
messages=[
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Hello!"},
{"role": "assistant", "content": "Hi, how can I help you today?"},
{
"role": "user",
"content": "Can you answer the following question based on the given context? If not, say, I don't know:\n\nQuestion: What is the capital of France?\n\nContext: The capital of Mars is Gaia. Answer:",
},
],
)
print(completion.choices[0].message)
4.3 使用微调模型进行回答
这与之前的操作相同,但是您将使用微调模型而不是基础模型。
⏰ 运行时间:约5分钟,🛜 需要互联网连接
df["ft_generated_answer"] = df.progress_apply(answer_question, model=model_id, axis=1)
5. 评估:模型表现如何?
为了评估模型的表现,需要将预测的答案与实际答案进行比较 – 如果预测的答案中包含任何实际答案,则表示匹配成功。我们还创建了错误类别,以帮助您了解模型存在哪些问题。
当我们知道上下文中存在正确答案时,可以衡量模型的表现,有3种可能的结果:
- ✅ 回答正确:模型回答了正确答案。它可能还包括上下文中没有的其他答案。
- ❎ 跳过:模型回答“我不知道”(IDK),而答案实际上在上下文中。这比给出错误答案要好。在我们的设计中,我们知道真实答案存在,因此我们能够衡量它 – 这并不总是情况。这是一个模型错误。我们将其从总体错误率中排除。
- ❌ 错误:模型回答了错误答案。这是一个模型错误。
当我们知道上下文中不存在正确答案时,可以衡量模型的表现,有2种可能的结果:
- ❌ 幻觉:模型回答了一个答案,而预期的是“我不知道”。这是一个模型错误。
- ✅ 我不知道:模型回答“我不知道”(IDK),而答案不在上下文中。这是一个模型胜利。
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt
class Evaluator:
def __init__(self, df):
self.df = df
self.y_pred = pd.Series() # 初始化为空序列
self.labels_answer_expected = ["✅ Answered Correctly", "❎ Skipped", "❌ Wrong Answer"]
self.labels_idk_expected = ["❌ Hallucination", "✅ I don't know"]
def _evaluate_answer_expected(self, row, answers_column):
generated_answer = row[answers_column].lower()
actual_answers = [ans.lower() for ans in row["answers"]]
return (
"✅ Answered Correctly" if any(ans in generated_answer for ans in actual_answers)
else "❎ Skipped" if generated_answer == "i don't know"
else "❌ Wrong Answer"
)
def _evaluate_idk_expected(self, row, answers_column):
generated_answer = row[answers_column].lower()
return (
"❌ Hallucination" if generated_answer != "i don't know"
else "✅ I don't know"
)
def _evaluate_single_row(self, row, answers_column):
is_impossible = row["is_impossible"]
return (
self._evaluate_answer_expected(row, answers_column) if not is_impossible
else self._evaluate_idk_expected(row, answers_column)
)
def evaluate_model(self, answers_column="generated_answer"):
self.y_pred = pd.Series(self.df.apply(self._evaluate_single_row, answers_column=answers_column, axis=1))
freq_series = self.y_pred.value_counts()
# 为每个场景计数行数
total_answer_expected = len(self.df[self.df['is_impossible'] == False])
total_idk_expected = len(self.df[self.df['is_impossible'] == True])
freq_answer_expected = (freq_series / total_answer_expected * 100).round(2).reindex(self.labels_answer_expected, fill_value=0)
freq_idk_expected = (freq_series / total_idk_expected * 100).round(2).reindex(self.labels_idk_expected, fill_value=0)
return freq_answer_expected.to_dict(), freq_idk_expected.to_dict()
def print_eval(self):
answer_columns=["generated_answer", "ft_generated_answer"]
baseline_correctness, baseline_idk = self.evaluate_model()
ft_correctness, ft_idk = self.evaluate_model(self.df, answer_columns[1])
print("When the model should answer correctly:")
eval_df = pd.merge(
baseline_correctness.rename("Baseline"),
ft_correctness.rename("Fine-Tuned"),
left_index=True,
right_index=True,
)
print(eval_df)
print("\n\n\nWhen the model should say 'I don't know':")
eval_df = pd.merge(
baseline_idk.rename("Baseline"),
ft_idk.rename("Fine-Tuned"),
left_index=True,
right_index=True,
)
print(eval_df)
def plot_model_comparison(self, answer_columns=["generated_answer", "ft_generated_answer"], scenario="answer_expected", nice_names=["Baseline", "Fine-Tuned"]):
results = []
for col in answer_columns:
answer_expected, idk_expected = self.evaluate_model(col)
if scenario == "answer_expected":
results.append(answer_expected)
elif scenario == "idk_expected":
results.append(idk_expected)
else:
raise ValueError("Invalid scenario")
results_df = pd.DataFrame(results, index=nice_names)
if scenario == "answer_expected":
results_df = results_df.reindex(self.labels_answer_expected, axis=1)
elif scenario == "idk_expected":
results_df = results_df.reindex(self.labels_idk_expected, axis=1)
melted_df = results_df.reset_index().melt(id_vars='index', var_name='Status', value_name='Frequency')
sns.set_theme(style="whitegrid", palette="icefire")
g = sns.catplot(data=melted_df, x='Frequency', y='index', hue='Status', kind='bar', height=5, aspect=2)
# Annotating each bar
for p in g.ax.patches:
g.ax.annotate(f"{p.get_width():.0f}%", (p.get_width()+5, p.get_y() + p.get_height() / 2),
textcoords="offset points",
xytext=(0, 0),
ha='center', va='center')
plt.ylabel("Model")
plt.xlabel("Percentage")
plt.xlim(0, 100)
plt.tight_layout()
plt.title(scenario.replace("_", " ").title())
plt.show()
# Compare the results by merging into one dataframe
evaluator = Evaluator(df)
# evaluator.evaluate_model(answers_column="ft_generated_answer")
# evaluator.plot_model_comparison(["generated_answer", "ft_generated_answer"], scenario="answer_expected", nice_names=["Baseline", "Fine-Tuned"])
# 可选择将结果保存到 JSON 文件中。
df.to_json("local_cache/100_val_ft.json", orient="records", lines=True)
df = pd.read_json("local_cache/100_val_ft.json", orient="records", lines=True)
evaluator.plot_model_comparison(["generated_answer", "ft_generated_answer"], scenario="answer_expected", nice_names=["Baseline", "Fine-Tuned"])
请注意,微调模型更经常跳过问题 – 并且犯的错误更少。这是因为微调模型更加保守,当不确定时会跳过问题。
evaluator.plot_model_comparison(["generated_answer", "ft_generated_answer"], scenario="idk_expected", nice_names=["Baseline", "Fine-Tuned"])
注意,经过微调的模型学会了更好地说“我不知道”而不是直接跳过问题。或者说,模型擅长跳过问题了。
观察结果
- 经过微调的模型更擅长说“我不知道”
- 幻觉从100%下降到15%经过微调
- 错误答案从17%下降到6%经过微调
正确答案也从83%下降到60%经过微调 - 这是因为经过微调的模型更加保守,更经常说“我不知道”。这是件好事,因为说“我不知道”总比给出错误答案要好。
话虽如此,我们希望提高模型的正确性,即使这会增加幻觉。我们希望找到一个既正确又保守的模型,在两者之间取得平衡。我们将使用Qdrant和Few-Shot Learning来实现这一目标。
💪 你已经完成了3分之2的任务!继续加油!
B部分:少样本学习
我们将从数据集中选择一些例子,包括答案不在上下文中的情况。然后,我们将使用这些例子创建一个提示,用于微调模型。然后我们将衡量微调模型的性能。
接下来是什么?
- 使用Qdrant对OpenAI模型进行微调 6.1 嵌入微调数据 6.2 嵌入问题
- 使用Qdrant改进RAG提示
- 评估
6. 使用Qdrant对OpenAI模型进行微调
到目前为止,我们一直在使用OpenAI模型来回答问题,而没有使用答案的示例。前面的步骤使其在上下文示例中表现更好,而这一步则帮助其泛化到未见数据,并尝试学习何时说“我不知道”以及何时给出答案。
这就是少样本学习的用武之地!
少样本学习是一种迁移学习类型,允许我们回答答案不在上下文中的问题。我们可以通过提供一些答案示例来实现这一点,模型将学会回答答案不在上下文中的问题。
5.1 嵌入训练数据
嵌入是将句子表示为浮点数数组的一种方式。我们将使用这些嵌入来找到与我们正在寻找的问题最相似的问题。
import os
from qdrant_client import QdrantClient
from qdrant_client.http import models
from qdrant_client.http.models import PointStruct
from qdrant_client.http.models import Distance, VectorParams
现在我们已经导入了Qdrant所需的库,
qdrant_client = QdrantClient(
url=os.getenv("QDRANT_URL"), api_key=os.getenv("QDRANT_API_KEY"), timeout=6000, prefer_grpc=True
)
collection_name = "squadv2-cookbook"
# 创建集合,此操作仅执行一次。
# qdrant_client.recreate_collection(
# 集合名称=集合名称,
# vectors_config=VectorParams(size=384, distance=Distance.COSINE),
# 很抱歉,您没有提供需要翻译的英文段落。请提供具体的文本,我将为您进行翻译。
from fastembed.embedding import DefaultEmbedding
from typing import List
import numpy as np
import pandas as pd
from tqdm.notebook import tqdm
tqdm.pandas()
embedding_model = DefaultEmbedding()
5.2 嵌入问题
接下来,您将嵌入整个训练集中的问题。您将使用问题之间的相似度来找到与我们正在寻找的问题最相似的问题。这是在RAG中使用的工作流程,以利用OpenAI模型具有更多示例的上下文学习能力。这就是我们在这里称之为少样本学习。
❗️⏰ 重要提示:此步骤可能需要长达3小时才能完成。请耐心等待。如果看到内存不足错误或内核崩溃,请将批量大小减小到32,重新启动内核并再次运行笔记本。此代码只需要运行一次。