背景
- ダッシュボードは便利だが、表示している以外の「もっと深掘りしたい」疑問に答えるのは難しい
- 知りたい事を即時ダッシュボードに反映するのは難しい
- そこで「自然文で質問 → SQL 自動生成 → データ取得 → 要約コメント返却」を試してみた
全体構成
ユーザーの自然文の質問を受け取り、最終的に要約された回答を返す一連の流れを自動化します。
以下のシーケンス図は、そのプロセスを示しています。
ディレクトリ構成、サンプルコード
サンプルコード
Functions/Text2SqlFunction.cs
using System.Data.SqlClient;
using System.Text.Json;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Connectors.OpenAI;
using Microsoft.SemanticKernel.ChatCompletion;
using Text2SqlApi.Services;
namespace Text2SqlApi.Functions;
public class Text2SqlFunction
{
private readonly IChatCompletionService _chat;
private readonly Kernel _kernel;
private readonly string _sqlConn;
public Text2SqlFunction(IConfiguration config)
{
var builder = Kernel.CreateBuilder();
builder.AddAzureOpenAIChatCompletion(
deploymentName: config["AOAI_DEPLOYMENT"]!,
endpoint: config["AOAI_ENDPOINT"]!,
apiKey: config["AOAI_APIKEY"]!);
_kernel = builder.Build();
_chat = _kernel.GetRequiredService<IChatCompletionService>();
_sqlConn = config["SQL_CONNECTIONSTRING"]!;
}
[Function("Text2SqlFunction")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequestData req)
{
var body = await JsonDocument.ParseAsync(req.Body);
var question = body.RootElement.GetProperty("question").GetString();
// --- SQL 生成 ---
var sysPrompt = PromptTemplates.BuildSqlPrompt(
"orders(order_id, order_date, product_id, quantity, price)\nproducts(product_id, product_name, category)",
question!);
var messages = new ChatHistory();
messages.AddSystemMessage(sysPrompt);
var resp = await _chat.GetChatMessageContentAsync(messages, _kernel);
var sql = resp.Content!.Trim();
SqlGuards.EnsureSafe(sql);
// --- SQL 実行 ---
var rows = new List<Dictionary<string, object>>();
using (var conn = new SqlConnection(_sqlConn))
{
await conn.OpenAsync();
using var cmd = new SqlCommand(sql, conn);
using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
var row = new Dictionary<string, object>();
for (int i = 0; i < reader.FieldCount; i++)
row[reader.GetName(i)] = reader.GetValue(i);
rows.Add(row);
}
}
// --- 要約 ---
var summaryPrompt = $"""
次のデータを要約し、質問「{question}」に答えてください。
特徴や傾向があれば指摘してください。
データ: {JsonSerializer.Serialize(rows)}
""";
var summaryMsg = new ChatHistory();
summaryMsg.AddSystemMessage(summaryPrompt);
var summaryRes = await _chat.GetChatMessageContentAsync(summaryMsg, _kernel);
var analysis = summaryRes.Content?.Trim();
// --- レスポンス ---
var res = req.CreateResponse();
await res.WriteAsJsonAsync(new { sql, result = rows, analysis });
return res;
}
}
Services/PromptTemplates.cs
namespace Text2SqlApi.Services;
public static class PromptTemplates
{
public static string BuildSqlPrompt(string schemaSummary, string question)
{
var template = """
あなたはSQL生成アシスタントです。
必ず SELECT 文のみを生成してください。
禁止: INSERT, UPDATE, DELETE, CREATE, DROP
スキーマ: <<SCHEMA>>
質問: <<QUESTION>>
""";
return template
.Replace("<<SCHEMA>>", schemaSummary)
.Replace("<<QUESTION>>", question);
}
}
Services/SqlGuards.cs
using System.Text.RegularExpressions;
namespace Text2SqlApi.Services;
public static class SqlGuards
{
static readonly Regex Dangerous =
new(@"(\bINSERT\b|\bUPDATE\b|\bDELETE\b|\bDROP\b|\bALTER\b|\bCREATE\b|;)", RegexOptions.IgnoreCase);
public static void EnsureSafe(string sql)
{
if (string.IsNullOrWhiteSpace(sql)) throw new Exception("Empty SQL.");
if (Dangerous.IsMatch(sql)) throw new Exception("Dangerous SQL detected.");
if (!sql.TrimStart().StartsWith("SELECT", StringComparison.OrdinalIgnoreCase))
throw new Exception("Only SELECT queries are allowed.");
}
}
実際に試してみる
API 呼び出し:
curl -X POST http://localhost:7071/api/Text2SqlFunction \
-H "Content-Type: application/json" \
-d '{"question":"2024年の月次売上を商品別に集計して"}'
レスポンス:
{
"sql": "SELECT p.product_name, SUM(o.quantity*o.price) AS total_sales, FORMAT(o.order_date,'yyyy-MM') AS month ...",
"result": [
{ "product_name": "ProductA", "total_sales": 500, "month": "2024-01" },
{ "product_name": "ProductB", "total_sales": 600, "month": "2024-02" }
],
"analysis": "2024年は1月はProductAが好調、2月はProductBが伸びています。月ごとに主力商品が入れ替わる傾向があります。"
}
結果と考察
結果
-
自然文→SQL→結果→要約の一連の体験が、1エンドポイント(Functions)で実現できた
-
SELECT限定のガードとスキーマ要約だけでも、一般的な問い合わせ(期間集計・ランキング)は再現性高く回答できた
-
要約コメントによって、表を目で追わなくても「押さえるべきポイント」を掴みやすい
考察
A. 体験価値:「Time-to-Insight」を短縮
- ダッシュボードは「定義済みの問い」に強い。一方で利用者はその場の疑問から始まることが多い
- 本仕組みは「気になったタイミングで聞ける」ため、
- 仮説検証の初動(“まず当たりを付ける”)
- 次の深掘りへの踏み出し(“週次でも見たい/カテゴリ別で見たい”)の敷居を下げられる
- 要約コメントの付与で、要点提示まで自動化
B. 導入効果
- 現状:「集計依頼 → 待ち」の時間を軽減できる
- 本仕組みは利用者が一次回答をセルフサービス化する事ができる
C. リスク & 制約:“便利さ”と“正確さ”のトレードオフ
-
誤SQL/誤結論のリスク:
- 列名・結合キーの誤選択、期間の過剰解釈、集計粒度の不一致など
- 要約は“解釈の提示”であり、事実の保証ではない
-
データガバナンス:
- 全テーブル見せるのは危険。ビュー/マート限定公開・RLS/マスキングで最小権限化
D. プロダクトに組み込むときの設計指針(MVP→拡張)
-
MVP(まず使える)
- 読み取り専用・ビュー限定、SELECTガード
- スキーマ要約をFew-shotと共に提示
- ログ観測:
question / sql / time / rows / user
-
拡張(精度と体験を上げる)
- RAGで「対象業務のスキーマ・用語集・良問例」を近傍検索してプロンプトに注入
- 自己修正ループ(実行エラー/0件時の再生成1回)
- 分析プリセット(“増減要因の説明”“時系列トレンド要約”など出力フォーマットを固定化)
- 可視化返却(表に加えグラフJSON/画像)
-
運用(信頼性とコスト管理)
-
評価データセットで自動回帰テスト(例:30問のゴールデンセット)
- 指標:SQL構文妥当率、実行成功率、ビジネス正解率(人手評価)
- スロットリング/レート制御とキャッシュ(同一質問/類似質問)
- メトリクス監視:レイテンシ、再生成率、平均行数、平均トークン量
-
評価データセットで自動回帰テスト(例:30問のゴールデンセット)
サービス実装に向けた課題
実際にサービスに組み込むには、次のような検討が必要です。
-
セキュリティ・権限管理
- DML/DDL を禁止するガードレール
- ユーザーごとのデータアクセス制御
-
スキーマの大規模化
- 大規模スキーマではプロンプトが長くなり性能低下
- 要約や検索ベースのスキーマ提示が必要
- スキーマ情報からLLMでSQLを生成できるが、データの構造(1:n, n:n)の情報がないと適切なテーブル結合ができない
-
コスト・性能
- LLM 呼び出しコスト管理(キャッシュ・再利用)
- SQL の重いクエリをどう防ぐか
-
ユーザー体験
- 結果の可視化
- データをグラフやビジュアルで返す工夫
まとめ
今回の試作では、Azure OpenAI と Functions、そして SQL Database を組み合わせることで、自然文の質問をそのまま SQL に変換し、さらに結果を要約して返す一連の体験を確認することができました。
これにより、SQL を書けない人でもデータに直接アクセスして「気になったことをすぐに調べられる」環境を実現することができます。
また、単に数字を返すだけでなく、LLM がデータの特徴や傾向を文章でまとめてくれるため、ユーザーはグラフや表を細かく見なくても重要なポイントを把握しやすくする事ができます。
一方で、実際のサービスに導入するためには、セキュリティや権限管理、スキーマ肥大化に伴うプロンプト最適化、コスト管理、そしてユーザー体験設計といった課題を考慮する必要があります。これらをクリアすることで、ダッシュボードを補完しつつ、データ活用をより民主化し、意思決定のスピードを高める仕組みへと進化させることができる可能性があると思います。