CCCMKホールディングス TECH LABの Tech Blog

TECH LABのエンジニアが技術情報を発信しています

ブログタイトル

RAGのパイプラインを評価するフレームワーク"RAGAS"でテストデータの作成から評価までを行ってみました。

こんにちは、CCCMKホールディングスTECH LAB三浦です。

先日は母の日でした。母の日って海外が発祥のイベントなんですよね。世界ではどんな風に母の日をお祝いしているのか、一度調べてみたいな、と思いました。

Large Language Model(LLM)が学習していない情報について回答させるテクニックとして、Retrieval Augmented Generation(RAG)があります。RAGが必要になるケースは結構あるのですが、RAGによってどれだけ質問に正しく回答出来ているのかという定量的な評価が出来ていないな、という課題感を持っています。

RAGはベースのプロンプトの作り方や関連情報の格納の仕方などに結構工夫出来るポイントがあるのですが、それらの工夫を施すことによってRAGの性能がどれだけ良くなったのかをこれまで人の感覚に基づいて評価してきました。この方法だと評価が人によってバラつきますし、何より作業が大変です。

RAGの評価を自動化出来て、評価の結果が数値化されて誰が見ても善し悪しが判断できる。こういった仕組みを作ることが出来れば様々なRAGのパターンから評価に基づいて最適なものを選ぶことが出来るようになります。

最近使ってみたRagasというフレームワークを使用することで、いずれそういったことが実現できるかも、と感じました。今回はRagasを使ってRAGのパイプラインを評価してみたので、その内容についてまとめてみたいと思います。

Ragas

RagasはRAGを構成する一連の処理(パイプライン)を評価するためのフレームワークです。

docs.ragas.io

いくつかの評価値を簡単に計算できる仕組みが搭載されています。また、評価をするために必要なテストデータを自動生成する機能もあります。

まずRagasに搭載されているRAGの評価値について、まとめてみたいと思います。

Ragasの評価値について

Ragasの評価値のスコープは、大きく"generation"と"retrieval"の領域に分かれています。"generation"はRAGで生成される回答に対する評価、"retrieval"はRAGパイプラインで抽出される追加情報(context)に対する評価です。

"generation"に属する評価値として"faithfulness"と"answer relevancy"が、"retrieval"に属する評価値として"context precision"と"context recall"があります。

faithfulness

生成された回答が冗長な内容になっておらず、かつretrievalプロセスで抽出されたcontextの内容と一致しているかどうかを評価する評価値です。生成された回答からいくつかの文章を生成し、それらの生成された文章のうち、与えられたcontextから推論出来るものがどれだけあるのかを計算します。

answer relevancy

生成された回答が冗長な内容になっておらず、かつ元の質問とどれだけ関連性があるかを評価する評価値です。生成された回答からさらに複数個の質問を生成し、それらと元の質問との関連性(埋め込み表現のコサイン類似度)を計算することで求めます。

context precision

正解(ground truth)にたどり着くために必要な情報が、どれだけRetrievalプロセスで抽出出来ているのかを評価する評価値です。

context recall

ground truthから抽出された文章のうち、contextの内容に属するものがどれだけあるのかを評価する評価値です。

Ragasのテストデータ生成について

Ragasのドキュメントの"Synthetic Test Data generation"のページを見ると、Ragasのテストデータ自動生成の流れはまず与えられたドキュメントから種になる質問を生成し、それを推論が必要な形に書き換えたり複数の情報が必要な形に書き換えたりといった"Evolution"という進化のプロセスを通じて多種多様なquestionとground truthのペアを生成しているようです。

docs.ragas.io

実験

ここからは実際のデータからRagasを使って検証に必要なテストデータを生成し、そのテストデータを使ってRAGのパイプラインの評価値を計算する流れについてまとめていきます。

使用するデータ

使用したデータは、このブログに掲載されているいくつかの記事の、下書きの段階で作成したMarkdown形式のファイルです。それらを1つのフォルダに格納しておきます。

実行・・・の前に

