BioErrorLog Tech Blog

試行錯誤の記録

LangCheckでLLMの回答を自動評価する

Citadel AIのLLM回答評価ツール"LangCheck"を使ってみます。

はじめに

LLMアプリケーションの開発では、そのLLM出力を評価する仕組みが重要です。 出力評価の仕組みなくしてLLMアプリケーション開発をすれば、チューニングの方針に迷うことになるでしょう。 OpenAIも、LLM出力を自動評価するプラクティスを推奨しています。

最近、Citadel AIという日本のスタートアップが公開したLangCheckというツールを見つけました。 LLMアプリケーションの出力評価を行うツールのようです。

今回は、このLangCheckを触って使用感を探りたいと思います。

LangCheckとは

基本的な使い方

使い方はシンプルです。

LLMのアウトプットを各評価関数に渡してあげると、メトリクスに従ってスコアが算出されます。

import langcheck

# LLM生成結果を格納
generated_outputs = [
    'Black cat the',
    'The black cat is.',
    'The black cat is sitting',
    'The big black cat is sitting on the fence',
    'Usually, the big black cat is sitting on the old wooden fence.'
]

# テキスト品質を評価しDataFrameとして結果を出力
langcheck.metrics.fluency(generated_outputs)

評価結果はDataFrame様の形式で出力できる | ドキュメントより引用


またlangcheck.metricsのメトリクス判定結果は、その評価スコアでassertすることができます。 単体テストにも簡単に組み込むことが可能です。

from langcheck.utils import load_json

# Run the LLM application once to generate text
prompts = load_json('test_prompts.json')
generated_outputs = [my_llm_app(prompt) for prompt in prompts]

# Unit tests
def test_toxicity(generated_outputs):
    assert langcheck.metrics.toxicity(generated_outputs) < 0.1

def test_fluency(generated_outputs):
    assert langcheck.metrics.fluency(generated_outputs) > 0.9

def test_json_structure(generated_outputs):
    assert langcheck.metrics.validation_fn(
        generated_outputs, lambda x: 'myKey' in json.loads(x)).all()

その他には、フィルタリングやグラフ描画、稼働アプリケーションでのガードレール機能などが紹介されています。

詳細はドキュメントを参照ください。

評価メトリクスには何があるか

LangCheckでサポートされている評価メトリクスは、大きく4種類あります。

評価メトリクス | 図はドキュメントより

  • Reference-Free Text Quality Metrics:
    生成回答に対するリファレンスを必要とせず、直接回答を評価するメトリクスタイプ。 例えばtoxicity()は回答の毒性/悪意性を0~1で評価する。
  • Reference-Based Text Quality Metrics:
    理想の回答たる"リファレンス"をあらかじめ用意し、LLM回答と比較する方法。 例えばsemantic_similarity()は、LLM回答とリファレンスの意味論的類似度を-1~1でスコアリングする。
  • Source-Based Text Quality Metrics:
    LLMの回答が、"ソーステキスト"に基づいているかどうかを判定する。 これはRAG(Retrieval Augmented Generation)アーキテクチャのアプリケーション評価に有用で、LLMによる最終回等が、RAGの中で取得される関連データ内の事実に基づいていることを評価するのに利用できる。
  • Text Structure Metrics:
    LLM回答のテキスト構造を判定する。 例えば、is_json_object()はLLMの出力結果がJSON形式かどうかを0 or 1で判定する。

補足: 評価メトリクス判定に使われるモデル

これらメトリクス判定は、デフォルトではHuggingFaceなどからダウンロードされる各種モデルがローカルで実行されることで行われます (どのモデルが使われるかはAPI Referenceに記載されてます)。

一方で、多くのメトリクス評価関数ではOpenAI(あるいはAzure OpenAI Service)のモデルを利用することが可能です。 特に、複雑なユースケースではOpenAIモデルを利用した方が高い精度で判定できるとのことで推奨されています。 (ただし後述のようにOpenAIモデルを使うことで癖が出てしまうこともあるので注意)

