Vanna.aiとPythonで本番運用に耐えうるText-to-SQLエンジンを構築する

AI tutorial - IT technology blog
AI tutorial - IT technology blog

アドホッククエリと汎用LLMが抱える課題

多くのエンジニアリングチームが同じ摩擦に直面しています。プロダクトマネージャー、サポートリード、経営陣は、週次ミーティングのために迅速な数値を必要としています。

その結果、シニアエンジニアはコア機能の開発に注力する代わりに、使い捨てのSQLクエリ作成に毎週4〜6時間も浪費することになります。非エンジニアのチームメンバーにDBeaverのようなデータベースクライアントへの直接アクセス権を渡してもうまくいきません。深いSQLの知識がなければテーブルを正しく結合(JOIN)できず、本番のリードレプリカへの直接アクセスを許可することは、システムの安定性やコンプライアンスの観点から深刻なリスクを招きます。

一方で、市販の基底モデルをそのままデータベースに接続しても、別の頭痛の種が生じます。GPT-4oに「あなたはPostgresのエキスパートです。ユーザーの質問にSQLで答えてください」といったプロンプトを渡すと、すぐに綻びが見え始めます。

基底モデルは、特定のビジネス固有のセマンティクスを理解していません。存在しないカラム名をハルシネーション(幻覚)したり、過去の論理削除フラグの仕様を取り違えたり、構文上は正しくても指標の計算が間違っているクエリを出力したりします。また、80テーブルにも及ぶスキーマ全体をプロンプトのコンテキストウィンドウに詰め込むと、リクエストごとに数万トークンを浪費するだけでなく、無関係なテーブル定義によってモデルを混乱させてしまいます。

基本概念:Vanna.aiが自然言語とSQLを橋渡しする仕組み

Vanna.aiは、Text-to-SQLを単なる「生成による推測」ではなく「情報検索(IR)の課題」として扱うことで、この問題を解決します。すべてのAPIコールでカタログ全体を送信するのではなく、ドメイン知識をインデックス化されたベクトル埋め込み(Vector Embeddings)として分離管理します。質問が届くと、関連するスキーマの断片とサンプルクエリのみを取得した上で、最終的な生成プロンプトを構築します。

コンテキストの3層構造

精度の高いクエリを生成するには、ドメイン固有のリファレンス情報が不可欠です。Vannaは学習データを以下の3つの層に整理します:

  • DDL(データ定義言語):主キー、外部キー、デフォルト値、カラム制約を網羅した正確なCREATE TABLE文。
  • ドキュメント:自然言語で記述されたビジネス用語集やドメイン固有のルール(例:「アクティブなMRRは、status = ‘active’ かつ billing_plan != ‘trial’ のアカウントのみを含む」など)。
  • ゴールデンクエリペア:人間によって監査・検証されたSQLと質問のペア。これらはFew-shotの具体例として機能し、チームが推奨するSQL方言や結合パターンをモデルに学習させます。

モジュラーアーキテクチャ

Vannaのコンポーネントは疎結合に設計されており、ブロックを組み合わせるように本番スタックを構成できます:

  1. ベクトルストア:セマンティックコンテキストの保存と検索を行うChromaDB、FAISS、Pinecone、Qdrant、PGVectorなど。
  2. 言語モデル:OpenAI(GPT-4o)、Anthropic Claude、Google Geminiなどのホスト型エンドポイント、またはOllamaやvLLMを介して実行されるセルフホスト型モデル。
  3. データベースドライバ:PostgreSQL、MySQL、Snowflake、BigQuery、SQLite、ClickHouse用のネイティブコネクタ。

実践:カスタムText-to-SQLパイプラインの構築

ローカルで動作するプロトタイプは、約15分で構築できます。以下は、データチーム向けに社内クエリアシスタントを構築する際に筆者が採用している構成です。

ステップ1:環境構築

コアパッケージとともに、ローカルのベクトルストレージとデータベースドライバをインストールします:

pip install "vanna[chromadb,openai]" psycopg2-binary

ステップ2:Vannaクライアントの初期化

VannaはPythonのMixinを使用して、選択したベクトルデータベースとLLMプロバイダを組み合わせます。この設定では、ディスク上に永続化されたローカルのChromaDBインスタンスを作成し、OpenAI APIに直接接続します:

import os
from vanna.openai.openai_chat import OpenAI_Chat
from vanna.chromadb.chromadb_vector import ChromaDB_VectorStore