Ragasではデータ作成時や評価値計算で1つのコマンドを実行すると裏側で複数のプロンプトがLLMに渡され実行されることがあります。特に"gpt-4"といったコストが高いLLMを指定して実行すると想像以上のコストが発生します常にコストを確認しながら、少しずつ試していく必要があると思いました。

テストデータ生成

最初にRAGの評価に使用するテストデータを作成します。手順はこちらを参考にしています。

docs.ragas.io

以下のライブラリを使用しました。

pip install \
langchain==0.1.16\
langchain-core==0.1.45\
ragas==0.1.7\
unstructured[md]==0.13.7\
pandas==1.5.3

Markdownファイルを格納したディレクトリからlangchainDirectoryLoaderを使ってDocumentとしてロードします。Ragasのデータ生成処理の過程でDocumentmetadataの"filename"というキーを参照するそうなので、別途作成しています。

from langchain.document_loaders import DirectoryLoader
loader = DirectoryLoader(data_dir)
documents = loader.load()

for document in documents:
    document.metadata['filename'] = document.metadata['source']

データ生成に使用するモデルを設定します。生成用のモデル(generator_llm)と評価用のモデル(critic_llm)、それから埋め込みモデル(embeddings)を使用します。generator_llmcritic_llmはどちらも"GPT-4 vision-preview"を使用しました。 3つのモデルを指定してragasTestsetGeneratorを作成します。

import os

from langchain_openai import AzureChatOpenAI, AzureOpenAIEmbeddings
from ragas.testset.generator import TestsetGenerator

generator_llm = AzureChatOpenAI(
    model="gpt-4v",
    azure_endpoint=os.environ.get("AZURE_OPENAI_API_BASE"),
    api_version="2024-02-01",
    max_tokens=1000
)
critic_llm = AzureChatOpenAI(
    model="gpt-4v",
    azure_endpoint=os.environ.get("AZURE_OPENAI_API_BASE"),
    api_version="2024-02-01",
    max_tokens=500
)
embeddings = AzureOpenAIEmbeddings(
    model="text-embedding-ada-002",
    api_version="2024-02-01",
    azure_endpoint=os.environ.get("AZURE_OPENAI_API_BASE"),
)

generator = TestsetGenerator.from_langchain(
    generator_llm,
    critic_llm,
    embeddings
)

TestsetGeneratorはそのままだと英語でテストデータを生成します。adaptというメソッドを呼び出すことで、生成される言語を設定することが出来ます。その際にevolutionsを指定していますが、これは先ほど記述したRagasのデータ生成過程における"Evolution"で具体的に実行される処理を表しており、それらも合わせて対応言語を"japanese"にします。

こちらを参考にしています。

docs.ragas.io

from ragas.testset.evolutions import simple, reasoning, multi_context

generator.adapt(
    language="japanese",
    evolutions=[simple, reasoning, multi_context]
)

テストデータの生成処理を開始します。test_sizeで生成するテストデータの数を指定することが出来ます。私の実行環境ではtest_sizeを50にして実行すると結果が返って来なくなり、またコストの発生を抑えるためにもまず小さいサイズから試していくのがよいと思います。distributionsでは"Evolution"の実行割合を設定することが出来ます。

testset = generator.generate_with_langchain_docs(
    documents, 
    test_size=10, 
    distributions={
        simple: 0.6, 
        reasoning: 0.2, 
        multi_context: 0.2
})

テストデータはpandasDataFrameに変換することが出来ます。

testset_df = testset.to_pandas()

生成されたテストデータは次のようになりました。

生成されたテストデータの一部分

"question"はたまに重複するものが生成されてしまうのですが、自然な内容が生成されています。それに対する"ground_truth"もたまに"nan"のように回答が生成出来ていない場合もありますが、それなりの品質になっていると感じました。なにより全自動でここまで生成してくれるのはありがたいですし、気になるところは人手で修正を加えれば、テストデータとして使えるのでは、と思いました。

今回は生成されたテストデータをそのまま使用します。テストデータの中の"question"と"ground_truth"をRAGのパイプラインの検証に使用します。

