こんにちは、CCCMKホールディングスTECH LAB三浦です。
先日は母の日でした。母の日って海外が発祥のイベントなんですよね。世界ではどんな風に母の日をお祝いしているのか、一度調べてみたいな、と思いました。
Large Language Model(LLM)が学習していない情報について回答させるテクニックとして、Retrieval Augmented Generation(RAG)があります。RAGが必要になるケースは結構あるのですが、RAGによってどれだけ質問に正しく回答出来ているのかという定量的な評価が出来ていないな、という課題感を持っています。
RAGはベースのプロンプトの作り方や関連情報の格納の仕方などに結構工夫出来るポイントがあるのですが、それらの工夫を施すことによってRAGの性能がどれだけ良くなったのかをこれまで人の感覚に基づいて評価してきました。この方法だと評価が人によってバラつきますし、何より作業が大変です。
RAGの評価を自動化出来て、評価の結果が数値化されて誰が見ても善し悪しが判断できる。こういった仕組みを作ることが出来れば様々なRAGのパターンから評価に基づいて最適なものを選ぶことが出来るようになります。
最近使ってみたRagasというフレームワークを使用することで、いずれそういったことが実現できるかも、と感じました。今回はRagasを使ってRAGのパイプラインを評価してみたので、その内容についてまとめてみたいと思います。
Ragas
RagasはRAGを構成する一連の処理(パイプライン)を評価するためのフレームワークです。
いくつかの評価値を簡単に計算できる仕組みが搭載されています。また、評価をするために必要なテストデータを自動生成する機能もあります。
まず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のペアを生成しているようです。
実験
ここからは実際のデータからRagasを使って検証に必要なテストデータを生成し、そのテストデータを使ってRAGのパイプラインの評価値を計算する流れについてまとめていきます。
使用するデータ
使用したデータは、このブログに掲載されているいくつかの記事の、下書きの段階で作成したMarkdown形式のファイルです。それらを1つのフォルダに格納しておきます。
実行・・・の前に
Ragasではデータ作成時や評価値計算で1つのコマンドを実行すると裏側で複数のプロンプトがLLMに渡され実行されることがあります。特に"gpt-4"といったコストが高いLLMを指定して実行すると想像以上のコストが発生します。常にコストを確認しながら、少しずつ試していく必要があると思いました。
テストデータ生成
最初にRAGの評価に使用するテストデータを作成します。手順はこちらを参考にしています。
以下のライブラリを使用しました。
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ファイルを格納したディレクトリからlangchain
のDirectoryLoader
を使ってDocument
としてロードします。Ragasのデータ生成処理の過程でDocument
のmetadata
の"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_llm
とcritic_llm
はどちらも"GPT-4 vision-preview"を使用しました。
3つのモデルを指定してragas
のTestsetGenerator
を作成します。
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"にします。
こちらを参考にしています。
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 })
テストデータはpandas
のDataFrame
に変換することが出来ます。
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"と一緒にdatasets
のDataset
として保存しておきます。
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()
を実行するとpandas
のDataFrame
に変換され、レコード単位で評価値を見ることが出来ます。
レコード単位で見てみると、"answer_relevancy"のスコアが{"chunk_size": 1000, "chunk_overlap": 200}
と{"chunk_size": 500, "chunk_overlap": 20}
で大きく異なるレコードが見つかりました。それがこちらです。
{"chunk_size": 500, "chunk_overlap": 20}
は回答が生成出来ていないようです。このように全体の評価からレコード単位の評価まで、Ragasを使うことで行うことが出来ます。
まとめ
今回はRAGのパイプラインの評価をRagasというフレームワークで行った話をご紹介しました。検証に使用するテストデータの生成からRagasで行うことが出来るのがとても便利だと思いますし、これなら誰が実行しても同じような精度でパイプラインの評価を行うことが出来そうです。RAGは構成要素を入れ替えることで様々なパイプラインを組むことが出来るため、こういった共通化出来る精度指標があると、検証がはかどりそうです。