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

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

続:RAGの評価をいい感じにできるようにしたい

前にRAGの評価をいい感じにできないか試行錯誤してみてました。

www.nogawanogawa.com

ただ、その後使ってみてやっぱりArize Phoenixに依存するとなにかとしんどい感じがしたので、特にツールを使用せず普通のコードだけで評価をいい感じにできるように色々やってみようと思います。

Arize Phoenixを使ってわかった辛いポイント

前回からやってみて気付いたしんどいポイントはだいたい下記です。

  1. Arize Phoenix自体に依存関係が多い
    • LlamaIndexの開発が非常に早い関係上、今まで使っていたコードが壊れて使えなくなることがしばしば
      • 直すのもしんどい
    • 利用しているライブラリのクセが強め(OpenTelemetoryとか使ってるので)
      • 別のライブラリをインストールしようとすると依存関係に引っかかることもある
  2. 永続化が単純にめんどくさい
    • Google Colabで永続化できなくもないが、単純にめんどくさい
    • LlamaTraceを使って楽することも考えたが、自分の用途だとProjectを分割できないとしんどい

まあLLM as a Judgeをやった結果がちゃんと残っていれば最低限はOKなので、別にCSV・スプレッドシートで管理できてれば良いと感じるようになったわけです。

RAG評価になにが必要か

何度も登場していますが、RAGの評価をやるのであれば

  • 検索
  • 生成

の両方の性能を横に並べて評価したいです。これらの性能を手動で管理していくことを考えてみます。

評価用データセット

検索にしろ生成にしろ、どちらの性能を測定するにしても評価用のデータセットがある方がうれしいです。 評価用データセットがなくても計算できる指標もありますが、やはり正解からどの程度外れているかがわからないと改善できない部分が大きいです。

そのため、自動で評価用データセットは用意できるようにしておきたいですね。 そのうえで、場合によっては手動で評価用データセットをチューニングしていくスタイルが現実的なのかなという気がしています。

検索性能

検索性能として多くの場合

  • precision
  • recall
  • ndcg

あたりを気にするんじゃないでしょうか。検索の自動評価ができるツールの具体的な評価指標をみてもだいたいこの辺が載っていることが多い気がします。

この内、必ずしも正解contextが一つではないこともあり、場合によっては正解となるcontextがいくつあるかわからないケースもあります。(正解のコンテキストはわかっているが、他にも正解のコンテキストがあるかもしれない場合)

そのため、最近はprecisionとndcgが計算されていれば基本的には悪くないんじゃないかと思っています。 ただし、chunkのサイズをいじったりすることがある関係上、動的に正解contextを判断できることが求められます。 そのため、正解chunkかどうかの判定についてはLLM as a judge でやらないと厳しいなと思います。

生成性能

生成も検索と同様に評価用データセットの回答と似ているかどうかで判断することが良いと思います。 ただし、必ずしも模範解答だけが正解ではないケースもあります。 そのため、模範解答との一致度合いだけでなく、生成結果をより多面的に判断していく必要があります。

場合によっては、言い回しや正解不正解に関わらず引き当てたchunkをもとにハルシネーションを判断したりもするので、生成ではそういった評価も必要に応じて行っていくことになります。

実装

評価用データセット作成

評価用データセット作成に関しては過去にもトライしてみました。

www.nogawanogawa.com

多分下記のようなデータが得られれば評価はできます。

id input correct_answer correct_context
(テストケースのid) (テストケースの問題文) (模範解答) (回答を作るのに必要なコンテキスト)

前回のやり方で改善点としてパッと思いつくのは

  • 正解となるコンテキストが1つではないケースが考慮されていない
    • retrievalの性能評価時に変なことになる恐れ
  • LLMが回答を作りやすい問題になりがち
    • LLMにとって扱いやすい問題なので人間から見ると簡単なものもある
  • 複数のdocumentがあるときに、質問が曖昧になることがある
    • どの文書に対する質問なのか人間でも回答にこまる質問になっている事がある

などでしょうか。

改良の余地はありますが、それは追々考えていこうと思います。 一旦前回のスクリプトを使いまわしていこうと思います。

検索性能

検索性能を測定するには評価用データセットに加えて、実際の実験時のログとして下記のような情報があれば計算できます。

question document_score reference
(テストケースの問題文) (検索時のscore) (検索されたcontext)

