Re:ゼロから始めるML生活

どちらかといえばエミリア派です

RAGのお試しテストケース用データセット作成

RAGをやっていて精度検証用データセットの作成で困ったことはないですか?

精度評価用のデータセットなんて作成することだけでも超めんどくさそうじゃないですか? ということで、今回はこの簡易精度評価に使うデータセット作成をやってみようと思います。

RAGの精度評価のおさらい

あまり細かい話はしませんが、RAGではRetrieval フェーズとGenerationフェーズの2つの段階が存在し、多くの場合それら両方の精度を評価したくなります。(多分みなさんそうだと思ってますが、もし違ったらこのブログ意味ないと思うんでご放念ください)

www.nogawanogawa.com

大体の場合、こんな感じの指標を使って評価するんじゃないでしょうか?

  • Generation
    • Faithfulness
    • Answer relevancy
  • Retrieval
    • Context precision
    • Context recall
    • Context relevancy
    • Context entity recall

この辺の指標をいい感じに計算できるデータセットがほしいわけです。

データセット作成を考える

じゃあどんなふうにして評価用データセットを作成したら良いかって話について考えてみます。

前提

RAGASとかでも自動でデータセットを作成できたりします。

docs.ragas.io

この辺はあんまり詳しくないですが、もうちょっと状況に応じてテストデータを作成したくなる場面もあります。 正直テストデータ自体の品質には議論の余地はありますが、ここではRAGASなどを使用せずに期待する形でテストデータセットを作成することを目指したいと思います。

Retrieval

Retrievalだと多くの場合、precisionやrecallのような指標が用いられますが、これを測定するためには「質問」の回答に必要となる「根拠となるテキスト」が必要になります。

chunkのidなどでも良いのかも知れませんが、場合によってはchunk分割の方法を改善する場合は評価のたびにchunkのidを振り直す手間が必要になるので、「根拠となるテキスト」を用意しておいてそれがretrieveされたchunkに含まれているかで動的にラベリングするのが良さそうに思えます。

Generation

RAGASとかだと多くの場合、Retrieveされたテキストに対して関連しているか、ハルシネーションしていないか、などが評価されます。 ただ現実問題として、最終的な回答が想定回答とどれだけ内容が合致しているかのほうが興味があるんじゃないでしょうか?

こういった最終的に回答できているかを評価するためには「質問」に対応する「模範解答」が必要であると考えられます。

要するに

まとめると、テスト用のデータセットの中から下記のような項目が与えられれば評価できるはずです。

  • 質問
  • Retrieval評価のために必要な情報
    • 模範解答のために必要な根拠となる本文のテキスト
  • Generation評価のために必要な情報
    • 回答例

RAGで使用する本文をもとにこの内容のテストデータがあれば精度とかは確認できそうです。 これを手動で作成するのも一つの方法ですが、めんどくさがり屋のわたしとしてはこれもAIにやってほしいわけです。

方針

というわけで、こんな感じにしたら自動化できるのでは?と思ったわけです。

手順としてはこんな感じにしようかと。

  1. テスト対象となる文書を決める
  2. その文書を基にテストケースのデータをLLMを通じて自動生成する
  3. テスト対象の文書を使って普通にRAGを実装
  4. テストケースを使用してRAGの評価を実施

これなら工数をかけずに簡易テストケースを作成することができるはずですし、やりたい評価が決まっていればそれをプロンプトに反映させることでテストケースもカスタマイズできるので、柔軟性も高そうです。

場合によっては一部人間が作ったデータを混ぜても良いかもしれません。LLMがテストケースを作るとどうしても回答しやすい質問になりやすいので、そのあたりは人間が実態に即してテストケースを追加すれば良いと思います。

試しにデータセットを作って評価してみる

ぶっちゃけ理屈とかはどうでも良いので、実際に試しにデータセットを作ってやってみましょう。

データセット作成

データセット作成に関してはこんな感じにしてみました。 要するにテストデータセットを

json_schema = {
  "$schema": "http://json-schema.org/draft-07/schema#",
  "type": "object",
  "properties": {
      "question_and_answers": {
          "type": "array", 
          "items": {
            "type": "object",
              "properties": {
                "question": {
                  "type": "string",
                },
                "reason_text": {
                  "type": "string",
                },
                "answer": {
                  "type": "string",
                },                  
              },
          },
      }
  },
  "required": ["question"]
}

system_message = """\
あなたは優秀な国語の先生として振る舞ってください。

これから文書を入力します。
与えられた文書に関して問題(qusetion)と模範解答(answer)、模範解答を導くのに参考にしなければならない箇所(reason_text)を10個作ってください。

## 条件
- 問題(question)は、与えられた文書中の模範解答を導くのに参考にしなければならない箇所(reason_text)無しではと回答できない質問にし、複数の模範解答が想定される問題にしないでください
- 模範解答(answer)は、与えられた文書中の模範解答を導くのに参考にしなければならない箇所(reason_text)に基づくものにしてください
- 模範解答を導くのに参考にしなければならない箇所(reason_text)は、与えられた文書から該当する箇所を書かれている通りに抜き出してください
"""


