使用 Qdrant 进行检索增强生成 (RAG) 的微调

2023 年 9 月 4 日
在 Github 中打开

本笔记本旨在详细介绍如何为检索增强生成 (RAG) 微调 OpenAI 模型。

我们还将集成 Qdrant 和少样本学习,以提高模型的性能并减少幻觉。 这可以作为机器学习从业者、数据科学家和对利用 OpenAI 模型的力量来解决特定用例的人工智能工程师的实用指南。 🤩

为什么要阅读这篇博客?

您想学习如何

  • 微调 OpenAI 模型 以用于特定用例
  • 使用 Qdrant 来提高 RAG 模型的性能
  • 使用微调来提高 RAG 模型的正确性并减少幻觉

首先,我们选择了一个数据集,保证检索是完美的。 我们选择了 SQuAD 数据集的一个子集,该数据集是关于维基百科文章的问题和答案的集合。 我们还包括了答案不在上下文中的样本,以演示 RAG 如何处理这种情况。

目录

  1. 设置环境

A 部分:零样本学习

  1. 数据准备:SQuADv2 数据集
  2. 使用基础 gpt-3.5-turbo-0613 模型回答
  3. 使用微调模型进行微调和回答
  4. 评估:模型表现如何?

B 部分:少样本学习

  1. 使用 Qdrant 改进 RAG 提示

  2. 使用 Qdrant 微调 OpenAI 模型

  3. 评估

  4. 结论

    • 汇总结果
    • 观察结果

术语、定义和参考文献

检索增强生成 (RAG)? 检索增强生成 (RAG) 这个短语来自 Facebook AI 的 Lewis 等人撰写的 最新论文。 其想法是使用预训练的语言模型 (LM) 生成文本,但使用单独的检索系统来查找相关文档,以作为 LM 的条件。

什么是 Qdrant? Qdrant 是一个开源向量搜索引擎,允许您在大型数据集中搜索相似的向量。 它是在 Rust 中构建的,在这里我们将使用 Python 客户端与之交互。 这是 RAG 的检索部分。

什么是少样本学习? 少样本学习是一种机器学习类型,其中模型通过在少量数据上进行训练或微调来“改进”。 在这种情况下,我们将使用它在少量来自 SQuAD 数据集的示例上微调 RAG 模型。 这是 RAG 的增强部分。

什么是零样本学习? 零样本学习是一种机器学习类型,其中模型通过在没有任何数据集特定信息的情况下进行训练或微调来“改进”。

什么是微调? 微调是一种机器学习类型,其中模型通过在少量数据上进行训练或微调来“改进”。 在这种情况下,我们将使用它在少量来自 SQuAD 数据集的示例上微调 RAG 模型。 LLM 是构成 RAG 生成部分的原因。

!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>"))
os.environ["QDRANT_URL"] = "https://xxx.cloud.qdrant.io:6333"
os.environ["QDRANT_API_KEY"] = "xxx"

A 部分

2. 数据准备:SQuADv2 数据子集

为了演示目的,我们将从 SQuADv2 数据集的训练集和验证集分割中制作小切片。 此数据集包含问题和上下文,其中答案不在上下文中,以帮助我们评估 LLM 如何处理这种情况。

我们将从 JSON 文件中读取数据,并创建一个包含以下列的数据帧:questioncontextansweris_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
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):
    """
    Get a diverse sample of the dataframe by sampling from each title
    """
    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。 我们有一个简单的提示指令部分

仅根据上下文回答以下问题。 仅从上下文中回答。 如果您不知道答案,请说“我不知道”。

其他提示也是可能的,但这是一个很好的起点。 我们将使用此提示来回答验证集中的问题。

# Function to get prompt messages
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 端点,该端点接受提示并返回完成的文本。

# Function with tenacity for retries
@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,
    )


# Main function to answer question
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 分钟, 🛜 需要互联网连接

