初心者向け）Oracle Database 23ai を使って DB内でチャンク・Embed・AI Vector Searchしてみた！

## はじめに
本記事では、5月にいよいよ利用可能になったOracle Database 23ai を使って、AI Vector searchを試しています！
__私のようなPythonアレルギー疾患者の方も簡単にできるので是非トライしてみてください!__
試したことは以下。
- ファイルからテキストに変換
- テキストからチャンク分割
- チャンクしたテキストをベクトルデータに変換
- ベクトル検索（AI Vector Search)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3763355/e4f6b321-c376-76fc-5b70-872ce15d6d79.png)


これらの変換は便利すぎる関数セットによって全てDB内で行うことができます！
- ファイル→テキストに変換：UTL_TO_TEXTおよびDBMS_VECTOR_CHAIN
- テキストのチャンク：UTL_TO_CHUNKS
- チャンクのベクトル化：UTL_TO_EMBEDDINGS

お勉強のため関数の詳細についても本記事では紹介していきます！

それでは始めていきましょう！
## 1.前提条件
- Oracle Database 23ai Freeをインストールする済みであること 
    - 諸々込みで15分ぐらいでできるのですぐにやっちゃいましょう
※インストール方法については、こちらの記事で紹介しております！
- ChicagoのRegionはサブスクリプション済みであること
    - 現在Genarative AIを利用できるのがシカゴリージョンのみであるため

## 2.準備
PDFは何でも大丈夫なのですが、とりあえず早く進めたい方はこちらをご利用ください。
https://www.oracle.com/jp/a/ocom/docs/jp-investor-relations/corporate-governance-202209-jp.pdf

oracleユーザーに変更
```
sudo su - oracle
```
日本語のデータセットを使用するのでNLS_LANGの設定もしておきます。
```
export NLS_LANG=Japanese_Japan.AL32UTF8
```
wgetで上記のドキュメント取得
```
wget https://www.oracle.com/jp/a/ocom/docs/jp-investor-relations/corporate-governance-202209-jp.pdf
```
取り込んだPDF格納用のディレクトリを作ってコピーします。
```
mkdir -p /home/oracle/data/vec_dump
cp corporate-governance-202209-jp.pdf /home/oracle/data/vec_dump
```
### 取得したPDFの読み込み
SYSユーザーでPDB（freepdb1）に接続します。
```SQL
sqlplus sys@localhost:1521/freepdb1 as sysdba
```
docuserというユーザーを作って、必要な権限を与えていきます。
```SQL
grant connect, ctxapp, unlimited tablespace, create credential, create procedure, create table to docuser identified by docuser;
grant execute on sys.dmutil_lib to docuser;
grant create mining model to docuser;
```
DBMS_NETWORK_ACL_ADMIN.APPEND_HOST_ACEプロシージャでアクセス許可情報を追加していきます。

```SQL
 BEGIN
    DBMS_NETWORK_ACL_ADMIN.APPEND_HOST_ACE(
      host => '*',
      ace => xs$ace_type(privilege_list => xs$name_list('connect'),
                        principal_name => 'docuser',
                        principal_type => xs_acl.ptype_db));
  END;
  / 
```
サンプルPDFを格納するため、サーバー上にディレクトリを作成します。
```SQL
create or replace directory VEC_DUMP as '/home/oracle/data/vec_dump';
```
読み/書きできる権限をdocuserに付与してコミットします。
```SQL
grant read, write on directory VEC_DUMP to docuser;
commit;
```
docuserでSQLPLUSに接続して以下を設定
```SQL
sqlplus docuser/docuser@freepdb1
```
出力が見やすくなるのでこちらもセットしておきます。
```SQL
  SET ECHO ON
  SET FEEDBACK 1
  SET NUMWIDTH 10
  SET LINESIZE 80
  SET TRIMSPOOL ON
  SET TAB OFF
  SET PAGESIZE 10000
  SET LONG 10000
```
リレーショナルテーブル（documentation_tab）を作成して、先ほどのPDFを保存します。
```SQL
CREATE TABLE documentation_tab (id number, data blob);
INSERT INTO documentation_tab values(1, to_blob(bfilename('VEC_DUMP', 'corporate-governance-202209-jp.pdf')));
commit;
```

