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?

GraphRAG を用いた Text-to-SQL 精度改善の試み

Last updated at Posted at 2025-09-23

はじめに

以前、Qiita にて以下の記事を執筆しました。

👉 自然言語から SQL を生成する Text-to-SQL のサンプル実装

この記事では Azure OpenAISemantic Kernel を用い、自然言語から PostgreSQL の SQL を生成する仕組みを紹介しました。

Text-to-SQL は「自然言語の質問 → SQL クエリ」変換を自動化できますが、実運用を想定した検証では以下の課題が明らかになりました。

  • JOIN の精度不足
    データ構造のメタ情報を与えていないため、LEFT JOIN が必要なケースでも INNER JOIN が生成されてしまう。

  • インデックス考慮の偏り
    WHERE 句ではインデックスを意識できるが、JOIN 条件では十分に考慮されない。

本質的な問題は 「スキーマ理解の曖昧さ」 にありました。
そこで今回は、GraphRAG にスキーマ情報を格納し、LLM に渡すことで JOIN 精度を改善するアプローチ を紹介します。


背景: なぜ RAG×SQL なのか

RAG(Retrieval-Augmented Generation)は本来ドキュメント検索に用いられる手法ですが、SQL スキーマにも応用できます。

  • 従来の Text-to-SQL
    LLM が曖昧な質問を受け取り、自身の知識や推測に頼って SQL を生成 → JOIN を誤りやすい。

  • RAG を導入した Text-to-SQL
    スキーマ情報を検索・取得し、その文脈をプロンプトに渡すことで JOIN 精度を高められる。

特にテーブル・カラム・リレーションは本質的に グラフ構造 を持っています。
これをナレッジグラフ化することで、JOIN 候補を構造的に探索可能となり、LLM の推論安定性が向上します。


ER 図 vs GraphRAG

従来のスキーマ表現は ER 図や DDL でした。人間には直感的ですが、LLM にとっては曖昧さが残ります。
一方で GraphRAG では、テーブルやカラムをノード、関係性をエッジとして格納するため、「どのカラムで JOIN すべきか」 を明示できます。

ER 図イメージ

※ 人間には直感的だが、LLM に渡すと解釈の揺れが生じやすい。

GraphRAG のスキーマ格納イメージ

  • テーブル → ノード
  • カラム → ノード
  • has_column / fk → エッジ

公開リポジトリ

今回の POC 実装は以下のリポジトリで公開しています。
👉 GraphRagText2Sql


実装のポイント①: SchemaSeeder

GraphRAG へのスキーマ投入を担うのが SchemaSeeder です。DDL をそのまま渡すのではなく、ノードとエッジに変換し Cosmos DB に格納します。

1. テーブルノード作成

// e.g. customers, orders, order_items, products をテーブルノード化
var tables = new[]
{
    "customers","orders","order_items","products"
}.Select(t => new GraphNode(
    $"t:{t}", "table", $"ecommerce.{t}", null, pk));

2. カラムノード作成

// orders テーブルのカラムをノード化
AddCols("orders", "order_id","customer_id","status","total_amount","placed_at");

3. has_column エッジ作成

// テーブルノードとカラムノードを結びつける
edges.Add(new GraphEdge(
    $"e:hascol:{col.table}:{col.name}",
    "has_column",
    from: $"t:{col.table!.Split('.').Last()}",
    to: col.id,
    pk));

4. 外部キーエッジ作成

// 外部キー関係を明示的に定義
Fk("orders","customer_id","customers","customer_id");
Fk("order_items","order_id","orders","order_id");

💡 Cosmos DB を採用した理由:
Neo4j 等の専用グラフDBも候補でしたが、Azure 環境でスケーラブルに利用でき、Azure OpenAI / Semantic Kernel との統合が容易なため Cosmos DB を選びました。


GraphRAG化による理論的メリット

SchemaSeeder による GraphRAG 化には以下の効果があります。

  1. 検索性
    自然言語のトークンをスキーマノードに直接マッチング可能。

  2. 制約性
    「このグラフに存在するスキーマだけを使え」と制限でき、ハルシネーションを抑制。

  3. 推論補助
    外部キーエッジが JOIN 候補を制限 → LLM の推論が安定。


実装のポイント②: CosmosGraphService(検索処理)

グラフを活用するうえで重要なのが、質問に応じて関連ノードとエッジを取り出す検索処理です。
本実装では CosmosGraphService がその責務を持ち、近傍探索をベースにサブグラフを抽出します。

役割

  • 自然文の質問から検索トークンを生成(Semantic Kernel)
  • Cosmos DB から**初期ノード(table/column)**を取得
  • has_column / fk エッジを辿る近傍探索(maxHops 可変)
  • ノード・エッジを重複排除しサブグラフ化、人間可読の要約も生成

1. キーワード抽出

自然文を英語化し、SQL に関連するキーワードへ展開します。

public async Task<IReadOnlyList<string>> ExtractTokensAsync(string question)
{
    var en = await KeywordExpander.TranslateToEnglishAsync(_kernel, question);
    var tokens = await KeywordExpander.ExtractSqlKeywordsAsync(_kernel, en);
    return tokens;
}

2. 初期ノードの検索

抽出トークンで table / column ノードを取得します。