# Use progress_apply with tqdm for progress bar
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
标题 问题 上下文 是否不可能 答案
0 苏格兰议会 建立苏格兰...的后果是什么 建立...的程序性后果是... 错误 [能够对国内立法进行投票,该立法适用于...]
1 帝国主义 帝国主义较少与哪个...相关联 帝国主义的原则通常是普遍的... 正确 []
2 经济不平等 哪些问题不能阻止妇女工作... 当一个人的能力降低时,他们... 正确 []
3 南加利福尼亚 洛杉矶、奥兰治、圣地亚哥是哪个县... 其洛杉矶县、奥兰治县、圣地亚哥县... 正确 []
4 法国和印第安人战争 加拿大人何时被驱逐出境? 英国控制了法属加拿大和阿卡迪亚... 正确 []
... ... ... ... ... ...
95 地质学 在分层地球模型中,内部...是什么 地震学家可以使用地震波的到达时间... 正确 []
96 素数 巴塞尔函数会具有什么类型的值... zeta 函数与素数密切相关... 正确 []
97 弗雷斯诺,加利福尼亚 圣华金河谷铁路穿过什么... 客运服务由 Amtrak S...提供 正确 []
98 维多利亚(澳大利亚) 哪个政党统治墨尔本的内陆地区? 中左翼澳大利亚工党 (ALP),... 错误 [绿党, 澳大利亚绿党, 绿党]
99 免疫系统 人类的杀伤反应速度... 在人类中,这种反应由补体激活... 错误 [信号放大, 信号放大, s...]

100 行 × 5 列

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))
class OpenAIFineTuner:
    """
    Class to fine tune OpenAI models
    """
    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 种可能的结果

  1. 回答正确:模型响应了正确答案。 它可能还包括上下文中不存在的其他答案。
  2. 跳过:模型响应“我不知道”(IDK),但答案在上下文中。 这比给出错误的答案要好。 模型说“我不知道”比给出错误的答案要好。 在我们的设计中,我们知道存在真实答案,因此我们能够衡量它——但情况并非总是如此。 这是一个模型错误。 我们将其从总体错误率中排除。
  3. 错误:模型响应了不正确的答案。 这是一个模型错误。

当我们知道上下文中不存在正确答案时,我们可以衡量模型的性能,有 2 种可能的结果

  1. 幻觉:当预期为“我不知道”时,模型响应了答案。 这是一个模型错误。
  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()  # Initialize as empty 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"])
image generated by notebook

请注意,微调模型跳过问题的频率更高——并且犯的错误更少。 这是因为微调模型更加保守,并且在不确定时会跳过问题。

evaluator.plot_model_comparison(["generated_answer", "ft_generated_answer"], scenario="idk_expected", nice_names=["Baseline", "Fine-Tuned"])
image generated by notebook

请注意,微调模型已经学会说“我不知道”,比提示要好得多。 或者,该模型已经擅长跳过问题。

观察结果

  1. 微调模型更擅长说“我不知道”
  2. 通过微调,幻觉从 100% 降至 15%
  3. 通过微调,错误答案从 17% 降至 6%

通过微调,正确答案也从 83% 降至 60% - 这是因为微调模型更保守,并且更频繁地说“我不知道”。 这是一件好事,因为说“我不知道”比给出错误的答案要好。

也就是说,我们希望提高模型的正确性,即使这会增加幻觉。 我们正在寻找一个既正确又保守的模型,在两者之间取得平衡。 我们将使用 Qdrant 和少样本学习来实现此目标。

💪 您已经完成了 2/3 的路程! 继续阅读!

B 部分:少样本学习

我们将从数据集中选择一些示例,包括答案不在上下文中的情况。 然后,我们将使用这些示例创建一个提示,我们可以使用该提示来微调模型。 然后,我们将衡量微调模型的性能。

下一步是什么?

  1. 使用 Qdrant 微调 OpenAI 模型 6.1 嵌入微调数据 6.2 嵌入问题
  2. 使用 Qdrant 改进 RAG 提示
  3. 评估

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"