リレーショナルテーブル(documentation_tab)を作成し、そのテーブル内にPDFドキュメントを格納します。
```SQL
  -- 初回の実行では必要なし
  -- drop table documentation_tab purge;

  CREATE TABLE documentation_tab (id number, data blob);
  INSERT INTO documentation_tab values(1, to_blob(bfilename('VEC_DUMP', 'corporate-governance-202209-jp.pdf')));
  commit;
```
documentation_tabテーブルのdata列に格納されたLOBデータの長さを取得して見ます（Byte単位）
```SQL
 SELECT dbms_lob.getlength(t.data) from documentation_tab t;
 ```
 サンプル用PDFだとこのような出力になります。
 ```sql
DBMS_LOB.GETLENGTH(T.DATA)
  --------------------------
                      310454
 ```
 
 ## 3.PDFドキュメント→テキストへの変換
 テキストPDFドキュメントをテキスト形式に変換します。
 UTL_TO_TEXT関数を使うとドキュメント（MS Word、HTML、PDFなど）をテキストに変換することがきます。
 
 ↓UTL_TO_TEXT関数詳細
 https://docs.oracle.com/en/database/oracle/oracle-database/23/vecse/chainable-utility-functions-and-common-use-cases.html

 ```
SELECT dbms_vector_chain.utl_to_text(dt.data) from documentation_tab dt;
   ```
出力結果
 ```sql
DBMS_VECTOR_CHAIN.UTL_TO_TEXT(DT.DATA)
--------------------------------------------------------------------------------
  コーポレートガバナンス
  CORPORATE
  GOVERNANCE
  ...
  1行が選択されました。
  ```
## 4.チャンク分割
### UTL_TO_CHUNKSについて
  テキストをチャンク (単語、文、または段落の一部) に分割します。データベース内にベクトル化したいデータがある場合この関数を実行するのみでチャンク分割できてしまいます！！

今回は下記のように設定しました。
  ```SQL
  SELECT ct.* from documentation_tab dt, dbms_vector_chain.utl_to_chunks(dbms_vector_chain.utl_to_text(dt.data), json('{"max": " 400", "overlap": "20%", "language": "JAPANESE", "normalize": "all"}')) ct;
```
パラメータ部分は下記のように調整してます。
```SQL
{"max": " 400", "overlap": "20%", "language": "JAPANESE", "normalize": "all"}
```
### UTL_TO_CHUNKS 詳細
少し調査したので書いておきます。
- JSON形式でパラメータを指定します。
```
  {
    "by”           :     mode,
    "max”          :     max,
    "overlap"      :     overlap,
    "split"        :     split_condition,
    "custom_list"  :     [ split_chars1, ... ],
    "vocabulary"   :     vocabulary_name,
    "language"     :     nls_language,
    "normalize"    :     normalize_mode,
    "norm_options" :     [ normalize_option1, ... ],
    "extended"     :     boolean
  }
```

上記の中でもよく設定しそうなパラメータをまとめました。
- by：任意の分割単位を指定
    - デフォルト：by words
    - 選択可能な値
        - by characters：文字数で計算
        - by words：単語数で計算
             ※単語間に空白が入る言語が対象
             ※日本語、中国語などは1文字が1単語としてカウント
        - by vocabulary：語彙のトークン数を計算
            - create_vocabulary関数で語彙登録できる
- max：上記の単位で最大値を設定してデータを分割
    - デフォルト：100
    - 指定可能な値
        - (by characters) 50~4000
        - (by words) 10~1000
        - (by vocabulary) 10~1000
- overlap：チャンクに含まれる前後の文字数
    - デフォルト：0
    - 指定可能な値
        - maxの5%~ 20%
- normalize：チャンクを最適化できるように前処理や後処理を行う
    - デフォルト：none
    - 選択可能な値
        - none：処理しない
        - all：マルチバイトの句読点をシングルバイトに正規化
        - punctuation：空白を最低限、改行やスペース、タブを削除
        - widechar：マルチバイト数値とローマ字をシングルバイトに変換