環境変数OPENAI_API_KEYにOpenAIキーを格納して引数model_typeopenaiとするか、引数openai_clientに直接OpenAIのクライアントを渡すことで、OpenAIモデルを使って判定させることができます。

import os
from langcheck.metrics.en import semantic_similarity

generated_outputs = ["The cat is sitting on the mat."]
reference_outputs = ["The cat sat on the mat."]

# Option 1: Set OPENAI_API_KEY as an environment variable
os.environ["OPENAI_API_KEY"] = 'YOUR_OPENAI_API_KEY'
similarity_value = semantic_similarity(generated_outputs,
                                       reference_outputs,
                                       model_type='openai')

# Option 2: Pass in an OpenAI client directly
from openai import OpenAI

client = OpenAI(api_key='YOUR_OPENAI_API_KEY')
similarity_value = semantic_similarity(generated_outputs,
                                       reference_outputs,
                                       model_type='openai',
                                       openai_client=client)

LangCheckで回答自動評価をやってみる

では、実際にLangCheckを触ってみます。

多くのLLMアプリケーション開発で評価テストの主軸になることの多いだろうリファレンスあり評価"Reference-Based Text Quality Metrics"を日本語で試してみます。

(日本語を対象とするので、パッケージは "langcheck.metrics.ja.reference_based_text_quality" からimportします。)

LangCheckのReference-Based Text Quality Metrics APIには、

  • rouge1()
  • rouge2()
  • rougeL()
  • semantic_similarity()

の4つのメソッドが用意されています。

rouge1(), rouge2(), rougeL()の3つは従来からある自然言語処理のアルゴリズムで(今回詳しくは割愛)、semantic_similarity()はEmbeddingによるコサイン類似度から意味論的類似度を測る方法です。

今回はsemantic_similarity()を使って、"理想回答"との類似度を評価してみます(-1~1で評価)。

まずはざっとダミーのLLM回答と理想回答のペアを適当に用意します。

LLM回答(ダミー) 理想回答 備考
メロスは激怒した。 メロスは激怒した。 完全一致
メロスは激しく怒った。 メロスは激怒した。 意味一致
メロスは、激しく、怒った。 メロスは激しく怒った 句読点のみ不一致
セリヌンティウスは待っていた。 メロスは激怒した。 意味完全不一致
単語のベクトル表現は、1960年代における情報検索用のベクトル空間モデルを元に開発された。潜在的意味分析は、特異値分解で次元数を削減することで、1980年代後半に導入された。 単語をベクトルとして表現する手法は、1960年代における情報検索用のベクトル空間モデルの開発が元になっている。特異値分解を使用して次元数を削減することにより、1980年代後半に潜在的意味分析が導入された。 だいたい意味一致
1960年以前に、ベクトル表現は開発された。のちに次元数を増幅することにより、潜在分析が導入された。 単語をベクトルとして表現する手法は、1960年代における情報検索用のベクトル空間モデルの開発が元になっている。特異値分解を使用して次元数を削減することにより、1980年代後半に潜在的意味分析が導入された。 意味不一致
すみません、ITヘルプデスクに電話で問い合わせてください。 大変申し訳ございませんが、弊社情報部門のヘルプデスクにメールでお問い合わせください。 少し意味不一致

では、これらをsemantic_similarity()で評価させてみます。

まずはOpenAIモデルを使わずに、デフォルトのlacalタイプで実行してみます (モデルは"paraphrase-multilingual-mpnet-base-v2"が使用される)。

from langcheck.metrics.ja.reference_based_text_quality import semantic_similarity

# Dummy outputs
generated_outputs = [
    'メロスは激怒した。',
    'メロスは激しく怒った。',
    'メロスは、激しく、怒った。',
    'セリヌンティウスは待っていた。',
    '1960年以前に、ベクトル表現は開発された。のちに次元数を増幅することにより、潜在分析が導入された。',
    'すみません、ITヘルプデスクに電話で問い合わせてください。',
]

