RAGアプリをOracle APEXの新機能で改善してみた その２ APEX_AIパッケージの活用

[前回の記事](https://qiita.com/ssfujita/items/cad4c896538e7cefe13e)では「対話型ダイアログ」を使ったRAGアプリ実装についてまとめました。
今回は同じく APEX 24.1 の新機能である「APEX_AIパッケージ」を使った実装を扱っていきます。

背景を簡単に再掲すると、以前Qiitaに投稿した23ai＋APEXを使ったRAGアプリを APEX 24.1 の新機能で改修する、という内容になります。
- [Oracle Database 23ai FreeとAPEXでRAGを使った生成AIアプリをローコード開発してみた (事前準備編)](https://qiita.com/ssfujita/items/34d9e3c39ca730d29351)
- [Oracle Database 23ai FreeとAPEXでRAGを使った生成AIアプリをローコード開発してみた (アプリ実装 前編)](https://qiita.com/ssfujita/items/c53579bbb3eed4145942)
- [Oracle Database 23ai FreeとAPEXでRAGを使った生成AIアプリをローコード開発してみた (アプリ実装 後編)](https://qiita.com/ssfujita/items/53df4f342e28c238a5a2)

`APEX_AI` パッケージには生成AIを使った文章生成やチャットを実装するための機能が搭載されています。
以前までは `APEX_WEB_SERVICE` パッケージなどを利用して生成AIのREST APIを直接叩き、帰ってきたJSONを解析する必要がありました。
APEX 24.1 からは `APEX_AI` パッケージを通してより簡単に生成AIアプリを実装できます。
`APEX_AI` パッケージの詳細は下記マニュアルをご参照ください。
[APIリファレンス - APEX_AI](https://docs.oracle.com/cd/G13834_01/aeapi/APEX_AI.html)
- CHATファンクション: チャット実装に使えるファンクション。会話履歴を保持する機能もある
- GENERATEファンクション: 単発のプロンプトに対する回答を生成するファンクション

ちなみに `APEX_AI` パッケージで実装したアプリのイメージが下記になります。
改修前のアプリと見た目は全く同じですが、ロジックの実装がシンプルになります。
![WS000000-1.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33182/df713998-5f60-dedf-5220-5c07fbaccb05.jpeg)

作成したAPEXアプリのexportファイルは以下にありますので、必要に応じてご参照ください。
前者は前回の記事で紹介した対話型ダイアログを使ったアプリ、後者は今回作成するアプリです。
https://github.com/mago1chi/apex/blob/main/apex_2413_rag_chatbot.sql (対話型ダイアログを使った方法)
https://github.com/mago1chi/apex/blob/main/apex_2413_rag_chain.sql (APEX_AIパッケージを使った方法)

以降は実装の流れをまとめていきます。

## 事前準備

環境構築などの事前準備は、前回の記事にまとめた下記項目を実施してください。
- [APEX 24.1 の作成](https://qiita.com/ssfujita/items/cad4c896538e7cefe13e#apex-241-%E3%81%AE%E4%BD%9C%E6%88%90)
- [その他の構築作業](https://qiita.com/ssfujita/items/cad4c896538e7cefe13e#%E3%81%9D%E3%81%AE%E4%BB%96%E3%81%AE%E6%A7%8B%E7%AF%89%E4%BD%9C%E6%A5%AD)

## UI実装

UIについては[前回の記事](https://qiita.com/ssfujita/items/cad4c896538e7cefe13e#ui%E3%81%AE%E7%B7%A8%E9%9B%86)とほぼ同じですが、違いとしては以下になります。
- `PX_ANSWER` アイテムを残しておく
- `AI_Assistant` ボタンは作成しない

最終状態は下記のようになります。
![WS000001.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33182/4250e9e0-f1e2-c5ae-0712-f0198a8c0f78.jpeg)

## APEX_AIを使った回答生成の実装

回答生成のロジックを実装していきます。
大きくは「ベクトル検索」と「生成AIによる回答生成」に分かれます。

まずページ・デザイナの「プロセス」タブに切り替えます。
作成済みの「回答生成」プロセスを削除します。
![WS000001-2.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33182/0a6bb241-a7c9-1629-9b44-c7ba23fc7a86.jpeg)

｢プロセス」を右クリックして「プロセスの作成」を選択します。
![WS000002.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33182/847eb384-2013-f04f-deb7-9634ab82b5f4.jpeg)

作成したプロセスを赤枠のように設定します。
ポイントは「タイプ」を「実行チェーン」とすることです。
![WS000003.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33182/9b0bc807-e274-6b83-c49b-0d80a3f952df.jpeg)

右ペインを下にスクロールし、赤枠のように設定します。
![WS000004.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33182/7be0743d-6a88-e0fd-6f0b-38c68a490cdf.jpeg)
これによって「検索」ボタンを選択すると、以降で子プロセスとして実装する「ベクトル検索」「回答」が順番に実行されます。

では親プロセスの「回答生成」を右クリックして「子プロセスの追加」を選択します。
![WS000005.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33182/d824c7a8-820b-5b9d-fde8-60fa114a8d6b.jpeg)

作成された子プロセスを赤枠のように設定します。
![WS000006.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33182/4a8998fe-bf3a-9b82-af93-f6c2c4c569b7.jpeg)
｢PL/SQLコード」には以下を入力します。

```sql
DECLARE
    l_context CLOB;
    l_prompt CLOB;
    cohere_params clob;
BEGIN
    -- OCI GenAI のEmbeddingを利用
    cohere_params := '
    { 
      "provider": "ocigenai",
      "credential_name": "OCI_GENAI", 
      "url": "https://inference.generativeai.us-chicago-1.oci.oraclecloud.com/20231130/actions/embedText",
      "model": "cohere.embed-multilingual-v3.0"
    }';

    -- ベクトル検索結果を一時格納するAPEXコレクションを定義
    APEX_COLLECTION.CREATE_OR_TRUNCATE_COLLECTION(
        p_collection_name => 'CITATION_RAG'
    );
    
    -- ベクトル検索を実行し、RAGに使用するコンテキストを取得
    IF :P9_CATEGORY IS NULL THEN
        IF :P9_DOCNUM IS NULL THEN
            -- カテゴリ、保証ドキュメント数の指定がない場合のクエリ
            FOR rec IN (SELECT doc.doc_filename doc_filename, em.embed_data embed_data
                FROM documents doc, embed em 
                WHERE doc.id = em.doc_id AND TRUNC(doc.doc_lastupd) BETWEEN :P9_BEGIN_DATE AND :P9_END_DATE
                ORDER BY VECTOR_DISTANCE(em.embedding, TO_VECTOR(DBMS_VECTOR.UTL_TO_EMBEDDING(:P9_QUESTION, JSON(cohere_params))), COSINE)
                FETCH APPROXIMATE FIRST :P9_NUM ROWS ONLY)
            LOOP
                -- プロンプトに埋め込むコンテキストを追記
                l_context := l_context || rec.embed_data;
                -- 引用元情報をAPEXコレクションに格納
                apex_collection.add_member(
                      p_collection_name => 'CITATION_RAG',
                      p_c001 => rec.doc_filename,
                      p_c002 => rec.embed_data
                );
            END LOOP;
        ELSE
            -- カテゴリの指定がなく、保証ドキュメント数の指定がある場合のクエリ
            FOR rec IN (SELECT doc.doc_filename doc_filename, em.embed_data embed_data
                FROM documents doc, embed em 
                WHERE doc.id = em.doc_id AND TRUNC(doc.doc_lastupd) BETWEEN :P9_BEGIN_DATE AND :P9_END_DATE
                ORDER BY VECTOR_DISTANCE(em.embedding, TO_VECTOR(DBMS_VECTOR.UTL_TO_EMBEDDING(:P9_QUESTION, JSON(cohere_params))), COSINE)
                FETCH APPROXIMATE FIRST :P9_DOCNUM PARTITIONS BY doc.id, :P9_NUM ROWS ONLY)
            LOOP
                -- プロンプトに埋め込むコンテキストを追記
                l_context := l_context || rec.embed_data;
                -- 引用元情報をAPEXコレクションに格納
                apex_collection.add_member(
                      p_collection_name => 'CITATION_RAG',
                      p_c001 => rec.doc_filename,
                      p_c002 => rec.embed_data
                );
            END LOOP;
        END IF;
    ELSE
        IF :P9_DOCNUM IS NULL THEN
            -- カテゴリの指定があり、保証ドキュメント数の指定がない場合のクエリ
            FOR rec IN (SELECT doc.doc_filename doc_filename, em.embed_data embed_data
                FROM documents doc, embed em 
                WHERE doc.id = em.doc_id AND 
                doc.category IN (SELECT column_value FROM APEX_STRING.SPLIT(:P9_CATEGORY,':')) AND 
                TRUNC(doc.doc_lastupd) BETWEEN :P9_BEGIN_DATE AND :P9_END_DATE
                ORDER BY VECTOR_DISTANCE(em.embedding, TO_VECTOR(DBMS_VECTOR.UTL_TO_EMBEDDING(:P9_QUESTION, JSON(cohere_params))), COSINE)
                FETCH APPROXIMATE FIRST :P9_NUM ROWS ONLY)
            LOOP
                -- プロンプトに埋め込むコンテキストを追記
                l_context := l_context || rec.embed_data;
                -- 引用元情報をAPEXコレクションに格納
                apex_collection.add_member(
                      p_collection_name => 'CITATION_RAG',
                      p_c001 => rec.doc_filename,
                      p_c002 => rec.embed_data
                );
            END LOOP;
        ELSE
            -- カテゴリ、保証ドキュメント数の指定がある場合のクエリ
            FOR rec IN (SELECT doc.doc_filename doc_filename, em.embed_data embed_data
                FROM documents doc, embed em 
                WHERE doc.id = em.doc_id AND 
                doc.category IN (SELECT column_value FROM APEX_STRING.SPLIT(:P9_CATEGORY,':')) AND 
                TRUNC(doc.doc_lastupd) BETWEEN :P9_BEGIN_DATE AND :P9_END_DATE
                ORDER BY VECTOR_DISTANCE(em.embedding, TO_VECTOR(DBMS_VECTOR.UTL_TO_EMBEDDING(:P9_QUESTION, JSON(cohere_params))), COSINE)
                FETCH APPROXIMATE FIRST :P9_DOCNUM PARTITIONS BY doc.id, :P9_NUM ROWS ONLY)
            LOOP
                -- プロンプトに埋め込むコンテキストを追記
                l_context := l_context || rec.embed_data;
                -- 引用元情報をAPEXコレクションに格納
                apex_collection.add_member(
                      p_collection_name => 'CITATION_RAG',
                      p_c001 => rec.doc_filename,
                      p_c002 => rec.embed_data
                );
            END LOOP;
        END IF;
    END IF;

    -- 最終的なコンテキストを非表示アイテムに保存
    :P9_SEARCH_RES := :P9_QUESTION || q'[
回答には次の文章を使ってください。
]' || l_context;
END;
```

もう一つ子プロセスを作成し、赤枠のように設定します。
![WS000007.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33182/21d5876b-2bbc-3448-857d-ef76adb05a10.jpeg)
ポイントは「タイプ」を「APIの呼出し」とした上で、`APEX_AI` パッケージの `GENERATE` ファンクションを実行するようにしている点です。
APEXのAPI呼出し機能によって、回答生成のロジックはPL/SQLを直接書く必要がありません。

`GENERATE` ファンクションの各パラメータも設定していきます。
｢ファンクションの結果」は赤枠のように設定します。
![WS000008.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33182/0374b12a-8d02-61d7-c6b3-e2cc62893482.jpeg)

｢p_prompt」は赤枠のように設定します。
![WS000009.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33182/7cc58a4f-7810-75b1-2855-824160861a41.jpeg)

｢p_service_static_id」は赤枠のように設定します。
![WS000010.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33182/66e5a556-1db9-ab94-f384-ef1723565efb.jpeg)
静的値には[前回記事の「連携する生成AIの設定」](https://qiita.com/ssfujita/items/cad4c896538e7cefe13e#%E9%80%A3%E6%90%BA%E3%81%99%E3%82%8B%E7%94%9F%E6%88%90ai%E3%81%AE%E8%A8%AD%E5%AE%9A)にて指定した静的IDを入力します。

以上で実装完了です。
チェーンと聞くとLangChainでも同じような実装方式を取りますね。
実行チェーン自体は 24.1 以前からありますが、生成AIアプリの実装と相性の良い機能かもしれません。

## 動作確認

[前回記事](https://qiita.com/ssfujita/items/cad4c896538e7cefe13e#%E5%8B%95%E4%BD%9C%E7%A2%BA%E8%AA%8D)と同じように2024年秋アニメのタイトル、あらすじがまとめられたテキストファイルをRAGに使います。
![WS000015.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33182/871ac961-6471-deff-5578-d2c968ac22af.jpeg)

適当な質問でベクトル検索します。
例えば以下のような質問を投げてみると、ベクトル検索結果をコンテキストとして使用した回答が表示されます。

```text
異世界バトルものアニメはありますか？
```

![WS000000-2.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33182/9dac6deb-36ff-825d-7fdd-c68ad843686c.jpeg)

24.1 がリリースされる前は、REST APIを直接実行した際に得られるJSONフォーマットの結果をパースして回答を表示していました。
今回は 24.1 から追加された `APEX_AI` パッケージの `GENERATE` ファンクションを使っており、生成AIとの連携や回答の取得が非常に簡素化されています。
そのため実装においては、前述した手順の通りPL/SQLを直接書く必要もなくなり、UI上の設定だけで済みました。

今回の内容は以上になります。
`APEX_AI` パッケージを使えばチャットボットはもちろん、業務アプリケーションの中に生成AIを組み込むのも容易に実現できそうです。
是非お試しください。