出力結果↓
```
  COLUMN_VALUE
  --------------------------------------------------------------------------------
  {"chunk_id":1,"chunk_offset":14,"chunk_length":619,"chunk_da
  ta":"コーポレートガバナンス \nCORPORATE \nGOVERNANC
  E \nOracle \nCorporationJapan\n\n最終更新日:2
  022年9月13日 \n日本オラクル株式会社\n\n代表執行役 法務室 マネージ
  ング・カウンシル 内海 寛子 \n問合せ先:IR部 03-6834-6666 \
  n証券コード: \n4716\n\nhttp://www.oracle.com/
  jp/corporate/investor-relations/index.ht
  ml\n\n当社のコーポレート・ガバナンスの状況は以下のとおりです。\n\n
  Ⅰ\n\nコーポレート・ガバナンスに関する基本的な考え方及び資本構成、企業属性その他
  の基本情報\n\n1.基本的な考え方 \n当社は継続的に企業価値を高めていく
  上で、コーポレート・ガバナンスの確立は重要な課題であると考え、すべてのステークホ
  ルダーに対する経 \n営の責任を果たすべく、日本の法制度等に合致し、さらに親会社で
  あるオラクル・コーポレーションのコーポレート・ガバナンス方針に基づいた体制 \nの
  整備に努めております。 \nまた、従業員に対しては全世界のオラクル
  ・グループ共通の「Oracle \nCode \nofEthicsand \nBusiness
  \nConduct(倫理とビジネス行動規範に関する規"}
  ...
  {"chunk_id":62,"chunk_offset":29600,"chunk_length":471,"chun
  k_data":"業界経験 \nテクノロジー\n\nデータセキュリティ・\n\n
  リスクマネージメント\n\nファイナンス\n\n法務・コンプライア\n\nン
  ス・コーポレートガバナンス\n\nエグゼクティブリーダーシップ・タレント\n\nディ
  ベロップメント・ダイバーシティ&イ\n\nンクルージョン\n\n三澤 \n智
  光 \n〇 \n〇 \n〇 \n〇 \n〇\n\nクリシュナ・シヴァラマン\n\n〇
  \n〇 \n〇 \n〇\n\nギャレット・イルグ \n〇 \n〇 \n〇 \n
  〇 \n〇\n\nヴィンセント・エス・グレリ\n\n〇 \n〇 \n〇 \n〇\
  n\nキンバリー・ウーリー \n〇 \n〇 \n〇 \n〇\n\n藤森 \n義明
  \n〇 \n〇\n\nジョン・エル・ホール \n〇 \n〇 \n〇 \n〇\n
  \n夏野 \n剛 \n〇 \n〇 \n〇\n\n⿊⽥ \n由貴子 \n〇 \n〇
  "}


  62行が選択されました。
```

ちゃんと指定した値でチャンク分割できていそうです！

## 5.ベクトル変換の下準備
### クレデンシャルの作成
OCIのGenAIをAPIコールしてベクトル変換処理をするので、APIアクセスのためのクレデンシャルを作成する必要があります。
以下を取得してください。

- user_ocid - キーペアが追加されるユーザーのOCID
- tenancy_ocid - テナンシのOCID
- compartment_ocid - 利用するコンパートメントのOCID
- private_key - ダウンロードした秘密キーのコピー
    - 秘密キーと公開キーはどちらもPEMフォーマットである必要があります
    (SSH-RSAフォーマットではありません)

