


目次
本記事では、生成AIを用いて自然言語からSQLを作成する技術について紹介します。
データベースからデータを取得するなどの操作のためには、SQLを書いて実行する必要があります。日常的にSQLに慣れ親しんだエンジニアであれば、「データベースから〜のデータを抽出して確認しよう」ということが簡単にできます。
しかしSQLに慣れ親しんだエンジニアでなければ、SQLを用いたデータの取得を含む集計業務はどうしても時間がかかります。
したがって今回はなるべく多くの人がデータ抽出をしやすい環境を実現するために、生成AIを用いて自然言語から対応するSQLを生成することに挑戦します。
Text-to-SQLは生成AIが発展する前から研究されている分野のひとつであり、自然言語で表現されたクエリをSQL文に変換することを指します。
大規模言語モデルを活用したText-to-SQLのシンプルなプロンプトとしては、下記のような構成のものが多く使われています。この次のセクションで記載する先行の技術調査で、LangChainやPinterest社の取り組みを見ていても、同様のプロンプト(Few Shot Promptingはどちらもない)で構成されています。
Text-to-SQLの技術自体はテーブル数が一定制限されているなどがあり、それなりの精度で止めて良いのであれば、特別難しいことではありません。しかしながらアプリケーションとしてユーザーが無邪気に使用することを想定するのであれば、セキュリティやユーザー体験など、考慮すべき事項がいくつかあります。
生成AIが出力するSQLは常に正しいものにはなりません。対象となるテーブルやカラム名を間違えて実行不可能である可能性や、複雑なクエリであれば生成されたSQL文自体が誤っている可能性などがあります。
テーブル名やカラム名のミスについては、SQLAlchemy などのORMを用いてテーブルメタデータからルールベースのバリデーションをしていくことで対応できます。SQLの構文自体については、sqlparse などのライブラリを用いて、適切なSQL文になっているかどうかを確認する処理を挟んで対応します。
SQLインジェクションで意図しないデータ操作が起こるリスクは避けなければなりません。データ分析用と限定するのであれば、データベースやデータに変更が加わるような、DDL、またINSERT、UPDATE、DELETEなどの処理を制限する必要があります。
LangChainではDO NOT make any DML statements (INSERT, UPDATE, DELETE, DROP etc.) to the database.というプロンプトにより、この問題に対処しようとしていました。
最終的なバリデーションにおいては、sqlparse などのライブラリを使用することで、SQL文が安全なものであるかを確認する必要があります。
このアプリケーションを使用するユーザーの権限等により、参照できるデータの範囲を制限する必要があります。いわゆるWebアプリケーションなどであれば、バックエンドのコードで認可制御をすることが多くなりますが、分析用にBIツール的に使用できるものであれば、行レベルセキュリティ などを活用したDBレイヤーでの認可制御が必要になることがあります。
生成AIが作成するSQLのパフォーマンスが必ずしもパフォーマンスに優れるものとは限りません。生成AIのエージェント的な機構の中でパフォーマンスの良いクエリを作成することを指示するだけではなく、そもそもの取り扱えるデータを制約するなどして、一定のパフォーマンスを確保する必要があります。
Text-to-SQLの精度面における課題としては、下記の4点があります。
自然言語自体は非常に複雑であり曖昧で、よほど注意深く質問を投げないことには、正確に一対一でSQLに変換可能な表現にはなりません。
正確なSQLを生成するには、データベースのスキーマを詳細に把握する必要があります。また全てを表現しようにも、Context Windowの制約などもあり、効率的にスキーマを伝えなければなりません。
サブクエリ、JOIN、Window関数などの複雑なSQLについては、学習データなどに多く含まれているわけではないので、適切な生成に難儀します。この場合はFew Shot PromptingやRAGなどを用いて、精度向上を目指す必要があります。
ドメインが異なれば、質問文やSQL文に出てくる単語が大きく異なります。したがって学習データと異なる入出力を多く期待しなければならないので、どのように精度を担保するかという課題があります。
LangChainにはcommunityとして、SQLを自然言語から生成する機能が提供されています。具体的には create_sql_agentという関数で、必要な一連の処理をAgentとして取得して実行できます。
では、このコードがどのような処理を実行しているのかを見ていきます。
いくつか処理のパターンがあるのですが、最もリッチに処理をする場合、下記のような処理で構成されます。
サンプルのデータベースを提供している https://github.com/lerocha/chinook-database のリポジトリを使用して、create_sql_agentによる一連の処理を実行してみます。
データベースにはSQLiteを使用するため、下記を実行してSQLiteのファイルを取得します。
curl -s https://raw.githubusercontent.com/lerocha/chinook-database/master/ChinookDatabase/DataSources/Chinook_Sqlite.sql | sqlite3 Chinook.db下記のPythonコードを実行します。
import os
from langchain_community.agent_toolkits import create_sql_agent
from langchain_community.utilities import SQLDatabase
from langchain_openai import ChatOpenAI
os.environ["OPENAI_API_KEY"] = "sk-proj-xxx"
if __name__ == "__main__":
db = SQLDatabase.from_uri("sqlite:///Chinook.db")
llm = ChatOpenAI(model="gpt-4o-mini")
agent_executor = create_sql_agent(llm, db=db, agent_type="openai-tools", verbose=True)
r = agent_executor.invoke("How many employees are there?")
print(r) 上記コードを実行すると、従業員数が8名であるという結果が得られました。
{'input': 'How many employees are there?', 'output': 'There are 8 employees in the database.'}実際にデータベースの中身をのぞいて見ると、確かに8名のデータがあるようです。
(1, 'Adams', 'Andrew', 'General Manager', None, '1962-02-18 00:00:00', '2002-08-14 00:00:00', '11120 Jasper Ave NW', 'Edmonton', 'AB', 'Canada', 'T5K 2N1', '+1 (780) 428-9482', '+1 (780) 428-3457', 'andrew@chinookcorp.com')
(2, 'Edwards', 'Nancy', 'Sales Manager', 1, '1958-12-08 00:00:00', '2002-05-01 00:00:00', '825 8 Ave SW', 'Calgary', 'AB', 'Canada', 'T2P 2T3', '+1 (403) 262-3443', '+1 (403) 262-3322', 'nancy@chinookcorp.com')
(3, 'Peacock', 'Jane', 'Sales Support Agent', 2, '1973-08-29 00:00:00', '2002-04-01 00:00:00', '1111 6 Ave SW', 'Calgary', 'AB', 'Canada', 'T2P 5M5', '+1 (403) 262-3443', '+1 (403) 262-6712', 'jane@chinookcorp.com')
(4, 'Park', 'Margaret', 'Sales Support Agent', 2, '1947-09-19 00:00:00', '2003-05-03 00:00:00', '683 10 Street SW', 'Calgary', 'AB', 'Canada', 'T2P 5G3', '+1 (403) 263-4423', '+1 (403) 263-4289', 'margaret@chinookcorp.com')
(5, 'Johnson', 'Steve', 'Sales Support Agent', 2, '1965-03-03 00:00:00', '2003-10-17 00:00:00', '7727B 41 Ave', 'Calgary', 'AB', 'Canada', 'T3B 1Y7', '1 (780) 836-9987', '1 (780) 836-9543', 'steve@chinookcorp.com')
(6, 'Mitchell', 'Michael', 'IT Manager', 1, '1973-07-01 00:00:00', '2003-10-17 00:00:00', '5827 Bowness Road NW', 'Calgary', 'AB', 'Canada', 'T3B 0C5', '+1 (403) 246-9887', '+1 (403) 246-9899', 'michael@chinookcorp.com')
(7, 'King', 'Robert', 'IT Staff', 6, '1970-05-29 00:00:00', '2004-01-02 00:00:00', '590 Columbia Boulevard West', 'Lethbridge', 'AB', 'Canada', 'T1K 5N8', '+1 (403) 456-9986', '+1 (403) 456-8485', 'robert@chinookcorp.com')
(8, 'Callahan', 'Laura', 'IT Staff', 6, '1968-01-09 00:00:00', '2004-03-04 00:00:00', '923 7 ST NW', 'Lethbridge', 'AB', 'Canada', 'T1H 1Y8', '+1 (403) 467-3351', '+1 (403) 467-8772', 'laura@chinookcorp.com')
またここで実際にどのようなプロンプトが送信されたのかを確認します。
初期プロンプトは、 https://github.com/langchain-ai/langchain/blob/master/libs/community/langchain_community/agent_toolkits/sql/prompt.py に定義されています。このコードがエージェントの種別により何通りかの組み合わされ方をしますが、ざっくりとこのような情報からSQLを生成しています。
SQL_PREFIX = """You are an agent designed to interact with a SQL database.Given an input question, create a syntactically correct {dialect} query to run, then look at the results of the query and return the answer.Unless the user specifies a specific number of examples they wish to obtain, always limit your query to at most {top_k} results.You can order the results by a relevant column to return the most interesting examples in the database.Never query for all the columns from a specific table, only ask for the relevant columns given the question.You have access to tools for interacting with the database.Only use the below tools. Only use the information returned by the below tools to construct your final answer.You MUST double check your query before executing it. If you get an error while executing a query, rewrite the query and try again. DO NOT make any DML statements (INSERT, UPDATE, DELETE, DROP etc.) to the database. If the question does not seem related to the database, just return "I don't know" as the answer.""" SQL_SUFFIX = """Begin! Question: {input}Thought: I should look at the tables in the database to see what I can query. Then I should query the schema of the most relevant tables.{agent_scratchpad}""" SQL_FUNCTIONS_SUFFIX = """I should look at the tables in the database to see what I can query. Then I should query the schema of the most relevant tables."""sql_db_list_tablesでは、DBに存在するテーブルの一覧を取得しています。下記のようにDB上にあるテーブルの名称のみを取得しています。
"Album, Artist, Customer, Employee, Genre, Invoice, InvoiceLine, MediaType, Playlist, PlaylistTrack, Track”この取得したテーブル名の一覧を、初期プロンプトに挿入しています。下記がプロンプトの一部抜粋。
Then I should query the schema of the most relevant tables.\nAI: \nTool: Album, Artist, Customer, Employee, Genre, Invoice, InvoiceLine, MediaType, Playlist, PlaylistTrack, Trackここからmost relevantなテーブルとして、Employeeが選択され、その詳細情報をsql_db_schemaから取得します。sql_db_schemaでは、テーブルスキーマとデータの例を3行分取得します。
https://github.com/langchain-ai/langchain/blob/c2f1d022a2e55dfddd313e54d01250d3f64c6eb2/libs/community/langchain_community/utilities/sql_database.py#L300 に処理が記載されており、今回のケースでは下記の出力をプロンプトに加えます。
"CREATE TABLE "Employee" (
"EmployeeId" INTEGER NOT NULL,
"LastName" NVARCHAR(20) NOT NULL,
"FirstName" NVARCHAR(20) NOT NULL,
"Title" NVARCHAR(30),
"ReportsTo" INTEGER,
"BirthDate" DATETIME,
"HireDate" DATETIME,
"Address" NVARCHAR(70),
"City" NVARCHAR(40),
"State" NVARCHAR(40),
"Country" NVARCHAR(40),
"PostalCode" NVARCHAR(10),
"Phone" NVARCHAR(24),
"Fax" NVARCHAR(24),
"Email" NVARCHAR(60),
PRIMARY KEY ("EmployeeId"),
FOREIGN KEY("ReportsTo") REFERENCES "Employee" ("EmployeeId")
)
/*
3 rows from Employee table:
EmployeeId LastName FirstName Title ReportsTo BirthDate HireDate Address City State Country PostalCode Phone Fax Email
1 Adams Andrew General Manager None 1962-02-18 00:00:00 2002-08-14 00:00:00 11120 Jasper Ave NW Edmonton AB Canada T5K 2N1 +1 (780) 428-9482 +1 (780) 428-3457 andrew@chinookcorp.com
2 Edwards Nancy Sales Manager 1 1958-12-08 00:00:00 2002-05-01 00:00:00 825 8 Ave SW Calgary AB Canada T2P 2T3 +1 (403) 262-3443 +1 (403) 262-3322 nancy@chinookcorp.com
3 Peacock Jane Sales Support Agent 2 1973-08-29 00:00:00 2002-04-01 00:00:00 1111 6 Ave SW Calgary AB Canada T2P 5M5 +1 (403) 262-3443 +1 (403) 262-6712 jane@chinookcorp.com
*/"上記をさらにプロンプトに加えます。
System: You are an agent designed to interact with a SQL database.\nGiven an input question, create a syntactically correct sqlite query to run, then look at the results of the query and return the answer.\nUnless the user specifies a specific number of examples they wish to obtain, always limit your query to at most 10 results.\nYou can order the results by a relevant column to return the most interesting examples in the database.\nNever query for all the columns from a specific table, only ask for the relevant columns given the question.\nYou have access to tools for interacting with the database.\nOnly use the below tools. Only use the information returned by the below tools to construct your final answer.\nYou MUST double check your query before executing it. If you get an error while executing a query, rewrite the query and try again.\n\nDO NOT make any DML statements (INSERT, UPDATE, DELETE, DROP etc.) to the database.\n\nIf the question does not seem related to the database, just return \"I don't know\" as the answer.\n\nHuman: How many employees are there?\nAI: I should look at the tables in the database to see what I can query. Then I should query the schema of the most relevant tables.\nAI: \nTool: Album, Artist, Customer, Employee, Genre, Invoice, InvoiceLine, MediaType, Playlist, PlaylistTrack, Track\nAI: \nTool: \nCREATE TABLE \"Employee\" (\n\t\"EmployeeId\" INTEGER NOT NULL, \n\t\"LastName\" NVARCHAR(20) NOT NULL, \n\t\"FirstName\" NVARCHAR(20) NOT NULL, \n\t\"Title\" NVARCHAR(30), \n\t\"ReportsTo\" INTEGER, \n\t\"BirthDate\" DATETIME, \n\t\"HireDate\" DATETIME, \n\t\"Address\" NVARCHAR(70), \n\t\"City\" NVARCHAR(40), \n\t\"State\" NVARCHAR(40), \n\t\"Country\" NVARCHAR(40), \n\t\"PostalCode\" NVARCHAR(10), \n\t\"Phone\" NVARCHAR(24), \n\t\"Fax\" NVARCHAR(24), \n\t\"Email\" NVARCHAR(60), \n\tPRIMARY KEY (\"EmployeeId\"), \n\tFOREIGN KEY(\"ReportsTo\") REFERENCES \"Employee\" (\"EmployeeId\")\n)\n\n/*\n3 rows from Employee table:\nEmployeeId\tLastName\tFirstName\tTitle\tReportsTo\tBirthDate\tHireDate\tAddress\tCity\tState\tCountry\tPostalCode\tPhone\tFax\tEmail\n1\tAdams\tAndrew\tGeneral Manager\tNone\t1962-02-18 00:00:00\t2002-08-14 00:00:00\t11120 Jasper Ave NW\tEdmonton\tAB\tCanada\tT5K 2N1\t+1 (780) 428-9482\t+1 (780) 428-3457\tandrew@chinookcorp.com\n2\tEdwards\tNancy\tSales Manager\t1\t1958-12-08 00:00:00\t2002-05-01 00:00:00\t825 8 Ave SW\tCalgary\tAB\tCanada\tT2P 2T3\t+1 (403) 262-3443\t+1 (403) 262-3322\tnancy@chinookcorp.com\n3\tPeacock\tJane\tSales Support Agent\t2\t1973-08-29 00:00:00\t2002-04-01 00:00:00\t1111 6 Ave SW\tCalgary\tAB\tCanada\tT2P 5M5\t+1 (403) 262-3443\t+1 (403) 262-6712\tjane@chinookcorp.com\n*/この後は実際にこのプロンプトからSQLを生成して実行することになるのですが、今回の主題であるSQLを自然言語から生成する文脈からは外れるので割愛します。
LangChainの処理はDBに実際に接続して様々な情報を付与しながら、自然言語からSQLを生成しています。実用を考える上では、データスキーマに大きな変更がない場合などは、事前にテーブル一覧やサンプルデータを静的ファイルなどに書き出しておき、SQL生成の過程ではDBへの接続をしないというケースも考えられそうです。
Pinterest社では How we built Text-to-SQL at Pinterest という記事において、自社のText-to-SQLの取り組みを紹介しています。
を取り込んでいるようです。
選択式などのカラムにおいて、platform=’WEB’を意図していても、platform=’web’という生成結果になることがあります。そのような少数の選択肢の情報を、テーブルスキーマの中に投入しているようです。上記のスキーマ一覧の中であれば、Column descriptionの中に入れているのではないかと想定されます。
テーブルスキーマが大きくなると、LLMのContext Windowの制限を上回ることがあります。そのためpinterest社では、下記の対応を実施しているようです。
なぜか4がなくて5と6に飛んでいるのが気になりますが、このプロンプトを使用していたそうです。
You are a {dialect} expert.
Please help to generate a {dialect} query to answer the question. Your response should ONLY be based on the given context and follow the response guidelines and format instructions.
===Tables
{table_schemas)
===Original Query
{original_query}
===Response Guidelines
1. If the provided context is sufficient, please generate a valid query without any explanations for the question. The query should start with a comment containing the question being asked.
2. If the provided context is insufficient, please explain why it can't be generated.
3. Please use the most relevant table(s).
5. Please format the query before responding.
6. Please always respond with a valid well-formed JSON object with the following format
===Response Format
{{
"query": "A generated SQL query when context is sufficient.",
"explanation": "An explanation of failing to generate the query."
}}
===Question
{question}Spiderデータセットによる評価を実施し、文献内と同様の精度を確認できたようです。しかしながら実際に取り扱うアプリケーションより簡単な評価データセットのため、実運用における初回で適切なSQLを生成できた割合を確認しながら、適宜精度向上の取り組みをしていたようです。
またText-to-SQLの仕組みにより、タスク完了までの速度が35%向上していることを確認したとも記載されています。
Pinterest社の数十万件あるDWHのテーブルから、適切なテーブルをユーザーが選択することは大きな課題であり、RAGを導入してユーザーがタスクに適したテーブルを選択しやすくする仕組みを、第二弾では導入したようです。
SQLのバリデーション
現在はLLMが生成したSQLをそのままユーザーに返しており、期待通りの動作が保証されていない状態であり、constrained beam searchなどを使用して更なる保証をユーザーに提供することを想定しているようです。
ユーザーフィードバック
テーブル検索はSQL生成結果に対するユーザーフィードバックを反映させやすいインターフェースを提供し、人と協働しながらSQLを生成してパフォーマンスを向上させることを想定しているとのことです。
Text-to-SQLに関する多くの研究がSpiderというデータセットで評価されています。最新の最も精度が良いモデル・研究については、 https://yale-lily.github.io/spider のリーダーボードをチェックしていけば良いでしょう。
LangChainは現時点では、必ずしも本番環境のアプリケーションとして実装するのに適したものであるとは言えません。ここでは https://github.com/openai/openai-python を主に使用し、Text-to-SQLプリケーションのコア部分を実装します。
import contextlib
import json
import os
import openai
import pydantic
import sqlalchemy
import sqlparse
from sqlalchemy import orm
from sqlalchemy.schema import CreateTable
from sqlparse.tokens import DML, DDL
engine = sqlalchemy.create_engine("sqlite:///Chinook.db")
Session = orm.sessionmaker(bind=engine)
@contextlib.contextmanager
def session_scope():
session = Session()
try:
yield session
session.commit()
except Exception:
session.rollback()
raise
finally:
session.close()
def is_safe_sql(sql_statement: str) -> bool:
# 本番稼働にはさらに厳密なチェックが必要
if sql_statement == "わかりません。":
return False
disallowed_types = (DML, DDL)
parsed = sqlparse.parse(sql_statement)
for statement in parsed:
for token in statement.tokens:
if token.ttype in disallowed_types:
if token.value.upper() in [
"INSERT",
"UPDATE",
"DELETE",
"CREATE",
"ALTER",
"DROP",
"TRUNCATE",
"REPLACE",
"GRANT",
"REVOKE",
"MERGE",
"CALL",
"EXPLAIN",
"LOCK",
"UNLOCK",
]:
return False # 不正なSQL文
return True # 安全なSQL文
class SQLOutput(pydantic.BaseModel):
sql_statement: str
if __name__ == "__main__":
metadata = sqlalchemy.MetaData()
metadata.reflect(bind=engine)
table_names: set[str] = set(metadata.tables.keys()) # テーブル名の一覧
# 各テーブルのCREATE文を生成
create_statements = {}
for table_name, table in metadata.tables.items():
# CreateTableオブジェクトを生成し、SQL文字列を取得
create_statement = str(CreateTable(table).compile(engine))
create_statements[table_name] = create_statement
system_prompt: str = f"""
あなたはSQLデータベースと対話するように設計されたエージェントです。
入力された質問に対して、構文的に正しいSQLiteクエリを作成してください。
データベースに対してDML文(INSERT、UPDATE、DELETE、DROPなど)を作成しないでください。
質問がデータベースと関係なさそうであれば、「わかりません。」を回答として返してください。
このデータベースのテーブルの一覧: {table_names}
各テーブルのスキーマ: {create_statements}
"""
user_prompt: str = "従業員は何名いますか?"
os.environ["OPENAI_API_KEY"] = "sk-proj-xxx"
client = openai.OpenAI()
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
]
r = client.beta.chat.completions.parse(
model="gpt-4o-mini",
messages=messages,
response_format=SQLOutput,
)
response = json.loads(r.choices[0].message.content)
messages.append({"role": "system", "content": response["sql_statement"]})
print(f"ユーザークエリ: {user_prompt}")
print(f"生成されたSQL文: {response['sql_statement']}")
if not is_safe_sql(response["sql_statement"]):
print("不正なSQL文です。")
exit(1)
with session_scope() as session:
r = session.execute(sqlalchemy.text(response["sql_statement"])).fetchall()
print(f"SQL実行結果: {r}")
analysis_prompt: str = f"""
あなたはユーザーのクエリから、データベースの情報に基づき、適切な情報分析を行い、その報告を行うように設計されたエージェントです。
ユーザーのクエリに対して、データベースの情報を分析し、その結果を報告してください。
質問: {user_prompt}
質問に対するSQLクエリ: {response['sql_statement']}
データベースの情報: {r}
"""
messages.append({"role": "user", "content": analysis_prompt})
r = client.beta.chat.completions.parse(
model="gpt-4o-mini",
messages=messages,
)
print(f"分析結果: {r.choices[0].message.content}")
このコードを実行した結果、下記のような結果が得られました。
$ rye run python src/text2sql.pyユーザークエリ: 従業員は何名いますか?生成されたSQL文: SELECT COUNT(*) FROM Employee;SQL実行結果: [(8,)]分析結果: 従業員の人数は8名です。分析に適さない質問が来た場合は、適切に「わかりません」という回答を出力し、分析しないような制御ができています。
$ rye run python src/text2sql.pyユーザークエリ: 今日の昼ご飯はなんですか。生成されたSQL文: わかりません。不正なSQL文です。本記事では、生成AIを活用して自然言語から適切なSQLを生成する方法について詳しく解説しました。LangChainの実装を分析し、自前でText-to-SQLを実装する手順を紹介することで、SQLに不慣れなユーザーでもデータベースから必要な情報を容易に取得できる可能性を示しました。
アプリケーション化する際の注意点として、今回は不正確なSQLの生成とSQLインジェクションに対する一部の対応を実施しました。実際にはさらなる対応をした上で、安全かつユーザー体験を損なわない制御を提供する必要があります。
当社では、最新のAI技術を活用したソリューション開発に注力しており、データ活用の課題解決や業務効率化を支援しています。生成AIによるText-to-SQLの導入にご興味がある方は、ぜひお気軽にお問い合わせください。
著者


山﨑 祐太
山﨑 祐太
山﨑 祐太
山﨑 祐太
山﨑 祐太
山﨑 祐太