2024年5月27日月曜日

Oracle Database 23aiのベクトル検索を使ったOracle APEXアプリを作る

Oracle Database 23aiのベクトル検索を使うAPEXのサンプル・アプリを作ってみました。以下の処理を実装しています。
  1. ファイルをアップロードする。
  2. アップロードしたファイルからテキストを取り出す。
  3. 取り出したテキストをチャンクに分割する。
  4. 分割したチャンクよりエンべディングを生成する。
  5. 問合せ文字列から、類似度の高いチャンクを一覧する。
  6. 問合せ文字列と類似度の高いチャンクをプロンプトに組み込み、言語モデルを使って回答文を生成する。
アプリケーションは基本的なワークフローの実装を目的としていて、実用性や精度は求めません。すべてローカルで実行し、費用の発生やセキュリティ上の心配は不要にします。

作成したアプリケーションは以下のように動作します。


アプリケーションの作成と実行には、以下の環境を使っています。
  1. エンべディングの生成と言語モデルの実行に、LM Studioを使います。
  2. 言語モデルとして、mmnga/ELYZA-japanese-Llama-2-7b-fast-instruct-ggufをロードします。
  3. エンべディングには、nomic-ai/nomic-embed-text-v1.5-GGUF/nomic-embed-text-v1.5.f16.ggufをロードします。
  4. Oracle Database 23aiはMacbook上のDocker/Colimaのx86_64エミュレーション環境で実行します。非常に遅いです。
  5. Oracle APEXは、23.2をOracle Database 23ai上で実行します。
以下より、サンプル・アプリケーションの作成手順を紹介します。

最初にアップロードしたファイルと、そのファイルに含まれる文字列を分割したチャンクを保存する表を作成します。

ファイルを保存する表はEBMJ_FILES、チャンクを保存する表はEBMJ_CHUNKSとして作成します。ファイルを保存する表EBMJ_FILESには、ファイルから取り出した文字列を列TEXTに保存します。チャンクを保存する表EBMJ_CHUNKSには、チャンクから生成したエンべディングを列EMBED_VECTORに保存します。

クイックSQLの以下のモデルを元に、これらの表を作成します。
# prefix: ebmj
files
    title   varchar2(200) /nn
    content file
    text    clob
    chunks /cascade
        chunk_id num /nn
        chunk_offset num /nn
        chunk_length num /nn
        chunk_data clob
        embed_vector vector

レビューおよび実行を行います。


Oracle APEX 23.2のクイックSQLは、ベクトル型を認識しません。そのため、列EMBED_VECTORについては、以下の記述に置き換えます。

embed_vector vector

実行するDDLは以下になります。

create table ebmj_files (
id number generated by default on null as identity
constraint ebmj_files_id_pk primary key,
title varchar2(4000 char) not null,
content blob,
content_filename varchar2(255 char),
content_mimetype varchar2(255 char),
content_charset varchar2(255 char),
content_lastupd date,
text clob
);
create table ebmj_chunks (
id number generated by default on null as identity
constraint ebmj_chunks_id_pk primary key,
file_id number constraint ebmj_chunks_file_id_fk
references ebmj_files on delete cascade,
chunk_id number not null,
chunk_offset number not null,
chunk_length number not null,
chunk_data clob,
embed_vector vector
);
-- table index
create index ebmj_chunks_i1 on ebmj_chunks (file_id);


後はDDLを実行し、2つの表を作成します。


アプリケーションの作成を開始します。

名前Sample Vector Searchとし、アプリケーションの作成ウィザードを使用します。


アプリケーション作成ウィザードが開きます。

ページの追加をクリックし、表EBMJ_FILESフォーム付き対話モード・レポートのページを追加します。


対話モード・レポートを選択します。


ページ名Filesとします。表またはビューとしてEBMJ_FILESを選択し、フォームを含めるチェックを入れます。

ページの追加をクリックします。


