🦫

コード解説:複数LLMの回答を比較しよう

2025/02/16に公開

こんにちは、あるいはこんばんは。
前回は、RAGシステムの構築プロセスコンペの背景をざっくり紹介しました。今回は、その実装例となるコードを元に「複数のLLM(Large Language Model)から回答を取得し、どのモデルが最も精度が高いかを判定する」流れを解説します。


全体の流れ

  1. 共通プロンプト(システムプロンプト)の設定
  2. 各LLM(Azure、Llama、Qwen)への問い合わせ関数の用意
  3. ダミー参照情報の生成(実際はRAGで該当部分を検索)
  4. CSVに入っている質問と正解を順に読み込む
  5. LLMの回答を取得
  6. 回答と正解のテキストを埋め込みベクトル化
  7. コサイン類似度で各回答の精度を評価
  8. モデルごとの平均スコアを出し、最も高いモデルを表示

コード分解

ライブラリのインポート部分

import requests
import json
import csv
import io
import numpy as np
import urllib3
  • requests … API呼び出し用
  • json … JSONデータのパース
  • csv/io … CSVデータを読み込むため
  • numpy … ベクトル計算(コサイン類似度)用
  • urllib3 … SSLの警告を抑制するために利用
# SSL警告を抑制(テスト用。※本番では適切な証明書設定を行うこと)
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

テスト環境でのSSL警告を抑制しています。本番ではSSL証明書の設定を整える必要があります。


共通のシステムプロンプト

COMMON_SYSTEM_PROMPT = (
    "あなたは企業の統合報告書および金融データに基づいた情報提供のエキスパートである。"
    "回答は体言止め、主語不要。"
    "以下のルールに従え:"
    "1. 簡潔かつ的確、回答は54トークン以内。"
    "2. 質問の繰り返し禁止。"
    "3. 理由や根拠の説明禁止、直接回答。"
    "4. 与えられた参照情報から回答。"
    "5. 数値計算や論理的導出が可能なら正確に。"
    "6. 参照情報不足の場合は「分かりません」と答える。"
)
  • LLMの出力スタイル制約を定義している部分です。
  • ここで指定した内容(「体言止め」「54トークン以内」など)が、質問とは別にLLMに常に渡されます。

API接続情報

# Azure OpenAI (4omini)
AZURE_CHAT_ENDPOINT = "~"
AZURE_EMBEDDING_ENDPOINT = "~"
AZURE_API_KEY = "~"

# エンドポイント 1: Llama3.3
MODEL_1 = "llama3.3:70b-instruct-fp16"
API_URL_1 = "https://~"
API_KEY_1 = "~"

# エンドポイント 2: Qwen 2.5
MODEL_2 = "Qwen 2.5:72b"
API_URL_2 = "https://~"
API_KEY_2 = "~"
  • 各LLMのエンドポイントと認証情報を設定しています。
  • Azure OpenAIのEmbeddingsも同じく、専用のエンドポイントとAPIキーで呼び出します。

ダミー参照情報生成

def generate_dummy_reference(question: str) -> str:
    if "ウエルシアホールディングス" in question:
        return "ウエルシアホールディングスの子会社数は統合報告書に記載、14社。"
    return "企業の統合報告書に基づく情報を含む。"
  • テスト用のダミーデータ。本来はRAG(検索エンジン)で対象文書を抽出してくるが、ここでは一部のキーワードに対してあらかじめ決まった参照情報を返しているだけ。

各LLMへの問い合わせ関数

1. Azure OpenAI (4omini)
def get_azure_answer(question: str, reference: str = "") -> str:
    system_message = f"{COMMON_SYSTEM_PROMPT}\n【参照情報】{reference}"
    data = {
        "messages": [
            {"role": "system", "content": system_message},
            {"role": "user", "content": question}
        ],
        "max_tokens": 54,
        "temperature": 0.7
    }
    headers = {
        "Content-Type": "application/json",
        "api-key": AZURE_API_KEY
    }
    try:
        response = requests.post(AZURE_CHAT_ENDPOINT, headers=headers, json=data)
        response.raise_for_status()
        result = response.json()
        answer = result.get("choices", [])[0]["message"]["content"]
        return answer.strip()
    except Exception as e:
        print("Azure APIエラー:", e)
        return "エラー"
  • システムプロンプトとユーザープロンプトを**messages**という形式で渡しています。
  • max_tokens=54 で応答文字数を制限。
  • 結果をJSONとして受け取り、choices 配列の先頭要素から回答テキストを取り出しています。
2. Llama3.3
def get_llama3_answer(question: str, reference: str = "") -> str:
    prompt = f"{COMMON_SYSTEM_PROMPT}\n【参照情報】{reference}\n質問: {question}"
    data = {
        "temperature": 0.7,
        "repeat_penalty": 1.0,
        "prompt": prompt
    }
    ...
    response = requests.post(API_URL_1, headers=headers, data=json.dumps(data), verify=False)
    ...
    result = response.json()
    return result.get("generated_text", "エラー")
  • promptにシステムプロンプトと参照情報をまとめて記述するタイプ。
  • generated_text キーで回答を取得し、なければ "エラー" とする。
