Oracle Database 23ai FreeとAPEXでRAGを使った生成AIアプリをローコード開発してみた (アプリ実装 後編)

前回はアプリ実装 前編として、非構造化データをアップロードし、Embedding結果を保存する機能を実装しました。
[Oracle Database 23ai FreeとAPEXでRAGを使った生成AIアプリをローコード開発してみた (アプリ実装 前編)](https://qiita.com/ssfujita/items/c53579bbb3eed4145942)
今回は後編にあたる生成AIに質問し、RAGを使った回答を表示させる機能を実装します。
+ 前編：任意のファイル (PDFやOfficeファイル) をアップロードしてEmbeddingするページ
+ 後編：質問文でベクトル検索し、質問文と検索結果をもとにした生成AIの回答を表示するページ

なおアプリのexportファイルを以下に置いています。
https://github.com/mago1chi/apex/blob/main/ragapp_cohere.sql
今回のアプリ実装は作業数が多いため、すぐに動くものを見たい方は上記ファイルをご自分のAPEX環境にimportしてお試しください。
その場合でも、以下記事の作業は事前に行う必要がありますのでご注意ください。
+ [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)
    - ｢ワークスペースの作成」までを実施します。
+ 本記事の「Web資格証明の設定」までを実施します。

後編で作成するページは以下のようなイメージになります。
![WS000089.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33182/c9431233-fe97-b50b-3417-8edfdb94d56d.jpeg)

こちらは以下の処理を行います。
+ 質問フォームに入力した質問文をEmbeddingし、登録済みファイルにベクトル検索を実行
+ ベクトル検索の際は以下の付加情報も利用
    - 取得行数: 類似するコンテンツのうち上位何件を利用するか
    - 保証ドキュメント数: マルチベクトル検索を使うにあたり、上位何件のドキュメントを含めるか
    - カテゴリ: ベクトル検索に含めるドキュメントをどのカテゴリのものに絞るか
    - 開始日付、終了日付: ベクトル検索に含めるドキュメントをいつ作成されたものに絞るか
+ マークダウン対応した回答欄に、生成AIからの回答を表示
+ 引用元欄にベクトル検索の結果RAGに使われたテキスト、対応ファイル名を表示

前編と同様、以降は手順を記載しつつ、実装に使うベクトル検索機能 (AI Vector Search) の説明も簡単に挟みたいと思います。
ちなみにRAG実装にあたり、LangChainなどのオーケストレーション・フレームワークは使用しておりません。
本例ではベクトル検索の結果をコンテキストとしてプロンプトに埋め込み、質問文と一緒にLLMへ連携する方式を取っています。

## APEXにウォレットを設定

外部サービスのREST APIを使うには、事前にルート認証局を設定したウォレットを作成する必要があります。
まずCLIでウォレット作成を行い、その後APEXにウォレットを読み込ませるための設定を行います。
手順は下記サイトを参考にしました。
https://apexugj.blogspot.com/2023/04/create-wallet-for-apex.html

```bash
## rootユーザで実行
## 必要なパッケージを導入
$ sudo yum install ca-certificates

## 以降はoracleユーザで実行
$ sudo su - oracle
$ export ORACLE_WALLET_PATH=/opt/oracle/ca_wallet
$ export ORACLE_WALLET_PWD=WalletPasswd123
$ mkdir -p ${ORACLE_WALLET_PATH}

## ウォレット作成
$ orapki wallet create -wallet ${ORACLE_WALLET_PATH} -pwd ${ORACLE_WALLET_PWD} -auto_login

## 証明書を取得
$ cd ${ORACLE_WALLET_PATH}
$ wget https://curl.haxx.se/ca/cacert.pem

## 認証局ごとに証明書を分割しファイルに保存
$ awk 'split_after == 1 {n++;split_after=0} /-----END CERTIFICATE-----/ {split_after=1} {print > "cert" n ".pem"}' < cacert.pem

## 分割した各認証局の証明書をウォレットにロード
$ for i in /opt/oracle/ca_wallet/cert*.pem
do
  orapki wallet add -wallet ${ORACLE_WALLET_PATH} -trusted_cert -cert "$i" -pwd ${ORACLE_WALLET_PWD}
done
```

次に作成したウォレットを読み込めるよう、APEXに設定を施します。
ワークスペースへログインしている場合は、一度サインアウトします。
![WS000051.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33182/15f57bac-8ad9-297d-02de-3014dcd71bcb.jpeg)

ログイン画面の下段にある「管理」からAPEXインスタンス管理のログイン画面へ遷移します。
管理ユーザでログインします。
![WS000052.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33182/a3035abd-a6d4-561c-d2c9-5d718008e6d3.jpeg)

｢インスタンスの管理」を選択し、「インスタンスの設定」へ進みます。
![WS000053.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33182/bcb1537b-e3e4-e93e-41ac-abc7b4c172df.jpeg)
![WS000054.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33182/3212d495-9ef6-239f-2c53-46035b4955c0.jpeg)

｢ウォレット」タブを選択し、赤枠を画像の通りに編集します。
![WS000055.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33182/f7d0136a-a787-b6fb-ecaf-2abbe63d0ddb.jpeg)

｢電子メール」タブを選択します。
こちらはウォレット設定とは関係ないですが、「電子メール」タブの内容も設定しないと「変更の適用」がエラーになるため対応が必要です。
｢デフォルトURL」を選択し、空欄となっている2箇所を埋めます。
![WS000056.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33182/860f7ede-028c-19d6-7f87-1baf3e496773.jpeg)

最後に「変更の適用」を選択します。
以上でウォレット設定が完了しました。

## Web資格証明の設定

CohereのLLMをREST API経由で利用するには、事前に資格証明 (利用に必要なAPIキー) をAPEX上で設定する必要があります。
まずワークスペースに再度ログインします。
メニューの「アプリケーション・ビルダー」にある「ワークスペース・ユーティリティ」の「すべてのワークスペース・ユーティリティ」を選択します。
![WS000077.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33182/b5f68b96-ad6c-bf6c-e998-f26fbefcb3a8.jpeg)

｢Web資格証明」を選択します。
![WS000078.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33182/f99df123-4760-40de-6561-904115200246.jpeg)

｢作成」を選択します。
![WS000079.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33182/b58974a2-6854-7f8b-85b8-ddea728dd264.jpeg)

赤枠を画像の通りに編集します。
![WS000080.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33182/3e5ea8ba-134e-4a33-c2e6-446f7aa2a9bd.jpeg)

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

```
名前: Credential for Cohere
静的ID: CREDENTIAL_FOR_COHERE
認証タイプ: HTTPヘッダー
資格証明名: Authorization
資格証明シークレット: bearer your_api_key
URLに対して有効: https://api.cohere.ai/
```

｢資格証明シークレット」には「bearer」のあとに半角スペースを1つ入れ、ご自分のAPIキーを入力してください。
また静的IDは後述するPL/SQLコードの中で利用します。
最後に「作成」ボタンを押下します。

## ユーザ・インターフェイスの編集

最初に空白ページとして作成した「RAG」という名前のページについて、ユーザ・インターフェイスを編集します。
アプリケーション・ビルダーのトップ画面から「4 - RAG」を選択します。
![WS000057.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33182/180c9243-59a2-ccc2-2ae2-f3d1c52acbef.jpeg)

ページ・デザイナを開いたら下段の「アイテム」タブにある「テキスト・フィールド」を、「BODY」エリアにD&Dで追加します。
![WS000058.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33182/9cb98244-3efb-eed7-6654-e8e91a8dc8b4.jpeg)

右ペイン赤枠を画像の通りに編集します。
![WS000059.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33182/bff2c2f8-9a32-2fb5-3352-3b07aa80e7ad.jpeg)

右ペインを下にスクロールし、赤枠を画像の通りに編集します。
![WS000060.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33182/e5e8a7c5-ae88-0a0e-e75d-eedb93d767ba.jpeg)

これで質問フォームを入力必須に設定します。

以降は同じようにアイテムやボタンをD&DでBODYエリアに追加していきます。
次は「数値フィールド」をBODYエリア「P4_QUESTION」の下に追加します。
右ペイン赤枠を画像の通りに編集します。
![WS000061.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33182/95c8098d-e65c-8a4e-49dd-af1c0bee2c46.jpeg)

右ペインを下にスクロールし、赤枠を画像の通りに編集します。
![WS000062.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33182/7461940b-a0b0-1b6c-55f8-3d56102643f3.jpeg)

さらに右ペインを下にスクロールし、赤枠を画像の通りに編集します。
![WS000062-2.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33182/c2cad689-ca9e-1a1d-838b-3c0ab0b93cd2.jpeg)

本フォームの値は「類似するコンテンツのうち上位何件を利用するか」を制御します。
入力できる範囲を 1～5 に制限し、デフォルト値を 3 にしています。
また入力を必須に設定しています。

｢数値フィールド」をBODYエリア「P4_NUM」の右に追加します。
右ペイン赤枠を画像の通りに編集します。
![WS000063.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33182/010cc2dd-d968-5039-e276-a17249cfe61b.jpeg)

本フォームの値は「マルチベクトル検索を使うにあたり、上位何件のドキュメントを含めるか」を制御します。
マルチベクトル検索については後述します。

｢ポップアップLOV」をBODYエリア「P4_DOCNUM」の右に追加します。
右ペイン赤枠を画像の通りに編集します。
![WS000064.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33182/545353fb-5f69-4394-e2b2-92fe3f71af68.jpeg)

右ペインを下にスクロールし、赤枠を画像の通りに編集します。
![WS000065.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33182/24a4d9f0-a365-7cce-e993-7b318bfcb8e6.jpeg)

さらに右ペインを下にスクロールし、赤枠を画像の通りに編集します。
![WS000066.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33182/f4eb9614-1176-56aa-d56f-651a247b2c5d.jpeg)

｢SQL問合せ」に入力するSQLは以下の通りです。

```sql
SELECT category d, category v FROM documents GROUP BY category order by 1;
```

本フォームの値は「ベクトル検索に含めるドキュメントをどのカテゴリに絞るか」を制御します。
ポップアップLOVについては前編でも利用した、選択リストの一種になります。

｢日付ピッカー」をBODYエリア「P4_CATEGORY」の右に追加します。
右ペイン赤枠を画像の通りに編集します。
![WS000067.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33182/cd0b2d1a-a4fe-5e50-34b4-3256b5376c32.jpeg)

右ペインを下にスクロールし、赤枠を画像の通りに編集します。
![WS000068.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33182/352e83fd-5f4e-3c54-1ee9-ebc9d0bdc613.jpeg)

さらに右ペインを下にスクロールし、赤枠を画像の通りに編集します。
![WS000069.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33182/560ad47b-b830-fdc6-50ed-dbff113b0c52.jpeg)

｢SQL問合せ」に入力するSQLは以下の通りです。

```sql
SELECT sysdate - 365 FROM dual;
```

本フォームの値は「ベクトル検索に含めるドキュメントをいつ作成されたものに絞るか」を制御します。
デフォルト値はその日の日付から1年前を設定しています。

再度「日付ピッカー」をBODYエリア「P4_BEGIN_DATE」の右に追加します。
右ペイン赤枠を画像の通りに編集します。
![WS000070.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33182/559471ef-6acb-596a-674a-2eadcb827bd9.jpeg)

右ペインを下にスクロールし、赤枠を画像の通りに編集します。
![WS000071.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33182/c3374a09-0878-a6a7-4633-9b5b0b024b5c.jpeg)

さらに右ペインを下にスクロールし、赤枠を画像の通りに編集します。
![WS000072.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33182/864b6fcf-dcac-83a9-3492-a6914046aa0e.jpeg)

｢SQL問合せ」に入力するSQLは以下の通りです。

```sql
SELECT sysdate FROM dual;
```

ここで「P4_BEGIN_DATE」の値が「P4_END_DATE」より大きくならないよう、検証する処理を追加します。
左ペインの「プロセス」タブを選択します。
｢検証中」の上で右クリックし「検証の作成」を選択します。
![WS000072-2.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33182/2fc2ca15-40fd-8945-ef46-5dd05ae18739.jpeg)

右ペイン赤枠を画像の通りに編集します。
![WS000072-3.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33182/2739128f-3d8a-3393-551e-1412631dcacd.jpeg)

｢PL/SQL式」に入力するコードは以下の通りです。

```sql
:P4_BEGIN_DATE <= :P4_END_DATE
```

下段の「ボタン」タブを選択し、「icon」と書かれたボタンをBODYエリア「P4_END_DATE」の右に追加します。
右ペイン赤枠を画像の通りに編集します。
![WS000073.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33182/a175db86-bf73-db7a-c238-d261e09836c8.jpeg)

ボタンの「テンプレート・オプション」についても、赤枠を画像の通りに編集します。
![WS000073-2.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33182/a7f261c4-e6ea-0391-04bc-34547f683c11.jpeg)

ボタンの配置をCSSで調整します。
左ペインの「ページ4: RAG」を選択し、右ペイン赤枠を画像の通りに編集します。
![WS000074.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33182/f051485b-9919-ee76-7812-7e31f54fd9c4.jpeg)

｢インライン」に入力するCSSは以下の通りです。

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

左ペインの「P4_NUM」を選択し、右ペイン赤枠を画像の通りに編集します。
![WS000075.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33182/df585d21-42d9-73be-673d-93f9e621efc7.jpeg)

｢行CSSクラス」に入力する値は以下の通りです。

```css
myc-aligncenter
```

生成AIからの回答を表示するアイテムを追加します。
｢リッチ・テキスト・エディタ」をBODYエリアの新しい行へ追加します。
右ペイン赤枠を画像の通りに編集します。
![WS000084.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33182/407d29fd-678e-fd55-b127-84d31f214b0b.jpeg)

右ペインを下にスクロールし、赤枠を画像の通りに編集します。
![WS000085.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33182/c8fef861-a392-43df-f976-cf16aa8a2e9c.jpeg)

このアイテムはユーザ側で編集しない想定のため、読取り専用にしています。

同じように引用元テキスト表示用のアイテムを追加します。
｢リッチ・テキスト・エディタ」をBODYエリアの新しい行へ追加します。
右ペイン赤枠を画像の通りに編集します。
![WS000086.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33182/71359d10-84a5-3427-fda2-b6cf95ce9c2b.jpeg)

右ペインを下にスクロールし、赤枠を画像の通りに編集します。
![WS000087.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33182/d572b86d-0fad-90e7-d7b7-6e791d90607d.jpeg)

このアイテムには引用元のテキスト、および紐づくファイル名を表示します。
その際、HTML形式で整形したテーブルを読み込ませますので、「書式」を「HTML」にしています。
またこのアイテムもユーザ側で編集しない想定のため、読取り専用にしています。

## PL/SQLを使ったベクトル検索、生成AI連携の実装

最後に生成AIと連携する内部処理をPL/SQLで実装します。
左ペインの「プロセス」タブを選択し、新しいプロセスを作成します。
右ペイン赤枠を画像の通りに編集します。
![WS000082.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33182/7e948e04-f824-2633-d845-86d219df8f13.jpeg)

｢PL/SQLコード」に入力するPL/SQLは以下の通りです。

```sql
DECLARE
    cohere_params clob;
    l_cohere_rest_url VARCHAR2(4000) := 'https://api.cohere.ai/v1/chat'; 
    -- Web資格証明に設定した「静的ID」を入力
    l_web_cred CONSTANT VARCHAR2(50) := 'CREDENTIAL_FOR_COHERE';
    l_context CLOB;
    l_prompt CLOB;
    l_response_json CLOB;
    l_body CLOB;
    l_citation CLOB;
    CURSOR C1  IS 
    SELECT jt.* 
    FROM   JSON_TABLE(l_response_json, '$' 
        COLUMNS (text VARCHAR2(4000)  PATH '$.text' )) jt; 
BEGIN
    -- Cohereの embed-multilingual-v3.0 を使ってEmbeddingするためのパラメータ
    cohere_params := '
    { 
      "provider": "cohere",
      "credential_name": "COHERE_CRED", 
      "url": "https://api.cohere.ai/v1/embed",
      "model": "embed-multilingual-v3.0",
      "input_type": "search_query"
    }';
    
    -- 引用元の情報を格納する変数を用意
    l_citation := '<table><tr><th>ファイル名</th><th>該当テキスト</th></tr>';
    
    -- ベクトル検索を実行し、RAGに使用するコンテキストを取得
    IF :P4_CATEGORY IS NULL THEN
        IF :P4_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 :P4_BEGIN_DATE AND :P4_END_DATE
                ORDER BY VECTOR_DISTANCE(em.embedding, DBMS_VECTOR.UTL_TO_EMBEDDING(:P4_QUESTION, JSON(cohere_params)), COSINE)
                FETCH EXACT FIRST :P4_NUM ROWS ONLY)
            LOOP
                -- プロンプトに埋め込むコンテキストを追記
                l_context := l_context || rec.embed_data;
                -- 引用元情報を追記
                l_citation := l_citation || '<tr><td>' || rec.doc_filename || '</td><td>' || rec.embed_data || '</td></tr>';
            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 :P4_BEGIN_DATE AND :P4_END_DATE
                ORDER BY VECTOR_DISTANCE(em.embedding, DBMS_VECTOR.UTL_TO_EMBEDDING(:P4_QUESTION, JSON(cohere_params)), COSINE)
                FETCH EXACT FIRST :P4_DOCNUM PARTITIONS BY doc.id, :P4_NUM ROWS ONLY)
            LOOP
                -- プロンプトに埋め込むコンテキストを追記
                l_context := l_context || rec.embed_data;
                -- 引用元情報を追記
                l_citation := l_citation || '<tr><td>' || rec.doc_filename || '</td><td>' || rec.embed_data || '</td></tr>';
            END LOOP;
        END IF;
    ELSE
        -- カテゴリの指定があり、保証ドキュメント数の指定がない場合のクエリ
        IF :P4_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(:P4_CATEGORY,':')) AND 
                TRUNC(doc.doc_lastupd) BETWEEN :P4_BEGIN_DATE AND :P4_END_DATE
                ORDER BY VECTOR_DISTANCE(em.embedding, DBMS_VECTOR.UTL_TO_EMBEDDING(:P4_QUESTION, JSON(cohere_params)), COSINE)
                FETCH EXACT FIRST :P4_NUM ROWS ONLY)
            LOOP
                -- プロンプトに埋め込むコンテキストを追記
                l_context := l_context || rec.embed_data;
                -- 引用元情報を追記
                l_citation := l_citation || '<tr><td>' || rec.doc_filename || '</td><td>' || rec.embed_data || '</td></tr>';
            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(:P4_CATEGORY,':')) AND 
                TRUNC(doc.doc_lastupd) BETWEEN :P4_BEGIN_DATE AND :P4_END_DATE
                ORDER BY VECTOR_DISTANCE(em.embedding, DBMS_VECTOR.UTL_TO_EMBEDDING(:P4_QUESTION, JSON(cohere_params)), COSINE)
                FETCH EXACT FIRST :P4_DOCNUM PARTITIONS BY doc.id, :P4_NUM ROWS ONLY)
            LOOP
                -- プロンプトに埋め込むコンテキストを追記
                l_context := l_context || rec.embed_data;
                -- 引用元情報を追記
                l_citation := l_citation || '<tr><td>' || rec.doc_filename || '</td><td>' || rec.embed_data || '</td></tr>';
            END LOOP;
        END IF;
    END IF;
    
    -- プロンプト生成
    -- ベクトル検索結果をコンテキストとして埋め込むことでRAGを実装
    l_prompt := 'Answer the following question using the supplied context assuming you are a subject matter expert. Question: ' || :P4_QUESTION || ' Context: ' || l_context; 
    
    -- LLMにPOSTリクエストするためのBody設定
    l_body := '{ "message": "'|| APEX_ESCAPE.JSON(l_prompt) ||'" }';

    -- LLMにPOSTリクエストするための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';  

    -- LLMにREST API発行
    l_response_json := APEX_WEB_SERVICE.MAKE_REST_REQUEST 
    (p_url                  => l_cohere_rest_url, 
        p_http_method          => 'POST', 
        p_body                 => l_body, 
        p_credential_static_id => l_web_cred); 

    -- LLMからの応答を回答表示用アイテムに格納
    FOR row_1 IN C1 LOOP
        :P4_ANSWER := row_1.text; 
    END LOOP;
    
    -- 引用元情報を引用元表示用アイテムに格納
    :P4_CITATION := l_citation || '</table>';
END;
```

コードの大まかな内容はコメントをご確認ください。
AI Vector Searchの機能を使っている箇所は別途解説したいと思います。
まず 35～36行目 の構文は AI Vector Search 用に23aiで新たに追加された機能を使っています。

```sql
ORDER BY VECTOR_DISTANCE(em.embedding, DBMS_VECTOR.UTL_TO_EMBEDDING(:P4_QUESTION, JSON(cohere_params)), COSINE)
FETCH EXACT FIRST :P4_NUM ROWS ONLY)
```

`ORDER BY VECTOR_DISTANCE` は第一引数と第二引数のベクトルデータ間の類似度を算出するものです。
第二引数のプロシージャは前編でも出てきました。
第三引数は類似度の算出方法を指定しますが、本例では「COSINE」(コサイン類似度) を使っています。
なお選択できるパラメータ (類似度の算出方法) は以下の通りです。
+ COSINE: コサイン類似度 (デフォルト値)
+ DOT: ドット積
+ EUCLIDEAN: ユークリッド距離
+ EUCLIDEAN_SQUARED: 平方ユークリッド距離 (ユークリッド距離の平方根なし版)
+ HAMMING: ハミング距離
+ MANHATTAN: マンハッタン距離

`FETCH EXACT FIRST :P4_NUM ROWS ONLY` は類似度の高い順に上から「:P4_NUM」件の結果を返す、というものです。
また最後に参考として記載しますが、こちらはベクトル索引を使わない場合の構文となります。

より詳細は下記マニュアルをご参照ください。
+ [Use SQL Functions for Vector Operations](https://docs.oracle.com/en/database/oracle/oracle-database/23/vecse/use-sql-functions-vector-operations.html)
+ [Vector Functions](https://docs.oracle.com/en/database/oracle/oracle-database/23/sqlrf/vector-functions.html#SQLRF-GUID-C0C477F1-8210-4CA9-A5FA-0A340C409892)

48行目 は上記と少し異なる構文ですが、こちらは「マルチベクトル検索」を使う場合の構文です。

```sql
FETCH EXACT FIRST :P4_DOCNUM PARTITIONS BY doc.id, :P4_NUM ROWS ONLY)
```

こちらの解釈は「類似度の高い上位 `:P4_DOCNUM` 件のドキュメントそれぞれから、類似度の高い上位 `:P4_NUM` 件のテキストを抽出する」という意味になります。
そのため出力される結果の行数は `:P4_DOCNUM x :P4_NUM` 件になります。
より詳細は下記マニュアルをご参照ください。
+ [Perform Multi-Vector Similarity Search](https://docs.oracle.com/en/database/oracle/oracle-database/23/vecse/perform-multi-vector-similarity-search.html)

最後に右ペインを下にスクロールし、赤枠を画像の通りに編集します。
![WS000083.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33182/8029afa9-264e-56fb-900a-3d53f89c8697.jpeg)

これで「検索」ボタンを押下すると、上述したPL/SQLコードが実行されます。

以上でアプリ実装は完了です。
今回作成した機能の使用感は以下のようなイメージです。
![WS000090.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33182/2cae2e48-3613-a9ad-361a-7b5913863f25.jpeg)

本例では以下のフィルタリングを施した上でクエリを投げています。
+ 取得行数: 上位3件
+ 保証ドキュメント数: 上位2件
+ カテゴリ: Linux
+ ファイル作成日: 2023/5/5～2024/5/4

マルチベクトル検索を使っているため、カテゴリ「Linux」に含まれるドキュメントのうち上位2件からそれぞれ、3件ずつ関連度の高いテキストが取得されています。
またそれらテキストをコンテキストとしてプロンプトに埋め込み、生成AIに質問を投げています。
結果として生成AIの回答は、コンテキストに含まれる文章を取り入れた回答になっています。

## (参考) EmbeddingにONNX形式でimportしたモデルを使う場合

ご参考までに、ONNX形式でimportしたモデルを使ってベクトル検索し、検索結果をLLMに連携するコードを掲載します。
importしたモデルを使うことで、DB内でEmbedding処理を実行できます。
LLMも閉域網内のサーバに自前で用意すれば、インターネットを経由しない形でRAGシステムを構築できます。

```sql
DECLARE
    l_cohere_rest_url VARCHAR2(4000) := 'https://api.cohere.ai/v1/chat'; 
    -- Web資格証明に設定した「静的ID」を入力
    l_web_cred CONSTANT VARCHAR2(50) := 'CREDENTIAL_FOR_COHERE';
    l_context CLOB;
    l_prompt CLOB;
    l_response_json CLOB;
    l_body CLOB;
    l_citation CLOB;
    CURSOR C1  IS 
    SELECT jt.* 
    FROM   JSON_TABLE(l_response_json, '$' 
        COLUMNS (text VARCHAR2(4000)  PATH '$.text' )) jt; 
BEGIN
    -- 引用元の情報を格納する変数を用意
    l_citation := '<table><tr><th>ファイル名</th><th>該当テキスト</th></tr>';
    
    -- ベクトル検索を実行し、RAGに使用するコンテキストを取得
    IF :P4_CATEGORY IS NULL THEN
        IF :P4_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 :P4_BEGIN_DATE AND :P4_END_DATE
                ORDER BY VECTOR_DISTANCE(em.embedding, TO_VECTOR(VECTOR_EMBEDDING(doc_model USING :P4_QUESTION as data)), COSINE)
                FETCH EXACT FIRST :P4_NUM ROWS ONLY)
            LOOP
                -- プロンプトに埋め込むコンテキストを追記
                l_context := l_context || rec.embed_data;
                -- 引用元情報を追記
                l_citation := l_citation || '<tr><td>' || rec.doc_filename || '</td><td>' || rec.embed_data || '</td></tr>';
            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 :P4_BEGIN_DATE AND :P4_END_DATE
                ORDER BY VECTOR_DISTANCE(em.embedding, TO_VECTOR(VECTOR_EMBEDDING(doc_model USING :P4_QUESTION as data)), COSINE)
                FETCH EXACT FIRST :P4_DOCNUM PARTITIONS BY doc.id, :P4_NUM ROWS ONLY)
            LOOP
                -- プロンプトに埋め込むコンテキストを追記
                l_context := l_context || rec.embed_data;
                -- 引用元情報を追記
                l_citation := l_citation || '<tr><td>' || rec.doc_filename || '</td><td>' || rec.embed_data || '</td></tr>';
            END LOOP;
        END IF;
    ELSE
        -- カテゴリの指定があり、保証ドキュメント数の指定がない場合のクエリ
        IF :P4_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(:P4_CATEGORY,':')) AND 
                TRUNC(doc.doc_lastupd) BETWEEN :P4_BEGIN_DATE AND :P4_END_DATE
                ORDER BY VECTOR_DISTANCE(em.embedding, TO_VECTOR(VECTOR_EMBEDDING(doc_model USING :P4_QUESTION as data)), COSINE)
                FETCH EXACT FIRST :P4_NUM ROWS ONLY)
            LOOP
                -- プロンプトに埋め込むコンテキストを追記
                l_context := l_context || rec.embed_data;
                -- 引用元情報を追記
                l_citation := l_citation || '<tr><td>' || rec.doc_filename || '</td><td>' || rec.embed_data || '</td></tr>';
            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(:P4_CATEGORY,':')) AND 
                TRUNC(doc.doc_lastupd) BETWEEN :P4_BEGIN_DATE AND :P4_END_DATE
                ORDER BY VECTOR_DISTANCE(em.embedding, TO_VECTOR(VECTOR_EMBEDDING(doc_model USING :P4_QUESTION as data)), COSINE)
                FETCH EXACT FIRST :P4_DOCNUM PARTITIONS BY doc.id, :P4_NUM ROWS ONLY)
            LOOP
                -- プロンプトに埋め込むコンテキストを追記
                l_context := l_context || rec.embed_data;
                -- 引用元情報を追記
                l_citation := l_citation || '<tr><td>' || rec.doc_filename || '</td><td>' || rec.embed_data || '</td></tr>';
            END LOOP;
        END IF;
    END IF;
    
    -- プロンプト生成
    -- ベクトル検索結果をコンテキストとして埋め込むことでRAGを実装
    l_prompt := 'Answer the following question using the supplied context assuming you are a subject matter expert. Question: ' || :P4_QUESTION || ' Context: ' || l_context; 
    
    -- LLMにPOSTリクエストするためのBody設定
    l_body := '{ "message": "'|| APEX_ESCAPE.JSON(l_prompt) ||'" }';

    -- LLMにPOSTリクエストするための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';  

    -- LLMにREST API発行
    l_response_json := APEX_WEB_SERVICE.MAKE_REST_REQUEST 
    (p_url                  => l_cohere_rest_url, 
        p_http_method          => 'POST', 
        p_body                 => l_body, 
        p_credential_static_id => l_web_cred); 

    -- LLMからの応答を回答表示用アイテムに格納
    FOR row_1 IN C1 LOOP
        :P4_ANSWER := row_1.text; 
    END LOOP;
    
    -- 引用元情報を引用元表示用アイテムに格納
    :P4_CITATION := l_citation || '</table>';
END;
```

ONNXによるDB内Embedding方式特有の構文は 24行目 が該当します。

```sql
ORDER BY VECTOR_DISTANCE(em.embedding, TO_VECTOR(VECTOR_EMBEDDING(doc_model USING :P4_QUESTION as data)), COSINE)
```

`VECTOR_EMBEDDING` というSQL関数はONNXを利用する場合のみ使える関数です。
`doc_model` は事前にONNX import時に作成したONNX向けDBオブジェクトのオブジェクト名です。
`USING` 句の直後にEmbeddingしたいテキストを入れます。

詳細は下記マニュアルをご参照ください。
[VECTOR_EMBEDDING](https://docs.oracle.com/en/database/oracle/oracle-database/23/sqlrf/vector_embedding.html#GUID-5ED78260-6D21-4B6B-86E0-A1E70EFA11CA)

## (参考) ベクトル索引の利用

生成AIからの応答速度は実運用する上で重要です。
特にRAGを使ったベクトル検索では、ベクトル検索の応答速度も留意が必要です。
23aiではベクトル検索を高速化するために、下記2つのベクトル索引を利用できます。
+ Hierarchical Navigable Small World (HNSW)
+ Inverted File Flat (IVF)
各索引の詳細は下記マニュアルをご参照ください。
[Manage the Different Categories of Vector Indexes](https://docs.oracle.com/en/database/oracle/oracle-database/23/vecse/manage-different-categories-vector-indexes.html)

### HNSW索引

HNSW索引の利用には `vector_memory_size` という新しい初期化パラメータの設定が必要です。
HNSW索引向けに、23aiからSGAに新しい領域「Vector Pool」が作られました。
詳細は下記マニュアルをご参照ください。
[Size the Vector Pool](https://docs.oracle.com/en/database/oracle/oracle-database/23/vecse/size-vector-pool.html)

また23.4では、HNSW索引を利用する際の制限として、HNSW索引を作ったテーブルにDMLを実行できなくなります。
実行した場合 `ORA-51928` が発生します。

まずは `vector_memory_size` を設定します。

```sql
-- sysユーザでCDBにログイン
SQL> show parameter vector_memory_size

NAME     TYPE
------------------------------------ ---------------------------------
VALUE
------------------------------
vector_memory_size     big integer
0

SQL> ALTER SYSTEM SET vector_memory_size=1G SCOPE=SPFILE;
SQL> shutdown immediate
SQL> startup

ORACLE

Total System Global Area 1603726344 bytes
Fixed Size    5360648 bytes
Variable Size  335544320 bytes
Database Buffers  184549376 bytes
Redo Buffers    4530176 bytes
Vector Memory Area 1073741824 bytes

SQL> show parameter vector_memory_size

NAME     TYPE
------------------------------------ ---------------------------------
VALUE
------------------------------
vector_memory_size     big integer
1G
```

HNSW索引を作成します。

```sql
-- vectorユーザで実行
SQL> CREATE VECTOR INDEX hnsw_idx ON embed (embedding)
ORGANIZATION INMEMORY NEIGHBOR GRAPH
DISTANCE COSINE
WITH TARGET ACCURACY 95;
```

`Vector Pool` の利用状況は以下のように `V$VECTOR_MEMORY_POOL` ビューで確認できます。

```sql
SQL> select CON_ID, POOL, ALLOC_BYTES/1024/1024 as ALLOC_BYTES_MB,
USED_BYTES/1024/1024 as USED_BYTES_MB
from V$VECTOR_MEMORY_POOL order by 1,2;

    CON_ID POOL                                                                           ALLOC_BYTES_MB USED_BYTES_MB
---------- ------------------------------------------------------------------------------ -------------- -------------
         3 1MB POOL                                                                                  704             1
         3 64KB POOL                                                                                 304         .3125
         3 IM POOL METADATA                                                                           16            16
```

`1MB POOL` がHNSW索引本体を格納する領域、`64KB POOL` がメタデータ情報を格納する領域です。
この構造はIn-Memoryと類似してますね。

実際にHNSW索引を使った問合せを実行し、実行計画を確認します。

```sql
SQL> SELECT embed_data
FROM embed
ORDER BY vector_distance(embedding, DBMS_VECTOR.UTL_TO_EMBEDDING('営業課題は何ですか?', JSON(:onnx_params)), COSINE)
FETCH APPROXIMATE FIRST 2 ROWS ONLY;

Plan hash value: 3880687600

-------------------------------------------------------------------------------------------
| Id  | Operation                      | Name     | Rows  | Bytes | Cost (%CPU)| Time     |
-------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT               |          |     2 |  4004 |     2  (50)| 00:00:01 |
|*  1 |  COUNT STOPKEY                 |          |       |       |            |          |
|   2 |   VIEW                         |          |     2 |  4004 |     2  (50)| 00:00:01 |
|*  3 |    SORT ORDER BY STOPKEY       |          |     2 |  9282 |     2  (50)| 00:00:01 |
|   4 |     TABLE ACCESS BY INDEX ROWID| EMBED    |     2 |  9282 |     1   (0)| 00:00:01 |
|   5 |      VECTOR INDEX HNSW SCAN    | HNSW_IDX |     2 |  9282 |     1   (0)| 00:00:01 |
-------------------------------------------------------------------------------------------

Predicate Information (identified by operation id):
---------------------------------------------------

   1 - filter(ROWNUM<=2)
   3 - filter(ROWNUM<=2)



----------------------------------------------------------
       6307  recursive calls
         46  db block gets
       8839  consistent gets
         12  physical reads
        452  redo size
       1572  bytes sent via SQL*Net to client
        108  bytes received via SQL*Net from client
          2  SQL*Net roundtrips to/from client
        405  sorts (memory)
          0  sorts (disk)
          2  rows processed
```

HNSW索引を使っている様子が実行計画に表れています。
ちなみに上位何件を取得するか、を指定する下記構文ですが、索引を使わない場合と異なる記法になっています。

```sql
-- 索引未利用時
FETCH EXACT FIRST :P4_NUM ROWS ONLY
-- 索引利用時
FETCH APPROXIMATE FIRST 2 ROWS ONLY;
```

具体的には `EXACT` が `APPROXIMATE` になっています。
索引利用時はSQLの書き方にご注意ください。
ちなみにHNSW索引、IVF索引ともにですが、索引は検索精度を若干下げる代わりに、応答速度を高速化する手法です。

またこの状態でAPEXアプリから新規ファイルを登録しようとすると、以下のように `ORA-51928` が発生しエラーになります。
![WS000091.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33182/14ddf28e-df0c-dce2-2962-1359d892ae81.jpeg)

### IVF索引

IVF索引を利用するにあたり、初期化パラメータ等の設定は不要です。
以下のようにIVF索引を作成できます。

```sql
SQL> CREATE VECTOR INDEX ivf_idx ON embed (embedding) 
ORGANIZATION NEIGHBOR PARTITIONS 
DISTANCE COSINE 
WITH TARGET ACCURACY 95;
```

またIVF索引については、作成した表に対するDML実行の制限はありません。

以上がアプリ実装 後編でした。
本アプリの実装例が23aiを使ったRAGシステム構築の一助になりましたら幸いです。