RAG評価フレームワークのragasを使ってみた

記事タイトルとURLをコピーする

サーバーワークスの村上です。

このブログではRAGの評価フレームワークであるragasについて紹介します。

ragasとは

ragasはRAG(検索拡張生成)を評価するためのフレームワークです(RAG Assessmentが由来)。

github.com

RAGとは大規模言語モデル(LLM)の外部に情報の保管場所を作っておき、ユーザーの質問に関連する情報を検索・取得したうえで、LLMに入力し回答を求める手法です。

過去のブログに絵も載せていますのでご参照ください。

blog.serverworks.co.jp

RAGの評価イメージ

例えば、お使いのRAGアプリケーションで使うLLMはAnthropicのClaudeが良いのか、またはMetaのLlamaが良いのか?といった判断に有用です。

以下は私の勝手なイメージ図ですが、ragasではコンテキストや質問のサンプル回答と、LLMの回答を比較して様々なメトリクスを算出することができます。

評価のイメージ

ragasでできること概要(忙しい方向けのまとめ)

  • RAGのデータソースから、評価に使用する質問(question)・コンテキスト(ground_truth_context)・サンプル回答(ground_truths)を自動生成することができます。
    • データソースはLangchainのDocument形式などで渡す必要があります。
  • RAGアプリケーションから得られた回答(answer)を使って、様々な評価メトリクスを算出することができます。
    • メトリクスはfaithfulness(忠実度)、context_precision(コンテキストの精度)、answer_relevancy(回答の関連性)などです。詳細は後述します。

このブログで検証したこと

以下の弊社ホームページについての質問に回答するRAGアプリケーションでragasを使ってみました。

www.serverworks.co.jp

検証に利用したRAG構成

ragasの利用の流れ

大きく分けて3つの工程が必要です。

  • テストデータの作成
  • RAGアプリケーションの回答を取得
  • 評価

テストデータの作成

RAGのデータソースにある情報から質問を生成します。

ragasを使うと主に以下の情報を出力してくれます。仕組みとしてはLLMが使われており、デフォルトではChatGPTが利用されますが、Amazon BedrockなどのLLMも利用可能です。

項目 説明 具体例
ground_truth_context データソースから抽出したコンテキスト (抜粋)L2グレード:フレックスタイム制
月間の所定労働時間の範囲の中で始業時間と終業時間を自分の判断で変更することができます。
1か月の中で繁閑に差がある場合など、ある日は10時間働き、他の日は6時間働くなど、柔軟にはたらき方を選択することができます
question ground_truth_contextから生成した質問 若手社員の就業体系はどのようになっていますか?
ground_truth questionの回答 (抜粋)L2グレードはフレックスタイム制で、月間の所定労働時間の範囲内で始業時間と終業時間を自分で変更でき、繁閑に応じて柔軟に働くことができます

RAGアプリケーションの回答を取得

直前の手順で得られたquestionを、お使いのRAGアプリケーションに入力し回答を得ます。

得られた回答はanswerとして、この後の評価に利用します。

項目 説明 具体例
answer RAGアプリケーションが出力したquestionの回答 (抜粋)若手社員の就業体系は以下のようになっています
L1グレード(新卒新入社員~)は定時労働制。朝10時から夜19時までの定時労働とし、時間外労働が必要な場合は上長の許可を得てから就業する。
L2グレードはフレックスタイム制。月間の所定労働時間の範囲内で始業・終業時刻を自分で調整できる。

評価

ここまでで得られた情報から評価メトリクスを算出します。

評価メトリクスは以下のとおりです。

メトリクス 算出方法 説明
Faithfulness answerとground_truth_contextから算出。0-1の範囲で高いほど優れている answerがground_truth_contextから推測される内容であるかどうか
Answer Relevancy question とanswer から算出。0-1の範囲で高いほど関連性が高い answerがquestionと関連しているかどうか
Context Precision question、ground_truth_context、ground_truthsから算出。0-1の範囲で高いほど精度が高い question、ground_truth_context、ground_truthsから、ground_truth_contextは回答の役に立ったかどうかをLLMが判定。有用だったground_truth_contextが多いほど1に近づく
Context Relevancy question とground_truth_contexts から算出。0-1の範囲で高いほど関連性が高い ground_truth_contextの文の総数に占める、回答生成に役立った文の割合
Context Recall ground_truthsとground_truth_contextから算出。0-1の範囲で大きいほど優れている ground_truthsとground_truth_contextがどの程度一致しているか
Answer semantic similarity ground_truths とanswer から算出。0-1 の範囲で高いほど整合している ground_truths とanswerがどの程度意味的に類似しているか。Embeddingを用いた類似度を計算
Answer Correctness ground_truths とanswer から算出。0-1 の範囲で高いほど整合している ground_truths とanswerで共通する内容(True Positive)、ground_truthsにのみ存在する内容(False Negative)、answerにのみ存在する内容(False Positive)からF1スコアを算出。最後にAnswer semantic similarityの値も加味されスコアが決定する

True Positiveや混合行列などの概念は過去のブログでも紹介していますので、そちらをご参照ください。

Amazon SageMaker Clarifyのバイアスレポートを活用しよう - サーバーワークスエンジニアブログ

blog.serverworks.co.jp

具体的な実装

ドキュメントに記載がありますので、正確な情報はそちらをご確認ください。

Introduction | Ragas

以下、検証で利用したコードの抜粋を交えて説明します。

テストデータの作成

デフォルトではChatGPTが利用されますが、私はAmazon BedrockのClaude v2を使いました。

htmlのロード

データソースであるhtmlファイルをLangchainのDocumentクラスに変換します。

from langchain.document_loaders import UnstructuredHTMLLoader
loader = UnstructuredHTMLLoader("./サーバーワークスのはたらき方 – 株式会社サーバーワークス.html")

data = loader.load()

LLMの定義

Amazon Bedrockを使用するための設定です。ChatGPTを利用する場合はAPIキーを設定します。

from ragas.llms import LangchainLLM
from langchain.chat_models import BedrockChat
from langchain.embeddings import BedrockEmbeddings

config = {
    "credentials_profile_name": "default", 
    "region_name": "us-west-2",  
    "model_id": "anthropic.claude-v2", 
    "model_kwargs": {"temperature": 0.1}, 
}

bedrock_model = BedrockChat(
    credentials_profile_name=config["credentials_profile_name"],
    region_name=config["region_name"],
    endpoint_url=f"https://bedrock-runtime.{config['region_name']}.amazonaws.com",
    model_id=config["model_id"],
    model_kwargs=config["model_kwargs"],
)

ragas_bedrock_model = LangchainLLM(bedrock_model)

bedrock_embeddings = BedrockEmbeddings(
    credentials_profile_name=config["credentials_profile_name"],
    region_name=config["region_name"],
)

プロンプトのカスタマイズ

この手順はオプションです。

なぜプロンプトをカスタマイズしたのかというと、テストデータ生成の過程にあるバリデーションに対応するためです。

テストデータの生成過程は以下のとおりです。

テストデータの生成過程 説明 バリデーション 具体例
データソースから抽出した情報の採点(10点満点)

