46,000件の本番クエリから学んだLLMによるSQL生成の知見

674行のSQLパターンライブラリにより、Claudeは3100万行のDuckDB上で4.6万回のクエリを平均2〜3秒で返す。

日本語
コピー
题图:代码编辑器里的一段 Python 数据库配置,DB_NAME、DB_USER、DB_PASSWORD 等变量一览,画面带虚化

金融データサーバーを運用している。中身は3100万行のDuckDBデータベースだ。ユーザーはLLM(多くはClaude)につないで自然言語で質問し、モデルが我々のスキーマに対してSQLを生成する。

6か月間、すべてのクエリを記録した。ユーザーの自然言語プロンプト、LLMが生成したSQL、実行が成功したか、どれくらいかかったか、何行返したか。合計4万6000件。

以下がわかったことだ。

データベース

米国株64年分をカバーし、テーブルは12枚。日次の価格と出来高(3100万行)、四半期と通期の財務データ、日次のバリュエーション、56のテクニカル指標、SEC提出書類のメタデータ、インサイダー取引、アナリスト予想、企業の基本情報。NYSEとNASDAQの約9,971銘柄(株式とETF)。

ユーザーはMCP(Model Context Protocol)経由で接続する。LLMが外部ツールを呼び出すためのオープン標準だ。「3四半期連続で増収、かつマージンが拡大し続けている米国株を見せて」と聞けば、LLMは我々のスキーマを読み、クエリパターン集を読み、SQLを生成して実行する。プロンプトから結果テーブルまでの往復は平均2〜3秒。

秘密兵器:674行のパターン集

モデルをファインチューニングしたわけでも、サンプルクエリを中心にRAGパイプラインを組んだわけでもない。674行のmarkdownドキュメントを書いた。中身は23個のSQLパターンで、毎回のクエリの前にコンテキストとしてLLMに渡す。

各パターンは具体的な問題を1つ解決する。大規模な時系列データベースでLLMがつまずく問題だ。最も重要なもの:

-- Pattern P1: Latest row per symbol (multi-symbol safe)
WITH recent AS (
 SELECT symbol, date, close,
 ROW_NUMBER() OVER (PARTITION BY symbol ORDER BY date DESC) AS rn
 FROM shibui.stock_quotes
 WHERE date >= CURRENT_DATE - INTERVAL '7 days' -- pre-filter first
)
SELECT symbol, date, close FROM recent WHERE rn = 1
LIMIT 50

このパターンは3つのことをやっている。WHERE date >= CURRENT_DATE - INTERVAL '7 days' はウィンドウ関数が動く前に事前フィルタリングし、作業セットを小さく抑える。ROW_NUMBER() ... rn = 1 の書き方なら各銘柄の最新行を取れるうえ、GROUP BY の曖昧さが出ない。LIMIT は結果セットが暴走するのを防ぐ。

このパターンがなければ、LLMは迷わず SELECT * FROM stock_quotes ORDER BY date DESC を書き、3100万行をスキャンしようとする。あれば、同じ意図のクエリが数ミリ秒で終わる。

パターン集は他に22個をカバーする。リターン計算、LAG を使った前期比成長、複数テーブルのフィルタjoin、テクニカル指標のクエリ、MEDIAN を使ったセクター比較、カレンダー期間ごとのOHLCバーでのバックテスト、インサイダー取引の集計。

複雑さの上限

46%のクエリがJOINを使い、44%がCTE、40%がウィンドウ関数、30%が2テーブル以上をまたぐ。最長のクエリは2万2828文字。59個のウィンドウ関数を使ったクエリがあり、19個のJOINを使ったものもある。再帰CTEとLATERAL joinを組み合わせてzigzag波検出アルゴリズムを実装したものもあった。

おもちゃのクエリではない。実例を2つ挙げる。

単一クエリ内のマルチファクターランキングシステム

あるユーザーがLLMにクロスセクションの銘柄ランキングモデルを作らせた。LLMが生成したのはこれ(抜粋、完全なクエリは約9,800文字):