completion = client.chat.completions.create(
    model="gpt-4o-mini",  # モデルの指定
    messages=[
        {"role": "system", "content": system_message},
        {"role": "user",
         "content": [
             {"type": "text", "text": text}
         ],
         }
    ],
    functions=[
        {"name": "question_and_answers", "parameters": json_schema}
    ],
    function_call={"name": "question_and_answers"},

)

results = completion.choices[0].message.function_call.arguments

こんな感じにすればとりあえずほしかったテストケースは作成できます。

評価

次はこのテストケースを使用してどんな感じで評価するのかやってみようと思います。

  • Generation
    • Answer Correctness: 事前に用意した模範解答とRAGによる回答が同じ内容になっているか
  • Retrieval
    • Precision
    • Recall
    • NDCG

こんな感じに、今回用にカスタマイズしてやってみます。

Retrieval評価時

模範解答作成時に模範解答のために必要な根拠となる本文のテキストが本文の内容を正確に抽出できていれば良いですが、必ずしもそうなっていないことがあります。(、と, が変わってしまったり、改行コードが入ったり抜けたり、細かいところは変わったりするので)

RAGのRetrieval時にhitしたchunkが模範解答のために必要な根拠となる本文のテキストとぴったり一致するように分割されていないこともあるので、少なくとも完全一致や含有によって正解chunkを特定するのはちょっとむずかしそうです。

ということで、模範解答のために必要な根拠となる本文のテキストがretrievalされたchunkに含まれているかをLLMを使って判定しようと思います。

#@title chunkが本来の参照テキストと関連しているか判定する
def eval_chunk_relevance(reference, true_reason_text):
  json_schema = {
    "$schema": "http://json-schema.org/draft-07/schema#",
    "type": "object",
    "properties": {
        "relevance": {
            "type": "boolean", 
        }
    },
    "required": ["relevance"]
  }

  system_message = """\
これから文書を入力します。
text_1に記述されている内容がtext_2に含まれている場合はTrue, 含まれていない場合はFalseと返してください。
"""
  message = f"""\
## text_1
{true_reason_text}

## text_2
{reference}
"""
  completion = client.chat.completions.create(
      model="gpt-4o-mini",  # モデルの指定
      messages=[
          {"role": "system", "content": system_message},
          {"role": "user",
          "content": [
              {"type": "text", "text": message}
          ],
          }
      ],
      functions=[
          {"name": "eval_relevance", "parameters": json_schema}
      ],
      function_call={"name": "eval_relevance"},

  )

  results = completion.choices[0].message.function_call.arguments
  return json.loads(results)["relevance"]

Generation評価時

模範解答とRAGによる回答間に関しても一言一句同じになることはまずありえないので、双方の内容が一致しているかもLLMで判定してあげることにします。

def eval_answer_correctness(output, answer):
  json_schema = {
    "$schema": "http://json-schema.org/draft-07/schema#",
    "type": "object",
    "properties": {
        "correct": {
            "type": "boolean",
        }
    },
    "required": ["correct"]
  }

  system_message = """\
これから文書を入力します。
text_1に記述されている内容とtext_2に記述されている内容が同じ内容の場合はTrue, 異なる内容の場合はFalseと返してください。
"""
  message = f"""\
## text_1
{output}

## text_2
{answer}
"""
  completion = client.chat.completions.create(
      model="gpt-4o-mini",  # モデルの指定
      messages=[
          {"role": "system", "content": system_message},
          {"role": "user",
          "content": [
              {"type": "text", "text": message}
          ],
          }
      ],
      functions=[
          {"name": "eval_correctness", "parameters": json_schema}
      ],
      function_call={"name": "eval_correctness"},

  )

  results = completion.choices[0].message.function_call.arguments
  return json.loads(results)["correct"]

Arize phoenixできれいにモニターしてみる

最後にテストの評価をいい感じにブラウザで見てみようと思います。 これは完全におまけなのでやらなくて全然OKです。

Arize Phoenixに今回独自(?)に計算した指標を追加するにはこんな感じにしてあげれば追加できます。

from phoenix.trace import DocumentEvaluations, SpanEvaluations

px.Client().log_evaluations(
    SpanEvaluations(dataframe=ndcg_at_2, eval_name="ndcg@2"),
    SpanEvaluations(dataframe=precision_at_2, eval_name="precision@2"),
    DocumentEvaluations(dataframe=retrieved_documents_df, eval_name="relevance"),
)
from phoenix.trace import SpanEvaluations

px.Client().log_evaluations(
    SpanEvaluations(eval_name="QA Correctness", dataframe=queries_df),
)

画面でみるとこんな感じです。

個別にこうやって確認できるとちょっと嬉しいことがあるかもしれないです。

あとはRetrievalを改善するなりGenerationを改善するなりして、これらの指標が改善するかを見ていけば効率よく改善を回していけそうですね。

使ったnotebook

今回使用したnotebookはこちらです。

感想

以上、評価用データセットを作成するのがあまりに手間に感じたので、「いっそのことAIにやらせてしまってはどうか?」とやってみた記事でした。

もし「もっと良い方法あるんだけどなー」ってコメントがある人がいたらぜひ教えて下さい。