EBMJ_FILESをソースとしたフォーム付き対話モード・レポートのページが追加されます。


同様の手順で、表EBMJ_CHUNKS対話モード・レポートのページを追加します。手作業での編集は行わないため、フォームは付けません。

ページ名Chunks表またはビューとしてEBMJ_CHUNKSを選択します。

ページの追加をクリックします。


以上のページ構成で、アプリケーションの作成をクリックします。


アプリケーションが作成されます。

これから、それぞれのページに修正を加えていきます。

最初にページ番号の表EBMJ_FILES対話モード・レポートを修正します。

ページ・デザイナで開きます。


アップロードしたファイルから取り出したテキストを保存する列TEXTは長文なので、レポートでは表示できません。コメント・アウトして変更を保存します。


ページ番号の、表EBMJ_FILESを対象としたフォームのページを開きます。

こちらも列TEXTをソースとしたページ・アイテムP3_TEXTコメント・アウトします。


アップロードしたファイルより、テキストの取り出し、チャンク分割、エンべディングの生成までを行うプロセスを実装します。

左ペインでプロセス・ビューを表示します。プロセス作成します。

識別名前Extract, Chunking and Embeddingとします。タイプとして実行チェーンを選択します。

サーバー側の条件タイプリクエストは値に含まれるを選択し、としてCREATE SAVEを指定します。


作成した実行チェーンに子プロセスを追加します。

識別名前ExtractソースPL/SQLコードとして以下を記述します。アップロードしたファイルを列CONTENTから読み出し、取り出したテキストを列TEXTに保存します。

declare
C_PARAMS constant json := json(
json_object(
key 'plaintext' value true
,key 'charset' value 'UTF8'
)
);
C_FILE_ID constant number := :P3_ID;
l_content blob;
l_text clob;
begin
select content into l_content from ebmj_files where id = C_FILE_ID;
l_text := dbms_vector_chain.utl_to_text(
data => l_content
,params => C_PARAMS
);
update ebmj_files set text = l_text where id = C_FILE_ID;
end;


子プロセスを追加します。識別名前ChunkingソースPL/SQLコードとして以下を記述します。ファイルから取り出したテキストをチャンクに分割し、表EBMJ_CHUNKSに保存します。ファンクションVECTOR_CHUNKSに与えているパラメータについては、調整の余地が多分にあります。

declare
C_FILE_ID constant number := :P3_ID;
l_text clob;
begin
select text into l_text from ebmj_files where id = C_FILE_ID;
delete from ebmj_chunks where id = C_FILE_ID;
insert into ebmj_chunks(file_id, chunk_id, chunk_offset, chunk_length, chunk_data)
select C_FILE_ID file_id, rownum chunk_id, t.chunk_offset, t.chunk_length, t.chunk_text chunk_data
from vector_chunks(
l_text
by words
max 300
overlap 0
split by recursively
language japanese
normalize all
) t;
end;


子プロセスを追加します。識別名前EmbeddingソースPL/SQLコードに以下を記述します。チャンクからエンべディングを生成し、表EBMJ_CHUNKSに保存します。