WITH
-- Monthly closing prices with momentum signals
mo AS (
 SELECT symbol, mes, close,
 close / NULLIF(LAG(close, 6) OVER (PARTITION BY symbol ORDER BY mes), 0) - 1 AS r6,
 close / NULLIF(LAG(close, 12) OVER (PARTITION BY symbol ORDER BY mes), 0) - 1 AS r12,
 close / NULLIF(MAX(close) OVER (PARTITION BY symbol ORDER BY mes
 ROWS BETWEEN 11 PRECEDING AND CURRENT ROW), 0) AS dist52,
 LEAD(close, 1) OVER (PARTITION BY symbol ORDER BY mes)
 / NULLIF(close, 0) - 1 AS fwd
 FROM px),

-- Quality and fundamental metrics joined in
b AS (
 SELECT mo.*, d.trailing_pe, d.free_cash_flow_yield,
 f.roe, f.pm, f.piot,
 f.rg - f.rg_ant AS acc_rev, -- revenue acceleration
 f.eg - f.eg_ant AS acc_eps -- earnings acceleration
 FROM mo
 JOIN v ON v.symbol = mo.symbol AND v.mes = mo.mes AND v.rn = 1
 JOIN d ON d.symbol = mo.symbol AND d.mes = mo.mes AND d.rn = 1
 JOIN shibui.general_info g ON g.symbol = mo.symbol
 ASOF JOIN fq f ON f.symbol = mo.symbol AND f.fdate 2e9 AND mo.close > 5),

-- Cross-sectional percentile ranks (both absolute and sector-relative)
p AS (
 SELECT *,
 PERCENT_RANK() OVER (PARTITION BY mes ORDER BY roe) AS g_roe,
 PERCENT_RANK() OVER (PARTITION BY mes ORDER BY r12) AS g_m12,
 -- Blended: 50% absolute rank + 50% within-sector rank
 50 * PERCENT_RANK() OVER (PARTITION BY mes ORDER BY roe)
 + 50 * PERCENT_RANK() OVER (PARTITION BY mes, sec ORDER BY roe) AS c_roe,
 -- ... 20+ more ranking columns
 FROM b),

-- Composite score: 30% quality + 25% growth + 20% value + 25% momentum
sc AS (
 SELECT *,
 0.30 * (g_roe + g_pm + g_pi) / 3
 + 0.25 * (g_rg + g_eg) / 2
 + 0.20 * (g_fcf + g_pe) / 2
 + 0.25 * (g_m6 + g_m12) / 2 AS S1
 FROM p)

-- Final output with bonus/penalty adjustments for momentum and cyclicality
SELECT mes, symbol, fwd, S1, S2
FROM fin
ORDER BY mes, S1 DESC

完全なクオンツ銘柄ランキングモデルだ。6か月と12か月のモメンタム、52週高値からの距離、ファンダメンタルズの質の指標を計算し、PERCENT_RANK で全銘柄を絶対ランクとセクター相対ランクの両方で順位づけする。これらを明示的な重みで合成して総合スコアにし、利益の加速に加点、循環的な悪化に減点し、バックテスト用のフォワードリターンを出力する。

完全なクエリは59個のウィンドウ関数、5個のJOIN(うち1つは ASOF JOIN)、8個のCTEを使い、約2,000銘柄の6年分の月次データで動く。実行は約5秒。

これはベンチマークなしだ。

段階的スクリーニングファネル

別のユーザーは保守的な高配当株をスクリーニングしたかった。LLMが組んだクエリは、各段階のフィルタ後に何銘柄が残ったか、前段階からの通過率はいくつかを報告する:

WITH latest_val AS (
 SELECT symbol, market_cap,
 ROW_NUMBER() OVER (PARTITION BY symbol ORDER BY date DESC) AS rn
 FROM shibui.valuation
 WHERE date >= CURRENT_DATE - INTERVAL '7 days'
),
latest_q AS (
 SELECT symbol, return_on_equity, current_ratio, net_income,
 ROW_NUMBER() OVER (PARTITION BY symbol ORDER BY date DESC) AS rn
 FROM shibui.fundamentals_quarterly
 WHERE date >= CURRENT_DATE - INTERVAL '6 months'
),
latest_dd AS (
 SELECT symbol, dividend_yield, payout_ratio,
 ROW_NUMBER() OVER (PARTITION BY symbol ORDER BY date DESC) AS rn
 FROM shibui.fundamentals_derived_daily
 WHERE date >= CURRENT_DATE - INTERVAL '7 days'
)
SELECT '1. Base universe (US Common Stock)' AS filter_step, COUNT(*) AS passing, NULL AS pct_of_prev
 FROM shibui.general_info g WHERE g.type = 'Common Stock' AND g.country_iso = 'US'