public async Task<List<GraphNode>> FindSeedNodesAsync(IEnumerable<string> tokens)
{
    var likeClauses = string.Join(" OR ", tokens.Select((t, i) => $"CONTAINS(c.name, @t{i})"));
    var q = new QueryDefinition($"SELECT * FROM c WHERE c.label IN ('table','column') AND ({likeClauses})");
    int i = 0; foreach (var t in tokens) q.WithParameter($"@t{i++}", t);
    var it = _container.GetItemQueryIterator<GraphNode>(q);
    var result = new List<GraphNode>();
    while (it.HasMoreResults) result.AddRange((await it.ReadNextAsync()).Resource);
    return result;
}

3. 近傍探索(maxHops)

初期ノードから has_column / fk を辿って拡張します。

public async Task<(List<GraphNode> nodes, List<GraphEdge> edges)> ExpandAsync(
    IReadOnlyCollection<string> seedIds, int maxHops = 2)
{
    var nodes = new Dictionary<string, GraphNode>();
    var edges = new Dictionary<string, GraphEdge>();
    var frontier = seedIds.ToHashSet();

    for (int hop = 0; hop < maxHops && frontier.Count > 0; hop++)
    {
        var eDef = new QueryDefinition(
            "SELECT * FROM c WHERE c.label IN ('has_column','fk') AND " +
            "(ARRAY_CONTAINS(@ids, c[\\"from\\"]) OR ARRAY_CONTAINS(@ids, c[\\"to\\"]))")
            .WithParameter("@ids", frontier.ToArray());

        var eit = _container.GetItemQueryIterator<GraphEdge>(eDef);
        var next = new HashSet<string>();
        while (eit.HasMoreResults)
        {
            foreach (var e in (await eit.ReadNextAsync()).Resource)
            {
                edges[e.id] = e;
                next.Add(e.from);
                next.Add(e.to);
            }
        }

        // 取得した隣接ノードを解決
        if (next.Count > 0)
        {
            var nDef = new QueryDefinition(
                "SELECT * FROM c WHERE ARRAY_CONTAINS(@ids, c.id)")
                .WithParameter("@ids", next.ToArray());
            var nit = _container.GetItemQueryIterator<GraphNode>(nDef);
            while (nit.HasMoreResults)
            {
                foreach (var n in (await nit.ReadNextAsync()).Resource)
                    nodes[n.id] = n;
            }
        }
        frontier = next;
    }
    return (nodes.Values.ToList(), edges.Values.ToList());
}

4. サブグラフ構築

重複排除して GraphContext にまとめます。

public GraphContext BuildContext(IEnumerable<GraphNode> nodes, IEnumerable<GraphEdge> edges)
{
    return new GraphContext
    {
        Nodes = nodes.DistinctBy(n => n.id).ToList(),
        Edges = edges.DistinctBy(e => e.id).ToList()
    };
}

5. 出力整形(任意)

LLMが読みやすい要約を生成します。

public string BuildSchemaContext(GraphContext g)
{
    var sb = new StringBuilder();
    foreach (var t in g.Nodes.Where(n => n.label == "table"))
    {
        var cols = g.Edges
            .Where(e => e.label == "has_column" && e.from == t.id)
            .Select(e => g.Nodes.First(n => n.id == e.to).name);
        sb.AppendLine($"TABLE {t.name} (columns: {string.Join(", ", cols)})");
    }
    return sb.ToString();
}

public string BuildRelationships(GraphContext g)
{
    var sb = new StringBuilder();
    foreach (var e in g.Edges)
    {
        if (e.label == "fk") sb.AppendLine($"fk: {e.fromName} -> {e.toName}");
        if (e.label == "has_column") sb.AppendLine($"has_column: {e.fromName} -> {e.toName}");
    }
    return sb.ToString();
}

デモ: 自然言語 → SQL → 実行結果 → 要約

例1: 「直近30日間で最も売れた商品は?」

入力

直近30日間で最も売れた商品は?

生成された SQL

SELECT
    p.product_id,
    p.name,
    SUM(oi.quantity) AS total_quantity_sold
FROM ecommerce.order_items oi
JOIN ecommerce.products p
    ON oi.product_id = p.product_id
WHERE oi.order_id IN (
    SELECT o.order_id
    FROM ecommerce.shipments o
    WHERE o.shipped_at > NOW() - INTERVAL '30 days'
)
GROUP BY
    p.product_id,
    p.name
ORDER BY
    total_quantity_sold DESC
LIMIT 1;

実行結果(DB取得結果&要約)

[
 "Rows": [
    {
      "product_id": 5,
      "name": "メカニカルキーボード",
      "total_quantity_sold": 1
    }
  ],
  "Summary": "直近30日間で最も売れた商品は「メカニカルキーボード」です。この商品は合計で1個売れました。他の商品と比較して販売数が最も高かったことが確認されています。売れ行きの詳細な傾向は見られないものの、特定のニーズや需要があることが示唆されます。このデータを基にさらなるマーケティングや商品展開を検討することができます。",
]

まとめ

Text-to-SQL の最大の課題は「スキーマ理解」でした。
今回紹介した GraphRAG によるスキーマのグラフ化 によって、JOIN 精度と SQL の安定性を大幅に改善できることを確認しました。

改善できた点

  • JOIN 選択の誤りが減少
  • スキーマ外のテーブル・カラムを利用しない制約が可能
  • SQL の生成安定性が向上
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?