RAGパイプライン

ここからは基本的なRAGのパイプラインをlangchainで組んでいきます。

使用するモデルは以下のようにしました。

import os
from langchain_openai import AzureChatOpenAI, AzureOpenAIEmbeddings

llm = AzureChatOpenAI(
    model="gpt-35-turbo-16k",
    azure_endpoint=os.environ.get("AZURE_OPENAI_API_BASE"),
    api_version="2024-02-01",
)
embeddings = AzureOpenAIEmbeddings(
    model="text-embedding-ada-002",
    api_version="2024-02-01",
    azure_endpoint=os.environ.get("AZURE_OPENAI_API_BASE"),
)

テストデータを生成した時と同様、ブログ下書き用のMarkdownをロードします。

from langchain.document_loaders import DirectoryLoader
from langchain_chroma import Chroma
from langchain_text_splitters import CharacterTextSplitter

loader = DirectoryLoader(data_dir)
documents = loader.load()

text_splitter = CharacterTextSplitter(
    separator="\n\n",
    chunk_size=chunk_size,
    chunk_overlap=chunk_overlap,
    length_function=len,
    is_separator_regex=False,
)

splitted_documents = text_splitter.split_documents(documents)
db = Chroma.from_documents(splitted_documents, embeddings)
retriever = db.as_retriever()

次にLangChainのLCEL(LangChain Expression Language)を使ってパイプラインを構築します。

from operator import itemgetter

from langchain_core.prompts import ChatPromptTemplate, HumanMessagePromptTemplate
from langchain_core.messages import SystemMessage
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough

system_message_prompt = \
    """ユーザーからの質問に、与えられた参考情報を参照して回答してください。
    参考情報を参照しても質問に答えられない場合は分からないと回答してください。"""

human_message_prompt_template = \
    """### 参考情報 ###
    {context}

    質問: {question}
    回答:"""

chat_message_prompt_template = ChatPromptTemplate.from_messages(
    [
        SystemMessage(content=system_message_prompt),
        HumanMessagePromptTemplate.from_template(human_message_prompt_template)
    ]
)

retriever_chain = itemgetter("question")|retriever

rag_chain = \
    {
        "context": retriever_chain,
        "question": RunnablePassthrough()
    }|chat_message_prompt_template|llm|StrOutputParser()

Ragasの評価時は"answer"だけでなく、関連情報の"context"も必要です。先述のコードで作成したrag_chainは"answer"しか出力出来ないので、"question"に対する"answer"と"context"の両方を取得出来る次のような関数を作成しました。

def get_answer_contexts(question: str):
    input_question = {"question": question}
    answer = rag_chain.invoke(input_question)
    # langchainのDocumentからRagas評価時に使うテキストデータだけ取り出す。
    contexts = retriever_chain.invoke(input_question)
    contexts = [c.page_content for c in contexts]
    return {"answer": answer, "contexts": contexts}

最後に先ほど作成したテストデータを読み込み、"question"から"answer"と"context"をRAGパイプラインを通して生成し、"ground_truth"と一緒にdatasetsDatasetとして保存しておきます。

from datasets import Dataset
import pandas as pd

test_data = pd.read_csv(test_data_path)
test_data = test_data.fillna("回答なし") #nanは"回答なし"に変換する
questions =test_data["question"]
ground_truths = test_data["ground_truth"]

results = [get_answer_contexts(s) for s in questions]

result_ds = Dataset.from_dict(
    {
        "question" : questions,
        "answer" : [r["answer"] for r in results],
        "contexts": [r["contexts"] for r in results],
        "ground_truth": ground_truths
    }
)

result_ds.save_to_disk(f"evaluate_datset_{chunk_size}_{chunk_overlap}")

検証

ここからはRagasを使ってRAGのパイプラインの検証を行います。比較対象としてVectorDBを作る際の"chunk_size"と"chunk_overlap"をそれぞれ{"chunk_size": 1000, "chunk_overlap": 200}{"chunk_size": 500, "chunk_overlap": 20}にした2パターンで検証してみました。