もし各OCIDの取得方法が分からない方は [こちら](https://docs.oracle.com/ja-jp/iaas/Content/API/Concepts/apisigningkey.htm)も参考になると思います。
テキストエディタなどにコピーしてクレデンシャル作成に必要な値を代入していきます。

```sql
  -- 初回の実行では必要なし
  -- exec dbms_vector.drop_credential('OCI_CRED');
  declare
  jo json_object_t;
  begin
  -- create an OCI credential
  jo := json_object_t();
  jo.put('user_ocid', 'user ocid value');
  jo.put('tenancy_ocid', 'tenancy ocid value');
  jo.put('compartment_ocid', 'compartment ocid value');
  jo.put('private_key', 'private key value');
  jo.put('fingerprint', 'fingerprint value');
  dbms_output.put_line(jo.to_string);
  dbms_vector.create_credential(
    credential_name => 'OCI_CRED',
    params          => json(jo.to_string));
  end;
  ```
  実行例(一部マスクしてます)
  priaveteキーは値を直で貼るのでご注意を・・・

```sql
exec dbms_vector.drop_credential('OCI_CRED');
  declare
  jo json_object_t;
  begin
 
  jo := json_object_t();
  jo.put('user_ocid', 'ocid1.user.oc1..asnejq');
  jo.put('tenancy_ocid', 'ocid1.tenancy.oc1..atkguca');
  jo.put('compartment_ocid', 'ocid1.compartment.oc1..adbpq');
  jo.put('private_key', '-----BEGIN PUBLIC KEY----
  /5iUW0sjks...................................................
  ..........................................0+ULpOi1WBq+AnZog/0X=
  -----END PUBLIC KEY-----');
  jo.put('fingerprint', '1c:a5:4d:62:2d:ac:42:d2:07:0a:2f:e0:9a:ce:16:ae');
  dbms_output.put_line(jo.to_string);
  dbms_vector.create_credential(
    credential_name => 'OCI_CRED',
    params          => json(jo.to_string));
  end;
```
### embed_genai_paramsについて
OCI GenAIサービスを利用するためのパラメータを設定します。
設定できるパラメーターは以下の通りです。
- provider：Embeddingのために使用するプロバイダを指定
    - デフォルト：Database
    - 選択可能な値
        - Databese：Datbaseに取り込まれているデフォルトのONNXモデル
        - User：ユーザー自身のスキーマにロードしたONNXモデル
        - OCIgenai | HaggingFace：サポートされている外部プロバイダを指定、それぞれのONNXモデルをDB内にロードする
- credidential_name ：クレデンシャルの名前  - デフォルト：自身のクレデンシャルの名前
    - 選択可能な値
        - schema.credential_name：REST APIコールを行うためのアクセスを可能にするクレデンシャル
        - 事前にdbms_vector.create_credentialを実行しておき、ここで作成したクレデンシャル名を指定
- url：外部プロバイダーのAPIコールに使用するエンドポイントのURL
- model：モデル名
    - 選択可能な値
        - schema.model_name
        - プロシージャ―呼び出し元の元スキーマが呼び出される（スキーマ修飾されていない場合）

今回はembedモデルにcohere.embed-multilingual-v3.0を使用していきます。
```SQL
  var embed_genai_params clob;
  exec :embed_genai_params :=
  '{"provider": "ocigenai",
  "credential_name": "OCI_CRED", 
  "url": "https://inference.generativeai.us-chicago-1.oci.oraclecloud.com/20231130/actions/embedText", 
  "model": "cohere.embed-multilingual-v3.0"}';
  ```
  続いて、テストとしてhelloをembeddingしてみます。
```SQL
select et.* from dbms_vector_chain.utl_to_embeddings('hello', json(:embed_genai_params)) et;
```

以下のように表示されたらEmbedding成功しています！
```SQL
  COLUMN_VALUE
  --------------------------------------------------------------------------------
  {"embed_id":"1","embed_data":"hello","embed_vector":"[0.0035
  934448,0.028701782,0.031051636,-0.001415
  ...
  1行が選択されました。
  ```
### 表の作成とEmbedding
それではテストが成功したので先ほどのテキストをチャンク分割、embeddingし、doc_chunks表を作成していきます。
  ```SQL
  create table doc_chunks as
  with t_chunk as (
  select dt.id as doc_id, et.chunk_id as embed_id, et.chunk_data as embed_data
  from
    documentation_tab dt,
      dbms_vector_chain.utl_to_chunks(dbms_vector_chain.utl_to_text(dt.data), 
      json('{"max": "400", "overlap": "20%", "language": "JAPANESE", "normalize": "all"}')) t, JSON_TABLE(t.column_value, '$[*]' COLUMNS (chunk_id NUMBER PATH '$.chunk_id', chunk_data VARCHAR2(4000) PATH '$.chunk_data')) et
  where dt.id = 1),
  t_embed as (
  select dt.id as doc_id, rownum as embed_id, to_vector(t.column_value) as embed_vector
  from
    documentation_tab dt,
    dbms_vector_chain.utl_to_embeddings(
      dbms_vector_chain.utl_to_chunks(dbms_vector_chain.utl_to_text(dt.data), 
      json('{"max": "400", "overlap": "20%", "language": "JAPANESE", "normalize": "all"}')), json('{"provider": "ocigenai", "credential_name": "OCI_CRED", "url": "https://inference.generativeai.us-chicago-1.oci.oraclecloud.com/20231130/actions/embedText", "model": "cohere.embed-multilingual-v3.0"}')) t
  where dt.id = 1)
  select t_chunk.doc_id doc_id, t_chunk.embed_id as embed_id, t_chunk.embed_data as embed_data, t_embed.embed_vector as embed_vector
  from t_chunk
  join t_embed on t_chunk.doc_id = t_embed.doc_id and t_chunk.embed_id = t_embed.embed_id;
  ```
cohere.embed-multilingual-v3.0をAPIコールし、Embeddingしたデータを格納した表を作成できました！


----------------------------------------------
### ↑上記 "create table doc_chunks..." SQL 文の解説
SQL文のサブクエリ、列別名やら使っていて複雑なので、内部で何やっているのか分からない方に向けて解説です。（SQL Silver所持私、半泣き）

このSQL文は①～③の順に読んでいくと分かりやすいです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3763355/a33e47ba-15ca-4250-8373-caaeebda767d.png)