デフォルトのプロンプトはこちら
詳細に説明している場合は高スコア、外部リンクや個人情報を含む場合は低スコア 回答にはスコアのみ出力する必要がある
例えばScore is 8など数値以外が含まれていた場合はInvalid Scoreとなり、以降のテストデータ生成が中止される(参考
データソースから抽出した情報: L2グレード:フレックスタイム制 月間の所定労働時間の範囲の中で始業時間と終業時間を自分の判断で変更することができます。
スコア:8.5
questionの生成

デフォルトのプロンプトはこちら
データソースから抽出した情報から質問を生成する 特になし 若手社員の就業体系はどのようになっていますか?
questionのバリデーション

デフォルトのプロンプトはこちら
- 生成したquestionの意味が理解できるか判定する。Noの場合は以降のテストデータ生成が中止される(参考
また、出力はJSONである必要がある
{"reason":"質問は文法的に正しく、追加のコンテキストなしでも理解できます。","verdict":"Yes"}
ground_truth_contextの生成

デフォルトのプロンプトはこちら
questionの答えが含まれていそうなコンテキストを抽出する 特になし " 以下の文が質問に関連すると思われます。
L1グレード(新卒新入社員~):定時労働制 朝10:00~夜19:00までの定時労働制として就業します。L2グレード:フレックスタイム制...(以下省略)
ground_truthの生成

デフォルトのプロンプトはこちら
questionとground_truth_contextから回答(ground_truth)を生成する 特になし 若手社員の就業体系は、L1グレード(新卒新入社員)は定時労働制で、朝10時から夜19時までの定時労働制です。L2グレードはフレックスタイム制で、月間の所定労働時間の範囲内で始業時間と終業時間を自分で変更でき、繁閑に応じて柔軟に働くことができます。

「データソースから抽出した情報の採点」では、LLMからの出力は数値のみである必要があります。

デフォルトのプロンプトにも一応Output the score only.と指示がありますが、これだけでは効かないケースがありました。例えばLLMが採点理由を補足してしまいInvalidとなってしまう、などです。

よってカスタマイズしたプロンプトではoutput exampleを追加するなど、数値のみ出力するよう、さらに手厚く指示しています。

次に「questionのバリデーション」では、質問の意味が理解できるかのバリデーションが入るのですが、サーバーワークスという特定の名前が含まれており理解できないという理由で判定がNoとなってしまうケースがあったので、質問が文法的に正しいのであれば判定をYesとするようカスタマイズしています。

from ragas.testset import TestsetGenerator
from langchain.prompts import HumanMessagePromptTemplate
from langchain.prompts import ChatPromptTemplate
from ragas.testset.utils import load_as_score
from ragas.utils import load_as_json
    
# スコアの数値のみ出力するように例を交えて指示
SCORE_CONTEXT = HumanMessagePromptTemplate.from_template(
    """Evaluate the provided context and assign a numerical score between 0 and 10 based on the following criteria:
1. Award a high score to context that thoroughly delves into and explains concepts.
2. Assign a lower score to context that contains excessive references, acknowledgments, external links, personal information, or other non-essential elements.
Output the score only.
Answer only numbers with a decimal point and no more than 3 characters. For example, an output should look like this.
<output example1>
7
</output example1>
<output example2>.
8.5
</output example2>
Context:
{context}
Score(Answer only numbers with a decimal point and no more than 3 characters):
"""  # noqa: E501
)
    
# 「問題文は10語以内とし、可能な限り略語を使用すること」というルールを削除
SEED_QUESTION = HumanMessagePromptTemplate.from_template(
    """\
Your task is to formulate a question from given context satisfying the rules given below:
    1.The question should make sense to humans even when read without the given context.
    2.The question should be fully answered from the given context.
    3.The question should be framed from a part of context that contains important information. It can also be from tables,code,etc.
    4.The answer to the question should not contain any links.
    5.The question should be of moderate difficulty.
    6.The question must be reasonable and must be understood and responded by humans.
    7.Do no use phrases like 'provided context',etc in the question
    8.Avoid framing question using word "and" that can be decomposed into more than one question.
    
context:{context}

Output the questiononly.
"""  # noqa: E501
)
    
# 質問が文法的に正しいのであれば判定をYesとするよう指示
FILTER_QUESTION = HumanMessagePromptTemplate.from_template(
    """\
Determine if the given question can be clearly understood even when presented without any additional context. Specify reason and verdict is a valid json format.
question: What tools does Serverworks use?
{{"reason":"Questions are grammatically correct and understandable.", "verdict": "Yes"}}
Output only JSON.
question:{question}
"""  # noqa: E501
)
    
class CustomTestsetGenerator(TestsetGenerator):
    def _seed_question(self, context: str) -> str:
        human_prompt = SEED_QUESTION.format(context=context)
        prompt = ChatPromptTemplate.from_messages([human_prompt])
        results = self.generator_llm.generate(prompts=[prompt])
        return results.generations[0][0].text.strip()
    
    def _filter_context(self, context: str) -> bool:
        """
        context: str
            The input context

        Checks if the context is has information worthy of framing a question
        """
        human_prompt = SCORE_CONTEXT.format(context=context)
        prompt = ChatPromptTemplate.from_messages([human_prompt])
        results = self.critic_llm.generate(prompts=[prompt])
        output = results.generations[0][0].text.strip()
        score = load_as_score(output)
        return score >= self.threshold
    
    def _filter_question(self, question: str) -> bool:
        human_prompt = FILTER_QUESTION.format(question=question)
        prompt = ChatPromptTemplate.from_messages([human_prompt])

        results = self.critic_llm.generate(prompts=[prompt])
        results = results.generations[0][0].text.strip()
        json_results = load_as_json(results)
        return json_results.get("verdict") != "No"

テストデータの作成

ここでは実際にhtmlファイルからquestionを生成します。

生成できるquestionにもいくつかタイプがあります。

質問のタイプ 説明
simple 通常の質問
reasoning 質問を書き換えて、それに効果的に答えるために推論が必要となるようにします
Conditioning 質問に条件付き要素を導入し、質問の複雑さを増加させます
multi_context 質問を再構成して、答えを形成するために複数の関連するセクションやチャンクからの情報が必要となるようにします

また、会話型の質問(chat_qa)を生成することも可能です。

Synthetic Test Data generation | Ragas

今回はsimpleな質問を生成することにします。

generator_llm = ragas_bedrock_model
critic_llm = ragas_bedrock_model
embeddings_model = bedrock_embeddings
    
# simpleなquestionのみ生成
testset_distribution = {
    "simple": 1,
    "reasoning": 0.0,
    "multi_context": 0.0,
    "conditional": 0.0,
}
    
# 会話型の質問の割合
chat_qa = 1.0
    
test_generator = CustomTestsetGenerator(
    generator_llm=generator_llm,
    critic_llm=critic_llm,
    embeddings_model=embeddings_model,
    testset_distribution=testset_distribution,
    chat_qa=chat_qa,
)
testset = test_generator.generate(data, test_size=1)

生成できる質問の数

また、生成できる質問の数(さきほどのコードのtest_size)には限りがあります。

具体的にはデータソース(今回の場合はhtmlファイル)から作成したDocumentの量に依存します。

裏側ではllama_indexのSimpleNodeParserが使われていて、ドキュメントをchunk_size=512で区切ったノードに区切ります(参考

生成する質問の数は、このノード数よりも少なくする必要があります(参考

生成したテストデータをCSVに保存

test_df = testset.to_pandas()
test_df.to_csv("testset.csv")

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

question ground_truth_context ground_truth question_type episode_done
若手社員の就業体系はどのようになっていますか? ['以下の文が質問に関連すると思われます。\n\nL1グレード(新卒新入社員~):定時労働制\n\n朝10:00~夜19:00までの定時労働制として就業します。\n\nL2グレード:フレックスタイム制\n\n月間の所定労働時間の範囲の中で始業時間と終業時間を自分の判断で変更することができます。\n\n1か月の中で繁閑に差がある場合など、ある日は10時間働き、他の日は6時間働くなど、柔軟にはたらき方を選択することができます。'] ['若手社員の就業体系は、L1グレード(新卒新入社員)は定時労働制で、朝10時から夜19時までの定時労働制です。L2グレードはフレックスタイム制で、月間の所定労働時間の範囲内で始業時間と終業時間を自分で変更でき、繁閑に応じて柔軟に働くことができます。'] simple TRUE

補足ですが、episode_doneは、生成される質問が会話型であり、かつ最初の質問である場合に False に設定されます(参考)。

それ以外の場合 True に設定されます。今回はtest_size=1としたためかTrueになりました。

RAGアプリケーションの回答を取得

Langchainのドキュメントを参考にシンプルなRAGアプリケーションを作成します。

import pandas as pd
    
from langchain.document_loaders import UnstructuredHTMLLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.vectorstores import FAISS
from langchain.chat_models import BedrockChat
from langchain.embeddings import BedrockEmbeddings
from langchain.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough
from langchain_core.output_parsers import StrOutputParser
    
loader = UnstructuredHTMLLoader("html/サーバーワークスのはたらき方 – 株式会社サーバーワークス.html")
pages = loader.load_and_split()
    
text_splitter = RecursiveCharacterTextSplitter(chunk_size=400, chunk_overlap=0)
docs = text_splitter.split_documents(pages)
print(f"type(docs): {type(docs)}")
    
config = {
    "credentials_profile_name": "default",  
    "region_name": "us-west-2",  
    "model_id": "anthropic.claude-v2",  
    "model_kwargs": {"temperature": 0.1}, 
}
    
bedrock_embeddings = BedrockEmbeddings(
    credentials_profile_name=config["credentials_profile_name"],
    region_name=config["region_name"],
)
    
vectorstore = FAISS.from_documents(
    docs, embedding=bedrock_embeddings
)
retriever = vectorstore.as_retriever()
    
template = """Answer the question based only on the following context:
{context}
    
Question: {question}
"""
    
prompt = ChatPromptTemplate.from_template(template)
    
bedrock_model = BedrockChat(
    credentials_profile_name=config["credentials_profile_name"],
    region_name=config["region_name"],
    endpoint_url=f"https://bedrock-runtime.{config['region_name']}.amazonaws.com",
    model_id=config["model_id"],
    model_kwargs=config["model_kwargs"],
)
    
model = bedrock_model

chain = (
    {"context": retriever, "question": RunnablePassthrough()}
    | prompt
    | model
    | StrOutputParser()
)
    
# さきほどの手順で作成したテストデータにanswerカラムを追加し、RAGの回答を保存する
df = pd.read_csv('./testset.csv')
df['answer'] = 0
    
for q in df['question']:
    df.loc[df['question'] == q, 'answer'] = chain.invoke(q)
print(df)
df.to_csv('./testset_with_answer.csv')

この手順で新たにtestset_with_answer.csvを作成しました。末尾のカラムにRAGアプリケーションから得られたanswerが追加されています(見づらいのでquestion_type とepisode_done のカラムは省略)。

question contexts ground_truths answer
若手社員の就業体系はどのようになっていますか? ['以下の文が質問に関連すると思われます。\n\nL1グレード(新卒新入社員~):定時労働制\n\n朝10:00~夜19:00までの定時労働制として就業します。\n\nL2グレード:フレックスタイム制\n\n月間の所定労働時間の範囲の中で始業時間と終業時間を自分の判断で変更することができます。\n\n1か月の中で繁閑に差がある場合など、ある日は10時間働き、他の日は6時間働くなど、柔軟にはたらき方を選択することができます。'] ['若手社員の就業体系は、L1グレード(新卒新入社員)は定時労働制で、朝10時から夜19時までの定時労働制です。L2グレードはフレックスタイム制で、月間の所定労働時間の範囲内で始業時間と終業時間を自分で変更でき、繁閑に応じて柔軟に働くことができます。'] 文書によると、若手社員の就業体系は以下のようになっています。

- L1グレード(新卒新入社員~)は定時労働制。朝10時から夜19時までの定時労働とし、時間外労働が必要な場合は上長の許可を得てから就業する。

- L2グレードはフレックスタイム制。月間の所定労働時間の範囲内で始業・終業時刻を自分で調整できる。

- L3グレードからは裁量労働制が適用され、成果に応じて評価される。

- 若手社員にはグレードに応じた就業体系が設けられている。

評価

いよいよ評価をしていきます。ここまでAmazon BedrockのClaude v2を使用してきましたが、評価には違うLLMを使いたいのでChatGPTを利用することにします。

OpenAIのAPIキーをセットしておきます。

データセットを作成

Huggingface Datasetsを作成します。今回はCSVがありますので、CSVから作成します。

from datasets import load_dataset
dataset = load_dataset("csv", data_files="testset_with_answer.csv")

メトリクスを定義

どのメトリクスを使用するのか定義します。

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,
    harmfulness,
]

メトリクスの算出

定義済みのメトリクスを算出します。併せてCSVに保存します。

from ragas import evaluate
   
result = evaluate(
    dataset["train"],
    metrics=metrics,
)

df = result.to_pandas()
df.to_csv("result.csv")

算出されたメトリクスはこのようになりました。

faithfulness answer_relevancy context_recall context_precision harmfulness
1 0.97898345 1 0 0

まとめ

以上、ragasの利用の流れを説明させていただきました。

日々新しいモデルが生まれる昨今ですので、どのモデルが良いのか比較するには有用なツールかと思いました。

また、RAGは検索システムがとても重要なので、そのあたりも気を付ける必要があります。

blog.serverworks.co.jp

以上、長いブログでしたが読んでいただきありがとうございました。

村上博哉 (執筆記事の一覧)

2020年4月入社。機械学習が好きです。記事へのご意見など:hiroya.murakami@serverworks.co.jp