reference_outputs = [
    'メロスは激怒した。',
    'メロスは激怒した。',
    'メロスは激しく怒った',
    'メロスは激怒した。',
    '単語をベクトルとして表現する手法は、1960年代における情報検索用のベクトル空間モデルの開発が元になっている。特異値分解を使用して次元数を削減することにより、1980年代後半に潜在的意味分析が導入された。',
    '大変申し訳ございませんが、弊社情報部門のヘルプデスクにメールでお問い合わせください。',
]


def test_semantic_similarity():
    results = semantic_similarity(generated_outputs, reference_outputs)
    print(results)

    assert results > 0.9

テスト実行結果がこちら:

$ pytest -s 

# 中略

Progress: 100%|████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 1/1 [00:00<00:00,  2.71it/s]
Metric: semantic_similarity
  prompt source                                   generated_output                                   reference_output explanation  metric_value
0   None   None                                          メロスは激怒した。                                          メロスは激怒した。        None      1.000000
1   None   None                                        メロスは激しく怒った。                                          メロスは激怒した。        None      0.980329
2   None   None                                      メロスは、激しく、怒った。                                         メロスは激しく怒った        None      0.961034
3   None   None                                    セリヌンティウスは待っていた。                                          メロスは激怒した。        None      0.250888
4   None   None  単語のベクトル表現は、1960年代における情報検索用のベクトル空間モデルを元に開発された。潜...  単語をベクトルとして表現する手法は、1960年代における情報検索用のベクトル空間モデルの開発...        None      0.979269
5   None   None  1960年以前に、ベクトル表現は開発された。のちに次元数を増幅することにより、潜在分析が導入...  単語をベクトルとして表現する手法は、1960年代における情報検索用のベクトル空間モデルの開発...        None      0.649559
6   None   None                      すみません、ITヘルプデスクに電話で問い合わせてください。         大変申し訳ございませんが、弊社情報部門のヘルプデスクにメールでお問い合わせください。        None      0.768630F

======================= FAILURES ==============================================================================================

# 中略

======================= short test summary info ======================================================================================
FAILED test_main.py::test_semantic_similarity - assert Metric: semantic_similarity\n  prompt source                                   generated_output                        ...             すみません、ITヘルプデスクに電話で問い合わせてくだ...
======================= 1 failed, 2 warnings in 3.37s ======

テスト評価結果を整理します。

LLM回答(ダミー) 理想回答 評価結果(metric_value)
メロスは激怒した。 メロスは激怒した。 1.000000
メロスは激しく怒った。 メロスは激怒した。 0.980329
メロスは、激しく、怒った。 メロスは激しく怒った 0.961034
セリヌンティウスは待っていた。 メロスは激怒した。 0.250888
単語のベクトル表現は、1960年代における情報検索用のベクトル空間モデルを元に開発された。潜在的意味分析は、特異値分解で次元数を削減することで、1980年代後半に導入された。 単語をベクトルとして表現する手法は、1960年代における情報検索用のベクトル空間モデルの開発が元になっている。特異値分解を使用して次元数を削減することにより、1980年代後半に潜在的意味分析が導入された。 0.979269
1960年以前に、ベクトル表現は開発された。のちに次元数を増幅することにより、潜在分析が導入された。 単語をベクトルとして表現する手法は、1960年代における情報検索用のベクトル空間モデルの開発が元になっている。特異値分解を使用して次元数を削減することにより、1980年代後半に潜在的意味分析が導入された。 0.649559
すみません、ITヘルプデスクに電話で問い合わせてください。 大変申し訳ございませんが、弊社情報部門のヘルプデスクにメールでお問い合わせください。 0.768630

なかなか直感とも合うスコアリングのように感じます。 スコア0.9あたりに閾値を設ければ、ちょうど良いテストのassertにできそうです。


