「RAGやpgvectorを試したが、整備済みのテーブルに対しては、ベクター検索よりSQLで直接クエリする方が精度が高い」
そんな場面で使えるのが、ローカルLLMを使ったText-to-SQLです。この記事では、OllamaとPythonを組み合わせ、日本語の質問をPostgreSQLのSELECT文に変換して業務データを検索するパイプラインを構築する手順を解説します。
スキーマの動的取得からOllamaへのプロンプト設計、読み取り専用ユーザーによる安全な実行、SQLインジェクション対策まで、Ubuntu Server上でそのまま動かせる実装を一通りカバーします。社内にPostgreSQLが既にある場合、この構成はRAGとは別の有力な選択肢になります。
この記事のポイント
・OllamaのPOST /api/generateに「CREATE TABLEスキーマ+質問」を渡すとSQL文が得られる
・information_schemaでスキーマを動的取得すれば、テーブル追加後もプロンプト更新不要になる
・生成SQLは読み取り専用ユーザー+SELECTのみ許可フィルタで安全に実行できる
・few-shot例をプロンプトに追加するとJOINや集計クエリの生成精度が大幅に向上する
でも安心してください。プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
Text-to-SQLとRAGの違い|どちらを使うべき場面の判断基準
Text-to-SQLとRAGは、どちらも「質問に答える」仕組みですが、向いている場面が異なります。RAGは非構造化テキスト(PDF・マニュアル・議事録)を対象とし、ベクター検索で関連チャンクを取得してLLMに渡します。一方、Text-to-SQLは整形されたリレーショナルDBが前提で、質問をSQLに変換してDBに直接クエリします。
・業務DBに月次売上・顧客情報・在庫数量などの構造化データがある ~ Text-to-SQLが適している
・社内マニュアル・FAQドキュメント・ログ集などのテキスト資料がある ~ RAGが適している
両者は排他ではなく、構造化データにはText-to-SQL、非構造化データにはRAGと使い分けるのが実務的な判断です。
Ollamaの構築がまだの場合は、Ubuntu ServerでローカルLLMを構築する方法で環境を整えてから戻ってきてください。
前提環境の確認|PostgreSQL・Ollama・Pythonのバージョン要件
この記事の手順は以下の環境を前提とします。・Ubuntu Server 22.04 LTS または 24.04 LTS
・PostgreSQL 14 以上
・Ollama 0.3.0 以降(/api/generate が安定動作するバージョン)
・Python 3.10 以上
Ollamaが動作しているかを事前に確認します。
# Ollamaのサービス状態を確認 $ systemctl status ollama # llama3.3またはmistralのモデルがあるか確認 $ ollama list NAME ID SIZE MODIFIED llama3.3:70b-instruct-q4_0 ab12cd34ef56 43 GB 3 days ago mistral:7b-instruct-q4_0 ff11aa22bb33 4.1 GB 5 days ago
モデルの使い分け基準についてはローカルLLMのモデルを比較する方法も参照してください。
PostgreSQLにサンプルデータベースを用意する手順
1. サンプルテーブルとデータの作成
今回は社内の受注管理DBを想定したサンプルを使います。psqlでデータベースとテーブルを作成します。# postgres ユーザーでpsqlを起動 $ sudo -u postgres psql -- データベースとロールの作成 postgres=# CREATE DATABASE bizdb; postgres=# CREATE USER bizadmin WITH PASSWORD 'admin_pw'; postgres=# GRANT ALL PRIVILEGES ON DATABASE bizdb TO bizadmin; postgres=# CREATE USER bizreader WITH PASSWORD 'reader_pw'; postgres=# \c bizdb bizdb=# GRANT CONNECT ON DATABASE bizdb TO bizreader; -- サンプルテーブルの作成 bizdb=# CREATE TABLE customers ( id SERIAL PRIMARY KEY, name VARCHAR(100) NOT NULL, prefecture VARCHAR(20), created_at DATE DEFAULT CURRENT_DATE ); bizdb=# CREATE TABLE orders ( id SERIAL PRIMARY KEY, customer_id INTEGER REFERENCES customers(id), product_name VARCHAR(100) NOT NULL, amount INTEGER NOT NULL, order_date DATE NOT NULL ); -- サンプルデータの投入 bizdb=# INSERT INTO customers (name, prefecture) VALUES ('株式会社アルファ', '東京都'), ('ベータ工業', '大阪府'), ('ガンマ商事', '愛知県'); bizdb=# INSERT INTO orders (customer_id, product_name, amount, order_date) VALUES (1, 'Linux研修パッケージ', 198000, '2026-07-15'), (2, 'サーバー保守契約', 480000, '2026-07-20'), (1, 'クラウド移行支援', 320000, '2026-08-01'), (3, 'Linux研修パッケージ', 198000, '2026-08-10'); bizdb=# \q
2. 読み取り専用ユーザーへのSELECT権限付与
Text-to-SQLで生成したSQLはbizreaderユーザーで実行します。SELECT権限のみを付与することで、誤ったSQLが生成されてもデータが書き換わるリスクを排除します。$ sudo -u postgres psql -d bizdb bizdb=# GRANT USAGE ON SCHEMA public TO bizreader; bizdb=# GRANT SELECT ON ALL TABLES IN SCHEMA public TO bizreader; bizdb=# ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO bizreader; bizdb=# \q # 接続確認(bizreaderでログイン) $ psql -U bizreader -d bizdb -h localhost -c "SELECT count(*) FROM orders;" count ------- 4 (1 row)
テーブルスキーマをinformation_schemaから動的取得する
1. スキーマ情報を文字列で取得する関数
Text-to-SQLプロンプトには、LLMがSQLを生成するためのテーブル定義情報が必要です。information_schemaから自動取得することで、テーブルが追加・変更された後もコード修正不要になります。import psycopg2 def get_schema_ddl(conn, schema_name="public"): cur = conn.cursor() cur.execute( """ SELECT c.table_name, c.column_name, c.data_type, c.is_nullable FROM information_schema.columns c WHERE c.table_schema = %s ORDER BY c.table_name, c.ordinal_position """, (schema_name,) ) rows = cur.fetchall() tables = {} for table_name, col_name, data_type, nullable in rows: if table_name not in tables: tables[table_name] = [] col_def = f" {col_name} {data_type.upper()}" if nullable == "NO": col_def += " NOT NULL" tables[table_name].append(col_def) ddl = "" for table_name, cols in tables.items(): ddl += f"CREATE TABLE {table_name} (\n" ddl += ",\n".join(cols) ddl += "\n);\n\n" return ddl.strip() # 動作確認 conn = psycopg2.connect( dbname="bizdb", user="bizreader", password="reader_pw", host="localhost" ) print(get_schema_ddl(conn))
OllamaでSQL生成プロンプトを設計して自然言語クエリを実装する
1. プロンプトテンプレートの設計
SQL生成の精度はプロンプト設計に大きく左右されます。重要なポイントは3つです。・テーブル定義をCREATE TABLE形式で提示する(LLMがカラム名と型を正確に把握できる)
・SELECT文のみを生成するよう明示的に制約する
・コードブロック記号(```)なしでSQLのみを出力させる
def build_prompt(schema_ddl, question): return f"""あなたはPostgreSQLの専門エンジニアです。 以下のデータベーススキーマを参照して、質問に対応するSQLクエリを生成してください。 制約: - SELECT文のみ生成する。INSERT/UPDATE/DELETE/DROPは使わない - コードブロック記号を含めず、SQLのみを返す - 結果が多い場合はLIMIT 20を付ける スキーマ: {schema_ddl} 質問: {question} SQL:"""
2. OllamaのAPIを呼び出してSQLを取得する
import requests def generate_sql(prompt, model="llama3.3:70b-instruct-q4_0"): resp = requests.post( "http://localhost:11434/api/generate", json={ "model": model, "prompt": prompt, "stream": False, "options": { "temperature": 0.1, # 決定論的に生成したいので低めに設定 "num_ctx": 4096 } }, timeout=120 ) resp.raise_for_status() sql = resp.json()["response"].strip() # コードブロック記号が含まれていた場合は除去 if "```" in sql: lines = sql.split("\n") sql = "\n".join( l for l in lines if not l.strip().startswith("```") ) return sql.strip()
生成されたSQLを安全に実行するパイプライン
1. SQLインジェクション対策のバリデーション関数
LLMが生成したSQLは必ず実行前にバリデーションします。SELECTで始まるかチェックし、データ変更・DDL系のキーワードが含まれていないかを検証します。import re DANGEROUS = re.compile( r"\b(INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE|" r"GRANT|REVOKE|EXECUTE|COPY|VACUUM)\b", re.IGNORECASE ) def validate_sql(sql): if not sql.strip().upper().startswith("SELECT"): return False, "SELECT文で始まっていません" if DANGEROUS.search(sql): m = DANGEROUS.search(sql) return False, f"危険なキーワードが含まれています: {m.group()}" # 末尾以外のセミコロン(複数クエリ)を検出 if ";" in sql.rstrip(";"): return False, "複数のクエリが含まれています" return True, "OK"
2. バリデーション通過後にPostgreSQLで実行する
def run_text_to_sql(question): conn = psycopg2.connect( dbname="bizdb", user="bizreader", password="reader_pw", host="localhost" ) schema_ddl = get_schema_ddl(conn) prompt = build_prompt(schema_ddl, question) sql = generate_sql(prompt) ok, reason = validate_sql(sql) if not ok: conn.close() return {"error": reason, "sql": sql, "rows": []} cur = conn.cursor() try: cur.execute(sql) columns = [desc[0] for desc in cur.description] rows = cur.fetchall() except psycopg2.Error as e: conn.rollback() conn.close() return {"error": str(e.pgerror), "sql": sql, "rows": []} conn.close() return {"sql": sql, "columns": columns, "rows": rows} # 実行例 result = run_text_to_sql( "東京都の顧客の注文を合計金額が高い順に見せてください" ) print("生成SQL:", result["sql"]) for row in result.get("rows", []): print(row)
# 実行例の出力 生成SQL: SELECT c.name, o.product_name, o.amount FROM customers c JOIN orders o ON c.id = o.customer_id WHERE c.prefecture = '東京都' ORDER BY o.amount DESC LIMIT 20; ('株式会社アルファ', 'クラウド移行支援', 320000) ('株式会社アルファ', 'Linux研修パッケージ', 198000)
社内AIツールの導入背景については社内でChatGPTが使えないときの代替手段も参考にしてください。
精度を高める工夫|few-shot例と列コメントの活用
1. few-shot例をプロンプトに追加する
プロンプトに「質問 → SQL」のサンプルを数例追加すると、生成精度が大幅に上がります。特に日本語カラム値のLIKE句、JOINの使い方、集計関数などを例示すると効果的です。FEW_SHOT = """ 質問: 全顧客の一覧を見せてください SQL: SELECT id, name, prefecture, created_at FROM customers ORDER BY id; 質問: 2026年8月の注文件数を教えてください SQL: SELECT count(*) AS order_count FROM orders WHERE order_date >= '2026-08-01' AND order_date < '2026-09-01'; 質問: 顧客ごとの合計注文金額を高い順に見せてください SQL: SELECT c.name, sum(o.amount) AS total_amount FROM customers c JOIN orders o ON c.id = o.customer_id GROUP BY c.name ORDER BY total_amount DESC; """ def build_prompt(schema_ddl, question): return f"""(上記プロンプトのスキーマ部分は同じ) 参考例: {FEW_SHOT} 質問: {question} SQL:"""
2. PostgreSQLのCOMMENT ONでカラムの意味を補足する
カラム名だけでは意味が分かりにくい場合、COMMENT ON COLUMNでコメントを追加してスキーマ取得時に含める方法も効果的です。$ sudo -u postgres psql -d bizdb bizdb=# COMMENT ON COLUMN orders.amount IS '注文金額(税込、円単位)'; bizdb=# COMMENT ON COLUMN customers.prefecture IS '都道府県名(例: 東京都、大阪府)'; bizdb=# \q
よくあるエラーと対処法|Text-to-SQL実装時のトラブルシューティング
エラー1: 存在しないカラム名のSQLが生成される
モデルがスキーマを誤読してカラム名を捏造することがあります。特にMistral 7Bなど小さなモデルで起こりやすいです。psycopg2のcur.execute()をtry/exceptで囲み、PostgreSQLのエラーをキャッチしてユーザーに返すのが基本対策です。精度が低い場合は`llama3.3:70b-instruct-q4_0`への切り替えと、few-shot例の追加を先に試してください。
try: cur.execute(sql) rows = cur.fetchall() except psycopg2.Error as e: conn.rollback() return {"error": f"SQL実行エラー: {e.pgerror}", "sql": sql, "rows": []}
エラー2: Ollamaのレスポンスがタイムアウトする
70Bモデルでプロンプトが長い場合、CPUのみの環境では生成に数分かかることがあります。`requests.post()`のtimeout引数を180以上に設定します。またOllamaのsystemd override.confで`OLLAMA_NUM_PARALLEL=1`と`OLLAMA_MAX_LOADED_MODELS=1`を設定するとメモリ消費が安定します。
# systemdのoverride.confでOllama環境変数を設定 $ sudo systemctl edit ollama # 追加するセクション [Service] Environment="OLLAMA_NUM_PARALLEL=1" Environment="OLLAMA_MAX_LOADED_MODELS=1" $ sudo systemctl daemon-reload && sudo systemctl restart ollama
エラー3: バリデーションが正当なSQLを弾く
ANALYZEなどのキーワードが集計クエリ(EXPLAIN ANALYZE等)に含まれていると誤検出されることがあります。その場合はDANGEROUSの正規表現からANALYZEを外し、EXPLAINを追加するなどパターンを調整してください。本番環境では要件に合わせてホワイトリスト方式も検討します。まとめ|OllamaとText-to-SQLで自然言語からPostgreSQLを安全に検索する要点
OllamaとPythonによるText-to-SQLパイプラインは、整備済みの業務DBに対して「SQLを書かずにデータを参照したい」という現場ニーズに直接応えられます。pgvectorやRAGが非構造化テキスト向けであるのに対し、Text-to-SQLはリレーショナルDBと相性がよく、月次売上・在庫・顧客情報の検索に強みを発揮します。
| 工程 | 実装ポイント |
|---|---|
| スキーマ取得 | SELECT ... FROM information_schema.columns WHERE table_schema='public' |
| プロンプト設計 | CREATE TABLE定義+few-shot例+「SELECTのみ生成」制約を明示 |
| SQL生成API | POST http://localhost:11434/api/generate(temperature: 0.1) |
| SQLバリデーション | SELECTで始まるか+DANGEROUS_KEYWORDSの正規表現チェック |
| SQL実行 | GRANT SELECT ON ALL TABLESの読み取り専用ユーザーで接続して実行 |
| 精度向上 | few-shot例の追加、COMMENT ON COLUMNによる意味補足、70Bモデル利用 |
Text-to-SQLとローカルLLM活用を2日間のハンズオンで体験する
OllamaによるText-to-SQL構築から、pgvectorを使ったRAGパイプラインの実装まで、本記事で解説した内容を実機のGPUサーバーで試したい方向けに、「ローカルAIマスターセミナー」を開催しています。
少人数(最大8名)ZOOMハンズオン形式で実施しています。
・Ubuntu ServerでローカルLLMを構築する方法|Ollamaで機密データを外に出さず業務AIを動かす完全ガイド
・社内でChatGPTが使えないときの代替手段|機密データを守るローカルLLMという選択肢
・ローカルLLMのモデルを比較する方法|Llama3.3・Mistral・Gemma・Phi-4をUbuntuで使い分けるポイント
3,100名以上が実践した「型」を無料で公開中
プロのエンジニアはコマンドを暗記していません。
「現場で使える型」を効率よく使いこなしているだけです。
その「型」を図解60Pにまとめた入門マニュアルを、完全無料でプレゼントしています。
姓・名・メールの3つだけ/30秒/解除は3秒 / 詳細はこちら
- 前のページへ:OllamaをHAProxyでロードバランシングする方法|複数LinuxサーバーにOllamaを分散して高可用性ローカルLLMクラスターを構築する手順
- この記事の属するカテゴリ:ローカルLLMへ戻る

無料メルマガで学習を続ける
Linuxの実践スキルをメールで毎週お届け。
登録は30秒、解除もいつでも可。
登録無料・いつでも解除できます