# # Create the collection, run this only once
# 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 的函数分解

  1. 初始化batch_size = 512total_batches 设置一次处理多少个问题的阶段。 这是为了防止内存问题。 如果您的机器可以处理更多,请随意增加批处理大小。 如果您的内核崩溃,请将批处理大小减小到 32 并重试。
  2. 进度条tqdm 为您提供了一个漂亮的进度条,这样您就不会睡着。
  3. 批处理循环:for 循环遍历批处理。 start_idxend_idx 定义要处理的 DataFrame 的切片。
  4. 生成嵌入batch_embeddings = embedding_model.embed(batch, batch_size=batch_size) - 这是奇迹发生的地方。 你的问题变成了嵌入。
  5. 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")
    
    # Generate embeddings in batches to improve performance
    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()
    
    # Convert embeddings to list of lists
    embeddings_list = [embedding.tolist() for embedding in embeddings]
    
    # Create a temporary DataFrame to hold the embeddings and existing DataFrame columns
    temp_df = df.copy()
    temp_df["embeddings"] = embeddings_list
    temp_df["id"] = temp_df.index
    
    # Generate PointStruct objects using DataFrame apply method
    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 检索相似的问题来做到这一点。 以下是高级工作流程

  1. 从 Qdrant 中检索答案在上下文中的相似问题
  2. 从 Qdrant 中检索答案不可能的相似问题,即预期答案是“我不知道”在上下文中查找
  3. 使用检索到的问题创建提示
  4. 使用提示微调模型
  5. 使用相同的提示技术在验证集上评估微调模型
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

    # Query Qdrant for similar questions that have an answer
    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,
                    ),
                ),
            ],
        )
    )

    # Query Qdrant for similar questions that are IMPOSSIBLE to answer
    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"""
    # If there is a next best question, add it to the prompt
    
    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
# ⏰ Time: 2 min
train_sample["few_shot_prompt"] = train_sample.progress_apply(get_few_shot_prompt, axis=1)
# Prepare the OpenAI File format i.e. JSONL from train_sample
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))
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
# Let's try this out
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"])
image generated by notebook

这非常惊人——我们能够获得两全其美的效果! 我们能够使模型既正确又保守

  1. 模型在 83% 的时间内是正确的——这与基础模型相同
  2. 模型给出错误答案的时间仅为 8%——低于基础模型的 17%

接下来,让我们看看幻觉。 我们希望减少幻觉,但不能以牺牲正确性为代价。 我们希望在两者之间取得平衡。 我们在这里取得了良好的平衡

  1. 模型产生幻觉的时间为 53%——低于基础模型的 100%
  2. 模型说“我不知道”的时间为 47%——高于基础模型的 0%
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"])
image generated by notebook

使用 Qdrant 进行少样本微调是控制和引导 RAG 系统性能的好方法。 在这里,通过使用 Qdrant 查找相似的问题,我们使模型与零样本相比不那么保守,并且更加自信。

您还可以使用 Qdrant 使模型更加保守。 我们通过给出答案不在上下文中的问题的示例来做到这一点。
这会使模型更频繁地说“我不知道”。

同样,也可以使用 Qdrant 通过给出答案在上下文中的问题的示例来使模型更加自信。 这会使模型更频繁地给出答案。 权衡是模型也会更频繁地产生幻觉。

您可以通过调整训练数据:问题和示例的分布,以及您从 Qdrant 检索的示例的种类和数量来权衡取舍。

9. 结论

在本笔记本中,我们演示了如何为特定用例微调 OpenAI 模型。 我们还演示了如何使用 Qdrant 和少样本学习来提高模型的性能。

汇总结果

到目前为止,我们分别查看了每种场景的结果,即每种场景的总和为 100。 让我们将结果作为一个聚合来查看,以更广泛地了解模型的性能

类别基础微调使用 Qdrant 微调
正确44%32%44%
跳过0%18%5%
错误9%3%4%
幻觉47%7%25%
我不知道0%40%22%

观察结果

与基础模型相比

  1. 对于回答答案在上下文中的问题,使用 Qdrant 进行少样本微调的模型与基础模型一样好。
  2. 当答案不在上下文中时,使用 Qdrant 进行少样本微调的模型更擅长说“我不知道”。
  3. 使用 Qdrant 进行少样本微调的模型更擅长减少幻觉。

与微调模型相比

  1. 与微调模型相比,使用 Qdrant 进行少样本微调的模型获得了更多正确的答案:83% 的问题回答正确,而微调模型为 60%
  2. 当答案不在上下文中时,使用 Qdrant 进行少样本微调的模型更擅长决定何时说“我不知道”。 普通微调模式的跳过率为 34%,而使用 Qdrant 进行少样本微调的模型的跳过率为 9%

现在,您应该能够

  1. 注意正确答案数量和幻觉之间的权衡——以及训练数据集的选择如何影响这一点!
  2. 微调 OpenAI 模型以用于特定用例,并使用 Qdrant 来提高 RAG 模型的性能
  3. 开始了解如何评估 RAG 模型的性能