開発ノート

評価データセットの作り方:ゴールデンケースの集め方

管理者2026.06.11 公開 ・ 20 min read
評価データセットの作り方:ゴールデンケースの集め方

01TL;DR

  • ゴールデンデータセットは「実利用ログの代表ケース」と「失敗事例」の2つを核にして構築します。
  • 全ケースを網羅するより、カバレッジを意識した少数精鋭(目安20〜50件)の方が開発サイクルを回しやすくなります。
  • 期待出力(ゴールデン)の定義は曖昧にせず、採点基準まで一緒に記録します。
  • データセットはコードと同じようにバージョン管理し、失敗が起きるたびに拡張します。
  • サンプルコードはTypeScript(Node.js v22.x)で記述しています。

02はじめに

この記事の対象読者

エージェントの評価を「手動で確認する」段階から「自動テストで回す」段階へ移行したい方を想定しています。 より具体的には、次のような状況に置かれた方に向けて書きます。

  • 評価指標は決まったが、「何で評価するか」のデータが手元にない
  • ログは大量にあるが、テストケースとして使える形に整理できていない
  • 「良いケース」と「悪いケース」の区別がまだ直感に頼っている

(関連記事: 評価設計の基本:エージェントの品質をどう測るか)

前提知識

LLMエージェントの基本的な動作と、評価指標の概念(タスク達成・応答品質など)を理解していることを前提とします。 特定フレームワークの知識は必要ありません。

この記事で得られること

  • ゴールデンデータセットとは何か、なぜ少数で効くのか
  • 実利用ログから代表ケースを抽出する手順とコード
  • 期待出力(ゴールデン)の定義方法と採点基準の書き方
  • データセットのファイル形式と管理方法
  • 失敗事例を追加してデータセットを育てる仕組み
  • 個人情報を含むログの匿名化処理

03ゴールデンデータセットとは

ゴールデンデータセットとは、エージェントの自動評価に使う「入力と期待出力のペア集合」のことです。 各ケースには少なくとも次の3要素が含まれます。