次は、localモデルではなくOpenAI embeddingモデル(現時点では"text-embedding-ada-002")でsemantic_similarity()を実行してみます。 テストケースは↑と同じです。

semantic_similarity()でOpenAIモデルを使った場合はスコアが高くなる傾向にある、という注意がドキュメントに記載されてますが、どうなるでしょうか。

NOTE: when using OpenAI embeddings, the cosine similarities tend to be skewed quite heavily towards higher numbers.

from langcheck.metrics.ja.reference_based_text_quality import semantic_similarity

# Dummy outputs
generated_outputs = [
    'メロスは激怒した。',
    'メロスは激しく怒った。',
    'メロスは、激しく、怒った。',
    'セリヌンティウスは待っていた。',
    '単語のベクトル表現は、1960年代における情報検索用のベクトル空間モデルを元に開発された。潜在的意味分析は、特異値分解で次元数を削減することで、1980年代後半に導入された。',
    '1960年以前に、ベクトル表現は開発された。のちに次元数を増幅することにより、潜在分析が導入された。',
    'すみません、ITヘルプデスクに電話で問い合わせてください。',
]

reference_outputs = [
    'メロスは激怒した。',
    'メロスは激怒した。',
    'メロスは激しく怒った',
    'メロスは激怒した。',
    '単語をベクトルとして表現する手法は、1960年代における情報検索用のベクトル空間モデルの開発が元になっている。特異値分解を使用して次元数を削減することにより、1980年代後半に潜在的意味分析が導入された。',
    '単語をベクトルとして表現する手法は、1960年代における情報検索用のベクトル空間モデルの開発が元になっている。特異値分解を使用して次元数を削減することにより、1980年代後半に潜在的意味分析が導入された。',
    '大変申し訳ございませんが、弊社情報部門のヘルプデスクにメールでお問い合わせください。',
]


# Prerequisite: Set OPENAI_API_KEY as an environment variable
def test_semantic_similarity_by_openai():
    results = semantic_similarity(generated_outputs, reference_outputs, model_type='openai')
    print(results)

    assert results > 0.9

テスト実行結果がこちら:

$ pytest -s 

# 中略

Computing embeddings: 100%|████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 1/1 [00:05<00:00,  5.37s/it]
Computing semantic similarity: 100%|██████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 1/1 [00:00<00:00, 164.58it/s]
Metric: semantic_similarity
  prompt source                                   generated_output                                   reference_output explanation  metric_value
0   None   None                                          メロスは激怒した。                                          メロスは激怒した。        None      0.999999
1   None   None                                        メロスは激しく怒った。                                          メロスは激怒した。        None      0.990493
2   None   None                                      メロスは、激しく、怒った。                                         メロスは激しく怒った        None      0.976332
3   None   None                                    セリヌンティウスは待っていた。                                          メロスは激怒した。        None      0.817436
4   None   None  単語のベクトル表現は、1960年代における情報検索用のベクトル空間モデルを元に開発された。潜...  単語をベクトルとして表現する手法は、1960年代における情報検索用のベクトル空間モデルの開発...        None      0.986894
5   None   None  1960年以前に、ベクトル表現は開発された。のちに次元数を増幅することにより、潜在分析が導入...  単語をベクトルとして表現する手法は、1960年代における情報検索用のベクトル空間モデルの開発...        None      0.906200
6   None   None                      すみません、ITヘルプデスクに電話で問い合わせてください。         大変申し訳ございませんが、弊社情報部門のヘルプデスクにメールでお問い合わせください。        None      0.930603
F
======================= FAILURES ==============================================================================================

# 中略

======================= short test summary info ======================================================================================
FAILED test_main.py::test_semantic_similarity_by_openai - assert Metric: semantic_similarity\n  prompt source                                   generated_output                        ...             すみません、ITヘルプデスクに電話で問い合わせてくだ...
======================= 1 failed, 1 warning in 7.24s ======