まずragasの検証項目の設定を行います。

from ragas.metrics import (
    context_precision,
    answer_relevancy,
    faithfulness,
    context_recall,
)
from ragas.metrics.critique import harmfulness

metrics = [
    faithfulness,
    answer_relevancy,
    context_recall,
    context_precision,
]

検証に使用するモデルを設定します。

import os
from langchain_openai import AzureChatOpenAI, AzureOpenAIEmbeddings

llm = AzureChatOpenAI(
    model="gpt-4v",
    azure_endpoint=os.environ.get("AZURE_OPENAI_API_BASE"),
    api_version="2024-02-01",
    max_tokens=1000
)
embeddings = AzureOpenAIEmbeddings(
    model="text-embedding-ada-002",
    api_version="2024-02-01",
    azure_endpoint=os.environ.get("AZURE_OPENAI_API_BASE"),
)

検証を行います。まず{"chunk_size": 1000, "chunk_overlap": 200}の設定で実行した場合の検証を行います。

from datasets import Dataset
from ragas import evaluate

chunk_size = 1000
chunk_overlap = 200

dataset = Dataset.load_from_disk(f"evaluate_datset_{chunk_size}_{chunk_overlap}")
result_ptn1 = evaluate(
    dataset, metrics=metrics, llm=llm, embeddings=embeddings
)

print(f"chunk_size: {chunk_size}, chunk_overlap: {chunk_overlap}")
print(result_ptn1)

結果は次のようになりました。

chunk_size: 1000, chunk_overlap: 200
{'faithfulness': 0.9857, 'answer_relevancy': 0.8812, 'context_recall': 0.9000, 'context_precision': 0.8056}

同様に{"chunk_size": 500, "chunk_overlap": 20}の設定で実行した場合の検証を行うと、次のような結果になりました。

chunk_size: 500, chunk_overlap: 20
{'faithfulness': 0.9361, 'answer_relevancy': 0.6987, 'context_recall': 0.7556, 'context_precision': 0.7778}

比較しやすいように、レーダーチャートで表示してみます。

import plotly.graph_objects as go

fig = go.Figure()

fig.add_trace(go.Scatterpolar(
      r=[r[1] for r in result_ptn1.items()],
      theta=[r[0] for r in result_ptn1.items()],
      fill='toself',
      name='chunk_size=1000, overlap=200'
))
fig.add_trace(go.Scatterpolar(
      r=[r[1] for r in result_ptn2.items()],
      theta=[r[0] for r in result_ptn2.items()],
      fill='toself',
      name='chunk_size=500, overlap=20'
))

fig.update_layout(
  polar=dict(
    radialaxis=dict(
      visible=True,
      range=[0, 1]
    )),
  showlegend=True
)

fig.show()

レーダーチャート

レーダーチャートを見ると、全体的に{"chunk_size": 1000, "chunk_overlap": 200}の方が良い結果と言えそうです。検証結果は全体を通じた値を見ることも出来ますが、result_ptn1.to_pandas()を実行するとpandasDataFrameに変換され、レコード単位で評価値を見ることが出来ます。

レコード単位での評価値の確認

レコード単位で見てみると、"answer_relevancy"のスコアが{"chunk_size": 1000, "chunk_overlap": 200}{"chunk_size": 500, "chunk_overlap": 20}で大きく異なるレコードが見つかりました。それがこちらです。

RAGパイプラインの比較

{"chunk_size": 500, "chunk_overlap": 20}は回答が生成出来ていないようです。このように全体の評価からレコード単位の評価まで、Ragasを使うことで行うことが出来ます。

まとめ

今回はRAGのパイプラインの評価をRagasというフレームワークで行った話をご紹介しました。検証に使用するテストデータの生成からRagasで行うことが出来るのがとても便利だと思いますし、これなら誰が実行しても同じような精度でパイプラインの評価を行うことが出来そうです。RAGは構成要素を入れ替えることで様々なパイプラインを組むことが出来るため、こういった共通化出来る精度指標があると、検証がはかどりそうです。