UNION ALL
SELECT '2. + Market cap > $5B', COUNT(*), ROUND(...)
 FROM ... WHERE ... AND v.market_cap > 5e9
UNION ALL
SELECT '3. + Dividend yield > 3%', COUNT(*), ROUND(...)
 FROM ... WHERE ... AND dd.dividend_yield > 0.03
UNION ALL
SELECT '4. + Payout ratio 1.5', COUNT(*), ROUND(...)
 FROM ... WHERE ... AND f.current_ratio > 1.5
UNION ALL
SELECT '6. + Net income positive', COUNT(*), ROUND(...)
 FROM ... WHERE ... AND f.net_income > 0

このクエリは19個のJOIN(各フィルタ段階でそれらのCTEを再joinする)と3テーブルを使う。ユーザーは自分のスクリーニング条件を診断できる。どの段階で銘柄が落ちたのか、ある条件が厳しすぎないかが見える。

LLMはこれらのパターンから何を学んだか

LLMがパターン集の各パターンを採用した頻度を集計した:

パターン採用率
LIMIT98.1%
INTERVAL 日付の事前フィルタリング39.6%
ROUND() 表示値用36.4%
NULLIF() 除算の安全策21.9%
ROW_NUMBER() ... rn = 120.4%
複数テーブルのフィルタjoin(3テーブル以上)17.7%
LAG/LEAD 期間比較用10.9%
COALESCE NULL処理用4.4%
MEDIAN でセクター比較0.7%
LATERAL join0.4%

LIMIT の効果が最も際立っている。パターン集でLLMに常に LIMIT 句を付けるよう指示したところ、98.1%のケースで従った。この指示1つで、3100万行のテーブル上での結果セット暴走を防いでいる。

さらに重要なのは、4万6131件のクエリでタイムアウトがゼロだったことだ。40%のクエリが INTERVAL の事前フィルタリングパターンを採用し、データベースが stock_quotes を全表スキャンすることを防いでいる。これに LIMIT を加えれば、この2つのパターンだけで全クエリをタイムアウト閾値の下に保てる。

LLM はパターンをそのまま当てはめるだけでなく、組み合わせる。典型的なスクリーニングクエリでは、P1 の ROW_NUMBER ... rn = 1、P5 の INTERVAL による事前フィルタ、P7 の NULLIF、そして P4 の複数テーブル join が、ひとつの CTE チェーンに織り込まれる。パターンは積み木、LLM は建築家だ。

エラー分析:失敗率 3.8%

46,131 件のクエリのうち 1,776 件が失敗し、成功率は 96.2%。

本当の問題を語るのはエラーの内訳のほうだ:

エラー種別件数エラーに占める割合
列が存在しない1,40479.1%
構文エラー26715.0%
制限超過784.4%
メモリ(OOM)70.4%
型の不一致40.2%

エラーの 79% は「列が存在しない」。 これは、データリリースの合間に列の名前を変えたり移したりしたのに、LLM の schema コンテキストがまだ追いついていないときに起きる。たとえば 2026 年 8 月、return_on_equityfundamentals_quarterly から fundamentals_derived_quarterly へ移した。数日のあいだ、古い位置を参照するクエリはすべて失敗した。これは schema の同期の問題であって、SQL 生成の問題ではない。

真の構文エラーは 15% だけ。LLM が構造的に無効な SQL を出すことはほとんどない。

そして大規模データベースで最も致命的なエラー——タイムアウトと OOM による kill——はほぼ存在しない。46,131 件のクエリで OOM は 7 回だけ、タイムアウトはゼロ。パターンライブラリは元が取れている。

会話コンテキストという要因

prompt から SQL への膨張比は極端だった。中央値は 7.1 倍(50 文字の prompt が 354 文字の SQL を生む)。だが裾はとんでもない。