イメージしやすいよう図でも表します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3763355/00b2d90c-c2df-3065-a5e9-477c5eee0182.png)



①まずは一時テーブル t_chunkとして（全て列別名です）
- doc_id ：読み込んだドキュメントのID
- embed_id ：チャンクした文章のID
- embed_data：チャンクしたテキスト

を定義しています。

ドキュメントIDは読み込んだPDFの個数なので今回は dt.id =doc_id= 1です。

②次に一時テーブルt_embed表として
- dt_id ：読み込んだドキュメントのID
- embed_id ：チャンクした文章のID
- embed_vector：チャンクしたテキストをベクトル変換したデータ

を定義しています。

③ 最後に①～②で定義した一時テーブルをもとに
- dt_id *：複合主キー
- embed_id * ：複合主キー
- embed_data          　
- embed_vector

で結合してdoc_chunks表を作成しています。
なんとなく理解いただけたでしょうか。。。



  ## 6.ベクトル検索の実行
 こちらでは表doc_chuncksに対してdbms_vector_chain.utl_to_embeddingsを使い、"コーポレートガバナンスに対する基本的な考え方"を問い合わせます。 
  ```SQL
SELECT doc_id, embed_id, embed_data
  FROM doc_chunks
  ORDER BY vector_distance(embed_vector , (SELECT to_vector(et.embed_vector) embed_vector
  FROM
  dbms_vector_chain.utl_to_embeddings('コーポレート・ガバナンスに関する基本的な考え方', JSON(:embed_genai_params)) t,
  JSON_TABLE ( t.column_value, '$[*]'
  COLUMNS (
  embed_id NUMBER PATH '$.embed_id', embed_data VARCHAR2 ( 4000 ) PATH '$.embed_data', embed_vector CLOB PATH '$.embed_vector'
  ))et), COSINE)
  FETCH EXACT FIRST 4 ROWS ONLY;
```

こちらも簡単にこのSQL文解説いたしますと、

1. `dbms_vector_chain.utl_to_embeddings('コーポレート・ガバナンスに関する基本的な考え方', JSON(:embed_genai_params)) t,......))et)`にて質問文をベクトル化
2. `vector_distance(...,COSINE)`表内のembed_vector（＝ベクトルデータ）と質問文をCOSINE類似度によって比較
3. `ORDER BY ...
`にて表内で類似度が高い順にembed_vectorを並び替え
4.  `SELECT doc_id, embed_id, embed_data....FETCH EXACT FIRST 4 ROWS ONLY`によって
↑で並び替えた上位4つのembed_vector（＝ベクトルデータ）に対応する列 embed_data（＝テキスト）をフェッチ

では、実際に実行してみます。

```SQL
    DOC_ID   EMBED_ID
  ---------- ----------
  EMBED_DATA
  --------------------------------------------------------------------------------
          1         15
  値向上と、循環型経済の実現に向けITの側面から支援します。(5)ビジネスパートナーと
  のエコシステムを強化当社とステークホルダーの強みを合
  わせ、イニシアチブを推進します。
  ...
  よび会計監査人は、それぞれ監査委員と適宜会合を持
  ち、当社が対処すべき課題、監査委員会による監査の環境整備の状況、監査上の重要課題
  等について意見を交換し、代表執行役、会計監査人
  および監査委員の間で相互認識を深める体制をとっております。


  4行が選択されました。
  ```

EMBED_DATAが4行選択されていることが分かります。
これで Oracle Database 23ai を使って DB内でチャンク・Embed・AI Vector Searchすることができました！！

## 今後やりたいこと
今回はUTL_TO_CHUNKS関数でチャンク分割の単位を単語数としましたが、
チャンクを文字数や語彙数にするとベクトル検索の精度は変わるのか検証していきます。