3. Qwen 2.5
def get_qwen2_answer(question: str, reference: str = "") -> str:
    prompt = f"{COMMON_SYSTEM_PROMPT}\n【参照情報】{reference}\n質問: {question}"
    data = {
        "temperature": 0.7,
        "repeat_penalty": 1.0,
        "prompt": prompt
    }
    ...
    response = requests.post(API_URL_2, headers=headers, data=json.dumps(data), verify=False)
    ...
    result = response.json()
    return result.get("generated_text", "エラー")
  • Llama同様の形式で、"generated_text" に回答が入っている想定です。

埋め込みAPI(Azure)とコサイン類似度

def get_embedding(text: str) -> list:
    """
    Azure の埋め込みAPIを用いてテキストの埋め込みベクトルを取得する。
    """
    data = {"input": text}
    headers = {
        "Content-Type": "application/json",
        "api-key": AZURE_API_KEY
    }
    try:
        response = requests.post(AZURE_EMBEDDING_ENDPOINT, headers=headers, json=data)
        ...
        embedding = result.get("data", [{}])[0].get("embedding", [])
        return embedding
    except Exception as e:
        ...
        return []
  • Azure Embedding API に文章を渡すと、高次元ベクトルが返ってきます。
  • 返却されたJSONから "embedding" を取り出してリスト化。
def cosine_similarity(vec_a, vec_b):
    a = np.array(vec_a)
    b = np.array(vec_b)
    if np.linalg.norm(a) == 0 or np.linalg.norm(b) == 0:
        return 0.0
    return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))
  • コサイン類似度を計算する関数。ベクトル同士の「角度の近さ」を見ています。
  • 0に近いほど似ていない、1に近いほど似ているという指標。

CSV読み込みと評価

csv_data = """problem,ground_truth
大成温調が積極的に資源配分を行うとしている高付加価値セグメントを全てあげてください。,改修セグメント、医療用・産業用セグメント、官公庁セグメント
花王の生産拠点数は何拠点ですか?,36拠点
...
ウエルシアホールディングスの子会社は全部で何社ですか?,14社
"""
csv_file = io.StringIO(csv_data)
reader = csv.DictReader(csv_file)
  • テスト用データをCSV形式の文字列で作成。
  • DictReader で行ごとに problemground_truth を取得します。
scores = {
    "Azure_OpenAI": {"sum": 0.0, "count": 0},
    "Llama3.3": {"sum": 0.0, "count": 0},
    "Qwen2.5": {"sum": 0.0, "count": 0},
}
  • 3つのモデルごとに、類似度の合計値回答回数を管理します。
for row in reader:
    question = row["problem"].strip()
    ground_truth = row["ground_truth"].strip()
    
    reference_info = generate_dummy_reference(question)
    
    # 各モデルから回答を取得
    azure_ans = get_azure_answer(question, reference_info)
    llama_ans = get_llama3_answer(question, reference_info)
    qwen_ans  = get_qwen2_answer(question, reference_info)
    
    # 埋め込み取得
    gt_emb = get_embedding(ground_truth)
    azure_emb = get_embedding(azure_ans)
    llama_emb = get_embedding(llama_ans)
    qwen_emb  = get_embedding(qwen_ans)
    
    # コサイン類似度
    azure_sim = cosine_similarity(gt_emb, azure_emb) if azure_emb else 0.0
    llama_sim = cosine_similarity(gt_emb, llama_emb) if llama_emb else 0.0
    qwen_sim  = cosine_similarity(gt_emb, qwen_emb)  if qwen_emb  else 0.0
    
    # スコア加算
    scores["Azure_OpenAI"]["sum"] += azure_sim
    scores["Azure_OpenAI"]["count"] += 1
    scores["Llama3.3"]["sum"] += llama_sim
    scores["Llama3.3"]["count"] += 1
    scores["Qwen2.5"]["sum"] += qwen_sim
    scores["Qwen2.5"]["count"] += 1
  • ground_truth回答 をそれぞれ埋め込みベクトル化し、コサイン類似度を計算。
  • 3つのモデルでそれぞれ同じ処理をし、合計を足し合わせます。

平均類似度と最も精度の高いモデル

avg_scores = {}
for model, data in scores.items():
    avg = data["sum"] / data["count"] if data["count"] > 0 else 0.0
    avg_scores[model] = avg

best_model = max(avg_scores, key=avg_scores.get)
print(f"\n最も精度が高いと判定されたモデル: {best_model}")
  • 1問あたりの平均スコアを算出。
  • max() を使って最も平均スコアが高いモデルを選び、コンソールに表示します。

このコードのポイント

  1. 複数のLLMを同じ質問に対して呼び出し、回答結果を比較できる
  2. 事前に設定したシステムプロンプトを使って、回答スタイルやトークン数をコントロール
  3. ベクトル化 + コサイン類似度により、回答がどの程度「正解」に近いかを数値化
  4. 簡易的な評価フレームワークとして、最も精度の高いモデルをシンプルに集計・判定

まとめ

このコードを活用すれば、各LLMの回答性能を効率的に評価できます。実際のRAGシステムでは、以下のように発展させることが一般的です。

  • データベースから関連する文書を検索し、その内容をプロンプトに組み込んで回答を生成する
  • 回答を再評価・リファインする仕組みを追加し、答えの正確性をさらに高める
  • さまざまなLLMを継続的に評価して、最新のモデルの性能をモニタリングする

次回は、このコードを使った各LLMの評価結果について詳細を述べていきます。

Discussion