PromptPrompt の長さSQL の長さ倍率
"Quarterly momentum net CAGR, fixed units."41 文字20,725 文字505x
"Semi-annual momentum net CAGR net of fees across 6 offsets."59 文字20,695 文字351x
完全な株式分析リクエスト(非英語、27 文字)27 文字8,397 文字311x

これらの数字は単体で見ると誤解を招く。41 文字の prompt が 20,000 文字の SQL を無から生み出すはずがない。LLM は会話履歴を丸ごと引きずっている。あの「固定単位」の prompt は、同じバックテストクエリを 10 回以上反復したあとに現れたもので、その過程でユーザーがパラメータを調整し、エッジケースを修正し、戦略を少しずつ組み上げていった。

本番環境の NL-to-SQL はこう動く。一発勝負ではなく反復だ。ユーザーはターンをまたいで絞り込み、LLM はコンテキストを積み上げる。標準的な benchmark は各クエリを独立したものとして扱い、この点を完全に無視している。

膨張比は初回のクエリでは依然として意味を持つ。そこでは英文ひとつが本当に数百行の SQL を生む。だが極端な裾は会話の蓄積から来ている。

多言語での SQL 生成

ユーザーは英語、北京語、韓国語、日本語、スペイン語、オランダ語、ドイツ語で prompt を書く。パターンライブラリは英語、schema も英語。入力がどの言語であれ、LLM は正しい SQL を生成する。

同じ種類の複雑なクエリ——複数テーブルを join するスクリーニング、walk-forward 検証付きのバックテスト、自動モニタリング、ポートフォリオ全体の分析——がどの言語でも現れる。27 文字の北京語の prompt が 8,397 文字の正しい SQL を生成し、6 テーブルを join した。LLM は prompt の言語を schema やパターンライブラリの言語に合わせる必要がない。

クエリコーパスには 1,896 種類の ticker が含まれ、mega-cap から micro-cap まで及ぶ。最も多くクエリされたのは AAPL(650 回)、次いで MSFT(570)、GOOGL(410)、NVDA(390)。

実践から得た要点

LLM に SQL を生成させるシステムを作っているなら、46,000 件の本番クエリから学んだことを挙げておく:

モデルを微調整するのではなく、パターンライブラリを書け。 markdown ドキュメント 1 枚に載った 23 個のパターンで、3,100 万行のデータベースに対して成功率 96.2%、タイムアウトゼロを達成した。パターンは LLM に防御的な習慣(ウィンドウ関数の前に事前フィルタ、除算には NULLIF、必ず LIMIT)を教え、複雑なクエリを組み立てるための組み合わせ可能な積み木を与える。微調整より安く、保守しやすく、モデルをまたいで通用する。

最も重要なパターンは、壊滅的な失敗を防ぐパターンだ。 LIMITINTERVAL の事前フィルタは高度な SQL ではない。だがクエリが 200ms で終わるか、3,100 万行をスキャンしてタイムアウトするかを決める。LIMIT 遵守率 98.1% とタイムアウトゼロは、2 つの単純な指示から生まれている。

エラーを生むのは、まずい SQL ロジックよりも schema のドリフトだ。 エラーの 79% は column-not-found で、リリース間の schema 変更が引き金になる。schema を進化させるなら、LLM のコンテキストが遅れることを前提にしなければならない。schema ドキュメントはバージョン管理しろ。LLM の SQL 生成は堅実だ。難しいのは、それを現実と同期させ続けることのほうだ。

単発のクエリではなく、対話のために設計する。 実際のユーザーは同じやり取りを何度も重ねる。10〜20ターンかけてフィルタ条件を絞り込み、時間枠を調整し、エッジケースを修正し、複雑な分析を少しずつ組み上げていく。システムはそれを支えるべきだ。セッションのコンテキストはコストではない。本番環境で NL-to-SQL が動くのは、まさにそれが理由だ。

開示:私は Shibui Finance を開発・運営している。本記事で紹介する金融データ MCP サーバーである。クエリの統計はすべて集計・匿名化したもので、個々のユーザーデータや独自のクエリは一切含まれない。

出典: HackerNoon← ホームへ戻る