サマリー
- 「AIはClickHouse方言でよく間違える」と思っていたので、Claude(Haiku 4.5 / Sonnet 5)に合計380本のClickHouse SQLを書かせ、実際にClickHouseで実行して正解と照合してみました。
- 文字列クォート・
ARRAY JOIN・IS NULL・LIMIT BYといった文法レベルの方言ミスはほぼ皆無でした(素のClaudeで正答率95.7%、ReplacingMergeTreeを除く6タスクは100%)。 - 唯一にして最大の落とし穴が
ReplacingMergeTreeの重複です。しかもエラーで落ちず、それっぽい"嘘の数字"を静かに返します。 - スキーマの見せ方で、結果は激変しました。DDL(
ENGINEやORDER BYを含む)を隠して列名だけで渡すと正答率24%(うち約74%が"静かな誤答")→ DDLを見せると90% → 数行のチートシート(Skill)で100%(今回は80/80)です。 - つまり危険なのは"文法"ではなく、スキーマが教えてくれない"意味(セマンティクス)"の側にあります。
検証はすべてローカルのDocker ClickHouseで実行し、生成SQLの実行結果と正解を機械照合しています。数字は再現可能な範囲の実測値です。
AIはClickHouseのSQL文をどこまで間違えずに生成できるのか?
Claude Code にClickHouseを触らせる記事をいくつか書いてきました。その中でずっと気になっていたのが、「LLMはClickHouse特有の方言(MySQL/PostgreSQLと違う書き方)で、どれくらい間違えるのか」という点です。
体感では「たまに変なSQLを書く」程度でしたが、体感で語るのではなく実測したい。そこで、こんな実験を組みました。
- わざと"方言の罠"を仕込んだテーブルを用意し、
- 自然文の質問をClaudeに渡してSQLを生成させ、
- 生成SQLを本物のClickHouseで実行して、
- あらかじめ用意した正解と機械照合する
生成は確率的なので、同じ質問を複数回投げて「誤答率」で見ていきます。
Claude Codeのワークフローを使って実験してみる
ClickHouseをDockerで起動する
docker run -d --name ch-sql-exp -p 8123:8123 -p 9000:9000 \
-e CLICKHOUSE_PASSWORD=exp_pw \
clickhouse/clickhouse-server:latest
# 今回の検証は 26.5.1 で実施
検証用のスキーマとデータを用意する
ECサイトを模した2つのテーブルを使います。ユーザーの状態を持つ user_state と、行動ログの events です。
CREATE TABLE shop.user_state
(
user_id UInt32,
status LowCardinality(String), -- 'active' | 'churned' | 'trial'
plan String,
updated_at DateTime
)
ENGINE = ReplacingMergeTree(updated_at)
ORDER BY user_id;
CREATE TABLE shop.events
(
event_id UInt64,
user_id UInt32,
event_type LowCardinality(String), -- 'view' | 'click' | 'purchase'
ts DateTime,
amount Nullable(Decimal(10,2)),
tags Array(String),
session_id String
)
ENGINE = MergeTree
ORDER BY (ts, event_id);
データは5ユーザー・12イベントの小さなものです(投入手順はリポジトリの seed_shop.sql にまとめました)。この2テーブルに、ClickHouseでMySQL/PostgreSQL感覚だと踏みやすい方言をいくつか仕込んでおきます。
7種類の方言トラップを自然文の問題にする
罠ごとに自然文の質問を用意して、それぞれ正解を実測し、"答え合わせ表"を作りました。
| 罠 | 質問の例 | 正解 |
|---|---|---|
| ReplacingMergeTree重複 | 現在activeなユーザー数 | 3(素朴だと5) |
| argMaxで最新値 | 各ユーザーの最新plan | 5行 |
| 配列→ARRAY JOIN | tag別イベント件数トップ5 | mobile7, jp7… |
| 文字列クォート | purchaseの平均amount | 2500.125 |
| 日付の丸め | 日別イベント件数 | 2日分 |
| グループ内トップN(LIMIT BY) | 各ユーザー直近2件 | 10行 |
| NULL比較 | amountがNULLの件数 | 8(=NULLだと0) |
採点方法:生成したSQLを実行し、正解と機械照合する
Claude Code のサブエージェントを並列に立てて、1本の質問につき1本のSQLを返させます。これをモデル×条件×タスク×反復の組み合わせで大量に生成し、返ってきたSQLをそのままClickHouseに流して、結果を正解と突き合わせました。
判定は3種類に分けます。ここで大事なのが真ん中です。
- ERROR:SQLが構文・型エラーで落ちる(気づける失敗)
- WRONG:実行は成功するが答えの数字が違う**(=静かに間違う、一番怖い失敗)
- PASS:正解
採点コードの核はこれだけです。
def run_sql(sql):
p = subprocess.run(
["docker","exec","ch-sql-exp","clickhouse-client",
"--password","exp_pw","--format","TSV","--query", sql],
capture_output=True, text=True, timeout=20)
return p.returncode, p.stdout, p.stderr
# 例:T1 の正解は "3"
def verdict(rc, out, expected="3"):
if rc != 0: return "ERROR"
if out.strip()!=expected: return "WRONG" # 実行できたのに数字が違う
return "PASS"
複数行の結果は順序非依存の集合として比較し、ROW_NUMBER()版とLIMIT BY版のように書き方が違っても答えが同じなら正解として扱います。
ラウンド1:文法系の7トラップは、素のClaudeでも96%正解した
まずはDDL(CREATE TABLE。当然エンジンや型もそのまま見えます)を渡して、7タスク×2条件×2モデル×5反復=140本を生成しました。条件は次の2つです。
- A(素のClaude):ClickHouse固有の注意は与えない
- B(Skill矯正):ClickHouse方言のチートシートをシステムプロンプトに付ける
結果はこうなりました。
| 条件 | Haiku 4.5 | Sonnet 5 |
|---|---|---|
| A(素のClaude) | 35/35(100%) | 32/35(91%) |
| B(Skill) | 35/35 | 35/35 |
素のClaude(条件A)は7タスク全体で67/70(95.7%)、しかもReplacingMergeTreeを除く6タスクは60/60(100%)でした。拍子抜けするほど正確です。文字列はちゃんとシングルクォート、日付はtoDate、配列はARRAY JOIN、NULLはIS NULL、グループ内トップNはLIMIT BYかROW_NUMBER()。狙って仕込んだ"うっかり方言ミス"には、ほとんど引っかかりません。DDLで型やエンジンが見えていれば、現行モデルはClickHouse方言をだいたい正しく書いてくれます。
ところが、唯一の失敗3件は、すべて同じ罠 ―― ReplacingMergeTree のタスクに集中していました。しかも間違えたのは、上位モデルのSonnetの方です。
ここで、この罠の正体を説明しておきます。ClickHouseの ReplacingMergeTree は「同じ ORDER BY キーの行は、バックグラウンドのマージで最新版に畳まれる」という設計で、マージが走るまでは古いバージョンの行が残ります。今回の user_state は、その"重複が残ったまま"の状態を再現しています(1行ずつ別INSERTで入れ、SYSTEM STOP MERGES でマージを止めています。詳細はリポジトリの seed_shop.sql に置きました)。
だから「最新ステータスがactiveなユーザー数」を素朴に数えると、重複した古い行まで数えて5になります。正解は3人(u1, u4, u5)です。
-- ❌ 素朴(重複した古い行も数える)→ 5
SELECT count() FROM shop.user_state WHERE status = 'active';
-- ✅ 正解(argMax や FINAL で1ユーザー1行に畳んでから)→ 3
SELECT count() FROM (
SELECT user_id, argMax(status, updated_at) AS s
FROM shop.user_state GROUP BY user_id
) WHERE s = 'active';
(argMax は同点の updated_at があると結果が不定になるので、バージョン列が一意でないときは FINAL の方が安全です。FINAL はテーブル参照の直後、別名があればその後ろに置きます。)
やっかいなのは、**間違えてもエラーで落ちず、それっぽい数字(5)を静かに返す点です。実際、Sonnetの3件の失敗は、まさにこれを踏んでいました。
-- Sonnet の失敗①:判定ロジックは合っているのに、生テーブルからcountして重複を数える → 5(正解3)
SELECT count() FROM shop.user_state
WHERE user_id IN (
SELECT user_id FROM shop.user_state
GROUP BY user_id HAVING argMax(status, updated_at) = 'active'
);
-- Sonnet の失敗②③:重複を無視した素朴集計 → 5
SELECT count() FROM shop.user_state WHERE status = 'active';
一方でSonnetは、別の試行では (user_id, updated_at) IN (SELECT user_id, max(updated_at) ...) ときれいに重複排除できていました。同じモデル・同じ質問でも、5回中3回は外しました。1発テストでは検出できない類のブレで、ここで「反復して率で見る」設計が効いてきます。
ラウンド2:ReplacingMergeTreeの罠を、スキーマの開示度を変えて検証する
ラウンド1で犯人が特定できたので、ReplacingMergeTreeだけを4タスクに増やし、スキーマの見せ方を3段階に変えて240本を生成しました。
-
hidden:DDLを見せず、列名と大まかな型だけを文章で説明する(
ENGINEもORDER BYも落ちる=雑にテーブルを説明された想定) -
ddl:
CREATE TABLEをそのまま提示(ENGINE = ReplacingMergeTreeが見える) - skill:DDL+方言チートシート
4タスク(現在activeなユーザー数/ステータス別ユーザー数/現在proのユーザー数/activeユーザーの総イベント数)は、いずれも重複排除を忘れると"エラーにならず数字だけズレる"ように作ってあります。
エンジンを隠すと24%、DDLを見せると90%、Skillで100%
| 条件 | 合算正答率 | 静かに誤り(WRONG) | エラー |
|---|---|---|---|
| hidden(エンジン非開示) | 24%(19/80) | 59 | 2 |
| ddl(エンジン提示) | 90%(72/80) | 5 | 3 |
| skill(チートシート付与) | 100%(80/80) | 0 | 0 |
DDL(ENGINE や ORDER BY を含む)を列名だけの説明に置き換えると、正答率は24%まで落ちました。しかも不正解の大半(80本中59本=約74%)はエラーではなく"静かな誤答"です。ログには何のエラーも出ず、ダッシュボードには誤った数字が並びます。これが一番怖いところです。
タスク別に見ても、傾向は同じです。
| タスク | hidden | ddl | skill |
|---|---|---|---|
| 現在activeなユーザー数 | 3/20 | 17/20 | 20/20 |
| ステータス別ユーザー数 | 7/20 | 16/20 | 20/20 |
| 現在proのユーザー数 | 2/20 | 19/20 | 20/20 |
| activeユーザーの総イベント数 | 7/20 | 20/20 | 20/20 |
手掛かりの有無で、HaikuとSonnetの得意不得意が逆転する
面白いのがモデル差です。
| hidden | ddl | |
|---|---|---|
| Haiku 4.5 | 5%(2/40) | 98%(39/40) |
| Sonnet 5 | 42%(17/40) | 82%(33/40) |
- Haiku は
ReplacingMergeTreeを見ると、反射的にFINALを付けます。だからDDLが見えていればほぼ完璧(98%)。でもエンジンを隠されると無防備で、素朴なWHERE status='active'を連発して壊滅します(5%)。 - Sonnet はエンジンを隠されても「重複しているのでは?」と疑って工夫します(だからhiddenでも42%と、Haikuよりマシ)。ただし反射的に
FINALを使うわけではなく、凝った書き方をして詰めを外すことも多いです。
「賢いモデルほど強い」という単純な話ではなく、手掛かりの有無で得意不得意が入れ替わるわけです。ここは個人的にも面白いところでした。
実際に出た「エラーにならず数字だけ間違うSQL」を見る
今回集まった誤答の実物から、代表的なものを並べます。どれも実行は成功し、それっぽい数字を返します。
-- ❌ 定番:エンジンを知らなければ誰でもこう書く → 5(正解3)
SELECT count() FROM shop.user_state WHERE status = 'active';
-- △ 半分正しいのに罠:u2の「古いactive行」がサブクエリに紛れ込む → 10(正解8)
SELECT count() FROM shop.events
WHERE user_id IN (SELECT user_id FROM shop.user_state WHERE status = 'active');
-- ❌ JOINでファンアウト:重複行と結合して件数が水増し → 13(正解8)
SELECT count() FROM shop.events e
INNER JOIN shop.user_state u ON e.user_id = u.user_id
WHERE u.status = 'active';
なお、エラーで落ちてくれる方がむしろ親切、というケースもいくつかありました。これらは"気づける失敗"です。
-- FINALの置き場所ミス(FINALはテーブル参照の直後、別名があればその後。WHEREの後ろには置けない)
SELECT count(DISTINCT user_id) FROM shop.user_state WHERE status = 'active' FINAL;
-- → Syntax error
-- Sonnetが LIMIT BY で畳もうとして HAVING の位置を間違えた
SELECT count() FROM shop.events WHERE user_id IN (
SELECT user_id FROM shop.user_state
ORDER BY user_id, updated_at DESC LIMIT 1 BY user_id
HAVING status = 'active' -- ここには置けない
);
-- → Syntax error
正しい数字が出ても、正しいクエリとは限らない
hiddenで、たまたま正解してしまった例もあります。
-- hidden で PASS したが……
SELECT count(DISTINCT user_id) FROM shop.user_state WHERE plan = 'pro'; -- → 4(正解4)
これは「proの行を持つユーザー数」を数えているだけで、たまたま今回のデータで「現在proのユーザー数」と一致しただけです。もし誰かがpro→freeにダウングレードしていれば、この式は過大にカウントします。正しい数字が出たからといって、正しいクエリとは限りません。
対策:数行のチートシート(Skill)を渡すと正答率が100%に戻る
ReplacingMergeTreeの罠は、たった数行の"方言チートシート"をClaudeに渡すだけで消えました(ラウンド2のskill条件=80/80)。今回使ったのは、この内容です。
# ClickHouse 方言チートシート(必ず守ること)
- ReplacingMergeTree のテーブルは、同じ ORDER BY キーの古いバージョン行が
マージされるまで残る。最新の値だけを見るには argMax(col, version_col)(version_col が
一意である前提)を使うか、テーブル参照の直後(別名があればその後)に FINAL を付ける。
素の WHERE / GROUP BY / JOIN で集計すると、重複した古い行を含んで件数が過大になる。
- ReplacingMergeTree を JOIN する場合も、事前に FINAL か argMax で
1キー1行に畳んでから結合する(そのまま結合すると重複でファンアウトする)。
- 文字列リテラルはシングルクォート 'value'。ダブルクォートは識別子用。
- NULL の判定は IS NULL / IS NOT NULL。
Claude Codeなら、これをSkill(SKILL.md)やプロジェクトのCLAUDE.md、あるいはMCPサーバーが返すスキーマ説明に忍ばせておけば大丈夫です。要は「このテーブルはReplacingMergeTreeで、最新だけ見るならこう書く」という"意味"を、スキーマと一緒に必ず渡すこと。ClickHouse公式もAgent Skillsを公開していて、こうした方言・ベストプラクティスをエージェントに教える方向に進んでいます。
まとめ:危険なのは「文法」ではなく「スキーマが教えてくれない意味」
- 現行のClaudeは、DDLさえ見せればClickHouse方言の"文法"はほぼ正しく書きます(今回95.7%、ReplacingMergeTreeを除けば100%)。「AIはClickHouse方言が苦手」という思い込みは、少なくとも構文レベルでは当たりませんでした。
- 本当に危険なのは
ReplacingMergeTreeのような"意味レベル"の落とし穴です。エラーで落ちず、それっぽい嘘の数字を静かに返します。 - そしてそれはスキーマがエンジン種別を教えてくれるかどうかで激変します(隠すと24%、見せると90%)。人間がテーブルを口頭で説明したり、抽象化されたビュー越しに触らせたりすると、この手掛かりが失われます。
- 対策は難しくありません。数行のチートシート(Skill)でエンジンの意味を明示すれば、今回の80試行では100%(80/80)になりました。
LLMにデータ基盤を触らせるとき、警戒すべきは「変な構文を書くこと」ではなく、「スキーマに書かれていない前提を、それらしく埋めて静かに間違えること」です。ReplacingMergeTreeは、その典型例でした。
付録:再現用の正解値と実験規模
SYSTEM STOP MERGES で重複を固定した状態での正解値です。
| タスク | 素朴(誤) | 正解 |
|---|---|---|
| 現在activeなユーザー数 | 5 | 3 |
| ステータス別 | active5/churned1/trial2 | active3/churned1/trial1 |
| 現在proのユーザー数 | 5 | 4 |
| activeユーザーの総イベント数 | 13(JOIN)/10(IN) | 8 |
実験規模:Claude Haiku 4.5 / Sonnet 5、生成合計380本(ラウンド1=140、ラウンド2=240)、ClickHouse 26.5.1。トイデータでの検証なので、絶対値そのものより「条件を変えたときの相対変化」として読んでください。