declare
C_FILE_ID constant number := :P3_ID;
l_chunks vector_array_t;
l_chunk json_object_t;
l_vectors vector_array_t;
l_embedding json_array_t;
begin
-- チャンクの配列を作成する
l_chunks := vector_array_t();
for r in (
select chunk_id, chunk_offset, chunk_length, chunk_data
from ebmj_chunks where file_id = C_FILE_ID order by chunk_id asc
)
loop
l_chunks.extend;
l_chunk := json_object_t();
l_chunk.put('chunk_id', r.chunk_id);
l_chunk.put('chunk_offset', r.chunk_offset);
l_chunk.put('chunk_length', r.chunk_length);
l_chunk.put('chunk_data', r.chunk_data);
apex_debug.info('l_chunks(%s) = %s', r.chunk_id, r.chunk_data);
l_chunks(r.chunk_id) := l_chunk.to_clob();
end loop;
-- チャンクの配列からベクトル(エンべディング)の配列を生成する。
l_vectors := dbms_vector_chain.utl_to_embeddings(
data => l_chunks
,params => JSON(json_object(
key 'provider' value 'OpenAI'
,key 'credential_name' value 'OPENAI_CRED'
,key 'url' value 'http://host.docker.internal:8080/v1/embeddings'
,key 'model' value 'text-embedding-3-small'
,key 'batch_size' value 10
,key 'transfer_timeout' value 60
)
)
);
-- エンべディングを保存する
for i in l_vectors.first .. l_vectors.last
loop
apex_debug.info('l_vectors(%s) = %s', i, l_vectors(i));
update ebmj_chunks set embed_vector = to_vector(l_vectors(i))
where file_id = C_FILE_ID and chunk_id = i;
end loop;
end;


以上で、ファイルのアップロードからエンべディングを生成するまでの、一連の処理が実装できました。

アプリケーションを実行し、いくつかファイルをアップロードします。

その前にページ番号の表EBMJ_CHUNKSの対話モード・レポートより、列EMBED_VECTORコメント・アウトします。Oracle APEX 23.2のレポートでは、ベクトル型を扱うことができません。


アプリケーションを実行し、Filesのページを開きます。

作成をクリックします。


Titleを入力し、アップロードするファイルを選択します。

作成をクリックします。


ファイルがアップロードされます。


Chunksを開き、分割されたチャンクを確認します。エンべディングは表示されませんが、エラーが発生していなければ正常に生成されています。また、LM Studioのサーバー・ログからも確認できます。

単純にファイルからテキストを取り出して、単語数(日本語だとほとんど文字数)でチャンクに分割すると、さすがに精度がでない感じで分割されるな、とは思います。


ページ番号4の表EBMJ_CHUNKS対話モード・レポートで、問合せ文字列との類似検索を実装します。

類似検索を行う文字列を入力するページ・アイテムを作成します。

識別名前P4_TEXTタイプテキスト・フィールドラベルTextとします。

設定[Enter]を押すと送信オンにします。


類似検索を行うように、対話モード・レポートのソースを置き換えます。

ソースタイプSQL問合せに変更し、SQL問合せとして以下を記述します。FETCH句の部分が決めうちになっています。類似したチャンクを検索するにあたって、調整が必要になるでしょう。

select
id
,file_id
,chunk_id
,chunk_offset
,chunk_length
,chunk_data
from ebmj_chunks
order by
vector_distance(
(
select
dbms_vector_chain.utl_to_embedding(
data => :P4_TEXT
,params => JSON(json_object(
key 'provider' value 'OpenAI'
,key 'credential_name' value 'OPENAI_CRED'
,key 'url' value 'http://host.docker.internal:8080/v1/embeddings'
,key 'model' value 'text-embedding-3-small'
,key 'transfer_timeout' value 60
)
)
)
)
,embed_vector
) asc
fetch first 2 partitions by file_id, 2 rows only
ページ・アイテムP4_TEXTが空白のときは、SELECT文でエラーが発生します。そのため、サーバー側の条件タイプアイテムはNULLではないを選択し、アイテムP4_TEXTを指定します。


以上で、ページ・アイテムP4_TEXTに入力した文字列に類似しているチャンクを検索できます。

選択したエンべディング・モデルが日本語に対応していないのだろうと思いますが、あまり関連の無さそうなチャンクが検索されています。LM Studioで利用可能なエンべディング・モデルに選択肢がほぼ無いので、仕方がありません。実用を考えると、OpenAIやCohereといった外部サービスを呼び出す必要があるでしょう。


最後にホーム・ページに、問合せ文字列から類似検索したチャンクを含めたプロンプトを作成し、言語モデルを呼び出して回答文を生成する機能を実装します。

ページ・デザイナで、ページ番号ホーム・ページを開きます。