class CustomVanna(ChromaDB_VectorStore, OpenAI_Chat):
    def __init__(self, config=None):
        ChromaDB_VectorStore.__init__(self, config=config)
        OpenAI_Chat.__init__(self, config=config)

vn = CustomVanna(config={
    'api_key': os.environ.get("OPENAI_API_KEY"),
    'model': 'gpt-4o',
    'path': './vanna_chroma_db'  # ローカル埋め込み用の永続化ディレクトリ
})

ステップ3:スキーマとコンテキストによるパイプラインの学習

実際の現場での精度は、このステップにかかっています。生のDDLをインデックス化するだけでは不十分な場合がほとんどです。ビジネスルールや検証済みクエリを追加することで、複雑なリレーショナルスキーマに対する生成精度を約60%から90%以上へと引き上げることができます。

# 1. DDL文による学習
vn.train(ddl="""
CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    email VARCHAR(255) UNIQUE NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    is_active BOOLEAN DEFAULT TRUE
);

CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    user_id INT REFERENCES users(id),
    amount NUMERIC(10, 2) NOT NULL,
    status VARCHAR(50) NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
""")

# 2. ビジネスルールと用語集による学習
vn.train(documentation="""
'確定売上(Completed revenue)'は、status = 'paid' の注文のみを指します。
status が 'pending' または 'refunded' の注文は、売上集計から必ず除外する必要があります。
""")

# 3. 検証済みの質問とSQLのペアによる学習
vn.train(
    question="累計利用額が多い上位5人の顧客は誰ですか?",
    sql="""
    SELECT u.id, u.email, SUM(o.amount) AS total_spent
    FROM users u
    JOIN orders o ON u.id = o.user_id
    WHERE o.status = 'paid'
    GROUP BY u.id, u.email
    ORDER BY total_spent DESC
    LIMIT 5;
    """
)

ステップ4:データベースへの接続とクエリ実行

コンテキストのインデックス化が完了したら、Vannaをデータベースインスタンスに接続し、自然言語を実行済みのpandas DataFrameに変換します:

vn.connect_to_postgres(
    host='localhost',
    dbname='analytics_db',
    user='readonly_user',
    password=os.environ.get("DB_PASSWORD"),
    port=5432
)

# 自然言語で質問を投げる
question = "先月のアクティブユーザーからの売上はいくらでしたか?"
sql = vn.generate_sql(question=question)
print("生成されたSQL:\n", sql)

# クエリを実行して結果のDataFrameを確認
df = vn.run_sql(sql=sql)
print("クエリ実行結果:\n", df.head())

ステップ5:本番運用のためのセキュリティ強化

LLMをデータベースの直接実行に接続する際は、防御的な設計が不可欠です。社内SlackチャンネルやWeb UIにエージェントを公開する前に、以下のガードレールを導入してください:

  • 専用の読み取り専用ユーザー:SELECT権限のみに厳格に制限された独立したデータベースロールを作成します。すべての本番スキーマに対して、書き込み権限とDDL権限を明示的に剥奪してください。
  • ASTによるクエリ検証:生成されたクエリをsqlglotやsqlparseなどのASTパーサーに通します。構文木からDROP、TRUNCATE、UPDATE、ALTERなどのデータ変更キーワードが検出された場合は、直ちに実行を拒絶します。
  • 厳格なクエリタイムアウト:データベースロールにハードセッションタイムアウトを設定します(例:SET statement_timeout = '3000ms')。これにより、インデックスのないフルテーブルスキャンや意図しない直積結合(デカルト積)によるデータベース負荷の増大を防ぎます。
  • 行数制限:ユーザーが曖昧な質問をした際のメモリ不足(OOM)クラッシュを防ぐため、返される行数に明確な上限(例:LIMIT 1000)を設定します。

結論:セルフサービスデータ分析の信頼性を高める

Text-to-SQLシステムの成否は、モデルに与えるコンテキストの質によって決まります。ベクトル検索と明示的なビジネス定義を組み合わせることで、Vannaは単純なLLMラッパーにありがちなハルシネーションを排除します。まずは使用頻度の高い3〜4個のテーブルをインデックス化し、重要な計算ルールをドキュメント化し、メンバーからカスタムレポートの依頼があるたびに検証済みクエリを追加していくスモールスタートがおすすめです。エンジニアチームを退屈なレポート作成業務から解放しながら、ステークホルダーへ即座にインサイトを届けられるようになります。

Share: