LangChain で Oracle ベクトル索引の効果を検証


[Oracle Database 23ai](https://www.oracle.com/database/23ai/)（以下，OracleDB）がリリースされ，ベクトルデータを格納できるようになりました．また，LangChain の Community パッケージ内に OracleDB をベクタストアとして操作するライブラリが提供されています．本記事では，[LangChain](https://www.langchain.com/) を利用して OracleDB にベクタデータを格納するスクリプトを記載し，それらについて説明します． 以下の内容を試してみました．

1. OracleDB から文書データの読込み
2. LangChain Document オブジェクトの作成
3. OracleDB のベクタストア構築
4. 厳密的な類似検索
5. ベクトル索引と近似的な類似検索

Google Colaboratory（以下，Colab）のノートブックを作成していますので，すぐに実行したい方は以下のノートブックを活用してください！

https://colab.research.google.com/drive/1YoLA26Rm1IJ7rp6xWcg1y_1qwFGDGYLr?usp=sharing

:::note warn
注意事項
OracleDB のインスタンスは必要です！インスタンスを準備したい方は [こちら](https://qiita.com/ryotayamanaka/items/156932a48e65d3ddc5ac) を参照してみてください．
:::

## 準備

本記事を試すために必要な準備を実行環境とデータに関する準備に分けて説明します．

### 実行環境

LangChain を扱いますので Python の実行環境を用意する必要はありますが，簡単に Python の REPL 環境を利用できる Colab を扱います．また，Oracle Database 23ai のインスタンスが必要になるのでそちらも準備しておきます．

- Python 実行環境（Colab）
- Oracle Database 23ai （自前で準備）

Oracle Cloud Infrastructure 上で OracleDB を立ち上げる方法は にまとめてありますのでご参照ください．
Docker を利用して無償版の OracleDB を立ち上げる方法は [こちらの記事](https://qiita.com/ryotayamanaka/items/156932a48e65d3ddc5ac) にまとめてあります。今回はベクトル索引で使用するメモリが多かったため，無償版ではなく Oracle Base Database Service を使用しました．

### プログラム・データの準備

ベクタストアに格納するベクトルデータを準備します．本記事では，Wikipedia 文書を Open AI の [Embeddings API](https://platform.openai.com/docs/guides/embeddings) を用いたテキスト埋込みによりベクトルデータを作成します．Wikipedia 文書を JSON データに変換し OracleDB に格納する [記事](https://qiita.com/ryotayamanaka/items/02a2efdcb74cac280b3e) でダンプデータを用意していますので，実践したい場合はそちらを活用してください．

提供している Wikipedia 文書の JSON スキーマは下記の通りです．

```json:WikipediaのJSONスキーマ
{
  "id":    string,
  "revid": string,
  "url":   string,
  "title": string,
  "text":  string
}
```

## プログラムと実行結果の説明

本節では，Colab に記述しているスクリプトとその実行結果について説明します．Colab ノートブックは次のリンクからアクセスできます．

https://colab.research.google.com/drive/1YoLA26Rm1IJ7rp6xWcg1y_1qwFGDGYLr?usp=sharing

### 必要な Python パッケージ

今回利用する Python パッケージは以下の通りです．

<dl>
  <dt>oracledb</dt>
  <dd>OracleDB のクライアントライブラリ</dd>

  <dt>langchain</dt>
  <dd>LangChain のコア機能を提供する基本パッケージ</dd>

  <dt>langchain_core</dt>
  <dd>LangChain の中心的なロジックと構造を提供するパッケージ</dd>

  <dt>langchain_community</dt>
  <dd>コミュニティが提供する LangChain の追加機能を含むパッケージ</dd>

  <dt>langchain-openai</dt>
  <dd>OpenAI の API との連携をサポートする LangChain パッケージ</dd>

  <dt>tqdm</dt>
  <dd>バッチ処理プログレスバーの表示ライブラリ</dd>
</dl>

次の [チャンク](https://colab.research.google.com/drive/1YoLA26Rm1IJ7rp6xWcg1y_1qwFGDGYLr?authuser=6#scrollTo=Zhb9IF8yp8Cj&line=1&uniqifier=1) を実行することでインストールでき，Colab のセッションが切れるまで利用できます．

```bash:Colab
%%bash
pip install \
  oracledb \
  langchain \
  langchain_community \
  langchain_core \
  langchain-openai \
  tqdm
```

ノートブックの実行に必要なパッケージをインポートするために下記の[チャンク](https://colab.research.google.com/drive/1YoLA26Rm1IJ7rp6xWcg1y_1qwFGDGYLr?authuser=6#scrollTo=mGZIQemVp4UE&line=4&uniqifier=1)を実行しておきましょう．

```python:Colab
# Colab secret data access
from google.colab import userdata

# Python standard library
import time
import array

# Progress bar
from tqdm import tqdm

# OracleDB client
import oracledb

# LangChain
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import oraclevs
from langchain_community.vectorstores.oraclevs import OracleVS
from langchain_community.vectorstores.utils import DistanceStrategy
from langchain_core.documents import Document

from langchain.text_splitter import CharacterTextSplitter
from langchain.schema.output_parser import StrOutputParser
from langchain.schema.runnable import RunnablePassthrough
from langchain_core.prompts import PromptTemplate

from langchain_openai import ChatOpenAI
```

### Colab シークレットの設定

データベースシステムへのアクセスや Open AI の API を利用するため，ユーザ名・パスワード，API キーなど，他人と共有したくない情報を扱います．そういった場合，Colab ではシークレットを設定することでノートブックを共有しても，秘匿にしたい情報まで共有せずに済みます．

シークレットの設定は Colab の UI 左メニューバーに鍵アイコンがあり，シークレットの名前・値を作成します．

<dl>
  <dt>DATABASE_USER</dt>
  <dd>OracleDB に接続するためのユーザー名</dd>

  <dt>DATABASE_PASSWORD</dt>
  <dd>OracleDB に接続するためのパスワード</dd>

  <dt>DATABASE_HOST</dt>
  <dd>OracleDB のホスト名またはIPアドレス</dd>

  <dt>DATABASE_PORT</dt>
  <dd>OracleDB に接続するためのポート番号</dd>

  <dt>DATABASE_SERVICE</dt>
  <dd>OracleDB のサービス名または SID </dd>

  <dt>OPENAI_API_KEY</dt>
  <dd>OpenAI API にアクセスするための API キー</dd>
</dl>

Python コードで `userdata.get(シークレット名)` を呼び出すと指定したシークレットの値を取得できます．

### OpenAI Embeddings API の設定

OpenAI の Embeddings API を利用する場合，環境変数 `OPENAI_API_KEY` を設定しておく必要があります [(Colab)](https://colab.research.google.com/drive/1YoLA26Rm1IJ7rp6xWcg1y_1qwFGDGYLr?authuser=6#scrollTo=N4J8_6wRG-Cy&line=1&uniqifier=1)．

```python:Colab
os.environ["OPENAI_API_KEY"] = userdata.get("OPENAI_API_KEY")
```

Embeddings API のモデル選択ですが，リクエスト単価の安い "text-embedding-3-small" にします [(Colab)](https://colab.research.google.com/drive/1YoLA26Rm1IJ7rp6xWcg1y_1qwFGDGYLr?authuser=6#scrollTo=ohlvZlPlIDJA&line=2&uniqifier=1)．

```python:Colab
OPENAI_EMBEDDING_MODEL="text-embedding-3-small"
model = OpenAIEmbeddings(model=OPENAI_EMBEDDING_MODEL)
```

### データベース接続

シークレットに設定したデータベース接続情報を使って，OracleDB に接続してみましょう [(Colab)](https://colab.research.google.com/drive/1YoLA26Rm1IJ7rp6xWcg1y_1qwFGDGYLr?authuser=6#scrollTo=YSn08V4UH3lD&line=11&uniqifier=1)．

```python:Colab
conn = None
try:
    conn = oracledb.connect(
        user=userdata.get('DATABASE_USER'),
        password=userdata.get('DATABASE_PASSWORD'),
        dsn=f"{userdata.get('DATABASE_HOST')}:{userdata.get('DATABASE_PORT')}/{userdata.get('DATABASE_SERVICE')}")
    print("Connection successful!")
except Exception as e:
    print("Connection failed!: ")
    print(e)
```

"Connection successful" と表示されれば接続成功です！

### Wikipedia データの読込み

OracleDB に格納している Wikipedia データ（WIKI_JA）から文書を取り出します．
文書のテキストが極端に少ない記事が多く含まれていますので，500 文字超過の文書を 10 万件ほど無作為に選定します．

OracleDB から読み出した文書をリストに格納するスクリプトがこちらです [(Colab)](https://colab.research.google.com/drive/1YoLA26Rm1IJ7rp6xWcg1y_1qwFGDGYLr?authuser=6#scrollTo=obl1hOnULTAC&line=8&uniqifier=1)．

```python:Colab
query = """
SELECT *
FROM   WIKI_JA
WHERE  LENGTH(JSON_VALUE(CONTENT_JSON, '$.text')) > 500
ORDER BY DBMS_RANDOM.VALUE
FETCH FIRST 1000000 ROWS ONLY
"""

docs = []
with conn.cursor() as cur:
  result = cur.execute(query)
  docs = [row[0] for row in result]

print(docs[:2])
```

```plaintext:output
[{'id': '305770', 'revid': '1780509', 'url': 'https://ja.wikipedia.org/wiki?curid=305770', 'title': '南廻線', 'text': '南廻線（なんかいせん）は、台湾屏東県枋寮郷の ...
```

Python の Dict オブジェクトとして変換されていることが出力によりわかると思います．

### LangChain Document の作成

Wikipedia 文書を選定した後，LangChain の Document オブジェクトを作成します．
Document オブジェクトに変換しておくことで，LangChain を利用した後述のテキスト処理を適用しやすくなります．

下記のコードで Wikipedia データを LangChain Document に変換できます [(Colab)](https://colab.research.google.com/drive/1YoLA26Rm1IJ7rp6xWcg1y_1qwFGDGYLr?authuser=6#scrollTo=RmQnvQUWPFwL&line=2&uniqifier=1)．

```python:Colab
docs_langchain = []

for doc in docs:
    metadata = {
        "wikiid": doc["id"],
        "revid": doc["revid"],
        "url": doc["url"],
        "title": doc["title"]
      }
    doc_langchain = Document(page_content=doc["text"], metadata=metadata)
    docs_langchain.append(doc_langchain)

print(docs_langchain[:2])
```

```plaintext:output
[Document(metadata={'wikiid': '305770', 'revid': '1780509', 'url': 'https://ja.wikipedia.org/wiki?curid=305770', 'title': '南廻線'}, page_content='南廻線（なんかいせん）は、台湾屏東県枋寮郷の枋寮駅から ...
```

Document ごとにメタデータを格納することができますので，今回は Wikipedia 文書の本文（"text"）以外の属性をメタデータに格納しておきました．

### チャンキング処理

LangChain の Document オブジェクトを作成しましたが，長文テキストを埋め込みすると平均的なベクトルができあがってしまいますので，意味のある単位（チャンク）に分ける処理が施されます．今回は最低 500 文字を一つのチャンクとし，連続するチャンクは最低 120 文字は重複するように設定します．

LangChain では CharacterTextSplitter というチャンキング処理用のユーティリティクラスが用意されています．チャンクサイズ（`chunk_size`）と重複文字数（`chunk_overlap`）をそれぞれ 500，120 と設定し，日本語文書なので分割文字（`separator`）を 句点「。」としました [(Colab)](https://colab.research.google.com/drive/1YoLA26Rm1IJ7rp6xWcg1y_1qwFGDGYLr?authuser=6#scrollTo=AnL7IqwgVGDW&line=2&uniqifier=1)．

```python:Colab
text_splitter = CharacterTextSplitter(separator="。", chunk_size=500, chunk_overlap=120)
docs_chunked = text_splitter.split_documents(docs_langchain)

print(docs_chunked[:2])
print(f"#Chunks: {len(docs_chunked)}")
```

実行すると設定したチャンクサイズよりも多くなっているという警告が出ますが，文の途中でぶつ切りにされるのは避けたいので無視します．

> WARNING:langchain_text_splitters.base:Created a chunk of size 637, which is longer than the specified 500

10 万件の文書をチャンキング処理を行った結果，249,780 件のチャンクが作られました．

### ベクタストアの構築および格納

ようやく OracleDB のベクタストアを扱う準備ができました．LangChain コミュニティが提供している [OracleVS](https://python.langchain.com/v0.2/docs/integrations/vectorstores/oracle/) を利用し，ベクタストアの操作を行います．

OracleDB にチャンキング処理を施した LangChain Document を格納していきます．ベクタストアの構築およびデータ追加はそれぞれ `OracleVS.from_documents`，`OracleVS.add_documents` を使います．ベクタストアを構築するメソッド `OracleVS.from_documents` に渡した Document の数だけ Embeddings API のリクエストが発生します．したがって，制限を下回るようにリストを切り分けて複数回に分けて，2 回目以降の処理は `OracleVS.add_documents` を使います．以上のようなバッチ処理を実行するスクリプトが下記の通りです [(Colab)](https://colab.research.google.com/drive/1YoLA26Rm1IJ7rp6xWcg1y_1qwFGDGYLr?authuser=6#scrollTo=vy8NHddeWYR-&line=18&uniqifier=1)．

ベクタストアごとに埋込みモデル（`model`）と距離計算方法（`distance_strategy`）を指定する必要があります．`langchain_community.vectorstores.utils` に [`DistanceStrategy` というユーティリティクラス](https://api.python.langchain.com/en/latest/vectorstores/langchain_postgres.vectorstores.DistanceStrategy.html#langchain_postgres.vectorstores.DistanceStrategy)があり，そこから距離計算方法を選択します．

```python:Colab
VS_TABLE = 'DOCS_WIKIJA_100K'
DISTANCE_STRATEGY = DistanceStrategy.COSINE

rpm = 10000
batch_size = int(rpm * 0.8 // 60)
vstore = None

for index, batch_docs in enumerate(
  tqdm([docs_chunked[i:i+batch_size] for i in range(0, len(docs_chunked), batch_size)])
):
  if index == 0:
    vstore = OracleVS.from_documents(
        batch_docs,
        model,
        client=conn,
        table_name=VS_TABLE,
        distance_strategy=DISTANCE_STRATEGY
    )
  else:
    vstore.add_documents(batch_docs)
  time.sleep(1)
```

毎分あたりのリクエスト回数制限（`rpm`）から余裕を持って 8 割ほどの毎秒のリクエスト回数制限を算出し，その値を 1 バッチあたりの文書数 `batch_size` としています．

```math
\texttt{batch_size} = \frac{\texttt{rpm} * 0.8}{60}
```

`enumerate` 関数を使い初回ループとそれ以降のループ処理を分けて，`OracleVS.from_documents` と `OracleVS.add_documents` を使い分けました．最後にリクエスト制限を超えないように 1 秒間スリープさせています．

余談ですが，tqdm パッケージは簡単に下記のようなプログレスバーを表示できるので重宝しています．

```plaintext
  7%|▋         | 139/1879 [02:19<29:05,  1.00s/it]
```

:::note info
既存ベクタストアの読込み
ベクタストアを構築済みの場合，[`OracleVS` のコンストラクタ](https://api.python.langchain.com/en/latest/vectorstores/langchain_community.vectorstores.oraclevs.OracleVS.html#langchain_community.vectorstores.oraclevs.OracleVS.__init__)を使うと既存ベクタストアが読み込めます．
:::

### Exact Similarity Search

それでは本題の類似検索についてです．OracleVS から類似検索を行う場合，正確に導出するか近似的に導出するかはベクトル索引が存在するかどうかになります．

したがって，正確な類似検索（Exact Similarity Search）を実行したいので，まずはベクトル索引を構築せずに類似検索をしてみます．類似検索を行うには `OracleVS.similarity_search` を使って，次のコードを実行します[(Colab)](https://colab.research.google.com/drive/1YoLA26Rm1IJ7rp6xWcg1y_1qwFGDGYLr?authuser=6#scrollTo=Mej-MbfA3pMu&line=2&uniqifier=1)．

```python:Colab
%%time
query = "日本人の小説家について教えて下さい．"

for index, doc in enumerate(vstore.similarity_search(query, k = 5)):
  text = f"""
{index + 1}. {doc.metadata["title"]}
{doc.page_content[:100]}

"""
  print(text)
```

クエリとして文字列を直接的に渡せば，メソッド内でクエリ文字列にたいして埋込み処理が実行され，`OracleVS.similarity_search_by_vector` に渡されます．同じクエリで何回か類似検索をするならば，埋込みモデル `model` を使ってクエリを埋め込んでおき [(Colab)](https://colab.research.google.com/drive/1YoLA26Rm1IJ7rp6xWcg1y_1qwFGDGYLr?authuser=6#scrollTo=1WRebfwi5Ote&line=2&uniqifier=1)，`OracleVS.similarity_search_by_vector` を呼び出せばリクエスト回数を節約できるようですね [(Colab)](https://colab.research.google.com/drive/1YoLA26Rm1IJ7rp6xWcg1y_1qwFGDGYLr?authuser=6#scrollTo=pUzxS7l-5Pf0&line=4&uniqifier=1)．

```plaintext:Output
1. 三咲光郎
は、日本の小説家、推理作家。
経歴・人物.
大阪府岸和田市生まれ。1983年、関西学院大学文学部日本文学科を卒業する。大阪府立高校国語科教諭として奉職する。1993年、『大正暮色』で大阪府堺市が主催す



2. 佐藤正午
は、日本の小説家。
経歴.
長崎県佐世保市生まれ。長崎県立佐世保北高等学校卒業、北海道大学文学部国文科中退。大学在学中、同郷の作家野呂邦暢の『諫早菖蒲日記』（1977年）を読んで感銘を受け、ファンレタ



3. 東山彰良
は、台湾出身の日本の小説家。福岡県小郡市在住。日本推理作家協会会員。
父親の王孝廉も、神話研究、散文、小説、詩などの分野で活躍し、台湾で知名な作家・文学者。
経歴・人物.
1968年、外省人の両親のも



4. 若山三郎
は日本の小説家。
人物.
1931年3月16日、新潟県刈羽郡高柳町（現柏崎市）出身。
日本作家クラブ理事長。日本文芸家協会会員。
新潟県立柏崎高等学校卒業、明治大学政治経済学部中退。
1951年から日



5. 紺野千昭
紺野 千昭（こんの ちあき）は、日本のライトノベル作家である。
人物.
長野県出身。大学生の時に一人暮らしを始め、本格的にアニメを見始めた。大学時代は日本文学を選考し、文学作品の執筆とは相性が合わなそ


CPU times: user 126 ms, sys: 11.8 ms, total: 138 ms
Wall time: 17.5 s
```

正確な類似検索だと 17 秒もかかっていますね．ですが，結果はなかなか良さそうです．

### Approximate Similarity Search

次に，ベクトル索引を構築して，近似類似検索（Approximate Similarity Search）を試してみます．OracleDB のベクトル索引は Hierarchical Navigable Small World（HNSW）と Inverted File Flat（IVF）をサポートしており，索引タイプを指定しなければデフォルトで HNSW を選択するようです．索引方法の違いについては別記事でまとめることにして，HNSW を利用してみます．

:::note warn
少々面倒ですが，HNSW を作成するにはメモリ領域を確保する必要があり，OracleDB の[ベクトル・プール](https://docs.oracle.com/cd/G11854_01/vecse/size-vector-pool.html)を設定する必要があります．ベクトル・プールの設定については [こちらの記事](https://qiita.com/ryotayamanaka/items/d1e665ceaa7152b22279#%E3%83%A1%E3%83%A2%E3%83%AA%E9%A0%98%E5%9F%9F%E3%81%AE%E7%A2%BA%E4%BF%9D) で説明しております．
:::

ベクトル索引を構築するには [`OracleVS.create_index`](https://api.python.langchain.com/en/latest/_modules/langchain_community/vectorstores/oraclevs.html#create_index) メソッドを使います [(Colab)](https://colab.research.google.com/drive/1YoLA26Rm1IJ7rp6xWcg1y_1qwFGDGYLr?authuser=6#scrollTo=r6IOgPY0cnGT&line=3&uniqifier=1)．

```python:Colab
%%time
INDEX_NAME_HNSW = f"IDX_{VS_TABLE}_HNSW"
oraclevs.create_index(
    conn,
    vstore,
    params={
        "idx_name":  INDEX_NAME_HNSW,
        # default:
        "idx_type": "HNSW",
        "neighbors": 32,
        "efConstruction": 200,
        "accuracy": 90,
   "parallel": 8,
    }
)
```

HNSW の構築には 7 分 28 秒かかりました．さて，近似類似検索の速度はどうなったでしょうか．コードを特に変更する必要はなく [ベクトルを使った類似検索のスクリプト](https://colab.research.google.com/drive/1YoLA26Rm1IJ7rp6xWcg1y_1qwFGDGYLr?authuser=6#scrollTo=pUzxS7l-5Pf0&line=4&uniqifier=1)を実行してみます．

```plaintext:output

1. 三咲光郎
は、日本の小説家、推理作家。
経歴・人物.
大阪府岸和田市生まれ。1983年、関西学院大学文学部日本文学科を卒業する。大阪府立高校国語科教諭として奉職する。1993年、『大正暮色』で大阪府堺市が主催す



2. 佐藤正午
は、日本の小説家。
経歴.
長崎県佐世保市生まれ。長崎県立佐世保北高等学校卒業、北海道大学文学部国文科中退。大学在学中、同郷の作家野呂邦暢の『諫早菖蒲日記』（1977年）を読んで感銘を受け、ファンレタ



3. 東山彰良
は、台湾出身の日本の小説家。福岡県小郡市在住。日本推理作家協会会員。
父親の王孝廉も、神話研究、散文、小説、詩などの分野で活躍し、台湾で知名な作家・文学者。
経歴・人物.
1968年、外省人の両親のも



4. 若山三郎
は日本の小説家。
人物.
1931年3月16日、新潟県刈羽郡高柳町（現柏崎市）出身。
日本作家クラブ理事長。日本文芸家協会会員。
新潟県立柏崎高等学校卒業、明治大学政治経済学部中退。
1951年から日



5. 紺野千昭
紺野 千昭（こんの ちあき）は、日本のライトノベル作家である。
人物.
長野県出身。大学生の時に一人暮らしを始め、本格的にアニメを見始めた。大学時代は日本文学を選考し、文学作品の執筆とは相性が合わなそ


CPU times: user 22.5 ms, sys: 3.81 ms, total: 26.3 ms
Wall time: 1.63 s
```

17 秒かかっていた類似検索が近似検索により 1.63 秒に短縮でき，おおよそ 10 倍速くなりました！今回の場合，検索結果が同じでしたが近似的な検索になりますので結果が変わることもあるでしょう．埋込みモデルの違いや HNSW 構築時のパラメタ次第で精度も変わってきそうですね．

### SQL による類似検索

本記事では，LangChain と OracleVS を用いて類似検索を行いましたのでデータ抽出のとき以外は SQL を記述する必要はありませんでした．ただし，EXACT / APPROX 検索を切り替える事ができないというような，今回簡単に使っただけでも柔軟さに欠ける部分はやはり見えてきました．OracleDB のベクタストアがより利用されるようになれば OracleVS も改善されていくと思いますが，どのような SQL が発行されるかパフォーマンスチューニングする上では重要です．

類似検索に関する SQL やクエリ実行計画については [こちらの記事](https://qiita.com/ryotayamanaka/items/d1e665ceaa7152b22279) で記述しているので参照してみてください！

## まとめ

Oracle Database 23ai のベクタストア OracleVS を使い，Wikipedia 文書の類似検索を実行してみました．言語処理に関することはほとんど LangChain を使い，短いスクリプトでベクタストアを試すことができました．

索引の効果を検証することができましたが，今回省略した HNSW や IVF の仕組みやそれら特性についてもデータを使って試しながら以降の記事で共有していきたいと思います！