生成AI活用の一種として、自然言語からSQLを生成し、DB問合せまで行う、というものがあります。
これもRAGに位置づけられる手法で、DBオブジェクト定義情報を生成AIにコンテキストとして連携し、質問に沿ったSQLを生成させます。
Oracleのソリューションとしては Autonomous Database (ADB) で提供されている Select AI が該当します。
Select AIは質問文からSQLを生成し、さらにDB問合せ結果をもとに回答文まで作ります。
今回は現状ADBでしか使えないSelect AIの機能を、オンプレでも使えないかと思い、外部サービスを使わずローカル環境でアプリとして作ってみました。
ただし最終回答は文章ではなく、SELECTした結果をJSONフォーマットで表したものになります。
以下が画面イメージです。
なおexportしたAPEXアプリを下記にアップロードしています。
https://github.com/mago1chi/apex/blob/main/text2sql.sql
手っ取り早く試したい方はこちらをお使いください。
exportファイルを使う場合も、本記事に掲載している以下作業は事前に行う必要がありますのでご注意ください。
検証環境
- VM: OCI VM.Standard3.Flex (3 OCPU、48GB RAM)
- OS: Oracle Linux Server release 8.9
- DB: Oracle Database 23ai Free
- AP: Oracle APEX 23.2
- LLM: vicuna-13b-v1.5.Q4_K_M
- オーケストレーション・フレームワーク: LangChain, LangServe
事前準備
下記順番で各コンポーネントをインストールします。
- Oracle Database 23ai Freeインストール
- APEXインストール
- ORDSインストール
- アプリに使うスキーマの作成
- ワークスペースの作成
- LangChainおよびLLMインストール
- LangServe
上記に加え、LangChainでテキストからSQL生成する機能を使うために、以下Pythonパッケージをインストールします。
対象のPython仮想環境をactivateした上で実行してください。
$ pip install langchain_experimental cx_Oracle
APEXアプリ実装で使うDBユーザのREST API有効化
LLMから返ってきたSQLを使ってDB問合せするにあたり、REST対応SQLサービスを使用します。
APEXの機能だけでは、動的に生成されたSQLをDBに対して直接実行するのが難しそうだったので、やや遠回りな実装ですがREST API経由でSQL実行する方式としました。
ワークスペースにログインし、「SQLワークショップ」⇒「RESTfulサービス」を選択します。
「サンプル・サービスのインストール」のチェックを外し、「スキーマ属性の保存」を選択します。
以上でREST対応SQLサービスが有効化されました。
DBにサンプルデータを挿入
APEXからサンプルデータを挿入します。
ワークスペースにログインし、「SQLワークショップ」⇒「ユーティリティ」⇒「サンプル・データセット」を選択します。
「EMP / DEPT」の横にある「インストール」ボタンを選択します。
以上で「EMP表」「DEPT表」が作られ、データが挿入されました。
試しにREST対応SQLサービスを使ってデータを取得してみます。
curl
コマンドでSQL文 select ename, job from emp fetch first 1 rows only
を投げます。
$ curl -i -X POST --user vector:VECTOR --data-binary "select ename, job from emp fetch first 1 rows only" -H "Content-Type: application/sql" -k http://localhost:8080/ords/rag_app/_/sql
HTTP/1.1 200 OK
Content-Type: application/json
X-Frame-Options: SAMEORIGIN
Transfer-Encoding: chunked
{"env":{"defaultTimeZone":"UTC"},"items":[{"statementId":1,"statementType":"query","statementPos":{"startLine":1,"endLine":2},"statementText":"select ename, job from emp fetch first 1 rows only","resultSet":{"metadata":[{"columnName":"ENAME","jsonColumnName":"ename","columnTypeName":"VARCHAR2","columnClassName":"java.lang.String","precision":50,"scale":0,"isNullable":1},{"columnName":"JOB","jsonColumnName":"job","columnTypeName":"VARCHAR2","columnClassName":"java.lang.String","precision":50,"scale":0,"isNullable":1}],"items":[{"ename":"中島 亜希子","job":"社長"}],"hasMore":false,"limit":10000,"offset":0,"count":1},"response":[],"result":0}]}
このようにJSONフォーマットで検索結果が返ってきます。
LangServeの実装
LangChain を使ってローカル環境でLLMを稼働させ、それを LangServe でREST APIとして公開します。
今回は自然言語からSQLを生成するための機能 SQLDatabaseChain
をREST API化します。
Pythonのコードは以下のようになります。
こちらを今回は serve.py
として保存します。
import os
from dotenv import load_dotenv
load_dotenv()
from fastapi import FastAPI
from langchain_community.llms.llamacpp import LlamaCpp
from langchain_community.utilities import SQLDatabase
from langchain_experimental.sql.base import SQLDatabaseChain
from langserve import add_routes
# Oracle DB接続情報
user = os.environ['username']
pwd = os.environ['password']
dsn = os.environ['service']
# DB接続
db = SQLDatabase.from_uri(f'oracle://{user}:{pwd}@{dsn}/?encoding=UTF-8&nencoding=UTF-8')
# ダウンロード済みLLMの絶対パス(ご自分の環境に合わせて適宜変更)
model_path = "/home/oracle/llm/vicuna-13b-v1.5.Q4_K_M.gguf"
# モデルの読み込み
llm = LlamaCpp(
model_path=model_path,
n_ctx=4096,
max_tokens=4096
)
# LLMとDBの連携を設定
db_chain = SQLDatabaseChain.from_llm(llm, db, verbose=True, return_sql=True, use_query_checker=True)
# REST API公開するアプリを定義
app = FastAPI(
title="LangServe",
version="1.0",
description="LangChain Server",
)
# Text to SQL向けエンドポイントの定義
add_routes(
app,
db_chain,
path="/sql",
)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app)
上記を実行する前に、同じ階層に配置した .env
ファイルに username
、password
、service
を定義しておきます。
service
は tnsnames.ora
に記載したPDB用のサービス名を記入します。
また以下のように TNS_ADMIN
を環境変数として設定しておきます。
export TNS_ADMIN=/opt/oracle/product/23ai/dbhomeFree/network/admin
実際に LangServe の動作を試してみます。
まずは serve.py
を実行します。
※LangServeが正常に起動したらプロンプトは戻ってきません。
$ python serve.py
/home/oracle/miniconda3/envs/rag/lib/python3.12/site-packages/langchain_community/utilities/sql_database.py:122: SAWa
rning: Did not recognize type 'VECTOR' of column 'embedding'
self._metadata.reflect(
llama_model_loader: loaded meta data with 19 key-value pairs and 363 tensors from /home/oracle/llm/vicuna-13b-v1.5.Q4
_K_M.gguf (version GGUF V2)
llama_model_loader: Dumping metadata keys/values. Note: KV overrides do not apply in this output.
llama_model_loader: - kv 0: general.architecture str = llama
llama_model_loader: - kv 1: general.name str = LLaMA v2
llama_model_loader: - kv 2: llama.context_length u32 = 4096
llama_model_loader: - kv 3: llama.embedding_length u32 = 5120
llama_model_loader: - kv 4: llama.block_count u32 = 40
...(略)...
INFO: Started server process [1740687]
INFO: Waiting for application startup.
__ ___ .__ __. _______ _______. _______ .______ ____ ____ _______
| | / \ | \ | | / _____| / || ____|| _ \ \ \ / / | ____|
| | / ^ \ | \| | | | __ | (----`| |__ | |_) | \ \/ / | |__
| | / /_\ \ | . ` | | | |_ | \ \ | __| | / \ / | __|
| `----./ _____ \ | |\ | | |__| | .----) | | |____ | |\ \----. \ / | |____
|_______/__/ \__\ |__| \__| \______| |_______/ |_______|| _| `._____| \__/ |_______|
LANGSERVE: Playground for chain "/sql/" is live at:
LANGSERVE: |
LANGSERVE: ┗━> /sql/playground/
LANGSERVE:
LANGSERVE: See all available routes at /docs/
INFO: Application startup complete.
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
新しいターミナルを開き、REST APIに curl
コマンドで質問を投げてみます。
質問は「部門が研究開発の従業員の平均給与を教えてください。」です。
$ curl -X 'POST' 'http://127.0.0.1:8000/sql/invoke' \
> -H 'accept: application/json' \
> -H 'Content-Type: application/json' \
> -d '{"input": {"query": "部門が研究開発の従業員の平均給与を教えてください。"}, "config": {}, "kwargs": {} }'
{"output":{"result":"SELECT AVG(sal) FROM emp WHERE deptno = (SELECT deptno FROM dept WHERE dname = '研究開発');"},"metadata":{"run_id":"aff03cbf-678e-4c14-8aa4-0e897bc298db","feedback_tokens":[]}}
回答として SELECT AVG(sal) FROM emp WHERE deptno = (SELECT deptno FROM dept WHERE dname = '研究開発');
が返ってきました。
こちらをsqlplusで実行すると、ちゃんと想定した結果を得られました。
SQL> SELECT AVG(sal) FROM emp WHERE deptno = (SELECT deptno FROM dept WHERE dname = '研究開発');
AVG(SAL)
----------
2175
APEX上でUIを実装
下記画像の赤枠部分を入力し、「アプリケーションの作成」を選択します。
アプリケーションが作成されたら「ページの作成」を選択します。
ページ・デザイナに遷移したら、下段の「アイテム」から「テキスト・フィールド」をBODYにドラッグ&ドロップします。
下記画像の赤枠部分を表示の通りに入力します。
下段の「ボタン」から「Text」をBODYにドラッグ&ドロップします。
下記画像の赤枠部分を表示の通りに入力します。
ボタンのテンプレート・オプションについて、下記画像の赤枠部分を表示の通りに入力します。
下段の「アイテム」から「テキスト・フィールド」をBODYにドラッグ&ドロップします。
下記画像の赤枠部分を表示の通りに入力します。
下段の「ボタン」から「Text」をBODYにドラッグ&ドロップします。
下記画像の赤枠部分を表示の通りに入力します。
ボタンのテンプレート・オプションについて、下記画像の赤枠部分を表示の通りに入力します。
下段の「アイテム」から「テキスト領域」をBODYにドラッグ&ドロップします。
下記画像の赤枠部分を表示の通りに入力します。
CSSを使ってボタンの位置を整えます。
左ペインの「ページ2: TEXT TO SQL」を選択し、下記画像の赤枠部分を表示の通りに入力します。
入力するCSSは以下の通りです。
.myc-aligncenter {
display: flex;
align-items: center;
}
左ペインの「P2_QUESTION」を選択し、下記画像の赤枠部分を表示の通りに入力します。
入力する値は以下の通りです。
myc-aligncenter
左ペインの「P2_RESULT」を選択し、下記画像の赤枠部分を表示の通りに入力します。
入力する値は先述した「P2_QUESTION」と同様です。
LLMにより自然言語からSQLを生成する処理の実装
UIが出来たので、中身の処理をPL/SQLで実装します。
左ペインのプロセスタブを選択します。
「プロセス」の上で右クリックし、「プロセスの作成」を選択します。
「PL/SQLコード」に入力する内容は下記になります。
DECLARE
-- LangServeのエンドポイント
l_rest_url VARCHAR2(4000) := 'http://127.0.0.1:8000/sql/invoke';
l_response_json CLOB;
l_body VARCHAR2(1000);
l_result VARCHAR2(4000);
-- REST APIのレスポンス解析用のカーソル
CURSOR C1 IS
SELECT jt.*
FROM JSON_TABLE(l_response_json, '$'
COLUMNS (result VARCHAR2(4000) PATH '$.output.result' )) jt;
BEGIN
-- POSTする際のbody設定
l_body := '{ "input": {"query": "'|| APEX_ESCAPE.JSON(:P2_QUESTION) ||'" }}';
-- Header設定
APEX_WEB_SERVICE.G_REQUEST_HEADERS.DELETE;
APEX_WEB_SERVICE.G_REQUEST_HEADERS(1).NAME := 'Content-Type';
APEX_WEB_SERVICE.G_REQUEST_HEADERS(1).VALUE := 'application/json';
-- REST API実行
l_response_json := APEX_WEB_SERVICE.MAKE_REST_REQUEST
(p_url => l_rest_url,
p_http_method => 'POST',
p_body => l_body,
p_transfer_timeout => 600);
-- レスポンスを取得
FOR row_1 IN C1 LOOP
l_result := row_1.result;
END LOOP;
-- APEXのアイテムに取得した値を設定
:P2_RESULT := l_result;
END;
以降はコードの解説です。
-- POSTする際のbody設定
l_body := '{ "input": {"query": "'|| APEX_ESCAPE.JSON(:P2_QUESTION) ||'" }}';
-- Header設定
APEX_WEB_SERVICE.G_REQUEST_HEADERS.DELETE;
APEX_WEB_SERVICE.G_REQUEST_HEADERS(1).NAME := 'Content-Type';
APEX_WEB_SERVICE.G_REQUEST_HEADERS(1).VALUE := 'application/json';
-- REST API実行
l_response_json := APEX_WEB_SERVICE.MAKE_REST_REQUEST
(p_url => l_rest_url,
p_http_method => 'POST',
p_body => l_body,
p_transfer_timeout => 600);
こちらはAPEX用のPL/SQLファンクションを使ってREST APIを叩いています。
-- レスポンスを取得
FOR row_1 IN C1 LOOP
l_result := row_1.result;
END LOOP;
冒頭で定義したカーソルを使ってREST APIのレスポンスを解析しています。
FOR文を使っていますが、結果は1行だけです。
-- APEXのアイテムに取得した値を設定
:P2_RESULT := l_result;
UI作成の際に定義したSQL結果表示用のアイテムに、取得した値を設定しています。
右ペインを下にスクロールし、下記画像の赤枠部分を表示の通りに入力します。
これで「Ask AI」ボタンを押下すると先述のPL\SQLコードが実行されます。
LLMが回答したSQLを実行する処理の実装
LLMが回答したSQLをREST対応SQLサービスを使って実行します。
まずはREST API実行に必要な認証情報をAPEXに設定します。
「アプリケーション・ビルダー」⇒「ワークスペース・ユーティリティ」⇒「すべてのワークスペース・ユーティリティ」を選択します。
下記画像の赤枠部分 (全7項目) を入力します。
名前と静的IDは任意の値を入力してください。
静的IDは後述のPL/SQLコード内で使うため、メモしておきます。
ユーザ名とパスワードは、APEXアプリ作成で使用しているDBスキーマのアカウント情報を入力します。
入力できたら「作成」を選択します。
次にPL\SQLコードを実装します。
「プロセス」の上で右クリックし、「プロセスの作成」を選択します。
下記画像の赤枠部分を表示の通りに入力します。
「PL/SQLコード」に入力する内容は下記になります。
DECLARE
-- REST対応SQLサービスのエンドポイント
l_rest_url VARCHAR2(4000) := 'http://localhost:8080/ords/rag_app/_/sql';
-- REST API実行に必要なWeb資格証明の静的ID
l_web_cred CONSTANT VARCHAR2(50) := 'vector_rest';
-- REST APIのレスポンス解析に使うJSON、JSON_OBJECT_T、JSON_ARRAY_T型の変数
l_response_json JSON;
l_obj JSON_OBJECT_T;
item_arr JSON_ARRAY_T;
item_obj JSON_OBJECT_T;
res_arr JSON_ARRAY_T;
res_obj JSON_OBJECT_T;
l_body VARCHAR2(1000);
l_result VARCHAR2(4000);
i VARCHAR2(64);
BEGIN
-- APEXアイテムに格納されているLLMが回答したSQLを、POSTメソッドのbodyとして用意
l_body := :P2_RESULT;
-- Header設定
APEX_WEB_SERVICE.G_REQUEST_HEADERS.DELETE;
APEX_WEB_SERVICE.G_REQUEST_HEADERS(1).NAME := 'Content-Type';
APEX_WEB_SERVICE.G_REQUEST_HEADERS(1).VALUE := 'application/sql';
-- REST API実行
l_response_json := JSON(APEX_WEB_SERVICE.MAKE_REST_REQUEST
(p_url => l_rest_url,
p_http_method => 'POST',
p_body => l_body,
p_credential_static_id => l_web_cred,
p_transfer_timeout => 600));
-- レスポンス (JSON) をPL/SQLの JSON_OBJECT_T に変換
l_obj := JSON_OBJECT_T(l_response_json);
-- JSONを解析してSQL実行結果が格納されている配列を取得
item_arr := l_obj.get_Array('items');
item_obj := JSON_OBJECT_T(item_arr.get(0));
res_arr := item_obj.get_Object('resultSet').get_Array('items');
-- 配列の中身を VARCHAR2 型として抽出
l_result := '[';
FOR i IN 0..res_arr.get_size - 1 LOOP
res_obj := JSON_OBJECT_T(res_arr.get(i));
l_result := l_result || res_obj.stringify;
IF i != res_arr.get_size - 1 THEN
l_result := l_result || ',</br>';
END IF;
END LOOP;
l_result := l_result || ']';
-- 抽出したSQL結果を、UI実装で作成した表示用APEXアイテムに格納
:P2_SQLRES := l_result;
END;
以降はコード内のポイント説明です。
-- REST API実行に必要なWeb資格証明の静的ID
l_web_cred CONSTANT VARCHAR2(50) := 'vector_rest';
こちらに入力する静的IDは、先ほど作成したWeb資格証明に入力した静的IDです。
-- REST API実行
l_response_json := JSON(APEX_WEB_SERVICE.MAKE_REST_REQUEST
(p_url => l_rest_url,
p_http_method => 'POST',
p_body => l_body,
p_credential_static_id => l_web_cred,
p_transfer_timeout => 600));
こちらはAPEX用のPL/SQLファンクションを使ってREST APIを叩いています。
静的IDを格納した l_web_cred
はこちらで使います。
REST APIのレスポンスは JSON 型の変数に格納しています。
-- レスポンス (JSON) をPL/SQLの JSON_OBJECT_T に変換
l_obj := JSON_OBJECT_T(l_response_json);
-- JSONを解析してSQL実行結果が格納されている配列を取得
item_arr := l_obj.get_Array('items');
item_obj := JSON_OBJECT_T(item_arr.get(0));
res_arr := item_obj.get_Object('resultSet').get_Array('items');
-- 配列の中身を VARCHAR2 型として抽出
l_result := '[';
FOR i IN 0..res_arr.get_size - 1 LOOP
res_obj := JSON_OBJECT_T(res_arr.get(i));
l_result := l_result || res_obj.stringify;
IF i != res_arr.get_size - 1 THEN
l_result := l_result || ',</br>';
END IF;
END LOOP;
l_result := l_result || ']';
JSON 型の変数に格納したレスポンスを、解析用にPL\SQLの JSON_OBJECT_T 型の変数に格納します。
最終的にSQL結果が入っている配列を JSON_ARRAY_T 型の変数に取り出しています。
さらに取り出した配列を VARCHAR2 型に変換・整形しています。
変換の過程で </br>
というHTMLタグを付与していますが、これは改行して表示を整形するために付けています。
結果を表示するAPEXアイテムをHTML対応にしているので、</br>
タグを使っています。
最後に右ペインを下にスクロールし、下記画像の赤枠部分を表示の通りに入力します。
これで「SQL実行」ボタンを押下すると先述のPL\SQLコードが実行されます。
以上で実装完了です。
使ってみる
「質問」に「職種がセールスの従業員の名前、給与を教えてください。」と入力し、「Ask AI」ボタンを選択します。
するとLLMが生成したSQLが「SQL文」に表示されます。
生成されたSQLは以下の通りでした。
SELECT e.ename, e.sal FROM emp e WHERE e.job = 'セールス';
さらに「SQL実行」ボタンを選択します。
すると「SQL文」のSQLがREST対応SQLサービスを通して実行され、「SQL実行結果」に結果が表示されます。
以上、自然言語からSQLを生成し、結果を取得する、という形態のRAGアプリ実装でした。