Localモデル版のテストとあわせ、スコア評価結果を整理します。

LLM回答(ダミー) 理想回答 Localモデル評価結果(metric_value) OpenAIモデル評価結果(metric_value)
メロスは激怒した。 メロスは激怒した。 1.000000 0.999999
メロスは激しく怒った。 メロスは激怒した。 0.980329 0.990493
メロスは、激しく、怒った。 メロスは激しく怒った 0.961034 0.976332
セリヌンティウスは待っていた。 メロスは激怒した。 0.250888 0.817436
単語のベクトル表現は、1960年代における情報検索用のベクトル空間モデルを元に開発された。潜在的意味分析は、特異値分解で次元数を削減することで、1980年代後半に導入された。 単語をベクトルとして表現する手法は、1960年代における情報検索用のベクトル空間モデルの開発が元になっている。特異値分解を使用して次元数を削減することにより、1980年代後半に潜在的意味分析が導入された。 0.979269 0.986894
1960年以前に、ベクトル表現は開発された。のちに次元数を増幅することにより、潜在分析が導入された。 単語をベクトルとして表現する手法は、1960年代における情報検索用のベクトル空間モデルの開発が元になっている。特異値分解を使用して次元数を削減することにより、1980年代後半に潜在的意味分析が導入された。 0.649559 0.906200
すみません、ITヘルプデスクに電話で問い合わせてください。 大変申し訳ございませんが、弊社情報部門のヘルプデスクにメールでお問い合わせください。 0.768630 0.930603

確かにドキュメントの注意書きの通り、OpenAIモデルではスコアが高く出てしまいました。

現状では、localモデルを使った方が無難そうです。

追記:

上記問題についてPull Requestを投げましたところ無事mergeしていただき、v0.5.0からデフォルトのOpenAI Embeddingモデルが"text-embedding-ada-002"から"text-embedding-3-small"にアップデートされ、この問題は解消されました。

補足: semantic_similarity()でEmbeddingモデルを指定する

上記のように、OpenAIモデルタイプのデフォルトである"text-embedding-ada-002"ではスコアが高く出てしまう問題がありましたが、semantic_similarity()では利用するモデルを指定することが可能です。

先日新しいEmbeddingタイプがOpenAIからリリースされたので、こちら新モデルを指定して結果を比較してみます。

指定方法は簡単で、引数openai_argsにモデル名を渡します。

# Prerequisite: Set OPENAI_API_KEY as an environment variable
def test_semantic_similarity_by_openai_3_small():
    results = semantic_similarity(
        generated_outputs,
        reference_outputs,
        model_type='openai',
        openai_args={'model': 'text-embedding-3-small'}
    )
    print(results)

    assert results > 0.9

結果を並べたのがこちら↓

詳しくはこちら: langcheck_sandbox - GitHub

"text-embedding-ada-002"で見られるスコアが高く出てしまう問題は、新モデル"text-embedding-3-small"では解消しているように見えます。

"text-embedding-3-small"であれば料金も従来より安くなっているので、OpenAIモデルタイプを指定する時は新モデルを指定した方が使い勝手が良さそうです。


※ 今回使った検証スクリプトはこちらに配置: github.com

おわりに

以上、LangCheckを使ったLLMの回答自動評価を試してみました。

LLMアプリケーションの開発では、いかにそのアプリの性能を評価/継続観測するかが重要だと日々感じています。 そういった仕組みを作る一つの選択肢として、LangCheckは有用と感じました。

どなたかの参考になれば幸いです。

[関連記事]

www.bioerrorlog.work

www.bioerrorlog.work

www.bioerrorlog.work

参考

GitHub - citadel-ai/langcheck: Simple, Pythonic building blocks to evaluate LLM applications.

LangCheck Documentation — LangCheck

API Reference — LangCheck

https://platform.openai.com/docs/guides/prompt-engineering/tactic-evaluate-model-outputs-with-reference-to-gold-standard-answers