LLMによる自然言語を用いたDB問い合わせ機能の実装例

LLMによる自然言語を用いたDB問い合わせ機能の実装例

はじめに

こんにちは、Zennチームの五十嵐です。

Zennでは、日々の運用業務(お問い合わせの調査やデータ分析など)において、DBに対してSQLでデータを参照する事があります。非定型作業については毎回SQLを手書きしていましたが、人間がSQLを書くのではなくAIに任せられないかと考え、独自の管理画面からデータベースに対して自然言語で問い合わせをできる機能を実装しました。(将来的にはタスクをAI Agentが自律的に解決できるようになるのがベストですが、今回はその第一歩です。)

当初このアイディアは、データベースに対応したMCPサーバーを用いることで実現できることが検証できました。しかし、現時点ではMCPサーバーのリスク診断を行うエコシステムが成熟していないため、もしMCPに悪意のあるコードが混入されたら?と考えると、本番環境で使うにはリスクが大きいと考えました。そこで、MCPを使わずに自然言語で問い合わせを実現する方法を検討しました。

検討していたところ、Claude CodeはMCPを使わずとも要求に応じたSQLを生成できることが分かりました。これは、プロジェクトがRuby on Railsであり、DBスキーマが schema.rb というファイルに定義されているためです。Claude Codeは主にこのファイルを読み込むことで、要求に応じたSQLを生成できることが分かりました。つまり、LLMにDBスキーマを読み込ませることで、SQLの生成ができるということを示しています。

実装

実装にはもともとプロジェクトで使用していたGemini SDKを使用しました。モデルはGemini 2.5 Flash(Thinkingあり)とGemini 2.5 Proを比較しましたが、どちらも同等の精度・速度であり、利用頻度が低いことからコストは考えずにGemini 2.5 Proを選択しました。

Gemini SDKの設定。結果をJSONで取りたいのでresponse_schemaを設定します。

VertexAiApi::Gemini.new(
    model: "gemini-2.5-pro",
    temperature: 0,
    thinking_budget: -1, # 動的思考をオンにする
    max_output_tokens: 2048,
    fallback_models: ["gemini-2.5-flash"],
    json: true,
    response_schema: response_schema
)

response_schemaは管理画面で使いやすいように以下のように定義しました。

  def response_schema
    {
      type: "object",
      properties: {
        sql_query: {
          type: "string",
          description: "生成されたSQL SELECTクエリ"
        },
        explanation: {
          type: "string",
          description: "クエリが何をするかの人間が読める説明"
        },
        confidence: {
          type: "integer",
          minimum: 0,
          maximum: 100,
          description: "生成されたSQLの信頼度 (0-100)"
        },
        warnings: {
          type: "array",
          items: { type: "string" },
          description: "クエリに関する警告や制限事項"
        }
      },
      required: ["sql_query", "explanation", "confidence"],
      additionalProperties: false
    }
  end

プロンプトは以下のように定義ました。

あなたはPostgreSQLのエキスパートで、自然言語から安全で正確なSELECTクエリを生成します。

重要な安全ルール:
- SELECT文のみを生成する
- INSERT、UPDATE、DELETE、DROP、CREATE、ALTER、TRUNCATEは絶対に使用しない
- システムテーブル (pg_*、information_schema) にはアクセスしない
- 結果は最大1000行に制限する
- テーブルを結合する際は適切なJOIN構文を使用する
- 可能な場合は常にパラメータ化クエリを使用する

データベーススキーマ:
#{format_schema_context(schema_context)}

ユーザークエリ: "#{query}"

この質問に答えるPostgreSQL SELECTクエリを生成してください。以下を含めてください:
1. SQLクエリ (SELECTのみ)
2. クエリが何をするかの明確な説明
3. 信頼度 (0-100)
4. 警告や制限事項

指定されたJSON形式で応答してください。

プロンプトで安全ルール(SELECT以外を実行しない)を設けていますが、SQLを実行する際はDBに読み取り専用で接続することで安全性を高めています。

「データベーススキーマ」にはDBのスキーマ情報が、「ユーザークエリ」にはユーザーからの自然言語での要求が入ります。

データベーススキーマは、ActiveRecordから以下のように取得します。

class SchemaContextGenerator
  # ActiveRecordのイントロスペクションを使用してデータベーススキーマコンテキストを動的に生成
  # これは、自然言語からSQLへの変換に使用するために、
  # すべてのテーブルとその関係の完全な概要を提供します

  def self.generate_context
    {
      tables: generate_tables_info,
      relationships: generate_relationships,
    }
  end

  def self.generate_tables_info
    ActiveRecord::Base.connection.tables.filter_map do |table_name|
      # Rails内部テーブルとシステムテーブルをスキップ
      next if skip_table?(table_name)

      columns = ActiveRecord::Base.connection.columns(table_name)

      {
        name: table_name,
        columns: columns.filter_map { |col| format_column(col) },
      }
    end
  end

  def self.generate_relationships
    relationships = {}

    ActiveRecord::Base.connection.tables.each do |table_name|
      next if skip_table?(table_name)

      foreign_keys = ActiveRecord::Base.connection.foreign_keys(table_name)
      relationships[table_name] = foreign_keys.map do |fk|
        {
          column: fk.column,
          references_table: fk.to_table,
          references_column: fk.primary_key,
          name: fk.name
        }
      end
    end

    relationships
  end

  def self.skip_table?(table_name)
    # Rails内部テーブルとシステムテーブルをスキップ
    internal_tables = %w[
      schema_migrations
      ar_internal_metadata
    ]

    internal_tables.include?(table_name)
  end

  def self.format_column(column)
    # クエリで公開すべきでない機密列をスキップ
    sensitive_columns = %w[
      encrypted_password
    ]

    return nil if sensitive_columns.any? { |sensitive| column.name.include?(sensitive) }

    {
      name: column.name,
      type: column.type.to_s,
      null: column.null,
      default: column.default,
      comment: column.comment
    }
  end
end

データベーススキーマは、Hashから以下の形式に変換してLLMに渡されます。

Table: articles
  Columns:
    id: integer
    user_id: integer
    slug: string
    title: string (nullable)
    ...

Table: ...
  Columns:
    ...

RELATIONSHIPS:
  articles.user_id -> users.id
  ...

管理画面

実際の動作をデモします。

まず最初にDBから参照したい情報を自然言語で入力します。

スクリーンショット 2025-07-06 22.16.32

「SQLを生成」を実行すると、下に生成されたSQLが表示されます。SQLは編集することも可能です。生成されたSQLでは、投稿期間の絞り込みや、ポリモーフィック関連のテーブルJOINが適切に表現できています。

スクリーンショット 2025-07-06 22.16.48

「SQLを実行」する前に、必ず「EXPLAIN実行」を必須にしています。意図せず高負荷なクエリを実行してしまうことを防ぐため、いまは人間の目で確認するようにしています。

スクリーンショット 2025-07-06 22.16.58

EXPLAINの結果が表示されます。問題なければ「SQLを実行」を行います。

スクリーンショット 2025-07-06 22.17.06

結果が表示されます。結果はCSVでダウンロードすることができます。

おわり

自然言語でDBに問い合わせを行う機能を紹介しました。導入してみて、自分でSQLを書くという作業がほぼなくなりました。参考になれば幸いです。

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.