Body以下にあるリージョンのページ・ナビゲーションを削除します。


問合せ文字列を入力するページ・アイテムを作成します。

識別名前P1_QUERYタイプテキスト・フィールドラベルQueryとします。

設定[Enter]を押すと送信オンにします。


言語モデルを呼び出して生成した回答文を表示するページ・アイテムを作成します。

識別名前P1_ANSWERタイプテキスト領域ラベルAnswerとします。セッション・ステートデータ型としてCLOBを選択します。


デバッグのために、回答文の生成に使用したプロンプトを表示するページ・アイテムを作成します。

識別名前P1_PROMPTタイプテキスト領域ラベルPromptとします。セッション・ステートデータ型としてCLOBを選択します。


プロセス・ビューを開き、問合せ文字列から回答を生成するプロセスを作成します。

識別名前Generate Answerとします。タイプコードの実行ソースPL/SQLコードとして、以下を記述します。

declare
l_vector_q vector;
l_prompt clob;
l_answer clob;
l_norm clob;
begin
-- 問合せ文字列のエンべディングを取得する
l_vector_q := dbms_vector_chain.utl_to_embedding(
data => :P1_QUERY
,params => JSON(json_object(
key 'provider' value 'OpenAI'
,key 'credential_name' value 'OPENAI_CRED'
,key 'url' value 'http://host.docker.internal:8080/v1/embeddings'
,key 'model' value 'text-embedding-3-small'
,key 'transfer_timeout' value 60
)
)
);
-- プロンプトを生成する
l_prompt := l_prompt || '以下に与える情報を元に、最後の質問に回答してください。';
l_prompt := l_prompt || apex_application.CRLF;
-- ベクトル検索を行って、プロンプトに検索されたチャンクを埋め込む
for r in (
select chunk_data
from ebmj_chunks
order by vector_distance(l_vector_q, embed_vector) asc
fetch first 2 partitions by file_id, 2 rows only
)
loop
l_prompt := l_prompt || '--------------------------';
l_prompt := l_prompt || apex_application.CRLF;
l_norm := r.chunk_data;
-- CRとLFを取り除く。
l_norm := replace(l_norm, chr(10));
l_norm := replace(l_norm, chr(13));
l_prompt := l_prompt || l_norm;
l_prompt := l_prompt || apex_application.CRLF;
end loop;
l_prompt := l_prompt || '--------------------------';
l_prompt := l_prompt || apex_application.CRLF;
l_prompt := l_prompt || :P1_QUERY;
-- 回答を生成する
apex_debug.info(l_prompt);
utl_http.set_body_charset('utf-8');
l_answer := dbms_vector_chain.utl_to_generate_text(
data => l_prompt
,params => JSON(json_object(
key 'provider' value 'OpenAI'
,key 'credential_name' value 'OPENAI_CRED'
,key 'url' value 'http://host.docker.internal:8080/v1/chat/completions?'
,key 'model' value 'gpt-3.5-turbo'
,key 'transfer_timeout' value 160
,key 'max_tokens' value 260
-- ,key 'temerature' value 1.0
)
)
);
:P1_ANSWER := l_answer;
-- デバッグ用にプロンプトを表示する
:P1_PROMPT := l_prompt;
end;

サーバー側の条件タイプアイテムはNULLではないを選択し、アイテムとしてP1_QUERYを指定します。


以上でアプリケーションは完成です。アプリケーションを実行すると、記事の先頭のGIF動画のように動作します。

ほとんど質問と関連のないチャンクがプロンプトに含まれているにもかかわらず、それっぽい回答文が生成されるので、少々驚きました。言語モデルにELYZAを選択しているからかもしれません。

今回作成したAPEXアプリケーションのエクスポートを以下に置きました。
https://github.com/ujnak/apexapps/blob/master/exports/sample-vector-search.zip

Oracle APEXのアプリケーション作成の参考になれば幸いです。