はじめに
私は普段、ClickHouseを活用・運用しているわけではありません。今回のQiita Tech Festaをきっかけに、「AIの評価結果を継続的に蓄積し、後から検証するためのデータベースには、どのような性質が必要なのだろうか」という関心から、ClickHouseについて調べてみることにしました。
大学で研究や教育に携わっていると、生成AIの性能を一つの数値だけで評価することの難しさを感じる場面があります。
たとえば、新しいモデルやプロンプトへ切り替えた結果、全体の平均スコアは改善したとしても、特定の言語、特定の課題、あるいは難しい入力だけで性能が低下しているかもしれません。また、品質が改善していても、処理時間やコストが大きく増加していれば、実際の利用には適さない可能性があります。
このような問題を考えるには、単に最新の評価値を保存するだけでは不十分です。
- どのモデルを使ったのか
- どのプロンプトを使ったのか
- どの評価ケースを実行したのか
- 以前の版と比べて何が変わったのか
- 特定の条件だけで悪化していないか
- 品質、速度、コストの間にどのような関係があるか
といった情報を、後から複数の観点で集計できる形にしておく必要があります。
そこで本記事では、Google Colab上で利用できるchDBを使い、ClickHouseによるAI評価ログの分析を試します。chDBは、ClickHouseの分析エンジンをPythonプロセス内で実行できる仕組みです。別途データベースサーバーを構築しなくても利用できるため、ClickHouseを初めて試す入口として扱いやすいと考えました。
今回使用するデータは、すべてSQLで生成した架空の評価ログです。実在するAIモデルの回答、個人情報、大学の内部データ、研究データ、学生に関する情報は使用しません。
実験では、50万件の評価ケースに対して3種類の候補を実行したことにし、合計150万行のデータを作成します。そして、次の点を確認します。
- 全体平均だけを見た場合、どのような判断になるか
- 言語やタスクごとに分けると、別の傾向が見つかるか
- 同じ評価ケースについて、新旧の結果を直接比較できるか
- 平均遅延だけでなく、P95やP99の遅延を確認できるか
- 品質、コスト、速度を同じ評価履歴から分析できるか
先に結果を述べると、今回のダミーデータでは、新しい候補版は全体平均では性能が改善しているように見えました。しかし、条件を分けて調べると、「日本語の要約」だけで明確な品質低下が見つかりました。また、典型的な処理時間は大きく変わらない一方で、P99遅延は基準版より大幅に増加していました。
これは、あらかじめ埋め込んだ架空の傾向ではありますが、AIシステムを評価するときに、平均値だけでは見えない問題があることを示す例にはなります。
本記事は、ClickHouseの高度な運用方法や、大規模クラスタの構築方法を解説するものではありません。ClickHouseを今回初めて本格的に調べた立場から、まずは小さな環境で動かし、
AI評価ログをデータベースへ保存すると、どのような問いをSQLで検証できるのか
を、初学者向けに順を追って確認することを目的とします。
本記事では、Google Colab上でサーバー不要のClickHouse実行環境であるchDBを使い、次のダミー評価ログを分析します。
- 評価ケース:50万件
- 比較候補:3種類
- 合計:150万行
- 言語:日本語・英語・スペイン語
- タスク:質問応答・要約・情報抽出・コード
- 保存する指標:品質、合否、遅延、トークン数、コスト、エラー
データはすべてSQLで人工的に作ります。実在するモデルの回答、プロンプト、個人情報、研究データ、外部AI APIは使用しません。
先に結果を書くと、ダミーの候補版candidateは全体平均では改善しました。しかし、言語とタスクで分けると、日本語の要約だけ平均スコアが0.0802低下し、同一ケース比較では75.72%が0.05点を超えて悪化していました。さらにP99遅延も基準版のほぼ2倍でした。
この記事で伝えたいことは、単に「ClickHouseは速い」ということではありません。
AI時代のデータベースには、最新の回答だけでなく、モデル・プロンプト・評価ケース・品質・コスト・遅延の履歴を残し、全体平均、スライス、同一ケース差分、テール遅延をすぐ調べられることが求められる。
という点です。
この記事で分かること
- ClickHouse、OLAP、列指向という用語の意味
- サーバーを立てずにGoogle ColabでClickHouseを試す方法
- 150万行のダミーAI評価ログをSQLだけで生成する方法
- 平均値の陰に隠れた「特定条件だけの劣化」を見つける方法
- P50、P95、P99で遅延の裾を調べる方法
- AI評価履歴をデータベースへ保存するときの基本設計
まず用語を整理する
ClickHouseとは
ClickHouseは、オンライン分析処理、つまりOLAPを得意とする列指向のSQLデータベースです。
データベースの仕事は大きく分けると、次の2種類があります。
| 種類 | 主な仕事 | 例 |
|---|---|---|
| OLTP | 少数の行を追加・更新・検索する | ユーザー登録、注文、在庫更新 |
| OLAP | 大量の行を読み、集計・比較する | ログ分析、売上集計、AI評価履歴の比較 |
AI評価ログに対して知りたいのは、たとえば次のようなことです。
- 150万件の平均品質
- モデル別の合格率
- 日本語かつ要約だけの品質差
- 遅延のP95とP99
- 同じ評価ケースで新旧版がどれだけ変わったか
これは大量の行をまとめて読むOLAPの仕事です。
列指向とは
通常の表は行と列でできています。
case_id | language | task_type | quality_score | latency_ms
--------+----------+-----------+---------------+-----------
1 | ja | summary | 0.81 | 1850
2 | en | qa | 0.76 | 1320
行指向のデータベースは、1行分の値を近くに保存するのが得意です。一方、列指向のデータベースは、同じ列の値をまとめて扱います。
たとえば平均品質を計算するときに主に必要なのはquality_score列です。列指向DBは、分析に必要な列だけを効率よく読みやすい設計になっています。
chDBとは
chDBは、ClickHouseの分析エンジンをPythonプロセス内で使えるインプロセスOLAPエンジンです。
通常のClickHouseではサーバーを起動して接続しますが、chDBなら次の形で始められます。
import chdb
chdb.query("SELECT 1 + 1")
本番の高可用性、複数ユーザーからの接続、レプリケーションなどを試すものではありませんが、Google ColabでSQL、テーブル設計、集計方法を学ぶには扱いやすい選択肢です。
AI評価ログとは
AI評価ログは、ある評価ケースに対して、どの版を実行し、どのような結果になったかを記録したものです。
本記事では次の列を保存します。
| 列 | 意味 |
|---|---|
case_id |
同じ問題を識別する番号 |
variant |
比較する候補。基準版、候補版、低コスト版 |
model_version |
モデルの版 |
prompt_version |
プロンプトの版 |
language |
言語 |
task_type |
質問応答、要約、抽出、コード |
quality_score |
0から1のダミー品質スコア |
passed |
合格かどうか |
latency_ms |
処理時間。単位はミリ秒 |
cost_usd |
ダミーの推定コスト |
error_flag |
エラーが起きたか |
baseline、candidate、economy
今回は3種類を比較します。
| variant | 意味 |
|---|---|
baseline |
現在使っている基準版 |
candidate |
リリース候補の新版 |
economy |
品質よりコストを優先した版 |
名前も価格もすべて架空です。特定企業や実在モデルの性能を表すものではありません。
スライス分析とは
スライスは、データを特定の条件で切り分けた小集団です。
全体
├── 日本語
│ ├── 質問応答
│ ├── 要約
│ ├── 情報抽出
│ └── コード
├── 英語
└── スペイン語
全体平均だけでなく、「日本語×要約」のような単位で見ることを、本記事ではスライス分析と呼びます。
P50、P95、P99とは
P95は、値を小さい順に並べたとき、95%のデータがその値以下に収まる境目です。
たとえばP95遅延が2,800msなら、約95%の処理は2,800ms以内で終わり、残り約5%はそれより遅い、という意味です。
- P50:中央値。典型的な体感に近い
- P95:遅い側の利用者を確認する
- P99:かなり遅い裾を確認する
平均遅延だけでは、ごく一部の極端に遅い処理を見落とすことがあります。
実験の設計
50万件の評価ケースそれぞれに対して、3候補を実行したことにします。
$$
500{,}000\ \text{cases} \times 3\ \text{variants}
= 1{,}500{,}000\ \text{rows}
$$
ダミーデータには、あえて次の特徴を埋め込みます。
-
candidateは通常のスライスでは品質が約0.04改善する - ただし「日本語×要約」だけは追加で0.12低下する
-
candidateは入力・出力トークンが少し多い -
candidateはP99側に重い遅延を持つ -
economyは品質が低い代わりに安くて速い
つまり、全体平均だけ見るとcandidateは良さそうですが、詳しく調べるとリリース判断を止めたくなる設計です。
実行環境
GPUは使いません。Google ColabのCPUランタイムで十分です。
事前検証では次の環境を使いました。
| 項目 | 値 |
|---|---|
| Python | 3.13.5 |
| chDB | 4.2.0 |
| ClickHouse kernel | 26.5.1.1 |
| 評価ケース | 500,000 |
| 行数 | 1,500,000 |
実行時間はColabの割り当てCPUや混雑状況で変わります。後で示す時間は普遍的なベンチマークではなく、実行例です。
1. chDBをインストールする
Google Colabで新しいノートブックを開き、次を実行します。
%pip -q install "chdb==4.2.0" pandas matplotlib
2. 初期化する
from __future__ import annotations
from pathlib import Path
import platform
import shutil
import statistics
import time
import chdb
import matplotlib.pyplot as plt
import pandas as pd
from chdb import session as chs
from IPython.display import display
ROOT = Path("/content/clickhouse_ai_eval")
DB_PATH = ROOT / "ai_eval.chdb"
RESULT_DIR = ROOT / "results"
FIGURE_DIR = ROOT / "figures"
# セルをやり直したときに、以前のSessionが残っていれば閉じます。
try:
sess.close()
except Exception:
pass
shutil.rmtree(ROOT, ignore_errors=True)
RESULT_DIR.mkdir(parents=True, exist_ok=True)
FIGURE_DIR.mkdir(parents=True, exist_ok=True)
N_CASES = 500_000
N_VARIANTS = 3
sess = chs.Session(str(DB_PATH))
print("Python:", platform.python_version())
print("chDB:", chdb.__version__)
print(
sess.query(
"SELECT version() AS clickhouse_kernel_version",
"DataFrame",
).to_string(index=False)
)
実行すると、Python、chDB、内部のClickHouseカーネルのバージョンが表示されます。
3. テーブルを作る
sess.query("CREATE DATABASE IF NOT EXISTS ai_eval")
sess.query("DROP TABLE IF EXISTS ai_eval.eval_results")
CREATE_SQL = r"""
CREATE TABLE ai_eval.eval_results
(
run_at DateTime,
case_id UInt32,
variant LowCardinality(String),
model_version LowCardinality(String),
prompt_version LowCardinality(String),
language LowCardinality(String),
task_type LowCardinality(String),
difficulty LowCardinality(String),
quality_score Float32,
passed UInt8,
latency_ms UInt32,
input_tokens UInt32,
output_tokens UInt32,
cost_usd Float64,
error_flag UInt8
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(run_at)
ORDER BY (variant, language, task_type, difficulty, case_id)
"""
sess.query(CREATE_SQL)
print("テーブルを作成しました。")
テーブル定義の見どころ
LowCardinality(String)
variantやlanguageのように、値の種類が少なく同じ文字列が何度も現れる列に使っています。
ClickHouseのLowCardinalityは、辞書エンコーディングを使って値を扱います。今回のようなカテゴリ列と相性があります。
MergeTree
MergeTreeは、ClickHouseで中心的に使われるテーブルエンジンです。
ORDER BY
ORDER BY (
variant,
language,
task_type,
difficulty,
case_id
)
ここでのORDER BYは、SELECT結果の表示順ではなく、保存時のソートキーです。
本記事では、variant、language、task_typeで絞る分析を多く行うため、この順番にしています。実運用では、最も多い検索条件に合わせて設計します。
なお、case_id単位の新旧比較が最重要なら、case_idを前に置く設計や、別のProjection・集計テーブルを用意する設計も候補になります。ソートキーには「すべてのクエリに唯一の正解」があるわけではなく、頻出する問い合わせとのトレードオフがあります。
PARTITION BY
PARTITION BY toYYYYMM(run_at)
月単位に分ける設計です。ただし今回のダミーデータは2026年6月だけなので、パーティション削減の性能は評価していません。複数月の保持や削除を想定した例として入れています。
4. 150万行のダミーデータを作る
次のSQLは、ClickHouseのnumbers()から連番を作り、ハッシュ値を疑似乱数のように使ってデータを生成します。
ハッシュ値から値を作るため、ダミーデータの分布は再現しやすくなっています。ただし、後で使うquantileTDigestは近似分位数なので、実行順序やバージョンによって末尾の値がわずかに変わる可能性があります。
INSERT_SQL = f"""
INSERT INTO ai_eval.eval_results
WITH
toUInt32(number % {N_CASES}) AS case_id,
toUInt8(intDiv(number, {N_CASES})) AS variant_id,
cityHash64(case_id, 11) AS h1,
cityHash64(case_id, 22) AS h2,
cityHash64(case_id, 33) AS h3,
cityHash64(case_id, 44) AS h4,
cityHash64(case_id, variant_id, 55) AS hv,
multiIf(
variant_id = 0, 'baseline',
variant_id = 1, 'candidate',
'economy'
) AS variant,
multiIf(
variant_id = 0, 'model-standard-v1',
variant_id = 1, 'model-standard-v2',
'model-economy-v1'
) AS model_version,
multiIf(
variant_id = 0, 'prompt-v1',
variant_id = 1, 'prompt-v2',
'prompt-v1'
) AS prompt_version,
multiIf(
h1 % 100 < 50, 'ja',
h1 % 100 < 90, 'en',
'es'
) AS language,
multiIf(
h2 % 4 = 0, 'qa',
h2 % 4 = 1, 'summarization',
h2 % 4 = 2, 'extraction',
'code'
) AS task_type,
multiIf(
h3 % 100 < 45, 'easy',
h3 % 100 < 80, 'medium',
'hard'
) AS difficulty,
0.78
+ multiIf(
task_type = 'extraction', 0.05,
task_type = 'code', -0.03,
0.0
)
+ multiIf(
difficulty = 'medium', -0.08,
difficulty = 'hard', -0.18,
0.0
)
+ multiIf(
language = 'ja', -0.015,
language = 'es', -0.03,
0.0
)
+ multiIf(
variant = 'candidate', 0.04,
variant = 'economy', -0.06,
0.0
)
+ if(
variant = 'candidate'
AND language = 'ja'
AND task_type = 'summarization',
-0.12,
0.0
)
+ (toFloat64(h4 % 1601) / 10000.0 - 0.08)
+ (toFloat64(hv % 1001) / 10000.0 - 0.05)
AS raw_quality,
toFloat32(
greatest(0.0, least(1.0, raw_quality))
) AS quality_score,
multiIf(
task_type = 'code', 1700,
task_type = 'summarization', 1300,
task_type = 'extraction', 850,
1000
)
+ multiIf(
difficulty = 'medium', 250,
difficulty = 'hard', 600,
0
)
+ multiIf(
variant = 'candidate', 180,
variant = 'economy', -180,
0
)
+ toUInt32(h1 % 500)
+ multiIf(
variant = 'candidate' AND hv % 1000 < 35,
3500 + toUInt32(h3 % 2500),
variant = 'baseline' AND hv % 1000 < 12,
1800 + toUInt32(h3 % 1500),
variant = 'economy' AND hv % 1000 < 6,
1200 + toUInt32(h3 % 1000),
0
) AS latency_ms,
toUInt32(
280
+ multiIf(
task_type = 'summarization', 900,
task_type = 'code', 450,
task_type = 'extraction', 180,
300
)
+ multiIf(
difficulty = 'medium', 160,
difficulty = 'hard', 420,
0
)
+ if(variant = 'candidate', 80, 0)
+ h2 % 160
) AS input_tokens,
toUInt32(
90
+ multiIf(
task_type = 'summarization', 280,
task_type = 'code', 420,
task_type = 'extraction', 80,
150
)
+ multiIf(difficulty = 'hard', 120, 0)
+ if(variant = 'candidate', 20, 0)
+ h3 % 100
) AS output_tokens,
toUInt8(
hv % 1000 < multiIf(
variant = 'baseline', 12,
variant = 'candidate', 15,
8
)
) AS error_flag,
toFloat64(
input_tokens * multiIf(
variant = 'economy', 0.00000035, 0.0000012
)
+ output_tokens * multiIf(
variant = 'economy', 0.0000010, 0.0000048
)
) AS cost_usd
SELECT
toDateTime('2026-06-01 00:00:00')
+ toIntervalDay(case_id % 30)
+ toIntervalSecond(h1 % 86400) AS run_at,
case_id,
variant,
model_version,
prompt_version,
language,
task_type,
difficulty,
quality_score,
toUInt8(
quality_score >= 0.70 AND error_flag = 0
) AS passed,
latency_ms,
input_tokens,
output_tokens,
cost_usd,
error_flag
FROM numbers({N_CASES * N_VARIANTS})
"""
started = time.perf_counter()
sess.query(INSERT_SQL)
insert_seconds = time.perf_counter() - started
print(f"{N_CASES * N_VARIANTS:,}行を生成しました。")
print(f"INSERT時間: {insert_seconds:.3f}秒")
※こちらのGoogleColab CPU環境では、150万行のINSERTは約2.766秒でした。
5. 保存サイズを確認する
STORAGE_SQL = r"""
SELECT
sum(rows) AS rows,
formatReadableSize(sum(bytes_on_disk)) AS disk_size,
formatReadableSize(sum(data_uncompressed_bytes))
AS uncompressed_size,
round(
sum(data_uncompressed_bytes)
/ sum(data_compressed_bytes),
2
) AS compression_ratio
FROM system.parts
WHERE active
AND database = 'ai_eval'
AND table = 'eval_results'
"""
storage = sess.query(STORAGE_SQL, "DataFrame")
display(storage)
storage.to_csv(RESULT_DIR / "storage.csv", index=False)
事前検証結果は次のとおりでした。
| rows | disk_size | uncompressed_size | compression_ratio |
|---|---|---|---|
| 1,500,000 | 33.73 MiB | 55.85 MiB | 1.66 |
この値はデータ型、ClickHouseの版、圧縮設定によって変わります。
6. 中身を少し見る
sample = sess.query(
"""
SELECT *
FROM ai_eval.eval_results
ORDER BY case_id, variant
LIMIT 8
""",
"DataFrame",
)
display(sample)
同じcase_idに対して、baseline、candidate、economyの3行が存在します。
この「同じ問題を複数候補で解いた」という対応関係が、後のペア比較で重要になります。
7. まず全体平均を見る
OVERALL_SQL = r"""
SELECT
variant,
count() AS rows,
round(avg(quality_score), 4) AS avg_quality,
round(100 * avg(passed), 2) AS pass_rate_pct,
round(100 * avg(error_flag), 2) AS error_rate_pct,
round(quantileTDigest(0.50)(latency_ms), 0) AS p50_ms,
round(quantileTDigest(0.95)(latency_ms), 0) AS p95_ms,
round(quantileTDigest(0.99)(latency_ms), 0) AS p99_ms,
round(
1000 * sum(cost_usd) / count(),
4
) AS cost_per_1000_requests_usd
FROM ai_eval.eval_results
GROUP BY variant
ORDER BY variant
"""
overall = sess.query(OVERALL_SQL, "DataFrame")
display(overall)
overall.to_csv(RESULT_DIR / "overall.csv", index=False)
本記事では読みやすさを優先し、P50・P95・P99をquantileTDigestで個別に書いています。複数の分位点を一度に求める実運用コードでは、状態を共有できるquantilesTDigest(0.50, 0.95, 0.99)も検討できます。
検証結果は次のとおりです。
| variant | rows | avg_quality | pass_rate_pct | error_rate_pct | p50_ms | p95_ms | p99_ms | cost_per_1000_requests_usd |
|---|---|---|---|---|---|---|---|---|
| baseline | 500,000 | 0.7105 | 55.86 | 1.20 | 1,653 | 2,434 | 3,588 | 3.0491 |
| candidate | 500,000 | 0.7355 | 64.02 | 1.50 | 1,849 | 2,819 | 7,131 | 3.2411 |
| economy | 500,000 | 0.6505 | 31.54 | 0.82 | 1,469 | 2,235 | 2,572 | 0.7309 |
candidateは、平均品質が0.7105から0.7355へ上がっています。
$$
0.7355 - 0.7105 = 0.0250
$$
合格率も55.86%から64.02%へ上がりました。ここだけ見れば、候補版を採用したくなります。
plt.figure(figsize=(8, 5))
plt.bar(overall["variant"], overall["avg_quality"])
plt.ylim(0, 0.8)
plt.xlabel("Variant")
plt.ylabel("Average quality score")
plt.title("Overall quality looks better for the candidate")
for i, value in enumerate(overall["avg_quality"]):
plt.text(i, value + 0.012, f"{value:.4f}", ha="center")
plt.tight_layout()
plt.savefig(
FIGURE_DIR / "01_overall_quality.png",
dpi=180,
)
plt.show()
しかし、この平均には罠があります。
8. 言語とタスクで分ける
同じcase_idについて、基準版と候補版の品質差を計算します。
SLICE_SQL = r"""
WITH paired AS
(
SELECT
case_id,
language,
task_type,
maxIf(
quality_score,
variant = 'baseline'
) AS baseline_score,
maxIf(
quality_score,
variant = 'candidate'
) AS candidate_score
FROM ai_eval.eval_results
WHERE variant IN ('baseline', 'candidate')
GROUP BY case_id, language, task_type
)
SELECT
language,
task_type,
count() AS cases,
round(
avg(candidate_score - baseline_score),
4
) AS avg_delta,
round(
100 * avg(
candidate_score < baseline_score - 0.05
),
2
) AS regression_rate_pct
FROM paired
GROUP BY language, task_type
ORDER BY avg_delta ASC
"""
slice_result = sess.query(SLICE_SQL, "DataFrame")
display(slice_result)
slice_result.to_csv(
RESULT_DIR / "slice_delta.csv",
index=False,
)
SQLの中では、maxIfを使って同じケースの値を横持ちにしています。
case_id | baseline_score | candidate_score
--------+----------------+----------------
1 | 0.72 | 0.76
2 | 0.81 | 0.69
そのうえで、次を計算しています。
$$
\Delta_i
= \text{candidate}_i - \text{baseline}_i
$$
avg_deltaが正なら候補版の方が高く、負なら候補版の方が低いことを表します。
結果の重要部分は次のとおりです。
| language | task_type | cases | avg_delta | regression_rate_pct |
|---|---|---|---|---|
| ja | summarization | 62,134 | -0.0802 | 75.72 |
| es | summarization | 12,703 | 0.0395 | 0.61 |
| ja | qa | 62,478 | 0.0397 | 0.53 |
| en | summarization | 50,188 | 0.0400 | 0.47 |
| ja | extraction | 62,504 | 0.0401 | 0.52 |
日本語の要約だけ、平均差が-0.0802です。他のスライスは約+0.04なので、全体平均ではこの問題が隠れました。
slice_plot = slice_result.copy()
slice_plot["slice"] = (
slice_plot["language"]
+ " / "
+ slice_plot["task_type"]
)
slice_plot = slice_plot.sort_values("avg_delta")
plt.figure(figsize=(9, 6))
plt.barh(slice_plot["slice"], slice_plot["avg_delta"])
plt.axvline(0, linewidth=1)
plt.xlim(-0.09, 0.055)
plt.xlabel("Average score delta (candidate - baseline)")
plt.ylabel("Language / task")
plt.title("Slice analysis reveals a hidden regression")
for y, value in enumerate(slice_plot["avg_delta"]):
if value < 0:
plt.text(
value + 0.003,
y,
f"{value:+.4f}",
va="center",
ha="left",
)
else:
plt.text(
value + 0.0015,
y,
f"{value:+.4f}",
va="center",
ha="left",
)
plt.tight_layout()
plt.savefig(
FIGURE_DIR / "02_slice_delta.png",
dpi=180,
)
plt.show()
この例では、候補版をそのまま全面リリースするのではなく、少なくとも次の判断が必要です。
- 日本語要約のプロンプトを見直す
- 日本語要約だけ基準版へ戻す
- 日本語要約の評価ケースを増やす
- 修正版を同じ
case_idで再評価する
9. 「平均差」だけでなく、何件悪化したかを見る
本記事では、候補版が基準版より0.05点を超えて低いケースを「回帰」と定義します。
\operatorname{regression}_i =\begin{cases}
1 & \text{if } \operatorname{candidate}_i < \operatorname{baseline}_i - 0.05 \\
0 & \text{otherwise}
\end{cases}
回帰率は次です。
$$
\operatorname{regression\ rate}
= \frac{\text{回帰したケース数}}{\text{比較できたケース数}}
$$
PAIRED_SQL = r"""
WITH paired AS
(
SELECT
case_id,
language,
task_type,
difficulty,
maxIf(
quality_score,
variant = 'baseline'
) AS baseline_score,
maxIf(
quality_score,
variant = 'candidate'
) AS candidate_score
FROM ai_eval.eval_results
WHERE variant IN ('baseline', 'candidate')
GROUP BY
case_id,
language,
task_type,
difficulty
)
SELECT
count() AS paired_cases,
round(
avg(candidate_score - baseline_score),
4
) AS mean_delta,
countIf(
candidate_score < baseline_score - 0.05
) AS regressed_cases,
round(
100 * countIf(
candidate_score < baseline_score - 0.05
) / count(),
2
) AS regression_rate_pct,
countIf(
candidate_score > baseline_score + 0.05
) AS improved_cases,
round(
100 * countIf(
candidate_score > baseline_score + 0.05
) / count(),
2
) AS improvement_rate_pct
FROM paired
"""
paired_summary = sess.query(PAIRED_SQL, "DataFrame")
display(paired_summary)
paired_summary.to_csv(
RESULT_DIR / "paired_summary.csv",
index=False,
)
検証結果は次のとおりでした。
| paired_cases | mean_delta | regressed_cases | regression_rate_pct | improved_cases | improvement_rate_pct |
|---|---|---|---|---|---|
| 500,000 | 0.0250 | 49,273 | 9.85 | 177,539 | 35.51 |
平均差は+0.0250ですが、49,273件、全体の9.85%は0.05点を超えて悪化しています。
平均が上がったことと、すべての利用者・タスクが良くなったことは同じではありません。
10. 遅延は平均ではなく裾を見る
全体集計を見ると、candidateのP50は1,849ms、P95は2,819msです。
一方、P99は7,131msで、baselineの3,588msのほぼ2倍でした。
latency_long = overall.melt(
id_vars="variant",
value_vars=["p50_ms", "p95_ms", "p99_ms"],
var_name="quantile",
value_name="latency_ms",
)
latency_pivot = latency_long.pivot(
index="variant",
columns="quantile",
values="latency_ms",
).loc[overall["variant"]]
ax = latency_pivot.plot(kind="bar", figsize=(9, 5))
ax.set_xlabel("Variant")
ax.set_ylabel("Latency (ms)")
ax.set_title("The candidate has a much heavier latency tail")
ax.tick_params(axis="x", rotation=0)
plt.tight_layout()
plt.savefig(
FIGURE_DIR / "03_latency_quantiles.png",
dpi=180,
)
plt.show()
このダミー例では、候補版は通常時には少し遅い程度ですが、ごく遅いケースが長い裾を作っています。
AIアプリでは、モデル呼び出し、検索、ツール実行、再試行が連鎖することがあります。そのため、平均だけでなくP95やP99も残しておく価値があります。
11. コストも同じ表で見る
1,000リクエストあたりのダミーコストは次のようになりました。
| variant | cost per 1,000 requests |
|---|---|
| baseline | $3.0491 |
| candidate | $3.2411 |
| economy | $0.7309 |
candidateは品質が上がる一方、トークン増加によりコストも少し上がる設計です。
economyは品質が低いものの、コストと遅延は小さくなっています。どれが正解かは用途次第です。
- 高品質が必要な契約文書の抽出
- 多少の品質低下を許容できる大量分類
- リアルタイム性を優先する対話
- 夜間バッチでよい要約
AI時代の評価DBには、品質だけでなく、コストと遅延を同じ行に保存する必要があります。
12. 150万行に対するクエリ時間を測る
次の4種類を6回ずつ実行し、1回目をウォームアップとして除外し、残り5回の中央値を取ります。
- 全体指標
- 言語×タスクのスライス分析
- 同一ケースの回帰分析
- 日次集計
DAILY_SQL = r"""
SELECT
toDate(run_at) AS day,
variant,
round(avg(quality_score), 4) AS avg_quality,
round(
quantileTDigest(0.95)(latency_ms),
0
) AS p95_ms,
round(sum(cost_usd), 2) AS total_cost_usd
FROM ai_eval.eval_results
GROUP BY day, variant
ORDER BY day, variant
"""
BENCHMARK_QUERIES = {
"overall_metrics": OVERALL_SQL,
"slice_analysis": SLICE_SQL,
"paired_regression": PAIRED_SQL,
"daily_summary": DAILY_SQL,
}
benchmark_rows = []
for name, sql in BENCHMARK_QUERIES.items():
times_ms = []
# 1回目はウォームアップ扱いにします。
for _ in range(6):
started = time.perf_counter()
sess.query(sql, "DataFrame")
times_ms.append(
(time.perf_counter() - started) * 1000
)
measured = times_ms[1:]
benchmark_rows.append(
{
"query": name,
"median_ms": round(
statistics.median(measured),
2,
),
"min_ms": round(min(measured), 2),
"max_ms": round(max(measured), 2),
}
)
benchmark = pd.DataFrame(benchmark_rows)
display(benchmark)
benchmark.to_csv(
RESULT_DIR / "benchmark.csv",
index=False,
)
事前検証結果は次のとおりでした。
| query | median_ms | min_ms | max_ms |
|---|---|---|---|
| overall_metrics | 105.05 | 104.57 | 204.08 |
| slice_analysis | 203.25 | 155.24 | 220.08 |
| paired_regression | 177.50 | 153.46 | 259.51 |
| daily_summary | 78.33 | 72.29 | 110.73 |
plt.figure(figsize=(9, 5))
plt.bar(benchmark["query"], benchmark["median_ms"])
plt.xlabel("Query")
plt.ylabel("Median execution time (ms)")
plt.title("ClickHouse queries over 1.5 million evaluation rows")
plt.xticks(rotation=15, ha="right")
plt.ylim(0, benchmark["median_ms"].max() * 1.18)
for i, value in enumerate(benchmark["median_ms"]):
plt.text(
i,
value + benchmark["median_ms"].max() * 0.025,
f"{value:.1f}",
ha="center",
)
plt.tight_layout()
plt.savefig(
FIGURE_DIR / "04_query_benchmark.png",
dpi=180,
)
plt.show()
この数字はchDB、割り当てCPU、キャッシュ状態、ClickHouseの版で変わります。異なるDB製品を公平に比較するベンチマークではありません。
ここで確認したかったのは、初心者がColabで試せる構成でも、150万件に対する複数列の集計、分位数、条件付き集計、同一ケース比較を対話的に実行できることです。
AI時代のデータベースで何が変わるのか
今回の実験から、私は次の5点が重要だと考えました。
1. 「現在値」だけでなく評価履歴を残す
従来の業務DBでは、現在のユーザー設定や最新の注文状態が重要です。
AIアプリでは、次の履歴も重要になります。
評価ケース
× モデル版
× プロンプト版
× データ版
× 実行日時
× 評価指標
新しい版が悪化したとき、「以前はどうだったか」を同じケースで比較できなければ原因を追えません。
2. 平均だけでなくスライスをすぐ切れるようにする
全体平均が改善しても、次の一部だけ悪化することがあります。
- 日本語だけ
- 長文だけ
- 難しい問題だけ
- 特定の顧客群だけ
- 特定のプロンプト版だけ
そのため、分析に使うカテゴリは、巨大なJSONの奥にだけ入れるのではなく、頻繁に使うものを型付きの列として持つ方が扱いやすくなります。
ClickHouseのオブザーバビリティ向けスキーマ設計ガイドでも、よく使う属性をトップレベル列として取り出す考え方が説明されています。
3. 「別集団の平均」より同一ケース差分を見る
基準版と候補版で評価ケースが違うと、難易度差が混ざります。
本記事ではcase_idをそろえて、同じケース同士を比較しました。
case 123:
baseline = 0.81
candidate = 0.74
差 = -0.07
この形なら、どのケースが改善し、どのケースが悪化したかを直接追えます。
4. 品質・コスト・遅延を別々にしない
品質だけ良くても、コストが10倍になったり、P99遅延が大きく伸びたりすれば、本番採用できない場合があります。
逆に、低コスト版の品質が少し低くても、用途によっては十分です。
したがって、評価テーブルには少なくとも次を一緒に残したいです。
- 品質
- 合否
- エラー
- 入出力トークン
- コスト
- P50/P95/P99を計算できる遅延
- モデル・プロンプト・データの版
5. 生のプロンプトを何でも保存しない
分析したいからといって、プロンプトと回答を無条件に保存すると、個人情報、機密情報、著作物、認証情報が混ざる危険があります。
本記事では完全なダミーデータだけを使いました。
実運用では、次を検討する必要があります。
- 入力・出力を保存する目的
- マスキングと削除
- 保存期間
- アクセス権限
- 暗号化
- スコアやハッシュだけで目的を満たせないか
AI時代のDB設計は、「何を高速に保存するか」だけでなく、「何を保存しないか」も重要です。
ClickHouseがこの用途に合うと感じた点
大量行の集計をSQLで書ける
平均、条件付き件数、分位数、グループ集計、同一ケース比較をSQLの中で表現できました。
特に今回使った関数は次です。
-
avg:平均 -
countIf:条件を満たす件数 -
maxIf:条件を満たす値の取得 -
quantileTDigest:近似分位数 -
multiIf:複数条件の分岐
quantileTDigestは近似値です。厳密値が必要な場合は別の分位数関数と計算コストを比較する必要があります。
カテゴリ列を型として持てる
language、task_type、variantを独立した列にしたため、SQLが読みやすくなりました。
WHERE language = 'ja'
AND task_type = 'summarization'
小さく試してからサーバー版へ進める
chDBならColabで始められます。
本番で複数アプリから接続する、高可用性が必要、データが継続的に流入する、といった段階では、ClickHouse OSSサーバーやClickHouse Cloudを検討する流れが自然です。
この実験の限界
ダミーデータである
品質、エラー率、トークン単価、遅延はすべて人工的に作った値です。実在モデルの比較結果ではありません。
品質スコアの作り方を評価していない
実運用の品質スコアは、人手評価、ルール、正解との一致、別モデルによる評価などで作られます。
別モデルを評価者にする場合、その評価者自身の偏りや再現性も検証が必要です。
1ケース1実行である
生成AIには揺らぎがあります。本番の評価では、同じケースを複数回実行し、平均だけでなく分散や失敗率を見る設計も考えられます。
chDBは本番サーバーの負荷試験ではない
今回はPythonプロセス内で動くchDBを使いました。ネットワーク、同時接続、レプリケーション、分散処理、障害復旧は評価していません。
パーティション効果を測っていない
ダミーデータが1か月分だけなので、月パーティションによる読み飛ばしは今回の測定対象ではありません。
後片付け
最後にSessionを閉じます。
# 作業終了時だけ実行してください。
sess.close()
print("chDB Sessionを閉じました。")
まとめ
本記事では、ClickHouseを専門的に利用してきた立場ではなく、AI評価ログの保存と分析に関心を持った初学者として、chDBを使った検証を行いました。
Google Colab上で50万件の評価ケースと3種類の候補を組み合わせ、合計150万行のダミーデータを作成しました。そのうえで、全体平均、条件別のスライス分析、同一ケースの差分比較、遅延の分位点、コストの集計を試しました。
全体平均だけを見ると、候補版candidateは基準版baselineより良い結果でした。
- 平均品質:0.7105から0.7355へ上昇
- 合格率:55.86%から64.02%へ上昇
しかし、言語とタスクに分けて確認すると、異なる様子が見えてきました。
- 日本語要約の平均品質差:
-0.0802 - 日本語要約の回帰率:
75.72% - 全50万ケースのうち、0.05点を超えて悪化したケース:49,273件
- P99遅延:3,588msから7,131msへ増加
つまり、同じデータであっても、全体平均だけを見る場合と、スライスや同一ケース差分、P99遅延まで確認する場合とでは、リリース判断が変わり得ます。
今回ClickHouseを調べ、実際に動かしてみて印象に残ったのは、単に大量データを高速に集計できることだけではありませんでした。
AIの評価履歴について、
モデルの版
× プロンプトの版
× 評価ケース
× 言語
× タスク
× 品質
× コスト
× 遅延
という複数の条件を保持し、必要になった時点で切り口を変えて問い直せることに、大きな意味があると感じました。
生成AIに関する議論では、ベクトル検索やRAGが注目されることが多いですが、AI時代のデータベースが担う役割は、それだけではないように思います。
モデルやプロンプトは継続的に更新されます。そのたびに評価結果を上書きしてしまうのではなく、過去の版と比較できる履歴として残すことが重要です。また、平均値だけではなく、特定の言語や課題で性能が低下していないか、処理時間やコストが許容範囲に収まっているかを検証できる必要があります。
大学で研究や教育に携わる立場から見ると、これは再現性の問題にもつながります。
ある時点で「性能が良かった」と記録するだけでは、後からその判断を検証できません。どのデータを、どの条件で、どの版に入力し、どの指標で評価したのかを残すことで、初めて結果を再確認できます。
今回の検証は、人工的に作ったデータを使った小規模な試行です。また、chDBはPythonプロセス内で動くため、ClickHouseサーバーの同時接続、分散構成、障害対応、継続的なデータ投入などは確認していません。そのため、本記事の結果だけから、実際のシステム構成を決めることはできません。
一方で、ClickHouseを初めて試す段階でも、SQLを用いて次の分析を一通り実行できました。
- 大量の評価ログの集計
- 条件別の品質比較
- 同じ評価ケースの新旧比較
- P50、P95、P99による遅延分析
- 品質、速度、コストを組み合わせた確認
このことから、ClickHouseは、AI評価履歴を分析するための選択肢として、さらに調べる価値があると感じました。
今後試すとすれば、公開データまたは利用条件を明確にした教育用データを使い、次の点を検討したいと考えています。
- 評価結果が時間とともにどう変化するか
- モデル版やプロンプト版をどのように管理するか
- 評価ケースを追加した際に過去結果とどう比較するか
- 生の入力や出力を保存せず、必要な分析を行えるか
- 保存期間、匿名化、アクセス権限をどのように設計するか
今回の試行を通して、「AI時代のデータベース、何が変わるのか」という問いに対して、現時点では次のように考えています。
AI時代のデータベースには、AIへ情報を渡すための機能だけでなく、AIの挙動を後から検証し、比較し、説明するための履歴基盤としての役割も求められる。
ClickHouseがあらゆる用途に適した唯一の答えだとは考えていません。しかし、大量の評価履歴を複数の切り口で調べるという用途において、列指向の分析データベースがどのように役立つのかを、今回の検証を通して具体的に理解することができました。
ClickHouseに初めて触れる方にとって、本記事が「まずはColabで動かし、AI評価ログを分析してみる」ための一例になれば幸いです。



