株式会社ブレインパッドプロダクトユニットでRtoaster GenAIの開発をしている依田です。
今回は生成AIを利用したText-to-SQLのハンズオンです。
はじめに
LLMを使えば自然言語でデータベースへ問い合わせできます。これを Text-to-SQL と呼びます。ただし、何も工夫せずに、特に前提知識を与えないまま「売上を集計して」と投げると、LLMは存在しないテーブル名や列名を使ったSQLを生成してしまいます。
この記事では、ECサイト風のデータベースに対してVertexAIでText-to-SQLを試し、プロンプトを段階的に改善することで精度がどう変わるかを実験形式で確認します。
この記事ではVertex AI(Google Cloud)を使用するため、従量課金による使用料が発生します。本記事の実験程度であれば数円〜数十円程度ですが、あらかじめご了承ください。
LLMの回答には揺らぎがあります。同じプロンプトを投げても、実行タイミングやモデルのバージョンによって生成されるSQLや成功率は変わることがあります。本記事の実験結果はあくまで一例です。
対象読者
- SQLを書ける人
- LLMにSQLを生成させてみたいが、どう精度を上げればいいかわからない人
環境構成
| 項目 | 内容 |
|---|---|
| OS | macOS Tahoe 26.4.1 |
| 言語 | Python 3.12 |
| DB | SQLite(インメモリ) |
| LLM | Gemini 3.1 Flash Lite(Vertex AI) |
パッケージのインストールと認証を済ませておきます。
pip install google-cloud-aiplatform
gcloud auth application-default login
各実験スクリプトは以下のコマンドで実行できます。your-project-id は自分の GCP プロジェクト ID に置き換えてください。
GOOGLE_CLOUD_PROJECT=your-project-id python3 prompt_naive.py
テスト用スキーマとデータ
ECサイトを想定した4テーブル構成です。現実のデータに近づけるため、以下の要素を意図的に含んでいます。
| 要素 | 内容 |
|---|---|
users.is_deleted |
退会済みユーザーのソフトデリートフラグ(1=退会済み) |
users.email |
ゲスト購入は NULL |
products.category |
未分類商品は NULL |
orders.discount_amount |
クーポン割引額(注文単位で発生) |
orders.status |
'cancelled' のキャンセル注文が混在 |
order_items.unit_price |
キャンペーン品は NULL(0円扱い) |
CREATE TABLE users (
user_id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT, -- NULL = ゲスト購入
joined_at TEXT NOT NULL,
is_deleted INTEGER NOT NULL DEFAULT 0 -- 1 = 退会済み
);
CREATE TABLE products (
product_id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
price REAL NOT NULL,
category TEXT -- NULL = 未分類
);
CREATE TABLE orders (
order_id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(user_id),
ordered_at TEXT NOT NULL,
status TEXT NOT NULL, -- 'pending'|'shipped'|'delivered'|'cancelled'
discount_amount REAL NOT NULL DEFAULT 0
);
CREATE TABLE order_items (
item_id INTEGER PRIMARY KEY,
order_id INTEGER NOT NULL REFERENCES orders(order_id),
product_id INTEGER NOT NULL REFERENCES products(product_id),
quantity INTEGER NOT NULL,
unit_price REAL -- NULL = 無償提供(キャンペーン品)
);
このスキーマに対して、以下の4つの質問を各パターンで試します。
| # | 質問 | 難しさのポイント |
|---|---|---|
| Q1 | 各ユーザーの実質的な支払い総額を多い順に表示してください | NULL unit_price の COALESCE + 注文単位の discount 減算 |
| Q2 | カテゴリが設定されている商品のみで、カテゴリ別の売上合計(キャンセルを除く)を表示してください | NULL category の除外 + キャンセル除外 |
| Q3 | アクティブなユーザーのうち、まだ一度も注文していない人を教えてください | is_deleted=0 フィルタ |
| Q4 | 最もよく売れた商品のトップ3を教えてください | キャンセル注文を除外した数量集計 |
本記事では status = 'cancelled' の注文のみ除外し、shipped・pending は売上対象として扱います。実際のシステムでは要件に応じて delivered のみを集計対象にするケースもあります。
共通コード(base.py)
Geminiクライアントの初期化、DBセットアップ、結果チェックなど全実験で使う共通処理をまとめます。
import sqlite3, os, json
from google import genai
from google.genai import types
SCHEMA_DDL = """
CREATE TABLE users (
user_id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT,
joined_at TEXT NOT NULL,
is_deleted INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE products (
product_id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
price REAL NOT NULL,
category TEXT
);
CREATE TABLE orders (
order_id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(user_id),
ordered_at TEXT NOT NULL,
status TEXT NOT NULL,
discount_amount REAL NOT NULL DEFAULT 0
);
CREATE TABLE order_items (
item_id INTEGER PRIMARY KEY,
order_id INTEGER NOT NULL REFERENCES orders(order_id),
product_id INTEGER NOT NULL REFERENCES products(product_id),
quantity INTEGER NOT NULL,
unit_price REAL
);
""".strip()
QUESTIONS = [
"各ユーザーの実質的な支払い総額を多い順に表示してください",
"カテゴリが設定されている商品のみで、カテゴリ別の売上合計(キャンセルを除く)を表示してください",
"アクティブなユーザーのうち、まだ一度も注文していない人を教えてください",
"最もよく売れた商品のトップ3を教えてください",
]
_PROJECT_ID = os.environ.get("GOOGLE_CLOUD_PROJECT", "your-project-id")
_MODEL = "gemini-3.1-flash-lite"
_client = genai.Client(vertexai=True, project=_PROJECT_ID, location="global")
def call_gemini(prompt: str) -> str:
response = _client.models.generate_content(
model=_MODEL,
contents=prompt,
config=types.GenerateContentConfig(
response_mime_type="application/json",
response_schema={
"type": "object",
"properties": {"sql": {"type": "string"}},
"required": ["sql"],
},
max_output_tokens=1024,
),
)
return json.loads(response.text)["sql"]
def setup_db() -> sqlite3.Connection:
# (サンプルデータの INSERT は省略。完全版は付録を参照)
...
def run_sql(conn, sql):
try:
return conn.execute(sql).fetchall(), None
except Exception as e:
return [], str(e)
def run_pattern(conn: sqlite3.Connection, prompt_fn, label: str):
# 各質問に prompt_fn を適用して SQL を生成・実行・検証する
...
response_mime_type と response_schema を指定することで、GeminiはスキーマにしたがったJSONを返そうとします。
これにより {"sql": "..."} 形式のレスポンスを安定して取得でき、文字列パース処理が不要になります。
マークダウンのコードブロックを正規表現や文字列処理で抽出する必要がなくなり、コードがシンプルになります。
このコードは概要説明用に簡略化しています。動作確認する際は記事末尾のコードを利用してください。
実験1:素朴な実装(スキーマ情報なし)
まずはスキーマを一切渡さず、自然言語だけで投げます。
from base import setup_db, run_pattern
def build_prompt(question: str) -> str:
return f"""ECサイトのデータベースに対して、次の質問に答えるSQLを生成してください。
SQLだけを返してください。説明は不要です。
質問: {question}
"""
if __name__ == "__main__":
conn = setup_db()
run_pattern(conn, build_prompt, "実験1: 素朴な実装(スキーマ情報なし)")
conn.close()
実行例:このときは 0/4 成功
Q1: 各ユーザーの実質的な支払い総額を多い順に表示してください
生成SQL: SELECT user_id, SUM(amount - COALESCE(discount, 0)) FROM orders GROUP BY user_id ...
❌ 実行エラー: no such column: amount
Q2: カテゴリが設定されている商品のみで...
生成SQL: SELECT c.category_name, SUM(oi.price * oi.quantity) FROM categories c JOIN products p ...
❌ 実行エラー: no such table: categories
Q3: アクティブなユーザーのうち...
生成SQL: SELECT user_id FROM users WHERE status = 'active' AND user_id NOT IN (...)
❌ 実行エラー: no such column: status
Q4: 最もよく売れた商品のトップ3を教えてください
生成SQL: SELECT product_id, SUM(quantity) FROM order_items GROUP BY product_id ...
⚠️ SQL は動くが結果が不正解(キャンセル注文が除外されていない)
スキーマを渡していないので、LLMは「一般的なECサイトならこういう構造だろう」という推測でSQLを作ります。
| 失敗の種類 | 例 |
|---|---|
| 存在しないテーブル名 |
categories(正しくは products.category カラムで管理) |
| 存在しないカラム名 |
amount、status(正しくは is_deleted) |
| キャンセル除外なし | Q4 で cancelled を含んだまま集計 |
実験2:スキーマ情報を渡す
CREATE TABLE 文をそのままプロンプトに含めます。
from base import setup_db, run_pattern, SCHEMA_DDL
def build_prompt(question: str) -> str:
return f"""以下のSQLiteスキーマを持つECサイトのDBに対して、SQLを生成してください。
SQLだけを返してください。
スキーマ:
{SCHEMA_DDL}
質問: {question}
"""
if __name__ == "__main__":
conn = setup_db()
run_pattern(conn, build_prompt, "実験2: スキーマ情報を渡す")
conn.close()
実行例:このときは 2/4 成功
Q1: 各ユーザーの実質的な支払い総額...
生成SQL: SELECT u.name, SUM(oi.quantity * oi.unit_price - o.discount_amount) ...
⚠️ SQL は動くが結果が不正解: [('田中 太郎', 103000.0), ...]
(正解は 105800.0。discount_amount の引き算が order_item 行ごとに適用されてしまっている)
Q2: カテゴリが設定されている商品のみで...
生成SQL: SELECT p.category, SUM(oi.quantity * oi.unit_price) ...
⚠️ SQL は動くが結果が不正解: [(None, None), ('electronics', 128900.0), ...]
(`WHERE p.category IS NOT NULL` が入っておらず、未分類商品が集計対象に含まれている)
Q3: アクティブなユーザー...
生成SQL: SELECT name FROM users WHERE is_deleted = 0 AND user_id NOT IN (...)
✅ 正解: [('伊藤 四郎',)]
Q4: 最もよく売れた商品...
生成SQL: SELECT p.name, SUM(oi.quantity) FROM order_items oi JOIN products p ...
✅ 正解: [('キーボード', 3), ('マウス', 3), ...]
スキーマを渡すだけで大幅に改善しましたが、「SQL が動く = 正しい結果」ではないという問題が浮き彫りになりました。
- Q1:
discount_amountを注文単位ではなく明細行ごとに引いてしまう - Q2:
WHERE p.category IS NOT NULLが欠落しており、未分類商品(category=NULL)が集計対象に含まれている
いずれも SQL 構文エラーにはならないため、実行してみるまで気づけません。
実験3:スキーマ + サンプルデータ
実際のデータ行をコメント形式でプロンプトに添えます。
from base import setup_db, run_pattern, SCHEMA_DDL
_SAMPLE_INFO = """
-- users(user_id, name, email, joined_at, is_deleted):
-- (1,'田中 太郎','tanaka@...',0), (4,'佐藤 三郎',NULL,1) ← 退会済み
-- products: (5,'ケーブル',800,NULL) ← 未分類
-- orders: (2,...,'delivered',5000) ← discount_amount あり
-- (5,...,'cancelled',0) ← キャンセル
-- order_items: (4,2,5,1,NULL) ← unit_price=NULL(無償品)
""".strip()
def build_prompt(question: str) -> str:
return f"""以下のSQLiteスキーマとサンプルデータを持つECサイトのDBに対して、SQLを生成してください。
SQLだけを返してください。
スキーマ:
{SCHEMA_DDL}
サンプルデータ(抜粋):
{_SAMPLE_INFO}
質問: {question}
"""
if __name__ == "__main__":
conn = setup_db()
run_pattern(conn, build_prompt, "実験3: スキーマ + サンプルデータ")
conn.close()
実行例:このときは 3/4 成功
Q1: ...
生成SQL: SELECT u.name, SUM(oi.quantity * COALESCE(oi.unit_price, 0)) - o.discount_amount ...
⚠️ SQL は動くが結果が不正解: [('田中 太郎', 103000.0), ...]
(COALESCE は使えるようになったが、discount の引き算方法がまだ正しくない)
Q2: ...
✅ 正解: [('electronics', 128900.0), ('furniture', 35000.0)]
(サンプルで category=NULL の未分類商品が明示されていたため、正しくフィルタリングできた)
Q3: ✅ Q4: ✅
サンプルデータを渡したことで Q2 が改善されました。サンプルデータに category=NULL の未分類商品が明示されていたため、LLM が WHERE p.category IS NOT NULL を正しく適用できるようになっています。
一方で Q1 は依然として不正解です。discount_amount を注文単位で引くには注文ごとに小計を求めてから集計する必要がありますが、LLM はサンプルデータだけでそのパターンを習得できませんでした。
実験4:Few-shot(正解例を見せる)
JOIN・COALESCE・サブクエリを使った正解 SQL の例を数件見せます。
from base import setup_db, run_pattern, SCHEMA_DDL
_EXAMPLES = """
Q: delivered 済み注文について、商品ごとの売上合計(無償品は0円)を表示してください
A:
SELECT p.name, SUM(COALESCE(oi.unit_price, 0) * oi.quantity) AS total_sales
FROM order_items oi
JOIN products p ON oi.product_id = p.product_id
JOIN orders o ON oi.order_id = o.order_id
WHERE o.status = 'delivered'
GROUP BY p.product_id, p.name;
Q: 退会していないユーザーごとに、割引後の支払い総額(キャンセル除く)を計算してください
A:
SELECT u.name, SUM(order_total - o.discount_amount) AS total_paid
FROM users u
JOIN orders o ON u.user_id = o.user_id
JOIN (
SELECT order_id, SUM(COALESCE(unit_price, 0) * quantity) AS order_total
FROM order_items
GROUP BY order_id
) t ON o.order_id = t.order_id
WHERE u.is_deleted = 0
AND o.status != 'cancelled'
GROUP BY u.user_id, u.name
ORDER BY total_paid DESC;
""".strip()
def build_prompt(question: str) -> str:
return f"""以下のSQLiteスキーマを持つECサイトのDBに対して、SQLを生成してください。
SQLだけを返してください。
スキーマ:
{SCHEMA_DDL}
例:
{_EXAMPLES}
Q: {question}
A:
"""
if __name__ == "__main__":
conn = setup_db()
run_pattern(conn, build_prompt, "実験4: Few-shot")
conn.close()
実行例:このときは 4/4 成功
Q1: ...
生成SQL:
SELECT u.name, SUM(t.order_total - o.discount_amount) AS total_paid
FROM users u
JOIN orders o ON u.user_id = o.user_id
JOIN (
SELECT order_id, SUM(COALESCE(unit_price, 0) * quantity) AS order_total
FROM order_items GROUP BY order_id
) t ON o.order_id = t.order_id
WHERE u.is_deleted = 0 AND o.status != 'cancelled'
GROUP BY u.user_id, u.name ORDER BY total_paid DESC
✅ 正解: [('田中 太郎', 105800.0), ('鈴木 花子', 35000.0), ('山田 次郎', 18100.0)]
Q2: ✅ Q3: ✅ Q4: ✅
今回の実行例では、Few-shot 例の「注文ごとに小計を求めてから集計する」パターンを手がかりに、Q1 の discount 計算も正しく行えるようになりました。
Few-shot が効く理由は、LLM がプロンプト内の例から推論パターンをその場で取り込む(In-context Learning)からです。Q1 の正解例は渡していませんが、「注文単位で小計を求める」という構造が類似しているため、LLM はそのパターンを Q1 にも適用できました。
Few-shot の効果は、例と質問の構造的な類似度に大きく依存します。
なお、LLM の出力には揺らぎがあるため、Few-shot を与えても常に同じ SQL が生成されるとは限りません。
実験5:エラーフィードバックによる自己修正
わざとスキーマなしで最初のリクエストを投げ、実行エラーが返ってきたらスキーマ + エラー内容を添えて再送信します。Few-shot を使えば最初からエラーが出ないため、あえてスキーマなしのナイーブなプロンプトから始めることで、自己修正の様子をターミナルで確実に観測できます。
from base import setup_db, run_sql, call_gemini, check_result, SCHEMA_DDL, QUESTIONS
def build_first_prompt(question: str) -> str:
"""スキーマ情報なしで投げる(意図的に失敗しやすくする)"""
return f"""ECサイトのデータベースに対して、次の質問に答えるSQLを生成してください。
SQLだけを返してください。説明は不要です。
質問: {question}
"""
def build_correction_prompt(question: str, sql: str, error: str) -> str:
"""エラー内容 + スキーマを添えて修正を依頼する"""
return f"""以下のSQLiteスキーマを持つDBに対してSQLを生成しましたが、実行エラーが発生しました。
修正したSQLだけを返してください。
スキーマ:
{SCHEMA_DDL}
質問: {question}
生成したSQL:
{sql}
エラー:
{error}
修正後のSQL:
"""
def run_with_self_correction(conn, question: str, q_idx: int) -> bool:
print(" [1回目] スキーマなしで送信...")
sql = call_gemini(build_first_prompt(question))
print(f" 生成SQL: {sql[:120].replace(chr(10), ' ')}")
rows, error = run_sql(conn, sql)
if error:
print(f" ❌ 実行エラー: {error}")
print(" [2回目] エラー内容 + スキーマを添えて自己修正プロンプトを送信...")
fixed_sql = call_gemini(build_correction_prompt(question, sql, error))
print(f" 修正SQL: {fixed_sql[:120].replace(chr(10), ' ')}")
rows, error2 = run_sql(conn, fixed_sql)
if error2:
print(f" ❌ 修正後もエラー: {error2}")
return False
elif check_result(q_idx, rows):
print(f" ✅ 自己修正成功: {rows[:3]}")
return True
else:
print(f" ⚠️ SQL は動くが結果が不正解: {rows[:3]}")
return False
elif check_result(q_idx, rows):
print(f" ✅ 1回目で正解(自己修正不要): {rows[:3]}")
return True
else:
print(f" ⚠️ SQL は動くが結果が不正解: {rows[:3]}")
return False
結果
Q1: 各ユーザーの実質的な支払い総額を多い順に表示してください
[1回目] スキーマなしで送信...
生成SQL: SELECT user_id, SUM(amount - COALESCE(discount, 0)) FROM orders GROUP BY user_id ...
❌ 実行エラー: no such column: amount
[2回目] エラー内容 + スキーマを添えて自己修正プロンプトを送信...
修正SQL: SELECT u.name, SUM(oi.quantity * oi.unit_price) - MAX(o.discount_amount) FROM users u ...
⚠️ SQL は動くが結果が不正解(discount の引き算方法が不正確)
Q2: カテゴリが設定されている商品のみで...
[1回目] スキーマなしで送信...
生成SQL: SELECT c.category_name, SUM(oi.price * oi.quantity) FROM categories c JOIN products p ...
❌ 実行エラー: no such table: categories
[2回目] エラー内容 + スキーマを添えて自己修正プロンプトを送信...
修正SQL: SELECT p.category, SUM(oi.unit_price * oi.quantity) FROM products p JOIN order_items oi ...
✅ 自己修正成功: [('electronics', 128900.0), ('furniture', 35000.0)]
Q3: アクティブなユーザーのうち...
[1回目] スキーマなしで送信...
生成SQL: SELECT u.user_id, u.user_name FROM users u ... WHERE u.is_active = true ...
❌ 実行エラー: no such column: u.user_name
[2回目] エラー内容 + スキーマを添えて自己修正プロンプトを送信...
修正SQL: SELECT u.user_id, u.name FROM users u ... WHERE u.is_deleted = 0 ...
✅ 自己修正成功: [(5, '伊藤 四郎')]
Q4: 最もよく売れた商品のトップ3...
[1回目] スキーマなしで送信...
生成SQL: SELECT product_id, SUM(quantity) FROM order_items GROUP BY product_id ...
⚠️ SQL は動くが結果が不正解(キャンセル注文が除外されていない)
成功率: 2/4
構文エラーのみを対象としたフィードバックの限界
Q2・Q3 はエラーを手がかりにスキーマを参照することで自己修正できました。一方で Q1・Q4 はSQL構文エラーを起こさなかったため修正が発動せず、意味的に間違った結果のままです。
| ケース | 自己修正の効果 |
|---|---|
| 存在しないテーブル名・列名エラー | ✅ スキーマを渡せばほぼ修正できる |
| NULL 値・キャンセル除外の考慮漏れ | ❌ エラーにならないので修正が発動しない |
| discount の集計方法の誤り | ❌ 同上 |
実験5から分かるのは、「自己修正ループを入れれば精度が上がる」ではなく、「自己修正ループは構文エラーには強いが、意味的な誤りにはほぼ無力」という点です。本実装は構文エラーメッセージしかフィードバックしていないため、SQL が動いてしまう誤りは検出の手がかりがありません。構文エラーを自動修復する能力と、ビジネスルール(NULL の扱いや discount の計算粒度)を正しく理解する能力は別物です。実運用では Few-shot や業務ルールの明示と組み合わせることが不可欠です。
意味的な誤りまで修正するには、「SQL の実行結果が期待値と一致するか」をアサーションで検証し、その結果もフィードバックへ含める Semantic Self-Correction が必要になります。例えば「結果が0件ではないこと」「数値カラムの合計が正の値であること」といったドメイン依存の検証ルールを組み込むことで、意味的な誤りを検出できる場合があります。ただし、これらは厳密な正解判定ではなく、明らかに不自然な結果を検出するためのヒューリスティックな検証です。業務要件に依存するため汎用的な実装は難しく、システムごとの設計が必要です。
実験結果まとめ
今回の4問に対する各実験パターンの結果は以下の通りです。
| パターン | 今回の実行結果 | ポイント |
|---|---|---|
| 1. 素朴(スキーマなし) | 0/4 | テーブル名・列名を完全に想像で作る |
| 2. スキーマあり | 2/4 | SQL は動くが discount・NULL の扱いが不正確 |
| 3. スキーマ + サンプルデータ | 3/4 | NULL の扱いは改善。discount 計算はまだ誤り |
| 4. Few-shot | 4/4 | サブクエリパターンを手がかりに全問正解 |
| 5. 自己修正ループ | 2/4 | 実行エラーは自動修復できる。意味的な誤りは修復不可 |
実験5の成功率が 2/4 にとどまっているのは意図的な設計です。「エラーフィードバックで直せる問題」と「直せない問題」の両方を観測するために、あえてスキーマなしで最初のリクエストを投げています。
実務への応用ポイント
1. 「SQL が動く ≠ 正しい結果」に注意
実験2・3で示したように、SQL の構文エラーがなくても意図した結果が返るとは限りません。discount_amount の計算ミスや COALESCE の漏れは実行してみないと気づけません。
本番で使うなら、テストクエリとその期待値を用意して自動検証する仕組みが必要です。
2. 実用的な実装パターン
Few-shot + 自己修正ループの組み合わせが現実的です。自己修正は無限ループしないよう試行回数に上限を設けてください。
def generate_sql_with_retry(conn, question, max_retries=2):
sql = call_gemini(fewshot_prompt(question))
for _ in range(max_retries):
rows, error = run_sql(conn, sql)
if not error:
return sql, rows
sql = call_gemini(build_correction_prompt(question, sql, error))
return sql, None
3. スキーマ設計が精度を左右する
実験1(スキーマなし)でLLMが生成したSQLの列名を確認してみます。
-
amount(実際はunit_price * quantity) -
status(実際はis_deleted) -
categories(実際はproducts.categoryカラムで管理)
今回の結果を見る限り、LLM は一般的なECサイトで使われそうな命名(amount・status・categoriesなど)を推測する傾向がありました。逆に言えば、独自の略語や社内用語を多用したスキーマは LLM との相性が悪く、Few-shot の数を増やしても精度が上がりにくくなります。
おわりに
今回の実験を通じて分かったのは、Text-to-SQL の精度改善は 「モデルに何を渡すか」の影響が大きい という点です。
- スキーマを渡すだけで、存在しないテーブル名・列名の問題は大幅に減る
- サンプルデータを渡すと、NULL 値の扱い方が改善される
- Few-shot で正解例を見せると、サブクエリを使った複雑な集計パターンを適用できる
- 自己修正ループを組み合わせると、実行エラーを自動で回復できる
そして忘れてはならないのが スキーマ設計の重要性 です。略語だらけのカラム名や意味が曖昧なテーブル名は、人間が読む場合と同じくLLMも苦手にします。JOIN最適化やインデックス設計と同じく、スキーマ設計は LLM 時代においても重要な基礎スキルであり続けます。
Text-to-SQL の精度向上は、コンテキスト設計(スキーマ・サンプルデータ・Few-shot)の影響が大きい、というのがこの記事を通じた結論です。
【付録】本記事で使用したコード
base.py
import sqlite3, os, json
from google import genai
from google.genai import types
SCHEMA_DDL = """
CREATE TABLE users (
user_id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT,
joined_at TEXT NOT NULL,
is_deleted INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE products (
product_id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
price REAL NOT NULL,
category TEXT
);
CREATE TABLE orders (
order_id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(user_id),
ordered_at TEXT NOT NULL,
status TEXT NOT NULL,
discount_amount REAL NOT NULL DEFAULT 0
);
CREATE TABLE order_items (
item_id INTEGER PRIMARY KEY,
order_id INTEGER NOT NULL REFERENCES orders(order_id),
product_id INTEGER NOT NULL REFERENCES products(product_id),
quantity INTEGER NOT NULL,
unit_price REAL
);
""".strip()
QUESTIONS = [
"各ユーザーの実質的な支払い総額を多い順に表示してください",
"カテゴリが設定されている商品のみで、カテゴリ別の売上合計(キャンセルを除く)を表示してください",
"アクティブなユーザーのうち、まだ一度も注文していない人を教えてください",
"最もよく売れた商品のトップ3を教えてください",
]
_PROJECT_ID = os.environ.get("GOOGLE_CLOUD_PROJECT", "your-project-id")
_MODEL = "gemini-3.1-flash-lite"
_client = genai.Client(vertexai=True, project=_PROJECT_ID, location="global")
def call_gemini(prompt: str) -> str:
response = _client.models.generate_content(
model=_MODEL,
contents=prompt,
config=types.GenerateContentConfig(
response_mime_type="application/json",
response_schema={
"type": "object",
"properties": {"sql": {"type": "string"}},
"required": ["sql"],
},
max_output_tokens=1024,
),
)
return json.loads(response.text)["sql"]
_SCHEMA_DDL_FULL = """
CREATE TABLE users (
user_id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT, -- NULL = ゲスト購入
joined_at TEXT NOT NULL,
is_deleted INTEGER NOT NULL DEFAULT 0 -- 1 = 退会済み
);
CREATE TABLE products (
product_id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
price REAL NOT NULL,
category TEXT -- NULL = 未分類
);
CREATE TABLE orders (
order_id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(user_id),
ordered_at TEXT NOT NULL,
status TEXT NOT NULL, -- 'pending'|'shipped'|'delivered'|'cancelled'
discount_amount REAL NOT NULL DEFAULT 0
);
CREATE TABLE order_items (
item_id INTEGER PRIMARY KEY,
order_id INTEGER NOT NULL REFERENCES orders(order_id),
product_id INTEGER NOT NULL REFERENCES products(product_id),
quantity INTEGER NOT NULL,
unit_price REAL -- NULL = 無償提供(キャンペーン品)
);
"""
_SAMPLE_INSERTS = """
INSERT INTO users VALUES
(1, '田中 太郎', 'tanaka@example.com', '2023-01-15', 0),
(2, '鈴木 花子', 'suzuki@example.com', '2023-03-22', 0),
(3, '山田 次郎', 'yamada@example.com', '2024-06-01', 0),
(4, '佐藤 三郎', NULL, '2024-02-10', 1),
(5, '伊藤 四郎', 'ito@example.com', '2024-08-01', 0);
INSERT INTO products VALUES
(1, 'ノートPC', 98000, 'electronics'),
(2, 'マウス', 2500, 'electronics'),
(3, 'デスク', 35000, 'furniture'),
(4, 'キーボード', 7800, 'electronics'),
(5, 'ケーブル', 800, NULL);
INSERT INTO orders VALUES
(1, 1, '2024-01-10', 'delivered', 0),
(2, 1, '2024-03-05', 'delivered', 5000),
(3, 2, '2024-04-20', 'shipped', 0),
(4, 3, '2024-05-15', 'pending', 0),
(5, 4, '2024-02-01', 'cancelled', 0);
INSERT INTO order_items VALUES
(1, 1, 1, 1, 98000),
(2, 1, 2, 2, 2500),
(3, 2, 4, 1, 7800),
(4, 2, 5, 1, NULL),
(5, 3, 3, 1, 35000),
(6, 4, 2, 1, 2500),
(7, 4, 4, 2, 7800),
(8, 5, 1, 1, 98000);
"""
def setup_db() -> sqlite3.Connection:
conn = sqlite3.connect(":memory:")
conn.executescript(_SCHEMA_DDL_FULL)
conn.executescript(_SAMPLE_INSERTS)
return conn
def run_sql(conn: sqlite3.Connection, sql: str) -> tuple[list, str | None]:
try:
return conn.execute(sql).fetchall(), None
except Exception as e:
return [], str(e)
_EXPECTED = [
{105800.0, 35000.0, 18100.0},
{("electronics", 128900.0), ("furniture", 35000.0)},
{"伊藤 四郎"},
{("マウス", 3), ("キーボード", 3)}, # Q4: 同数1位の2件のみ検証(3位は同数になりうるため)
]
def check_result(q_idx: int, rows: list) -> bool:
expected = _EXPECTED[q_idx]
try:
if q_idx == 0:
return expected == {round(float(r[-1]), 1) for r in rows}
elif q_idx == 1:
if any(r[0] is None for r in rows):
return False
return expected == {(r[0], round(float(r[1]), 1)) for r in rows}
elif q_idx == 2:
return expected == {str(r[-1]) for r in rows}
elif q_idx == 3:
return expected == {(r[0], int(r[1])) for r in rows[:3]}
except Exception:
return False
return False
def run_pattern(conn, prompt_fn, label: str):
print(f"\n{'='*60}\n{label}\n{'='*60}")
success = 0
for i, question in enumerate(QUESTIONS, 1):
print(f"\n Q{i}: {question}")
sql = call_gemini(prompt_fn(question))
print(f" 生成SQL: {sql[:120].replace(chr(10), ' ')}")
rows, error = run_sql(conn, sql)
if error:
print(f" ❌ 実行エラー: {error}")
elif check_result(i - 1, rows):
print(f" ✅ 正解: {rows[:3]}")
success += 1
else:
print(f" ⚠️ SQL は動くが結果が不正解: {rows[:3]}")
print(f"\n 成功率: {success}/{len(QUESTIONS)}")
return success
その他実験のコードは本文中のものをそのまま利用可能です。