要素 内容
入力(input ユーザーが投げるプロンプト・文脈
期待出力(golden 正解として認めたい応答の基準
採点基準(criteria 何をどう見て合格・不合格を判定するか

「完璧な正解文字列」を持つ必要はありません。 「この観点を満たしていれば合格」という採点基準があれば十分です。 この観点が曖昧なままだと、テストを自動化しても結果の解釈が人によってぶれます。

なぜ少数精鋭が効くのか

テストケースを増やすほど良いように思えますが、実際の開発では「少数・高品質」の方がうまく機能することが多いと筆者たちは感じています。 理由は3点あります。

1. フィードバックループが短くなります。 テストが100件あると1回の実行に時間がかかり、プロンプト修正の反応を確認するのが重い作業になります。 20〜50件に絞ると、変更→テスト→修正のサイクルが数分で回せます。

2. 品質の低いケースがノイズになります。 ログから機械的に大量抽出したケースには、「同じ意味の重複」「エッジケースではなく平凡なケース」「採点基準が書けない曖昧なケース」が混ざります。 こうしたケースは評価結果を不安定にします。

3. 重要度の高いケースに集中できます。 ビジネス上の影響が大きいシナリオ・過去に失敗したシナリオ・ユーザーが頻繁に投げるシナリオに絞ることで、テストが「品質保証の関所」として機能します。

もちろん、少数で始めてカバレッジが足りないと感じたら追加すれば問題ありません。 最初から完璧な数を目指すより、動かしながら育てる方が現実的です。


04ステップ1: 実利用ログからの代表ケース抽出

ログの前処理と匿名化

実利用ログには個人情報が含まれる可能性があります。 メールアドレス・氏名・電話番号・会社名などを含む発話をそのままテストケースに使うことは避け、必ず匿名化処理を通してからデータセットに追加します。

次のコードは、ログエントリから個人情報パターンを検出し、プレースホルダーへ置換するシンプルな実装例です。 本番環境では正規表現だけでなく、固有表現認識(NER)を組み合わせる方法も検討してください。

// anonymize.ts
// Node.js v22.x で動作確認しています

/**
 * ログエントリの個人情報を匿名化します。
 * 本番用途では NER ライブラリの併用を推奨します。
 */
export function anonymizeText(text: string): string {
  // メールアドレス
  let result = text.replace(
    /[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}/g,
    "[EMAIL]"
  );

  // 日本の電話番号(ハイフンあり・なし)
  result = result.replace(
    /0\d{1,4}[-\s]?\d{1,4}[-\s]?\d{4}/g,
    "[PHONE]"
  );

  // 郵便番号
  result = result.replace(/〒?\d{3}[-\s]\d{4}/g, "[ZIPCODE]");

  return result;
}

実行例:

入力: "山田太郎(yamada@example.com)から03-1234-5678に連絡してください"
出力: "山田太郎([EMAIL])から[PHONE]に連絡してください"

氏名については、姓名辞書を使わない単純な正規表現では精度が低くなります。 「山田太郎」のような固有名詞は文脈に依存するため、NERや形態素解析を使うか、手動で確認する体制を取ることをお勧めします。

ログの読み込みと基本集計

ログは多くの場合 JSONL(1行1エントリのJSON)形式で保存されています。 まず全体の傾向を把握する集計を行います。

// log-analyzer.ts
import * as fs from "fs";
import * as readline from "readline";

export interface LogEntry {
  sessionId: string;
  timestamp: string;
  userMessage: string;
  agentResponse: string;
  taskType: string;
  latencyMs: number;
  userRating?: number; // 1〜5、未収集の場合は undefined
}

/**
 * JSONL ファイルを非同期で読み込み、LogEntry の配列を返します。
 */
export async function loadLogs(filePath: string): Promise<LogEntry[]> {
  const entries: LogEntry[] = [];
  const fileStream = fs.createReadStream(filePath, { encoding: "utf-8" });
  const rl = readline.createInterface({ input: fileStream });

  for await (const line of rl) {
    if (line.trim() === "") continue;
    try {
      entries.push(JSON.parse(line) as LogEntry);
    } catch {
      // パース失敗行はスキップ
    }
  }
  return entries;
}

/**
 * タスク種別ごとの件数・平均レイテンシ・平均評価を集計します。
 */
export function summarizeByTaskType(
  entries: LogEntry[]
): Map<string, { count: number; avgLatencyMs: number; avgRating: number }> {
  const groups = new Map<string, LogEntry[]>();

  for (const entry of entries) {
    const list = groups.get(entry.taskType) ?? [];
    list.push(entry);
    groups.set(entry.taskType, list);
  }

  const summary = new Map<
    string,
    { count: number; avgLatencyMs: number; avgRating: number }
  >();

  for (const [taskType, list] of groups) {
    const avgLatencyMs =
      list.reduce((sum, e) => sum + e.latencyMs, 0) / list.length;
    const rated = list.filter((e) => e.userRating !== undefined);
    const avgRating =
      rated.length > 0
        ? rated.reduce((sum, e) => sum + (e.userRating ?? 0), 0) / rated.length
        : 0;
    summary.set(taskType, { count: list.length, avgLatencyMs, avgRating });
  }

  return summary;
}

実行例:

タスク種別: plan-generation
  件数: 128
  平均レイテンシ: 1842ms
  平均評価: 3.7

タスク種別: document-summary
  件数: 64
  平均レイテンシ: 920ms
  平均評価: 4.1

代表ケースの抽出戦略

集計結果をもとに、代表ケースを選ぶ際の考え方を示します。 抽出基準は「重要度が高い順」ではなく「カバレッジを意識した分散」が基本です。

全体ログ(例: 1000件)
  ├── タスク種別ごとに分類
  │     ├── plan-generation (128件)
  │     ├── document-summary (64件)
  │     └── ...
  │
  ├── 各タスク種別から「代表ケース」を選ぶ
  │     ├── 頻出パターン(入力の長さ・意図が典型的なもの): 2〜3件
  │     ├── 低評価ケース(userRating <= 2 のもの): 2〜3件
  │     └── 高評価ケース(userRating >= 4 のもの): 1〜2件
  │
  └── 抽出後: 20〜50件の精鋭セット

次のコードは、低評価ケースを優先的に抽出する実装例です。

// case-extractor.ts
import { anonymizeText } from "./anonymize.js";
import { type LogEntry } from "./log-analyzer.js";

export interface CandidateCase {
  taskType: string;
  userMessage: string;
  agentResponse: string;
  userRating?: number;
  reason: string; // なぜこのケースを選んだか
}

/**
 * ログから代表候補を抽出します。
 * - 低評価ケース(rating <= threshold)を優先
 * - タスク種別ごとに最大 maxPerType 件まで
 */
export function extractCandidates(
  entries: LogEntry[],
  options: {
    lowRatingThreshold?: number; // デフォルト: 2
    maxPerType?: number; // デフォルト: 5
  } = {}
): CandidateCase[] {
  const { lowRatingThreshold = 2, maxPerType = 5 } = options;

  const grouped = new Map<string, LogEntry[]>();
  for (const entry of entries) {
    const list = grouped.get(entry.taskType) ?? [];
    list.push(entry);
    grouped.set(entry.taskType, list);
  }

  const candidates: CandidateCase[] = [];

  for (const [taskType, list] of grouped) {
    let count = 0;

    // 低評価を先に並べる
    const sorted = [...list].sort((a, b) => {
      const ra = a.userRating ?? 99;
      const rb = b.userRating ?? 99;
      return ra - rb;
    });

    for (const entry of sorted) {
      if (count >= maxPerType) break;

      const reason =
        entry.userRating !== undefined && entry.userRating <= lowRatingThreshold
          ? `低評価ケース (rating=${entry.userRating})`
          : "頻出パターン";

      candidates.push({
        taskType,
        userMessage: anonymizeText(entry.userMessage),
        agentResponse: anonymizeText(entry.agentResponse),
        userRating: entry.userRating,
        reason,
      });
      count++;
    }
  }

  return candidates;
}

05ステップ2: 期待出力(ゴールデン)の定義

抽出した候補ケースに対して、「このケースをどう評価するか」を定義します。 これがゴールデンの実体であり、一番手間がかかる作業です。

ゴールデンの3形式

ケースの性質によって、期待出力の形式を使い分けます。

形式 向いているケース
完全一致型 答えが一意に定まる(数値・コード・分類) 「この金額の消費税は?」→ "1,100円"
キーワード包含型 必須要素が含まれているか確認したい 「リスクを教えて」→ ["リスク", "対策"] を含む
ルーブリック型 応答の質・バランスを評価したい 採点基準を5項目で定義する

完全一致型は実装が簡単ですが、エージェントが「1,100円です」と返しても「1,100円」と返しても、文字が異なれば不合格になります。 表現の揺れを許容したい場合はキーワード包含型かルーブリック型を選んでください。

ゴールデンケースのデータ型と定義例

// golden-case.ts

export type ScoringType = "exact" | "keyword" | "rubric";

export interface RubricCriterion {
  name: string;
  description: string;
  weight: number; // 0〜1、合計が 1.0 になるように設定
}

export interface GoldenCase {
  id: string;
  taskType: string;
  input: {
    userMessage: string;
    context?: Record<string, unknown>; // 必要であれば会話履歴や外部情報
  };
  golden: {
    type: ScoringType;
    // exact: 期待する完全一致文字列
    exactMatch?: string;
    // keyword: 含まれるべきキーワード一覧
    requiredKeywords?: string[];
    // rubric: 採点基準リスト
    rubric?: RubricCriterion[];
  };
  metadata: {
    addedDate: string; // ISO 8601
    source: "log" | "manual" | "failure"; // 追加起点
    priority: "high" | "medium" | "low";
    notes?: string;
  };
}

実際のケース定義例です。

// dataset/cases/plan-generation-001.ts
import { type GoldenCase } from "../golden-case.js";

export const planGeneration001: GoldenCase = {
  id: "plan-gen-001",
  taskType: "plan-generation",
  input: {
    userMessage:
      "新規事業の初期フェーズに必要な市場調査の項目を教えてください。",
    context: {
      userRole: "business-owner",
      industry: "SaaS",
    },
  },
  golden: {
    type: "rubric",
    rubric: [
      {
        name: "項目の網羅性",
        description:
          "市場規模・競合・顧客セグメント・課題の4要素が含まれているか",
        weight: 0.4,
      },
      {
        name: "具体性",
        description:
          "各項目に「何を調べるか」が明示されており、行動に移せる粒度か",
        weight: 0.3,
      },
      {
        name: "SaaS文脈への適合",
        description:
          "SaaSビジネス特有の観点(解約率・LTV・PLG等)が含まれているか",
        weight: 0.2,
      },
      {
        name: "回答の読みやすさ",
        description: "箇条書きや見出しを使い、構造が分かりやすいか",
        weight: 0.1,
      },
    ],
  },
  metadata: {
    addedDate: "2026-06-11",
    source: "log",
    priority: "high",
    notes: "BizPlan(事業計画エージェント)の主要ユースケース。頻出パターン。",
  },
};

ルーブリックを書くときの考え方

ルーブリックの基準を「ふわっと」書いてしまうと、LLMジャッジに採点させたときの結果がぶれます。 筆者たちが意識しているのは次の3点です。

採点者が違っても同じ結論に至る粒度で書く。 「良い回答か」ではなく「市場規模・競合・顧客セグメント・課題の4要素が含まれているか」のように、 確認すべき要素を列挙します。

合格基準を先に書く。 「何が含まれていれば合格か」を書いてから「含まれていない場合は何点か」を決める順番が書きやすいです。

重みは合計 1.0 にする。 重みが揃っていないと最終スコアの計算でミスが起きます。 定義時にバリデーションを入れておくと安全です。

// validate-rubric.ts
import { type GoldenCase } from "./golden-case.js";

export function validateGoldenCase(c: GoldenCase): string[] {
  const errors: string[] = [];

  if (c.golden.type === "rubric") {
    const rubric = c.golden.rubric ?? [];
    if (rubric.length === 0) {
      errors.push(`[${c.id}] rubric が空です`);
    }

    const totalWeight = rubric.reduce((sum, r) => sum + r.weight, 0);
    if (Math.abs(totalWeight - 1.0) > 0.001) {
      errors.push(
        `[${c.id}] weight の合計が 1.0 ではありません (${totalWeight.toFixed(3)})`
      );
    }
  }

  if (c.golden.type === "exact" && !c.golden.exactMatch) {
    errors.push(`[${c.id}] exactMatch が未定義です`);
  }

  if (
    c.golden.type === "keyword" &&
    (!c.golden.requiredKeywords || c.golden.requiredKeywords.length === 0)
  ) {
    errors.push(`[${c.id}] requiredKeywords が空です`);
  }

  return errors;
}

実行例:

$ npx ts-node validate-rubric.ts

[plan-gen-001] 検証OK
[doc-sum-002] weight の合計が 1.0 ではありません (0.900)
1 件のエラーが見つかりました

06ステップ3: データセットのファイル形式と管理

ディレクトリ構成

データセットはコードと同じリポジトリで管理し、CI でバリデーションが走るようにします。

eval-dataset/
  ├── cases/
  │   ├── plan-generation-001.ts   # ケース定義
  │   ├── plan-generation-002.ts
  │   ├── document-summary-001.ts
  │   └── ...
  ├── index.ts                     # 全ケースのエクスポート
  ├── golden-case.ts               # 型定義
  ├── validate-rubric.ts           # バリデーション
  └── README.md                    # データセットの説明・更新手順

ケース定義をTypeScriptで書く利点は、型チェックが効くことです。 JSONやYAMLでも管理できますが、スキーマのバリデーションを別途用意する必要があります。 チームの実情に合わせて選んでください。

インデックスファイルの例

// eval-dataset/index.ts
import { planGeneration001 } from "./cases/plan-generation-001.js";
import { planGeneration002 } from "./cases/plan-generation-002.js";
import { documentSummary001 } from "./cases/document-summary-001.js";
import { type GoldenCase } from "./golden-case.js";

export const ALL_CASES: GoldenCase[] = [
  planGeneration001,
  planGeneration002,
  documentSummary001,
];

/**
 * タスク種別でフィルタします。
 */
export function getCasesByTaskType(taskType: string): GoldenCase[] {
  return ALL_CASES.filter((c) => c.taskType === taskType);
}

/**
 * 優先度でフィルタします。
 */
export function getCasesByPriority(
  priority: GoldenCase["metadata"]["priority"]
): GoldenCase[] {
  return ALL_CASES.filter((c) => c.metadata.priority === priority);
}

JSONL 形式へのエクスポート

CI での差分確認や外部ツールへの連携のために、JSONL形式に変換するスクリプトも用意します。

// export-jsonl.ts
import * as fs from "fs";
import { ALL_CASES } from "./index.js";
import { validateGoldenCase } from "./validate-rubric.js";

const outputPath = "eval-dataset/export/golden.jsonl";

// バリデーション
const allErrors: string[] = ALL_CASES.flatMap(validateGoldenCase);
if (allErrors.length > 0) {
  console.error("バリデーションエラー:");
  allErrors.forEach((e) => console.error(` - ${e}`));
  process.exit(1);
}

// エクスポート
const lines = ALL_CASES.map((c) => JSON.stringify(c));
fs.mkdirSync("eval-dataset/export", { recursive: true });
fs.writeFileSync(outputPath, lines.join("\n") + "\n", "utf-8");

console.log(`${ALL_CASES.length} 件を ${outputPath} に出力しました`);

実行例:

$ npx ts-node export-jsonl.ts

28 件を eval-dataset/export/golden.jsonl に出力しました

バージョン管理のポイント

データセットをバージョン管理するときに役立つ習慣を挙げます。

コミットメッセージにケースIDを含める。 feat(eval): add plan-gen-003 (failure case from 2026-06-10) のように書くと、 いつ・なぜ追加されたかを後から追えます。

ケースを削除するときは理由をコメントに残す。 「このケースはもう再現しない」「別のケースに統合した」などを記録します。 何の根拠もなくケースが消えると、過去の失敗への意識が薄れます。

メタデータの source フィールドを活用する。 logmanualfailure の区別を見ると、データセットが「机上の設計」に偏っていないかを把握できます。 failure が少ない場合は、失敗事例の追加が不足しているサインです。


07ステップ4: ゴールデンを使った自動評価の実装

データセットが揃ったら、実際にエージェントを呼んでスコアを計算します。

完全一致型とキーワード型の評価

// evaluator.ts
import { type GoldenCase } from "./golden-case.js";

export interface EvalResult {
  caseId: string;
  passed: boolean;
  score: number; // 0〜1
  detail: string;
}

/**
 * 完全一致型の評価。
 * 前後の空白と改行を除去してから比較します。
 */
function evalExact(actual: string, expected: string): EvalResult["score"] {
  const normalize = (s: string) => s.trim().replace(/\s+/g, " ");
  return normalize(actual) === normalize(expected) ? 1.0 : 0.0;
}

/**
 * キーワード包含型の評価。
 * 含まれているキーワード数 / 必要キーワード数 をスコアにします。
 */
function evalKeyword(actual: string, keywords: string[]): number {
  const matched = keywords.filter((kw) => actual.includes(kw));
  return keywords.length > 0 ? matched.length / keywords.length : 0;
}

/**
 * キーワード型・完全一致型のケースを評価します。
 * ルーブリック型は LLM ジャッジが必要なため別関数で処理します。
 */
export function evaluateCase(
  goldenCase: GoldenCase,
  actualResponse: string
): EvalResult {
  const { id, golden } = goldenCase;

  if (golden.type === "exact") {
    const score = evalExact(actualResponse, golden.exactMatch ?? "");
    return {
      caseId: id,
      passed: score === 1.0,
      score,
      detail:
        score === 1.0
          ? "完全一致"
          : `不一致: expected "${golden.exactMatch}"`,
    };
  }

  if (golden.type === "keyword") {
    const keywords = golden.requiredKeywords ?? [];
    const score = evalKeyword(actualResponse, keywords);
    const missing = keywords.filter((kw) => !actualResponse.includes(kw));
    return {
      caseId: id,
      passed: score >= 1.0,
      score,
      detail:
        missing.length === 0 ? "全キーワード一致" : `不足: ${missing.join(", ")}`,
    };
  }

  // rubric は LLM ジャッジが必要なため保留
  return {
    caseId: id,
    passed: false,
    score: 0,
    detail: "rubric 型は LLM ジャッジが必要です",
  };
}

ルーブリック型のLLMジャッジ実装

ルーブリック型の採点はLLMに委ねます。 採点基準をシステムプロンプトに含め、採点結果をJSONで返させます。

// llm-judge.ts
import OpenAI from "openai";
import { type GoldenCase, type RubricCriterion } from "./golden-case.js";
import { type EvalResult } from "./evaluator.js";

const client = new OpenAI(); // OPENAI_API_KEY を環境変数から読みます

interface CriterionScore {
  name: string;
  score: number; // 0〜1
  reason: string;
}

interface JudgeOutput {
  criterionScores: CriterionScore[];
  totalScore: number;
  summary: string;
}

/**
 * ルーブリック採点のプロンプトを組み立てます。
 */
function buildJudgePrompt(
  userMessage: string,
  actualResponse: string,
  rubric: RubricCriterion[]
): string {
  const criteriaText = rubric
    .map(
      (r, i) =>
        `${i + 1}. ${r.name}(重み: ${r.weight})\n   ${r.description}`
    )
    .join("\n");

  return `あなたはAIエージェントの応答品質を評価する採点者です。
以下の採点基準に従い、応答を公平に採点してください。

## ユーザーの入力
${userMessage}

## エージェントの応答
${actualResponse}

## 採点基準
${criteriaText}

## 出力形式
次の JSON 形式のみで応答してください。説明文は不要です。

{
  "criterionScores": [
    { "name": "基準名", "score": 0〜1の数値, "reason": "判断理由" }
  ],
  "totalScore": 重み付き合計スコア(0〜1),
  "summary": "全体の採点コメント(1〜2文)"
}`;
}

/**
 * LLM に採点させ、EvalResult を返します。
 */
export async function evaluateWithLLMJudge(
  goldenCase: GoldenCase,
  actualResponse: string
): Promise<EvalResult> {
  const { id, input, golden } = goldenCase;
  const rubric = golden.rubric ?? [];

  const prompt = buildJudgePrompt(
    input.userMessage,
    actualResponse,
    rubric
  );

  const response = await client.chat.completions.create({
    model: "gpt-5.4-mini",
    messages: [{ role: "user", content: prompt }],
    response_format: { type: "json_object" },
    // GPT-5系では temperature 等のサンプリングパラメータを原則指定できません。
    // 採点の再現性は、複数回実行の多数決やルーブリックの明確化で担保します。
  });

  const raw = response.choices[0]?.message?.content ?? "{}";
  const output = JSON.parse(raw) as JudgeOutput;

  const threshold = 0.7; // 合格基準(調整可能)
  return {
    caseId: id,
    passed: output.totalScore >= threshold,
    score: output.totalScore,
    detail: output.summary,
  };
}

実行例(コンソール出力):

case: plan-gen-001
  score: 0.82
  passed: true
  detail: "市場調査の4要素をカバーしており具体的。SaaS固有の観点(LTV言及)は薄い。"

case: plan-gen-002
  score: 0.54
  passed: false
  detail: "競合調査の項目が不足しており、各項目の粒度も粗い。"

評価レポートの出力

全ケースを走らせてサマリーを出力します。

// run-eval.ts
import { ALL_CASES, getCasesByPriority } from "./eval-dataset/index.js";
import { evaluateCase } from "./eval-dataset/evaluator.js";
import { evaluateWithLLMJudge } from "./eval-dataset/llm-judge.js";
import { type GoldenCase } from "./eval-dataset/golden-case.js";
import { type EvalResult } from "./eval-dataset/evaluator.js";

/**
 * エージェントを呼び出す関数(実装は各プロジェクトに合わせて置き換えます)
 */
async function callAgent(
  input: GoldenCase["input"]
): Promise<string> {
  // ...実際のエージェント呼び出し処理...
  return "ここにエージェントの応答が入ります";
}

async function main() {
  // 高優先ケースから先に実行
  const cases = [
    ...getCasesByPriority("high"),
    ...getCasesByPriority("medium"),
    ...getCasesByPriority("low"),
  ];

  const results: EvalResult[] = [];

  for (const c of cases) {
    const actual = await callAgent(c.input);

    let result: EvalResult;
    if (c.golden.type === "rubric") {
      result = await evaluateWithLLMJudge(c, actual);
    } else {
      result = evaluateCase(c, actual);
    }

    results.push(result);
    const mark = result.passed ? "PASS" : "FAIL";
    console.log(`[${mark}] ${result.caseId} score=${result.score.toFixed(2)}`);
  }

  const passed = results.filter((r) => r.passed).length;
  const avgScore =
    results.reduce((sum, r) => sum + r.score, 0) / results.length;

  console.log("\n--- サマリー ---");
  console.log(`合格: ${passed} / ${results.length}`);
  console.log(`平均スコア: ${avgScore.toFixed(2)}`);
}

main().catch(console.error);

実行例:

[PASS] plan-gen-001 score=0.82
[FAIL] plan-gen-002 score=0.54
[PASS] doc-sum-001 score=0.91
...

--- サマリー ---
合格: 22 / 28
平均スコア: 0.74

08ステップ5: データセットを育てる仕組み

ゴールデンデータセットは「作って終わり」にはしません。 開発が進むにつれてエージェントの振る舞いが変わり、新しい失敗パターンが生まれます。 データセットを育てることで、過去の失敗が将来の防護柵になります。

失敗事例の追加フロー

本番でエージェントが失敗したとき(ユーザーからのクレーム・社内レビューでの指摘・自動評価でのFAIL継続など)は、 そのケースをデータセットに追加します。 次の手順が筆者たちの基本フローです。

1. 失敗ログを特定(セッションID・タイムスタンプ)
2. 匿名化処理を通す
3. なぜ失敗したかを分析(採点基準のどの要素が欠けていたか)
4. GoldenCase を定義(source: "failure" で追加)
5. プロンプトを修正してケースが PASS になることを確認
6. CI でデータセット全体を走らせ、既存ケースが壊れていないことを確認
// add-failure-case.ts
// 失敗事例を追加するときのテンプレートです。
// 実際のケース定義は cases/ 配下に追加します。

import { type GoldenCase } from "../golden-case.js";

// このファイルはテンプレートです。実際には cases/[tasktype]-[番号].ts として保存します。
export const failureTemplate: GoldenCase = {
  id: "plan-gen-003",
  taskType: "plan-generation",
  input: {
    userMessage:
      "競合が少ないニッチ市場での事業立ち上げ時の注意点を教えてください。",
  },
  golden: {
    type: "rubric",
    rubric: [
      {
        name: "ニッチ市場特有のリスク",
        description:
          "市場縮小リスク・参入障壁の低さ・需要の不安定性などに言及しているか",
        weight: 0.4,
      },
      {
        name: "検証アプローチ",
        description:
          "スモールスタート・MVP・顧客インタビューなど仮説検証の具体策が含まれているか",
        weight: 0.35,
      },
      {
        name: "スケール戦略",
        description:
          "ニッチから隣接市場への展開路線に触れているか",
        weight: 0.25,
      },
    ],
  },
  metadata: {
    addedDate: "2026-06-11",
    source: "failure",
    priority: "high",
    notes:
      "2026-06-10の本番ログ。競合・リスクの観点が薄く、ユーザーが「役に立たなかった」と評価(rating=1)。",
  },
};

重複ケースの検出

データセットが増えるにつれて、「ほぼ同じ入力のケースが複数ある」状態が起きます。 完全な重複検出は難しいですが、入力文字列の簡単な類似度チェックで粗いフィルタリングができます。

// dedup-check.ts
import { ALL_CASES } from "./eval-dataset/index.js";

/**
 * 2文字列間のジャカード係数(空白区切り単語ベース)を返します。
 * 高速な近似チェック用です。精密な類似度が必要な場合はベクトル距離を使います。
 * 注意: 日本語テキストは空白で区切られないため、文全体が1語として扱われます。
 * 日本語に適用する場合は文字n-gramや形態素解析ベースの類似度への置き換えを検討してください。
 */
function jaccardSimilarity(a: string, b: string): number {
  const wordsA = new Set(a.split(/\s+/));
  const wordsB = new Set(b.split(/\s+/));
  const intersection = new Set([...wordsA].filter((w) => wordsB.has(w)));
  const union = new Set([...wordsA, ...wordsB]);
  return union.size === 0 ? 0 : intersection.size / union.size;
}

/**
 * 類似度が threshold 以上のケアペアを報告します。
 */
export function detectDuplicates(threshold = 0.7) {
  const cases = ALL_CASES;
  const pairs: Array<{ idA: string; idB: string; similarity: number }> = [];

  for (let i = 0; i < cases.length; i++) {
    for (let j = i + 1; j < cases.length; j++) {
      const sim = jaccardSimilarity(
        cases[i].input.userMessage,
        cases[j].input.userMessage
      );
      if (sim >= threshold) {
        pairs.push({ idA: cases[i].id, idB: cases[j].id, similarity: sim });
      }
    }
  }

  return pairs;
}

// 実行
const duplicates = detectDuplicates(0.7);
if (duplicates.length === 0) {
  console.log("重複なし");
} else {
  console.log(`${duplicates.length} 件の類似ケアペアを検出しました`);
  duplicates.forEach((p) =>
    console.log(
      `  ${p.idA} <-> ${p.idB} (similarity=${p.similarity.toFixed(2)})`
    )
  );
}

実行例:

2 件の類似ケアペアを検出しました
  plan-gen-001 <-> plan-gen-004 (similarity=0.73)
  doc-sum-001 <-> doc-sum-003 (similarity=0.81)

類似ケースが見つかったら、2件を1件に統合するか、カバーしている側面が異なることを確認してそのまま残すかを判断します。

カバレッジのモニタリング

データセット全体の構成を定期的に確認します。 「どのタスク種別が手薄か」「失敗起源のケースが少なすぎないか」を見るだけでも、偏りに気づきやすくなります。

// coverage-report.ts
import { ALL_CASES } from "./eval-dataset/index.js";

export function printCoverageReport() {
  const byTaskType = new Map<string, number>();
  const bySource = new Map<string, number>();
  const byPriority = new Map<string, number>();

  for (const c of ALL_CASES) {
    byTaskType.set(c.taskType, (byTaskType.get(c.taskType) ?? 0) + 1);
    bySource.set(
      c.metadata.source,
      (bySource.get(c.metadata.source) ?? 0) + 1
    );
    byPriority.set(
      c.metadata.priority,
      (byPriority.get(c.metadata.priority) ?? 0) + 1
    );
  }

  console.log(`\n=== カバレッジレポート(合計: ${ALL_CASES.length} 件)===`);

  console.log("\n[タスク種別]");
  for (const [k, v] of byTaskType) {
    console.log(`  ${k}: ${v} 件`);
  }

  console.log("\n[ケース起源]");
  for (const [k, v] of bySource) {
    console.log(`  ${k}: ${v} 件`);
  }

  console.log("\n[優先度]");
  for (const [k, v] of byPriority) {
    console.log(`  ${k}: ${v} 件`);
  }
}

printCoverageReport();

実行例:

=== カバレッジレポート(合計: 28 件)===

[タスク種別]
  plan-generation: 14 件
  document-summary: 8 件
  question-answering: 6 件

[ケース起源]
  log: 18 件
  manual: 7 件
  failure: 3 件

[優先度]
  high: 12 件
  medium: 10 件
  low: 6 件

この例では failure が 3 件と少なく、本番でまだ失敗を蓄積できていない状態です。 failure が 10〜20% 程度になると、データセットが実際の弱点を反映した構成に近づいていきます。 ただし、これはあくまで目安であり、プロダクトの性質や運用期間によって変わります。


09データセットの規模感について

「何件あれば十分か」はよく聞かれる質問ですが、プロダクトの規模・タスク種別の多様性・チームのリソースによって異なるため、一概には言えません。 筆者たちが参考にしている考え方を共有します。

開発初期(0〜3ヶ月)

20〜30件から始めることを目安にしています。 この段階では「重要なケースを全部カバーする」より「CI が回せる状態を早く作る」方が価値があります。

安定期(3ヶ月以降)

失敗事例が積み上がるにつれて、50〜100件の範囲に自然と成長することが多いです。 この規模になると、タスク種別ごとのスコアトレンド(週次・月次での推移)が見えるようになります。

スコアが安定してきたら

数百件規模への拡張は、品質モニタリングよりもリグレッションテストに使う局面で検討します。 ただし、件数を増やすほど各ケースへの注意が薄れるリスクもあります。 「件数」より「各ケースの品質とカバレッジ」を優先する方針は、規模が大きくなっても変わりません。


10まとめ

この記事では、ゴールデンデータセットの構築を5つのステップで解説しました。

  1. 実利用ログを匿名化し、タスク種別・評価スコアで分類して代表ケースを抽出する
  2. 期待出力を「完全一致・キーワード包含・ルーブリック」の3形式で定義し、採点基準を明文化する
  3. TypeScriptで型を持たせて管理し、CIで自動バリデーションを走らせる
  4. キーワード型はコードで、ルーブリック型はLLMジャッジで自動採点する
  5. 失敗事例を source: "failure" で追加し、カバレッジレポートで偏りを確認しながら育てる

データセットは「一度作ったら完成」ではなく、プロダクトと一緒に成長させるものです。 失敗が起きるたびにケースを追加し、そのケースが将来の修正を守る防護柵になる、というサイクルを回すことが、長期的な品質安定につながります。

次回は「LLMジャッジの信頼性をどう検証するか」をテーマに、採点器自体の品質評価について書く予定です。


11参考文献

Author
管理者
Agent Store

記事で紹介した技術を、実際の業務でお試しください。

業務に合うエージェントを条件で絞り込んで選べます。すべて無料で、今すぐ利用できます。

エージェント一覧を見る →

コメント

まだコメントはありません。最初のコメントを投稿してみましょう。

コメントを投稿

ゲストコメントは管理者の承認後に公開されます。 ログインするとすぐにコメントが公開されます。

当サイトではCookieを使用しています。詳しくはCookieポリシーをご覧ください。