LoginSignup
6
6

自然言語からSQL生成してDB問合せするアプリを作ってみた (Oracle Database 23ai Free + Oracle APEX + LangServe)

Last updated at Posted at 2024-05-24

生成AI活用の一種として、自然言語からSQLを生成し、DB問合せまで行う、というものがあります。
これもRAGに位置づけられる手法で、DBオブジェクト定義情報を生成AIにコンテキストとして連携し、質問に沿ったSQLを生成させます。
Oracleのソリューションとしては Autonomous Database (ADB) で提供されている Select AI が該当します。
Select AIは質問文からSQLを生成し、さらにDB問合せ結果をもとに回答文まで作ります。
今回は現状ADBでしか使えないSelect AIの機能を、オンプレでも使えないかと思い、外部サービスを使わずローカル環境でアプリとして作ってみました。
ただし最終回答は文章ではなく、SELECTした結果をJSONフォーマットで表したものになります。
以下が画面イメージです。
WS000008.JPG

なお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

事前準備

下記順番で各コンポーネントをインストールします。

  1. Oracle Database 23ai Freeインストール
  2. APEXインストール
  3. ORDSインストール
  4. アプリに使うスキーマの作成
  5. ワークスペースの作成
  6. LangChainおよびLLMインストール
  7. 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サービス」を選択します。
WS000005.JPG

「ORDSにスキーマを登録」を選択します。
WS000006.JPG

「サンプル・サービスのインストール」のチェックを外し、「スキーマ属性の保存」を選択します。
WS000007.JPG

以上でREST対応SQLサービスが有効化されました。

DBにサンプルデータを挿入

APEXからサンプルデータを挿入します。
ワークスペースにログインし、「SQLワークショップ」⇒「ユーティリティ」⇒「サンプル・データセット」を選択します。
WS000000.JPG

「EMP / DEPT」の横にある「インストール」ボタンを選択します。
WS000001.JPG

「次」を選択します。
WS000002.JPG

「データセットのインストール」を選択します。
WS000003.JPG

「終了」を選択します。
WS000004.JPG

以上で「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 ファイルに usernamepasswordservice を定義しておきます。
servicetnsnames.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を実装

「アプリケーション・ビルダー」から「作成」を選択します。
WS000009.JPG

下記画像の赤枠部分を入力し、「アプリケーションの作成」を選択します。
WS000010.JPG

アプリケーションが作成されたら「ページの作成」を選択します。
WS000011.JPG

「空白ページ」を選択した状態で「次」を選択します。
WS000012.JPG

下記画像の赤枠部分を入力し、「ページの作成」を選択します。
WS000013.JPG

ページ・デザイナに遷移したら、下段の「アイテム」から「テキスト・フィールド」をBODYにドラッグ&ドロップします。
下記画像の赤枠部分を表示の通りに入力します。
WS000014.JPG

下にスクロールし、画像の赤枠部分を表示の通りに入力します。
WS000015.JPG

下段の「ボタン」から「Text」をBODYにドラッグ&ドロップします。
下記画像の赤枠部分を表示の通りに入力します。
WS000016.JPG

ボタンのテンプレート・オプションについて、下記画像の赤枠部分を表示の通りに入力します。
WS000017.JPG

下段の「アイテム」から「テキスト・フィールド」をBODYにドラッグ&ドロップします。
下記画像の赤枠部分を表示の通りに入力します。
WS000018.JPG

下段の「ボタン」から「Text」をBODYにドラッグ&ドロップします。
下記画像の赤枠部分を表示の通りに入力します。
WS000019.JPG

ボタンのテンプレート・オプションについて、下記画像の赤枠部分を表示の通りに入力します。
WS000020.JPG

下段の「アイテム」から「テキスト領域」をBODYにドラッグ&ドロップします。
下記画像の赤枠部分を表示の通りに入力します。
WS000021.JPG

CSSを使ってボタンの位置を整えます。
左ペインの「ページ2: TEXT TO SQL」を選択し、下記画像の赤枠部分を表示の通りに入力します。
WS000022.JPG

入力するCSSは以下の通りです。

.myc-aligncenter {
  display: flex;
  align-items: center;
}

左ペインの「P2_QUESTION」を選択し、下記画像の赤枠部分を表示の通りに入力します。
WS000023.JPG

入力する値は以下の通りです。

myc-aligncenter

左ペインの「P2_RESULT」を選択し、下記画像の赤枠部分を表示の通りに入力します。
WS000024.JPG

入力する値は先述した「P2_QUESTION」と同様です。

LLMにより自然言語からSQLを生成する処理の実装

UIが出来たので、中身の処理をPL/SQLで実装します。
左ペインのプロセスタブを選択します。
「プロセス」の上で右クリックし、「プロセスの作成」を選択します。
WS000025.JPG

下記画像の赤枠部分を表示の通りに入力します。
WS000026.JPG

「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結果表示用のアイテムに、取得した値を設定しています。

右ペインを下にスクロールし、下記画像の赤枠部分を表示の通りに入力します。
WS000026-2.JPG

これで「Ask AI」ボタンを押下すると先述のPL\SQLコードが実行されます。

LLMが回答したSQLを実行する処理の実装

LLMが回答したSQLをREST対応SQLサービスを使って実行します。
まずはREST API実行に必要な認証情報をAPEXに設定します。
「アプリケーション・ビルダー」⇒「ワークスペース・ユーティリティ」⇒「すべてのワークスペース・ユーティリティ」を選択します。
WS000028.JPG

「Web資格証明」を選択します。
WS000029.JPG

「作成」を選択します。
WS000030.JPG

下記画像の赤枠部分 (全7項目) を入力します。
名前と静的IDは任意の値を入力してください。
静的IDは後述のPL/SQLコード内で使うため、メモしておきます。
ユーザ名とパスワードは、APEXアプリ作成で使用しているDBスキーマのアカウント情報を入力します。
WS000031.JPG

入力できたら「作成」を選択します。

次にPL\SQLコードを実装します。
「プロセス」の上で右クリックし、「プロセスの作成」を選択します。
下記画像の赤枠部分を表示の通りに入力します。
WS000027.JPG

「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> タグを使っています。

最後に右ペインを下にスクロールし、下記画像の赤枠部分を表示の通りに入力します。
WS000027-2.JPG

これで「SQL実行」ボタンを押下すると先述のPL\SQLコードが実行されます。
以上で実装完了です。

使ってみる

「質問」に「職種がセールスの従業員の名前、給与を教えてください。」と入力し、「Ask AI」ボタンを選択します。
するとLLMが生成したSQLが「SQL文」に表示されます。
WS000032.JPG

生成されたSQLは以下の通りでした。

SELECT e.ename, e.sal FROM emp e WHERE e.job = 'セールス';

さらに「SQL実行」ボタンを選択します。
すると「SQL文」のSQLがREST対応SQLサービスを通して実行され、「SQL実行結果」に結果が表示されます。
WS000033.JPG

以上、自然言語からSQLを生成し、結果を取得する、という形態のRAGアプリ実装でした。

6
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
6