使用 Qdrant 和少样本学习对 OpenAI 模型进行检索增强生成 (RAG) 微调
本笔记本旨在全面介绍如何为检索增强生成 (RAG) 微调 OpenAI 模型。
我们还将集成 Qdrant 和少样本学习,以提高模型的性能并减少幻觉。这可以为有兴趣利用 OpenAI 模型针对特定用例的 ML 从业者、数据科学家和 AI 工程师提供实用指南。🤩
注意:本笔记本使用 gpt-3.5-turbo 模型。使用此设置在 SQuAD 数据集上进行微调,对于 gpt-4o 或 gpt-4.1 等更高级的模型,只会带来微小的收益。因此,本笔记本主要用作微调工作流和检索增强生成 (RAG) 实践的指南。
为什么要阅读这篇博文?
您想学习如何
- 微调 OpenAI 模型 以适应特定用例
- 使用 Qdrant 提高 RAG 模型的性能
- 使用微调来提高 RAG 模型的正确性并减少幻觉
首先,我们选择了一个数据集,其中我们保证检索是完美的。我们选择了 SQuAD 数据集的一个子集,该数据集是关于维基百科文章的问题和答案的集合。我们还包含了一些答案不在上下文中的样本,以演示 RAG 如何处理这种情况。
目录
- 设置环境
A 部分:零样本学习
- 数据准备:SQuADv2 数据集
- 使用基础 gpt-3.5-turbo-0613 模型进行回答
- 使用微调模型进行微调和回答
- 评估:模型表现如何?
B 部分:少样本学习
- 使用 Qdrant 改进 RAG 提示
- 使用 Qdrant 微调 OpenAI 模型
-
评估
-
结论
- 汇总结果
- 观察结果
术语、定义和参考
检索增强生成 (RAG)? “检索增强生成” (RAG) 一词来自 Facebook AI 的 Lewis 等人的一篇近期论文。其思想是使用预训练的语言模型 (LM) 来生成文本,但使用单独的检索系统来查找相关的文档以对 LM 进行条件化。
什么是 Qdrant? Qdrant 是一个开源向量搜索引擎,允许您在大型数据集中搜索相似的向量。它内置于 Rust 中,我们将在其中使用 Python 客户端进行交互。这是 RAG 的检索部分。
什么是少样本学习? 少样本学习是一种机器学习类型,其中模型通过在少量数据上进行训练或微调来“改进”。在这种情况下,我们将使用它在 SQuAD 数据集中的少量示例来微调 RAG 模型。这是 RAG 的增强部分。
什么是零样本学习? 零样本学习是一种机器学习类型,其中模型在没有任何特定数据集信息的情况下通过训练或微调来“改进”。
什么是微调? 微调是一种机器学习类型,其中模型通过在少量数据上进行训练或微调来“改进”。在这种情况下,我们将使用它在 SQuAD 数据集中的少量示例来微调 RAG 模型。LLM 是 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 密钥 here,并在创建免费集群后在此处获取 Qdrant 密钥 here。
os.environ["QDRANT_URL"] = "https://xxx.cloud.qdrant.io:6333"
os.environ["QDRANT_API_KEY"] = "xxx"
A 部分
2. 数据准备:SQuADv2 数据集子集
为了演示,我们将从 SQuADv2 数据集的训练和验证分割中创建小切片。此数据集包含问题和上下文,其中答案不在上下文中,以帮助我们评估 LLM 如何处理这种情况。
我们将从 JSON 文件中读取数据,并创建一个包含以下列的 DataFrame: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):
"""
通过对每个标题进行采样来获取 DataFrame 的多样化样本
"""
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
端点,该端点接受一个提示并返回完成的文本。
# 带重试的 tenacity 函数
@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
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 微调文档。
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 模型微调,请参阅 如何微调聊天模型笔记本。您也可以参阅 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, "rb"),
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 True:
# 检索最新的微调作业状态
self.fine_tuning_job = client.fine_tuning.jobs.retrieve(self.fine_tuning_job.id)
print("Job Status:", self.fine_tuning_job.status)
if self.fine_tuning_job.status in {'succeeded', 'failed', 'cancelled'}:
break
time.sleep(sleep_time)
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() # 初始化为空 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()
# Counting rows for each scenario
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"])
# Optionally, save the results to a JSON file
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"])
请注意,微调模型比提示更能学会说“我不知道”。或者,模型擅长跳过问题。
Observations
- 微调模型更擅长说“我不知道”
- 幻觉从微调的 100% 降至 15%
- 错误答案从微调的 17% 降至 6%
正确答案也从微调的 83% 下降到 60%——这是因为微调模型更保守,并且更频繁地说“我不知道”。这是一件好事,因为说“我不知道”比给出错误答案要好。
话虽如此,我们希望提高模型的正确性,即使这会增加幻觉。我们正在寻找一个既正确又保守的模型,在两者之间取得平衡。我们将使用 Qdrant 和少样本学习来实现这一目标。
💪 您已完成 2/3 的路程!继续阅读!
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(
# collection_name=collection_name,
# 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,重新启动内核并再次运行笔记本。此代码仅需运行一次。
generate_points_from_dataframe
函数详解
- 初始化:
batch_size = 512
和total_batches
为一次处理多少问题奠定了基础。这是为了防止内存问题。如果您的机器可以处理更多,请随时增加批处理大小。如果内核崩溃,请将批处理大小减小到 32 并重试。 - 进度条:
tqdm
提供了一个漂亮的进度条,让您不会睡着。 - 批处理循环:for 循环迭代批处理。
start_idx
和end_idx
定义要处理的 DataFrame 切片。 - 生成嵌入:
batch_embeddings = embedding_model.embed(batch, batch_size=batch_size)
- 这是发生魔法的地方。您的问题被转换成嵌入。 - PointStruct 生成:使用
.progress_apply
,它将每一行转换成一个PointStruct
对象。这包括 ID、嵌入向量和其他元数据。
返回 PointStruct
对象列表,可用于在 Qdrant 中创建集合。
def generate_points_from_dataframe(df: pd.DataFrame) -> List[PointStruct]:
batch_size = 512
questions = df["question"].tolist()
total_batches = len(questions) // batch_size + 1
pbar = tqdm(total=len(questions), desc="Generating embeddings")
# 批量生成嵌入以提高性能
embeddings = []
for i in range(total_batches):
start_idx = i * batch_size
end_idx = min((i + 1) * batch_size, len(questions))
batch = questions[start_idx:end_idx]
batch_embeddings = embedding_model.embed(batch, batch_size=batch_size)
embeddings.extend(batch_embeddings)
pbar.update(len(batch))
pbar.close()
# 将嵌入转换为列表的列表
embeddings_list = [embedding.tolist() for embedding in embeddings]
# 创建一个临时 DataFrame 来保存嵌入和现有的 DataFrame 列
temp_df = df.copy()
temp_df["embeddings"] = embeddings_list
temp_df["id"] = temp_df.index
# 使用 DataFrame apply 方法生成 PointStruct 对象
points = temp_df.progress_apply(
lambda row: PointStruct(
id=row["id"],
vector=row["embeddings"],
payload={
"question": row["question"],
"title": row["title"],
"context": row["context"],
"is_impossible": row["is_impossible"],
"answers": row["answers"],
},
),
axis=1,
).tolist()
return points
points = generate_points_from_dataframe(train_df)
将嵌入上传到 Qdrant
请注意,配置 Qdrant 超出了本笔记本的范围。请参阅 Qdrant 获取更多信息。我们为上传设置了 600 秒的超时,并使用 grpc 压缩来加快上传速度。
operation_info = qdrant_client.upsert(
collection_name=collection_name, wait=True, points=points
)
print(operation_info)
6. 使用 Qdrant 改进 RAG 提示
现在我们已经将嵌入上传到 Qdrant,我们可以使用 Qdrant 来查找与我们正在寻找的问题最相似的问题。我们将使用最相似的 5 个问题来创建一个提示,用于微调模型。然后,我们将使用相同的提示技术在相同的验证集上衡量微调模型的性能!
我们的主函数 get_few_shot_prompt
作为生成少样本学习提示的主力。它通过使用嵌入模型从 Qdrant(一个向量搜索引擎)检索相似问题来做到这一点。以下是高层工作流程:
- 从 Qdrant 检索答案存在于上下文中的相似问题
- 从 Qdrant 检索答案不可能(即预期答案是“我不知道”)的问题,以查找上下文中的相似问题
- 使用检索到的问题创建提示
- 使用提示微调模型
- 使用相同的提示技术在验证集上评估微调模型
def get_few_shot_prompt(row):
query, row_context = row["question"], row["context"]
embeddings = list(embedding_model.embed([query]))
query_embedding = embeddings[0].tolist()
num_of_qa_to_retrieve = 5
# 查询 Qdrant 中具有答案的相似问题
q1 = qdrant_client.search(
collection_name=collection_name,
query_vector=query_embedding,
with_payload=True,
limit=num_of_qa_to_retrieve,
query_filter=models.Filter(
must=[
models.FieldCondition(
key="is_impossible",
match=models.MatchValue(
value=False,
),
),
],
)
)
# 查询 Qdrant 中答案不可能回答的相似问题
q2 = qdrant_client.search(
collection_name=collection_name,
query_vector=query_embedding,
query_filter=models.Filter(
must=[
models.FieldCondition(
key="is_impossible",
match=models.MatchValue(
value=True,
),
),
]
),
with_payload=True,
limit=num_of_qa_to_retrieve,
)
instruction = """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'.\n\n"""
# 如果有下一个最佳问题,请将其添加到提示中
def q_to_prompt(q):
question, context = q.payload["question"], q.payload["context"]
answer = q.payload["answers"][0] if len(q.payload["answers"]) > 0 else "I don't know"
return [
{
"role": "user",
"content": f"""Question: {question}\n\nContext: {context}\n\nAnswer:"""
},
{"role": "assistant", "content": answer},
]
rag_prompt = []
if len(q1) >= 1:
rag_prompt += q_to_prompt(q1[1])
if len(q2) >= 1:
rag_prompt += q_to_prompt(q2[1])
if len(q1) >= 1:
rag_prompt += q_to_prompt(q1[2])
rag_prompt += [
{
"role": "user",
"content": f"""Question: {query}\n\nContext: {row_context}\n\nAnswer:"""
},
]
rag_prompt = [{"role": "system", "content": instruction}] + rag_prompt
return rag_prompt
# ⏰ 时间:2 分钟
train_sample["few_shot_prompt"] = train_sample.progress_apply(get_few_shot_prompt, axis=1)
7. 使用 Qdrant 微调 OpenAI 模型
7.1 将微调数据上传到 OpenAI
# 准备 OpenAI 文件格式,即来自 train_sample 的 JSONL
def dataframe_to_jsonl(df):
def create_jsonl_entry(row):
messages = row["few_shot_prompt"]
return json.dumps({"messages": messages})
jsonl_output = df.progress_apply(create_jsonl_entry, axis=1)
return "\n".join(jsonl_output)
with open("local_cache/100_train_few_shot.jsonl", "w") as f:
f.write(dataframe_to_jsonl(train_sample))
7.2 微调模型
⏰ 运行时间:约 15-30 分钟
fine_tuner = OpenAIFineTuner(
training_file_path="local_cache/100_train_few_shot.jsonl",
model_name="gpt-3.5-turbo",
suffix="trnfewshot20230907"
)
model_id = fine_tuner.fine_tune_model()
model_id
# 让我们试试这个
completion = client.chat.completions.create(
model=model_id,
messages=[
{"role": "system", "content": "You are a helpful assistant."},
{
"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:",
},
{
"role": "assistant",
"content": "I don't know",
},
{
"role": "user",
"content": "Question: Where did Maharana Pratap die?\n\nContext: Rana Pratap's defiance of the mighty Mughal empire, almost alone and unaided by the other Rajput states, constitute a glorious saga of Rajput valour and the spirit of self sacrifice for cherished principles. Rana Pratap's methods of guerrilla warfare was later elaborated further by Malik Ambar, the Deccani general, and by Emperor Shivaji.\nAnswer:",
},
{
"role": "assistant",
"content": "I don't know",
},
{
"role": "user",
"content": "Question: Who did Rana Pratap fight against?\n\nContext: In stark contrast to other Rajput rulers who accommodated and formed alliances with the various Muslim dynasties in the subcontinent, by the time Pratap ascended to the throne, Mewar was going through a long standing conflict with the Mughals which started with the defeat of his grandfather Rana Sanga in the Battle of Khanwa in 1527 and continued with the defeat of his father Udai Singh II in Siege of Chittorgarh in 1568. Pratap Singh, gained distinction for his refusal to form any political alliance with the Mughal Empire and his resistance to Muslim domination. The conflicts between Pratap Singh and Akbar led to the Battle of Haldighati. Answer:",
},
{
"role": "assistant",
"content": "Akbar",
},
{
"role": "user",
"content": "Question: Which state is Chittorgarh in?\n\nContext: Chittorgarh, located in the southern part of the state of Rajasthan, 233 km (144.8 mi) from Ajmer, midway between Delhi and Mumbai on the National Highway 8 (India) in the road network of Golden Quadrilateral. Chittorgarh is situated where National Highways No. 76 & 79 intersect. Answer:",
},
],
)
print("Correct Answer: Rajasthan\nModel Answer:")
print(completion.choices[0].message)
⏰ 运行时间:5-15 分钟
df["ft_generated_answer_few_shot"] = df.progress_apply(answer_question, model=model_id, prompt_func=get_few_shot_prompt, axis=1)
df.to_json("local_cache/100_val_ft_few_shot.json", orient="records", lines=True)
8. 评估
但是模型表现如何呢?让我们比较一下到目前为止我们看到的 3 种不同模型的結果:
evaluator = Evaluator(df)
evaluator.plot_model_comparison(["generated_answer", "ft_generated_answer", "ft_generated_answer_few_shot"], scenario="answer_expected", nice_names=["Baseline", "Fine-Tuned", "Fine-Tuned with Few-Shot"])
这非常了不起——我们能够获得两全其美!我们能够让模型既正确又保守:
- 模型正确率为 83%——与基础模型相同
- 模型给出错误答案的几率仅为 8%——比基础模型的 17% 有所下降
接下来,让我们看看幻觉。我们想减少幻觉,但不能以牺牲正确性为代价。我们希望在两者之间取得平衡。我们在这里取得了良好的平衡:
- 模型幻觉率为 53%——比基础模型的 100% 有所下降
- 模型说“我不知道”的几率为 47%——比基础模型的从不有所上升
evaluator.plot_model_comparison(["generated_answer", "ft_generated_answer", "ft_generated_answer_few_shot"], scenario="idk_expected", nice_names=["Baseline", "Fine-Tuned", "Fine-Tuned with Few-Shot"])
使用 Qdrant 进行少样本微调是控制和引导 RAG 系统性能的绝佳方法。在这里,我们通过使用 Qdrant 查找相似问题,使模型比零样本更不保守,并且更自信。
您也可以使用 Qdrant 使模型更保守。我们通过提供答案不存在于上下文中的问题的示例来做到这一点。这会使模型倾向于更频繁地说“我不知道”。
同样,也可以使用 Qdrant 通过提供答案存在于上下文中的问题的示例来使模型更自信。这会使模型倾向于更频繁地给出答案。其代价是模型也会更频繁地产生幻觉。
您可以通过调整训练数据:问题的分布和示例,以及从 Qdrant 中检索的示例的类型和数量来权衡这一点。
9. 结论
在本笔记本中,我们演示了如何针对特定用例微调 OpenAI 模型。我们还演示了如何使用 Qdrant 和少样本学习来提高模型的性能。
汇总结果
到目前为止,我们分别查看了每个场景的结果,即每个场景加起来为 100。让我们将结果作为汇总来看,以获得对模型性能更广泛的了解:
| Category | Base | Fine-Tuned | Fine-Tuned with Qdrant |
Category | Base | Fine-Tuned | Fine-Tuned with Qdrant |
---|---|---|---|
Correct | 44% | 32% | 44% |
Skipped | 0% | 18% | 5% |
Wrong | 9% | 3% | 4% |
Hallucination | 47% | 7% | 25% |
I don't know | 0% | 40% | 22% |
Observations
与基础模型相比
- 使用 Qdrant 进行少样本微调的模型在回答上下文中存在答案的问题方面与基础模型一样好。
- 使用 Qdrant 进行少样本微调的模型在上下文中不存在答案时更擅长说“我不知道”。
- 使用 Qdrant 进行少样本微调的模型在减少幻觉方面更好。
与微调模型相比
- 使用 Qdrant 进行少样本微调的模型比微调模型获得更多的正确答案:正确回答问题的比例为 83%,而微调模型为 60%
- 使用 Qdrant 进行少样本微调的模型在决定何时说“我不知道”(当答案不存在于上下文中时)方面更好。普通微调模式的跳过率为 34%,而使用 Qdrant 进行少样本微调的模型为 9%
现在,您应该能够:
- 注意正确答案数量和幻觉之间的权衡——以及训练数据集的选择如何影响这一点!
- 针对特定用例微调 OpenAI 模型,并使用 Qdrant 提高 RAG 模型的性能
- 开始了解如何评估 RAG 模型的性能