今回のテーマは「意味による検索」です。技術的/実装よりのキーワードでいうと「埋め込み (エンベッティング) とベクターストアを用いた検索システムの構築」になります。
経緯
前回の記事 で紹介したアプリは ChatGPT API を利用して画像加工プログラムへの指示文 (JSON) を作成しています。さらに、API 呼び出しの回数削減のため、「一度作成したJSONデータをデータベースに保存して、同じリクエストの際にはデータベース内のデータを再利用する」という仕組みも備えています。この仕組みは「意味による検索」を実現するために、従来のデータベースとは少し異なる「埋め込み (エンベッティング)」と「ベクターストア」という技術を用いています。今回はこのアプリを例にしてこの2つの技術の利用例を紹介します。
「意味による検索」とは?
最初に「意味による検索」について説明します。 これに関しては、このアプリでの検索の様子を見てもらうと理解しやすいかと思います。まずは以下の動画を見てみてください。前半は英語での検索パート、後半は日本語での検索パートになっています。
検索処理は以下の挙動になっています。
- “Input your request here” というフィールドにユーザーがリクエストを入力する
- 入力されたリクエストの内容をデータベースから検索する。
- その下の Did you mean? の部分にユーザーからのリクエスト内容と近しいものから順に検索結果を表示する
この動画で注目してほしいポイントは、英語で検索しても(前半パート)、日本語で検索しても(後半パート)、全く同じ結果を返すというところです。例えば英語で “black and white” と入力しても、日本語で「白黒写真にしてください」と入力しても、どちらも同じ “Make it a black and white photo” という結果が返ってきます(ちなみに動画にはありませんが、 “monochrome” でも「モノクロ写真」でも同様の結果になります)。
このように、言葉そのもので検索を行うのではなく、「言葉が持つ意味」で検索を行うのが「意味による検索」です。
従来は「言葉そのものによる検索」が一般的でした(というか現時点でも「言葉そのものによる検索」が主流です)。言葉そのもので検索を行う場合、データベースに “Make it a black and white photo” と格納されているならば、検索時の文字列にこれらの言葉が含まれていなければ検索できません。つまり、“black and white” というキーワードを指定すればヒットしますが、“monochrome” や「白黒写真にしてください」というキーワードで検索しても絶対にヒットしません。もしこれらのキーワードでもヒットさせるようにするにはあらかじめデータベースに類義語を登録しておくなど余計な手間が必要でした。私もかつてこのようなシステムにかかわったことがありましたが、類義語登録は手間がかかるだけではなく、副作用を起こして逆にヒット率を下げることもあり、非常に苦労した覚えがあります。
これに対して「意味による検索」の場合は、特に手間をかけなくても “black and white” に対して「白黒写真」のような字面が全く異なるものでもヒットします。データベースに言葉を格納する代わりに「言葉の意味」を格納し、検索時のキーワードもいったん意味に変換してから検索を行うからです。
「意味による検索」の手法を使えば従来のような類義語登録は不要になりますし、全く手間をかけずに多言語対応もできてしまうため、過去の経験を覚えている身としてはかなりの衝撃を感じています。
埋め込み (エンベッティング) とベクターストア
この「言葉の意味」は、実際のプログラムでは 「埋め込み (エンベッティング) 」 とよばれる数値ベクトルの形で扱います。つまり、意味を数列に置き換えて扱っているわけです。そして面白いことに “black and white”, “monochrome”, 「白黒」,「モノクロ」といった意味的に近いキーワードは数列としても近い値に変換されます。つまり、「数値として近いかどうか」を判定することで(数列同士の距離計算をすることで)「意味として近いかどうか」がわかるという仕組みになっているのです。
この数値ベクトルであるエンベッティングを容易に扱うことができるデータベースが「ベクターストア」です。
今回のアプリでは、言葉からエンベッティングへの変換に関しては OpenAI の Embeddings APIを用い、ベクターストアとしては Chromaを使用しています。
この2つを使った場合、言葉からエンベッティングへの変換処理は以下のように記述できます。
from chromadb.utils.embedding_functions import OpenAIEmbeddingFunction
# 変換用の関数を初期化する
embedding_function = OpenAIEmbeddingFunction(
api_key="...",
model_name="text-embedding-ada-002",
)
# 言葉をエンベッティングに変換する
embeddings = embedding_function(["画像の真ん中の人の顔だけぼかして"])
エンベッティングへの変換には text-embedding-ada-002 モデルを使用しています。これは現時点 (2024-01) で OpenAI が利用を推奨しているモデルです。このモデルを使った API 呼び出しも gpt-4 や gpt-3.5 と同様に従量課金ではありますが、下表のように他のモデルと比べるとかなり低コストで利用できるのが特徴です。
Model | Cost |
---|---|
gpt-4 (input) | $0.03 / 1K tokens |
gpt-3.5-turbo-1106 (input) | $0.001 / 1K tokens |
ada v2 | $0.0001 / 1K tokens |
※ 2024-01-14 調べ。最新の情報は OpenAIのサイトでご確認ください。
Chroma でのデータ操作
それでは次にベクターストアを使った検索の方法について説明しようと思います。が、その前にそもそもどういうふうにベクターストアにデータが格納されているのがわかっていないと話が見えづらくなるので、まずはそちらから説明したいと思います。
コレクションの作成
前述のとおり今回のアプリではベクターストアとして Chroma を使用しています。Chroma では コレクション(collection) と呼ばれる入れ物にデータを格納します。コレクションは RDB における「テーブル」と同じようなものイメージするとわかりやすいかもしれません。データを格納する前に必ずコレクションを作成しておく必要があります。
import chromadb
# Chroma を操作するためのクライアントを取得する
chroma_client = chromadb.PersistentClient()
# "workflow" という名前のコレクションを取得する (存在していない場合は作成する)
workflow_collection = chroma_client.get_or_create_collection(
"workflow", embedding_function=embedding_function
)
まず、chromadb.PersistentClient()
で Chroma を操作するためのクライアントを取得します。
PersistentClient
を利用した場合、現在の作業ディレクトリ上に自動的に chroma
というフォルダが作成されて、その中にデータが保存されることになります。
作成したクライアントの get_or_create_collection メソッドを呼び出せばコレクションが作成されます。もしすでに同じ名前のコレクション (このケースでは “workflow” という名前のコレクション) が存在する場合は新規作成せずに既存のコレクションを返します。この関数の embedding_function
引数には、さきほど作成したエンベッティング変換用関数 (OpenAIEmbeddingFunction
のインスタンス) を指定します。
エンベッティングの保存
データの保存処理は以下のような形で行っています。
import hashlib
# エンベッティングに変換して保存したい文字列を request という変数に格納する
request = "Blur only the face of the person in the center"
# 文字列をハッシュ化してid変数に格納する
id = hashlib.sha256(request.encode()).hexdigest()
# データを保存する
workflow_collection.upsert(
ids=[id],
documents=[request],
metadatas=[{"workflow": workflow}],
)
このコードにおいては、コレクションの upsert メソッドの呼び出しによってデータを保存しています。引数としては以下を指定しています:
- ids: 保存するデータのID (一意キー)
- documents: エンベッティング変換前の文字列
- metadatas: 付加情報。このアプリでは ChatGPT が作成した JSON データを指定しています。このデータの詳細については前回の記事 を参照してください
このメソッドを呼び出すと、documents 引数に指定したデータから自動的にエンベッティングの変換が行われて引数に指定したデータとともに保存されます。従って、このメソッドの呼び出しに際しては、エンベッティングそのものを明示的に指定する必要はありません。
IDに関しては、Chromaのドキュメントからは何を指定するのが妥当なのかは見つけられなかったのですが、IDという名前からして「一意性」と「ID検索の容易性」の考慮はしたほうがよさそうな気がしたので「エンベッティング変換前の文字列をsha256でハッシュ化した値をIDとして使用する」という実装にしてみました。
エンベッティングによる検索処理
ここからが本題の検索処理です。
# 検索対象文をエンベッティングに変換する
embeddings = embedding_function(["画像の真ん中の人の顔だけぼかして"])
# 検索を行う
results = workflow_collection.query(query_embeddings=embeddings, n_results=10)
# 検索結果を表示する
for dist, doc in zip(results["distances"][0], results["documents"][0]):
print(f"{dist:.2f}: {doc}")
実際に検索を行う処理はコレクションの query メソッドです。引数としては以下を指定しています
- query_embeddings: 検索対象文のエンベッティング
- n_results: 結果取得件数
これを実行すると以下のような結果が出力されます:
0.32: Blur only the face of the person in the center
0.36: blur the center face
0.41: Blur the rightmost face
0.46: Make it a black and white photo
0.48: line up the person on the far right with the person on the far left
0.48: line up the person on the far left with the person on the far right
0.50: Crop the left three
0.51: Make it sepia
0.52: make it sepia
0.52: Make the width of the image 100px
左側に表示されている数値が検索対象文に対する「意味の近さ」です。値が小さければ小さいほど近い意味を持つということを示しています。