これらがあれば検索によるcontextと正解contextが一致しているかは文字列一致でも良いですしLLMを使っても良さそうです。 検索によるcontextと正解contextを一致判定すれば、precision, ndcgは計算することができそうです。 recallについては正解contextの総数がわかっていないと行けないので評価用データセットを作成するのが大変ですが、できなくはなさそうですね。

def eval_chunk_relevance(
    question: str,
    true_reason_text: str,
    correct_answer: str,
    rag_context: str,
  ):
  json_schema = {
    "$schema": "http://json-schema.org/draft-07/schema#",
    "type": "object",
    "properties": {
        "relevance": {
            "type": "boolean",
        },
        "explanation": {
            "type": "string",
        }
    },
    "required": ["relevance"]
  }

  system_message = f"""\
あなた(evaluation-assistant)には別のアシスタント(retrieval-assistant)の検索した結果を評価していただきます。

以下に示す質問に対して期待するコンテキストの情報をもとに、期待する回答をretrieval-assistantが出力することを期待しています。

### 質問
{question}

### 期待するコンテキスト
{true_reason_text}

### 期待する回答
{correct_answer}
"""

  user_message = f"""\
retrieval-assistantが引き当てたコンテキストが期待するコンテキストに合致しているかをtrue, falseで判別してください。

## retrieval-assistantが引き当てたコンテキスト
{rag_context}

## Output
relevance:
explanation:
"""
  completion = client.chat.completions.create(
      model="gpt-4o-mini",  # モデルの指定
      messages=[
          {"role": "system", "content": system_message},
          {"role": "user",
          "content": [
              {"type": "text", "text": user_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)

生成性能

生成に関しては最低限下記の情報があればなんとかなります。

input output
(テストケースの問題文) (生成された回答)

生成はこれを検証用データセットの模範解答が一致しているかをLLMを使えばできそうです。

def eval_chunk_relevance(
    question: str,
    true_reason_text: str,
    correct_answer: str,
    rag_output: str,
  ) -> dict[str, str | float]:
  json_schema = {
    "$schema": "http://json-schema.org/draft-07/schema#",
    "type": "object",
    "properties": {
        "score": {
          "type": "number",
          "format": "float",
        },
        "reason": {
            "type": "string",
        }
    },
    "required": ["score", "reason"]
  }

  system_message = f"""\
あなた(evaluation-assistant)には別のアシスタント(suggestion-assistant)のメッセージを評価していただきます。

以下に示す質問に対してコンテキストの情報をもとに、期待する回答をsuggestion-assistantが出力することが期待しています。

### 質問
{question}

### コンテキスト
{true_reason_text}

### 期待する回答
{correct_answer}
"""

  user_message = f"""\
suggestion-assistantの出力がどの程度期待する回答に合致しているかで0.0〜1.0の範囲でscoreをつけてください。

## 点数の基準
- 1.0: 期待する回答の内容と合致している
- 0.8: 期待する回答の内容とおおよそ合致していて、わずかに異なっている
- 0.5: 期待する回答の内容と部分的に一致している
- 0.2: 期待する回答の内容とおおよそ異なっていて、ごく一部だけ合致している
- 0.0: 期待する回答の内容と完全に異なっている

## suggestion-assistantの出力
{rag_output}

## Output
score:
reason:
"""
  completion = client.chat.completions.create(
      model="gpt-4o-mini",  # モデルの指定
      messages=[
          {"role": "system", "content": system_message},
          {"role": "user",
          "content": [
              {"type": "text", "text": user_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)

試しに作ったnotebook

試しに作るとこんな感じでしょうか。 ひとまずArize Phoenixは引き剥がした状態で評価できるようになりました。

感想

ということで、ここ数ヶ月格闘したArize Phoenixから離れて自力でRAGの評価結果を管理することにしました。 ライブラリの依存関係がスッキリしたり安定してきたらLLMOps系ツールには再チャレンジしたいとはおもいますが、一旦は独力でなんとかしていこうと思います。

評価周りで次の改善としては、評価をしながら評価用データセットの問題・模範解答を見直しする機能が必要そうですね。 問題が簡単だったり、模範解答が正しくなかったりすることがあるので、実験しながら見直すことで品質の良い評価用データセットにしていくのはあると嬉しそうです。