メインコンテンツへスキップ

Langfuse Dataset Itemsのマルチモーダル対応:画像入りDatasetを作って評価を回す

著者
Hiromi Kuwa

以前の記事(【入門編】Langfuseで画像OCRの精度検証をシンプルに始める方法 )では、LangfuseのDataset Itemsがマルチモーダルコンテンツを直接保持できない仕様だったため、Datasetには CSV でファイル名だけを登録し、画像実体はローカルに置いてスクリプト側で読み込む構成を取っていました。

その後のアップデートで、Dataset Items が 画像・音声・PDFなどのマルチモーダルコンテンツに正式対応 しました。画像そのものを Langfuse 側に置いて、UI 上でプレビューしながら管理できるようになったのが大きな変化です。

本記事では、画像入り Dataset の作り方(UI と SDK の2通り)と、それを SDK 経由の Experiment で回すまでの流れを整理します。

利用バージョン

  • Langfuse: v3.195.0(本機能が追加されたバージョン)以降 ※Langfuse Cloud はすでに対応済
  • Python SDK: 4.13.2(本機能の対応は >= 4.10.0

何ができるようになったのか
#

Dataset Item の input / expected_output / metadata に、画像・音声・PDF などのメディアファイルを含められるようになりました。登録方法は以下の2通りです。

  • Langfuse UI の Item エディタ:画像ファイルを直接アップロード(少量の登録・差し替えに手軽)
  • Python / JS SDKLangfuseMedia でラップして create_dataset_item に渡す(一括登録や既存データ移行に向く)

なお、以下は現状非対応です。

  • CSV Import:CSV はテキスト / JSON 用のインポート経路として位置付けられており、マルチモーダル Dataset Item の登録には利用できません
  • UI 直接実行の Experiments:ダッシュボード上から直接プロンプトを流す形の Experiment 実行(LLM 呼び出しを Langfuse 側で完結させるタイプ)は、マルチモーダル Dataset には未対応です。回すときは SDK 経由の run_experiment を利用します(後述の Custom Experiment + Webhook 経由であれば、UI 上から実行可能です)

画像入り Dataset の作り方
#

登録方法には UI からアップロードSDK からアップロード の2通りがあります。用途に応じて使い分けます。

方法1:UIからアップロード
#

一番手軽な方法です。コードを書かずに数件だけ登録したい場合や、内容を目視で確認しながら1件ずつ操作したい場合はこちらが便利です。

大まかな手順は以下のとおりです。

  1. Datasets メニューから対象の Dataset を開く
  2. + New item から Item エディタを開く
  3. クリップアイコンをクリック、または input フィールドへのドラッグアンドドロップで画像ファイルをアップロード(expected_outputMetadata にも同様に添付可能)
  4. 保存

もちろん、既存アイテムの編集による添付も可能です。以下は以前の記事の際に登録したデータセットアイテムです。

以前の記事で作成した、Inputにローカルの画像ファイル名だけを保持しているデータセットアイテム
以前の記事で作成した、Inputにローカルの画像ファイル名だけを保持しているデータセットアイテム

今回の検証にあたり、ローカルの画像パス image001.png を指定していた状態から、Langfuse側で画像の実体を管理する方向に変更してみます。

まず、Input に記載されている内容を削除し、該当のファイルをドラッグアンドドロップで Input フィールドに添付します。

Item編集画面のInputフィールドに画像ファイルをドラッグアンドドロップで添付している様子
Item編集画面のInputフィールドに画像ファイルをドラッグアンドドロップで添付している様子

アップロード直後に、Dataset Item のプレビューで画像が正しく登録されているか目視できるのが利点です。この状態で Save Changes をクリックすれば完了です。

保存後のデータセットアイテム画面。Inputに画像が登録され、画面下部のAttachmentsにも表示されている
保存後のデータセットアイテム画面。Inputに画像が登録され、画面下部のAttachmentsにも表示されている

画面下部に Attachments としても表示されていますが、添付したエリア内のアイコンへマウスオーバーすると、上の画像のようにプレビューが表示されます。

方法2:SDKからアップロード
#

大量の画像を一括登録したい場合や、既存の CSV / JSON データを画像入り Dataset に移行する場合は、SDK からの登録が向いています。

from langfuse import get_client
from langfuse.media import LangfuseMedia

langfuse = get_client()

langfuse.create_dataset_item(
    dataset_name="[データセット名]",
    input=LangfuseMedia(
        file_path="[画像パス]",
        content_type="[Content Type(image/png など)]",
    ),
)

登録時に、画像実体は Langfuse のオブジェクトストレージへアップロードされます。Dataset Item の内部では以下のようなトークン文字列(Media Token)として保持されますが、SDK 側で自動的に解決されるため、この形式を書き手が意識する必要はありません。

@@@langfuseMedia:type=[Content Type]|id=abc123-...|source=bytes@@@

今回はどちらを使うか
#

今回は、以前の記事で使った登録用の CSV が手元にあるので、SDK で別データセットとして用意することにしました。件数自体は多くないですが、CSV は前回のまま使い回せて、変わるのは Langfuse に登録される中身だけです。

前回の CSV は以下のような構造です。

inputshop_namedateprice
image001.pngDAILY STORE2024/01/25780
image002.pngスーパーABC2024/01/261098
image003.pngCOFFEE STAND2024/01/271600

このように、既に CSV に対応関係が整理されていれば、CSV を読み込んで各行を create_dataset_item に流す小さなスクリプトで、画像入りのデータセットを一括で用意できます。

たとえば以下のようなイメージです。

import csv
from langfuse import get_client
from langfuse.media import LangfuseMedia

langfuse = get_client()

with open("dataset.csv", newline="", encoding="utf-8") as f:
    for row in csv.DictReader(f):
        langfuse.create_dataset_item(
            dataset_name="ocr_test_multimodal",
            input=LangfuseMedia(
                file_path=f"./data/{row['input']}",
                content_type="image/png",
            ),
            expected_output={
                "shop_name": row["shop_name"],
                "date": row["date"],
                "price": int(row["price"]),
            },
        )
ヒント

CSV 側に Langfuse の Dataset Item ID を持たせておくと、create_dataset_itemid を渡すだけで既存アイテムの上書き(upsert)になります。同じ CSV を流し直すだけで Expected Output の修正や画像の差し替えができるので、データセットを継続的にメンテナンスするなら、最初の登録時に ID を自分で決めておくと後が楽です。
今回は CSV に ID を含めていなかったこともあり、UI からの転記の手間を省いて新規作成としています。

一方で、既存データが少量で、内容を見ながら1件ずつ確認して差し替えたいというケースにおいては、UIから登録する方が手軽です。UIならプレビューで画像を目視確認できるので、登録ミスにも気付きやすいメリットもあります。

SDKで評価を回す
#

登録した Dataset を SDK から取得すると、Media Token は LangfuseMediaReference オブジェクト として自動的に取り出せる状態になります。そのまま LLM へ渡しても画像として認識されないため、渡す直前で必要な形式に変換できるよう、以下のヘルパーメソッドが用意されています。

取得したい形式PythonJS/TS
バイト列fetch_bytes()fetchBytes()
Base64 文字列fetch_base64()fetchBase64()
Data URIfetch_data_uri()fetchDataUri()

本記事では Python SDK のみ扱いますが、JS/TS SDK で同じことをする場合は @langfuse/client >= 5.6.0 が必要です。

前回の OCR スクリプトを、この仕組みに合わせて書き直すと以下のようになります。

import os
import json
from google import genai
from langfuse import get_client, Evaluation
from langfuse.media import LangfuseMediaReference

langfuse = get_client()

client = genai.Client(
    project=os.getenv("GOOGLE_CLOUD_PROJECT"),
    location=os.getenv("GOOGLE_CLOUD_REGION", "asia-northeast1"),
    vertexai=True,
)

prompt = langfuse.get_prompt("ocr")
dataset = langfuse.get_dataset(name="ocr_test_multimodal")


def ocr_task(*, item, **kwargs):
    # === 前回:ローカルパスからファイルを開いてバイト列を取得 ===
    # input_filename = item.input
    # file_path = os.path.join(target_file_path, input_filename)
    # with open(file_path, "rb") as f:
    #     image_bytes = f.read()

    # === 今回:Dataset Item に含まれる LangfuseMediaReference から取得 ===
    image_ref = item.input
    assert isinstance(image_ref, LangfuseMediaReference)
    image_bytes = image_ref.fetch_bytes()

    # 以下、前回と同じ
    compiled_prompt = prompt.compile()

    response = client.models.generate_content(
        model="gemini-2.5-flash",
        contents=[
            # 今回の Dataset は全件 PNG で登録しているため決め打ち
            genai.types.Part.from_bytes(data=image_bytes, mime_type="image/png"),
            compiled_prompt,
        ],
        config=genai.types.GenerateContentConfig(
            temperature=0.0,
            response_mime_type="application/json",
            # 前回と同じ。配列で返すスキーマを指定している
            response_schema={
                "type": "ARRAY",
                "items": {
                    "type": "OBJECT",
                    "properties": {
                        "shop_name": {"type": "STRING", "description": "店舗名"},
                        "date": {"type": "STRING", "description": "日付"},
                        "price": {"type": "NUMBER", "description": "金額"},
                    },
                    "required": ["shop_name", "date", "price"],
                },
            },
        ),
    )
    return response.text


def shop_name_evaluator(*, output, expected_output, **kwargs):
    # 実務では空配列や型のチェックを入れることを推奨
    parsed = json.loads(output)
    # response_schema が ARRAY なので先頭要素を取り出す
    ok = bool(parsed) and parsed[0].get("shop_name") == expected_output.get("shop_name")
    return Evaluation(
        name="shop_name_match",
        value=ok,
        data_type="BOOLEAN",
    )


# status: ACTIVEのみを取得
active_items = [item for item in dataset.items if item.status == "ACTIVE"]

result = langfuse.run_experiment(
    name=f"{prompt.name}_{prompt.version}",
    description=f"Dataset Run from SDK: {prompt.name}_{prompt.version}",
    task=ocr_task,
    data=active_items,
    evaluators=[shop_name_evaluator],
)

前回はローカルパスから open("data/xxx.png", "rb") で読み込んでいた部分が、image_ref.fetch_bytes() に置き換わっただけで、Task / Evaluator の構造はほぼそのままです。

発展:UIから起動する構成(既存のCustom Experimentとの組み合わせ)
#

UI 上で画像が確認できるようになったことで、「画像を目で見て Run を押す」というフローが自然に取れます。この起動部分は、Langfuse に以前から用意されている **Custom Experiment(Webhook) **と組み合わせることで実現できます。

Custom Experiment 自体は、Dataset に対して外部の Webhook 受信エンドポイントを登録しておくことで、UI 上の Run ボタンから評価をトリガーできる仕組みです。マルチモーダルな Dataset を対象にした場合でも、以下の流れで実行→結果確認までLangfuse上で行えます。

  1. Cloud Run / Lambda などに Webhook 受信サーバをデプロイし、内部で本記事の SDK スクリプトを実行するようにしておく
  2. Langfuse UI 上の Dataset の設定画面で、この Webhook URL を Custom Experiment として登録
  3. チームメンバーが Langfuse UI 上で Run をクリック → Langfuse から Webhook が飛ぶ
  4. Webhook 側で run_experiment を実行し、スコアを Langfuse に書き戻す
  5. Langfuse UI 上に、新しい Experiment run として結果が表示される

なお、これはあくまで簡易的な流れです。実際には認証を入れる、クローズドなネットワーク内に配置するなど、外部の第三者から起動されないよう配慮が必要です。Langfuse 側の設定で HMAC 署名(x-langfuse-signature ヘッダ)を有効にできるので、受信側でこれを検証するのが手軽です。

Custom Experiment 自体は前からあり、gs:// のようなパス文字列を Dataset に入れておく形でも成立していました。ただ、その場合は UI 上では文字列だけで対応関係が分かりづらく、「どの画像を評価しているのか」を見ながら回すには一手間かかっていました。今回のように UI 上で画像が直接プレビューできる状態と組み合わせると、「見て → 押す」の流れが直感的になります。

もっとも、エンジニアが手元で run_experiment を叩くのと比べて劇的にラクになるわけでもなく、Webhook 受信サーバのデプロイ・維持の手間を考えると、常に有利な選択肢とは限りません。非エンジニアのメンバーもプロンプト改善サイクルに参加したいなど、UI から起動できる価値が明確に効く場面向けの構成、という位置付けだと感じます。

Webhook 側の実装は、本記事の SDK スクリプトをそのまま HTTP エンドポイントで包むイメージです。詳細な設定手順は公式ドキュメント(Optional: Trigger SDK Experiment from UI )を参照してください。

参考:検証時の docker-compose 構成(ローカル環境で MinIO を利用したケース)

今回、以下の docker-compose.yml で Webhook 受信サーバをローカルに立てて動作確認しました。

Langfuse 本体の docker-compose.yml には手を入れず、別ファイル・別プロジェクトとして立てています。転送先の minio は Langfuse 標準の compose が持つサービス名なので、compose プロジェクトをまたいで名前解決できるよう、Langfuse 側が作ったネットワークに外部から参加させる形にしています。

services:
  webhook:
    build: .
    container_name: langfuse-webhook
    networks:
      - langfuse                       # Langfuse 側のネットワークに相乗り

  minio-loopback:
    image: alpine/socat
    network_mode: "service:webhook"    # webhook のネットワーク名前空間を共有
    command: TCP-LISTEN:9090,fork,reuseaddr TCP:minio:9000
    restart: always
    depends_on:
      - webhook

networks:
  langfuse:
    external: true
    name: langfuse_default

ポイントは minio-loopback の socat コンテナです。Langfuse が発行する署名付き URL は http://localhost:9090/...(ローカル環境の MinIO 想定)を指しているため、Webhook コンテナ内で SDK が fetch_bytes() を呼ぶと、コンテナ内の localhost:9090 は Webhook 自身を指してしまい Connection refused になります。

そこで network_mode: "service:webhook" で socat を Webhook とネットワーク名前空間を共有させ、Webhook 側の localhost:9090 で listen して Docker ネットワーク上の minio:9000 に転送させることで、署名付き URL を書き換えることなく MinIO に届くようになります。

前回との比較
#

前回と今回の構成の違いを整理すると以下のようになります。

前回(CSV Import + ローカルファイル / GCS 等)今回(画像入り Dataset Item)
登録方法CSV にファイル名だけ書いて ImportUI アップロード または SDK で LangfuseMedia
画像の実体実行環境にローカル配置、または GCS/S3 に配置Langfuse のオブジェクトストレージに保存
UI上での視認性ファイル名 / gs:// パスの文字列のみ画像がそのままプレビュー表示される
追加時の手数GCS/S3 バケットの整備、IAM 権限の設定などが必要UI からドラッグアンドドロップ、または SDK 呼び出し
Datasetの管理画像フォルダ/外部ストレージと対応付けて管理Dataset Item と画像を Langfuse 側で一元管理(セルフホストではオブジェクトストレージの構成が必要)
スクリプト側ローカルパスや gs:// から読み込みimage_ref.fetch_bytes()

前回の構成でも、GCS や S3 などのオブジェクトストレージにチーム全員がアクセスできる状態を作れば共同利用は可能でした。ただし、バケットの用意と権限設計が必要で、UI 上では gs://... のようなパス文字列でしかデータの中身が判別できない、というのが実運用上の弱点でした。

今回のマルチモーダル Dataset Item では、画像実体を Langfuse に置いて、UI 上でプレビューしながら管理・追加ができるようになりました。「Dataset に何が入っているか」を UI で直接見ながらメンテナンスできる、というのが実運用面で一番効いてくる変化かなと感じます。

注意点
#

セルフホスト(公式Dockerイメージ)で UI からのアップロードが失敗する場合
#

OSS セルフホスト版で公式配布の Docker イメージを利用している場合、ストレージバックエンドの構成によって UI からのアップロード時にエラーがおこることがあります。手元では以下の 2 ケースを確認しました。

ケース1:localhost の MinIO など、S3 互換のカスタムエンドポイント(CSP でブロック)
#

MinIO を http://localhost:9090 などのカスタムエンドポイントで動かしていると、UI からのアップロードで PUT が発行されず、ブラウザの Developer Console に以下のような CSP 違反エラーが記録されます。

Fetch API cannot load http://localhost:9090/langfuse/media/...
Refused to connect because it violates the document's Content Security Policy.

Langfuse Web フロントエンドの CSP connect-src には、一般的なエンドポイントは既定で含まれていますが、localhost や任意のカスタムエンドポイントは含まれていません
含まれている例:AWS S3(*.amazonaws.com)・Azure(*.blob.core.windows.net)・GCS(storage.googleapis.com

LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINTNext.js のビルド時に CSP に埋め込まれる仕様のため、公式イメージのランタイムに環境変数を渡しているだけでは反映されません(langfuse#14613 )。この issue は検証時点(2026年7月上旬)で既にクローズされていましたが、公式イメージでは依然として再現したため、以下の回避策を取りました。

今回は検証のため、リバースプロキシ(nginx)で Content-Security-Policy レスポンスヘッダを書き換えることで対応しました。

ケース2:GCS バケットへのアップロード(バケット側の CORS 設定不足)
#

GCS の場合は CSP 側が既に対応済みのバージョン(v3.203.0 以降)を使っていれば、CSP が原因である可能性はほぼありません。実際、開発者ツールの Network タブでは CSP ブロックではなく Status: CORS Error として観測されました。

対処としては、GCS バケット側で CORS 設定を追加します。設定方法は公式ドキュメント(Cloud Storage の CORS の構成方法 )を参照してください。

なお、この CORS 設定について Langfuse 側のドキュメントに記述は見当たりませんでした。Multi-Modality には「ブラウザから直接メディアを取得する」構成であることが前提として書かれていますが、それに必要な CORS 設定には触れられていません。公式の Terraform モジュール(langfuse-terraform-gcp )が作成する GCS バケットにも cors ブロックは含まれていないため、公式の手順どおりに構築した場合でも、この設定は自分で追加する必要があります。

いずれの場合も:SDK 経由での登録なら影響を受けない
#

前述の「方法2:SDKからアップロード」はサーバサイドで実行されるため、ブラウザ側の CSP・CORS のいずれからも影響を受けません。CSP 書き換えや GCS CORS 設定の権限がない環境で「今すぐ回避したい」場合は、SDK 経由での登録が最も確実です。

署名付きURLの有効期限
#

LangfuseMediaReference.fetch_*() は、署名付きURLでオブジェクトストレージからファイルを取得します。長時間実行される Experiments では URL が途中で失効するケースが考えられるので、その場合は langfuse.get_dataset() を再度呼び直して、新しい参照を取得してください。

ストレージ容量
#

画像・音声などを大量に登録すると、オブジェクトストレージの容量を消費します。セルフホスト環境では S3 バケット等の容量・コスト管理に注意してください。

Langfuse Cloud の場合、マルチモーダル添付はベータ期間中は無料という扱いです(Pricing の機能比較表に全プラン「Free while in beta」と記載)。公式ドキュメントにも「大きなマルチモーダルトレースに伴う追加のストレージ・計算コストを反映した新しい料金体系を導入する選択肢を留保する」と明記されているため(Multi-Modality — Availability )、こちらも念頭に置いておくと良さそうです。

まとめ
#

今回、Langfuse Dataset Items のマルチモーダル対応を、以前の記事の Dataset を題材に試してみました。触ってみての所感は以下のとおりです。

  • UI からのアップロード
    • 少量の登録・既存アイテムの差し替えに向いている
    • プレビューで画像が目視できるため、登録ミスに気付きやすい
  • SDK からのアップロード
    • 大量の一括登録に向いている
    • 既存のCSV/JSON等のデータを画像入り Dataset に移行するケースにもフィットする
  • 評価の実行
    • Langfuse UI 直接実行の Experiments は現時点で未対応のため、SDK の run_experiment を利用する
    • Task 関数側では LangfuseMediaReference.fetch_bytes() などで画像を取り出して LLM に渡す
    • 既存の Custom Experiment(Webhook)と組み合わせれば、UI で画像を見ながら Run を押す直感的なフローも取れる

変わったのは「評価データをどこに置くか」の部分です。評価の実行自体は SDK が前提で、UI から起動したいなら Webhook 受信サーバも必要になるため、Langfuse だけで完結するようになったわけではありません。

それでも、UI で中身を見ながらデータをメンテナンスできること、そして複数人で扱うときに評価対象のファイルをあらかじめ共有しておく必要や、ファイル管理用に別途ストレージを用意する必要もないことは、実運用でははっきり効いてくる部分だと感じます。

参考リンク
#