0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Claude CodeはClickHouseのSQL方言をどれだけ間違えるのか

0
Last updated at Posted at 2026-07-28

サマリー

  • 「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を書く」程度でしたが、体感で語るのではなく実測したい。そこで、こんな実験を組みました。

  1. わざと"方言の罠"を仕込んだテーブルを用意し、
  2. 自然文の質問をClaudeに渡してSQLを生成させ、
  3. 生成SQLを本物のClickHouseで実行して、
  4. あらかじめ用意した正解と機械照合する

生成は確率的なので、同じ質問を複数回投げて「誤答率」で見ていきます。

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。トイデータでの検証なので、絶対値そのものより「条件を変えたときの相対変化」として読んでください。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?