OpenAIのAPIで自然言語(日本語)を使ってDBに問い合わせるCLIツールを作ってみた

## 概要

ChatGPTで話題になっているOpenAIが提供しているAPIを使って、自然言語(日本語)でデータベースに問い合わせすることができる[CLIツール](https://github.com/dotneet/natuql)を作成してみました。Go言語で実装しています。記事内で使い方と仕組みの説明をします。

https://github.com/dotneet/natuql

## 動機

ChatGPTがデータベースを理解することに驚いたことや、[gpt-index](https://github.com/jerryjliu/gpt_index) の仕組みなどを調べていて、自然言語でのDB問い合わせは比較的簡単に実現できそうで未来的な面白さを感じたので実験的に実装してみました。自然言語を使ってSQLクエリを発行できれば、SQLを書けない人でも柔軟にDBから情報を取得できるようになります。非エンジニアが分析を行うときのハードルを相当下げることができます。おそらく似たものは既に存在するのだと思いますが、ノンガチ勢でもこういったものが簡単に作れる時代になったのは本当に素晴らしいことです。

## OpenAPIの費用について

OpenAIのAIを呼び出すため費用がかかります。
インデックス作成におよそ5テーブルあたり2000〜4000トークン(2〜5円)、問い合わせあたり1000〜2000トークン(1〜3円)程度の費用が発生します。

## 使い方

### 準備

以下のものが必要です。

 - go 1.19.x 以降
 - MySQLのデータベース
 - OpenAIのAPIキー

```bash
# インストール (goがインストールされている必要があります。)
go install github.com/dotneet/natuql@v0.0.4

# OpenAPIのAPIキーを指定します。
export OPENAI_API_KEY=YOUR_API_KEY
# 接続先データベースを指定します。(MySQLのみ対応)
export DATABASE_CONNECTION="root:root@tcp(127.0.0.1:3306)/yourdb"
# 現在のDBスキーマ情報を取得して自然言語による問い合わせができるように準備します。
natuql index-create
```

### 自然言語による問い合わせのサンプル

関数とかも使ってくれる。

```bash
natuql query "2022年の売上件数を取得して。"

Token usage: 1807.000000
SQL: SELECT COUNT(*) FROM sales WHERE YEAR(sold_at) = 2022;
Result:
2
```

副問い合わせとか使い出す。

```bash
natuql query "一度も商品を購入していないユーザーを抽出して"

Token usage: 1858.000000
SQL: SELECT * FROM users WHERE id NOT IN (SELECT user_id FROM sales);
Result:
6,Ishigami
```

JOINもしてくれる。が、人間様よりは理解度が低いので、詳細にクエリを書く必要がある。

```bash
# 残念ながら間違った結果を返すSQLを生成することもよくある。クエリを詳細にするとうまくいくことがある。
# natuql query "2022年の売り上げが一番大きかった商品の名前"
# SQL: SELECT name FROM items INNER JOIN sales ON items.id = sales.item_id WHERE YEAR(sold_at) = 2022 ORDER BY price_at_sold DESC LIMIT 1;
# Result:
# 商品２

natuql query "2022年の商品ごとの売り上げの合計が一番大きかった商品の名前"

Token usage: 1869.000000
SQL: SELECT name FROM items
JOIN sales ON items.id = sales.item_id
WHERE YEAR(sold_at) = 2022
GROUP BY item_id
ORDER BY SUM(price_at_sold) DESC
LIMIT 1;
Result:
商品２
```

## 仕組み

### ステップ

ざっくり下記のステップになっています。左の英語部分はサブコマンド名。

 1. index-create: DBのスキーマ情報を取得する。
 2. index-create: 取得したスキーマ情報にOpenAIのAPIを使って日本語コメントを付加して保存する。
 3. query: 問い合わせ文(日本語) をOpenAIのAPIを使ってSQLに変換する。このとき、2の手順で作った日本語コメント付きのスキーマが必要になる。詳しくは後述。
 4. query: 作成されたSQLを使ってDBに問い合わせを実行して結果を出力する。

### in-context learning について

natuql において最も重要な概念は in-context learning と呼ばれるものだと思います。これはプロンプト内に解答の前提となる知識を組み込むことで、解答の精度や出力結果を調整する仕組みです。

下記は実際に natuql が発行するプロンプトを分かりやすく少し改変したものです。

```
以下はコンテキストです。
---------------------
データベーススキーマ: """

-- 商品テーブル, 商品テーブル
CREATE TABLE `items` (
  -- 商品のID, 商品のID
  `id` int NOT NULL,
  -- 商品名, 商品の名前
  `name` varchar(255) NOT NULL,
  -- 価格, 価格
  `price` int NOT NULL,
  -- 店舗ID, 店舗のID
  `shop_id` int NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
;
"""

問い合わせと出力SQLの例: """
問い合わせ:table から condition を抜き出して fileds 取得して。
SQL:SELECT ${fields} FROM ${table} WHERE ${condition}
"""
---------------------
前提知識は使わず与えられたコンテキストを使って回答してください。
問い合わせ:一度も商品を購入していないユーザーを抽出して
SQL:
```

コンテキスト情報として、DBスキーマと出力の例示を受け取った上で、問い合わせ内容が下部に記載されています。プロンプト内で前提知識を与えることでAIは内部知識にない事柄について対応できるようになります。natuql ではインデックスの作成と問い合わせ処理で in-context learning を活用しています。

### コンテキストのサイズを絞る工夫

プロンプトの長さには4000トークンという上限があり、また長いほど費用が多く発生するため、すべてのテーブル定義をコンテキスト情報として渡すことはできません。このため、コンテキスト情報として渡すテーブルを絞る必要がありますが、正しいSQLを生成するためには、問い合わせが必要とするテーブルを見つけなければなりません。MySQLのテーブル名やカラムにご丁寧に日本語のコメントが入っていれば手掛かりにできるのですが、そんなプロジェクトはそうそうありません。

そこで、natuqlでは自然言語の問い合わせ内容からテーブルを選択するために、以下の手順を行っています。

 1. OpenAIのAPIを使ってテーブル定義(CREATE TABLE文)に日本語のコメントを付けて保存する。これを natuql では index と呼んでいますが、実際はただ日本語コメントが付いただけのCREATE TABLE文です。
 2. 日本語コメント付きのCREATE TABLE文と問い合わせのテキストの類似度を計算し、類似度の高いものを採用する。[gpt-index](https://github.com/jerryjliu/gpt_index) も同様の手法を使っています。

## 考察

### 4000トークンの上限が辛い

4000トークンの中で前提知識を与えなければいけないという制約が大きな足枷で、大きなプロジェクトに適用するのは非常に難しいです。現在は単純なテキスト類似によって前提知識として使うテーブルを選んでいますが、意味ベクトルのような仕組みを使うことで精度を上げられるのではないかと考えています。

また、大量のテーブルを同時に参照する必要があるSQLもトークンの上限があるので実現が難しく、これは意味ベクトルを使ったところで解決できません。

これらの問題を完全にクリアするには、今はファインチューニングしかなさそうです。OpenAIはファインチューニングの仕組みも備えていますが、davinch-3の場合、1トークンあたり6倍の料金です。学習にも料金がかかります。ただし、ファインチューニングによってコンテキスト情報を減らせるので、そのまま料金が6倍になったりはしないはずで、そのうちファインチューニングによるアプローチも試してみたいと思います。

### ChatGPTの方が賢い

natuql で使っているAIモデルは "text-davinci-003" で、これはChatGPTとは別物です。ChatGPT相当のAIモデルはまだAPIとして公開されていません。
検証のため、何度もChatGPTとAPIで同じプロンプトを実行しましたが、明らかにChatGPTの方がスマートで精度の高いSQLを作ってくれます。今後、ChatGPT相当の新しいモデルがAPIとして使えるようになれば、精度の向上が期待できます。

### 現実的な使い道

それなりに間違ったSQLを作成することもあるのと、非常に遅いSQLを平気で出力するので、今の精度で実用はほぼ無理だと思います。テーブル数とレコード数が少ないプロジェクトにおいて、SQLを理解している人がSQLのテンプレを吐かせる程度の使い方ならば意味はあるかもしれません。
おそらく50テーブルを超えてくると全くワークしない、コンテキスト情報に必要なテーブルが全然乗らない、といったことになるでしょう。

## そして脇道へ： ChatGPT と GitHub Copilot をフル活用した開発の素晴らしさ

natuql の開発には ChatGPT (Plusを契約してます) と GitHub Copilot をフル活用したのですが、とても素晴らしい体験でした。おかげで1日で natuql を実装できたのですが、ChatGPT と Copilot なかったら絶対もっとかかってます。この体験をたった $30/month で享受できることに震えます。自分で打ったコードがいったい何割だったのか気になります。Go言語は普段から使っているわけではなく、基本的なシンタックス以外はほぼ忘れていましたが、全く苦になりませんでした。

書き始めの頃は以下のような質問をChatGPTにしまくって、コピペしまくって開発を進めました。

```
Go言語でデータベースにアクセスするサンプルを書いて。どの種類のデータベースでもアクセスできるようにして。
```

```
openaiのapiを呼び出すgo言語のコードを書いて。
```

```
goのviperの使い方を教えて
```

```
viper を使うための go.mod の書き方を教えて
```

```
cobraでコマンドライン引数を処理するサンプルを書いて
```

```
cobraでサブコマンドを処理するサンプルを書いて
```

```
Goで[]stringを結合して。区切り文字にはスペースを使って。
```

```
下記のGoのコードをリファクタリングして。

promptTokens := response["usage"].([]interface{})[0].(map[string]interface{})["prompt_tokens"].(float64)
	completionTokens := response["usage"].([]interface{})[0].(map[string]interface{})["completion_tokens"].(float64)
	totalTokens := response["usage"].([]interface{})[0].(map[string]interface{})["total_tokens"].(float64)
```

少しでも「なんだっけ？」と思ったらChatGPTに聞きまくります。たまに回答が的外れすぎる時はWeb検索もします。

Copilot も `// write Hoge to json` のようにコメントすれば、ガンガン補完をやってくれます。定型的なエラー処理もガンガン補完してくれます。Go言語はコードが定型的になりやすい特徴があるので、相性が良いのではないかと思います。スライスをチャンク化する関数を書いた後などは、テストを丸ごと生成してくれて一発でテストをパスした時は驚きました。

いずれも本当に便利で、コピペや参考のために処理の塊を出してもらいたいときはChatGPT、頻出する定型処理の補完はGitHub Copilot という使い分けになるかと思います。

とりとめない内容ですが、あまりにも感動したのでちょっと書かせてもらいました。

## その他のChatGPT関連記事

https://qiita.com/devneko/items/0dc9fd8b37419d9e369c

https://qiita.com/devneko/items/7621ee3803105d2bf3d3