OpenAIの新鮮な埋め込みモデルとRAG: シンプルな実装で大きな可能性を探る
こんにちは、makokonです。
最近OpenAIが発表した新しいモデルとAPIの更新、聞いていますか?
多くの人が既にこの話題を取り上げていますが、私も試してみました。
特に、新しいEmbeddingモデルとGPT-4 Turbo Previewが注目点です。
では、さっそくRAGを作って新しいモデルの可能性を探ってみましょう。
なぜ、RAGなのかですか?やっぱりいちばん可能性がわかりやすいですよね。
新しいEmbeddingモデルの紹介
興味深い2つのモデルが追加されています。
小規模テキスト埋め込みモデルの特徴
text-embedding-3-small とても効率的で低コストで使えるようですよ。
大規模テキスト埋め込みモデルの特徴
text-embedding-3-large こっちはとても強力なパフォーマンスを提供してくれるようです。
GPT-4 Turbo Preview
GPT4モデルもGPT-4 Turbo Previewがリリースされます。タスクを最後まで完了しない怠け癖を直してくれたようです。今回のプログラムでは違いを実感することはできないでしょう。
使い方はこちらを参考に、https://platform.openai.com/docs/introduction
検索拡張生成(RAG)とは
これは、今更なので簡単に。LLMを使って、”2023年のプロ野球優勝チームは?”とか聞いても、”そんな新しいことは知りません”とか言われたりしますよね。または、組織内のデータとか特別に専門性が高いデータが必要な質問も正しく答えてくれない可能性が高いです。(しれっともっともらしく教えてくれることもあるけど信用できない)
そんなときに、必要な事前情報をプロンプトと一緒に渡して、質問すれば正しいことを教えてくれるだろうということです。この事前情報を用意したり効率的に検索するための仕組みがRAGで、これにEmbeddingモデルを利用すると便利なんですよ。
今回作成するプログラムの概要
今回作成するプログラムについて説明します。
RAGを説明するときに、例題として何を利用するかは、結構悩んでしまいますが、今回は百人一首を取り扱うことにしました。馴染もそれなりにあって、どんな事前情報を利用して回答に至ったかをなんとなくわかりますからね。
したがって、歌番号、作者、元の歌、歌の現代語訳を含むデータベースを作って、そのデータベースに基づいて百人一首について答えてもらいます。
なお、現代語訳とかは下記リンクを利用させてもらいました。
必要なライブラリの紹介
とりあえず、以下のライブラリはアップデートしておきます。今回はlangchainはテキストスプリッター以外使っていないのですが、今後拡張する予定があるので、新しくするほうがいいでしょう。
pip install --upgrade openai
pip install --upgrade langchain
pip install --upgrade langchain-community
pip install --upgrade langchain-openai
from openai import OpenAI
import openai
#from openai.embeddings_utils import get_embedding, cosine_similarity # 現在のバージョンでは利用できない
from langchain.text_splitter import CharacterTextSplitter
import numpy as np
import os
import json
import datetime
#from openai.embeddings_utils import get_embedding, cosine_similarity # 現在のバージョンでは利用できない
埋め込みの生成と、類似度の計算はもともと以前のOpenAIライブラリにする組まれていたのですが、今回はなぜかエラーが出て使えません。このへんのサポートを辞める理由はないので、原因不明ですが、仕方ないので今回は同じ名前の関数を作ることにします。(多分すぐにでもアップデートで復活すると信じてます)
getembedding(),cosine_similarity()の実装です。
getembedding()は公式にも乗っていましたが、引数textが[text]だと、うまく動きませんでした。
cosine_similarity()はnumpyを使って、最も単純なものを作りました。効率的にどうかと思いましたが、全然速度的には気になりませんでした。
openai.api_key = os.getenv("OPENAI_API_KEY").strip()
client = OpenAI()
def cosine_similarity(x, y):
# ベクトルxとyのドット積を計算
dot_product = np.dot(x, y)
# ベクトルxとyのノルム(長さ)を計算
norm_x = np.linalg.norm(x)
norm_y = np.linalg.norm(y)
# コサイン類似度を計算
similarity = dot_product / (norm_x * norm_y)
return similarity
def get_embedding(text, model="text-embedding-3-small"):
text = text.replace("\n", " ")
return client.embeddings.create(input = text, model=model).data[0].embedding
#
その他の関数の説明
残りの関数定義を示します。
def text_split(text):
max_tokens=500
# 長文対応
text_splitter = CharacterTextSplitter(
separator = "\n\n", # セパレータ
chunk_size = max_tokens, # チャンクの文字数
chunk_overlap = 00, # チャンクオーバーラップの文字数
)
document_splits = text_splitter.split_text(text)
return document_splits
def search_reviews(data, query, n=3, pprint=True):
embedding = get_embedding(query, model='text-embedding-3-small')
for i in data:
i["similarities"]=cosine_similarity(i["embedding"],embedding)
# 'similarities'の値で降順に並べ替え
sorted_data = sorted(data, key=lambda x: x['similarities'], reverse=True)
# 最も類似度が高いn個の辞書を取得
top_n = sorted_data[0:n]
# 上からn個の辞書を返す
return top_n
# generative text
def get_gpt(data,query,model="gpt-4-turbo-preview"):
# model gpt-4、、gpt-4-turbo-preview,gpt-3.5-turbo gpt-4-vision-preview
system_prompt="与えられた事前情報をもとに、質問に答えてください。適切な事前情報がない場合は、推定および一般的な回答を述べてください"
jizen="\n".join(item["text"] for item in data)
prompt=f"事前情報:{jizen}\n質問:{query}"
response = client.chat.completions.create(
model=model,
messages=[
{"role": "system","content":system_prompt},
{"role": "user", "content":prompt},
#{"role": "assistant", "content": "The Los Angeles Dodgers won the World Series in 2020."},
#{"role": "user", "content": "Where was it played?"}
]
)
#print(response)
#print(type(response))
#print(dir(response))
return response.choices[0].message.content
text_split(text):
与えられたテキストを適当なサイズに分館します。セパレータとmaxchunkで決められたサイズになります。適当な粒度と一貫性を保つように決めるのですが、今回は百人一首がテーマなので2,3首情報が含まれるようにすればいいかなと思いました。
search_reviews(data, query, n=3, pprint=True):
dataのリストに含まれる情報からqueryと関連の深い情報を指定した数だけピックアップします。デフォルトは3個です。処理の流れは、
queryをembeddedデータに変換する。
dataに含まれるすべてのebmeddedデータとqueryとの類似度をすべて計算して、辞書に追加する。
類似度をキーとしてソートして、上からN個のリストを返します。
get_gpt(data,query,model="gpt-4-turbo-preview"):
N個の類似度の高いデータに基づいてGPTからの応答を取得します。
モデルはgpt-4-turbo-previewを選びました。(新しいのを試したかったから)別に、pt-4、gpt-4-turbo-preview,gpt-3.5-turbo gpt-4-vision-previewあたりを選んでも問題なく動く(はずはずはず)でしょう。
事前情報に基づいて答えてもらいますが、普通のチャットとしても機能してほしいので、プロンプトでは、事前情報が不十分な場合は、推測や、一般的な質問としての回答も要求しています。目的によっては、「情報が不十分な場合はわからないと答え、決して推測では答えないでください」などと書くかもしれません。
与えられたdataから本のテキストをすべて接続して、事前情報の文字列にします。LLMによってはembeddedデータをそのまま使ったりするほうが便利なこともありますが、今回は、元データをそのまま使います。
個の文字列もプロンプトに組み込みます。
あとは、
response = client.chat.completions.create()を呼び出すだけです。
#print (response)
#print (type(response))
#print (dir(response))
return response.choices[0].message.content
ところで、今回応答テキストを単独で返すのでresponse.choices[0].message.content の形ですが、この書き方はいっつもわからなくて、悩むことになります。[messge][contentとかかもしれないですしね。type(response),dir(resonse)を確認すればわかりますが、それでも、確実性がないですが、上記情報を渡してChatGPTに確認して(教えて)もらっています。ChatGPT便利
これで、必要な関数は揃いました。
全コードの公開と説明
全コードを示します。
from openai import OpenAI
import openai
#from openai.embeddings_utils import get_embedding, cosine_similarity # 現在のバージョンでは利用できない
from langchain.text_splitter import CharacterTextSplitter
import numpy as np
import os
import json
import datetime
openai.api_key = os.getenv("OPENAI_API_KEY").strip()
client = OpenAI()
def cosine_similarity(x, y):
# ベクトルxとyのドット積を計算
dot_product = np.dot(x, y)
# ベクトルxとyのノルム(長さ)を計算
norm_x = np.linalg.norm(x)
norm_y = np.linalg.norm(y)
# コサイン類似度を計算
similarity = dot_product / (norm_x * norm_y)
return similarity
def get_embedding(text, model="text-embedding-3-small"):
text = text.replace("\n", " ")
return client.embeddings.create(input = text, model=model).data[0].embedding
#
def text_split(text):
max_tokens=500
# 長文対応
text_splitter = CharacterTextSplitter(
separator = "\n\n", # セパレータ
chunk_size = max_tokens, # チャンクの文字数
chunk_overlap = 00, # チャンクオーバーラップの文字数
)
document_splits = text_splitter.split_text(text)
return document_splits
def search_reviews(data, query, n=3, pprint=True):
embedding = get_embedding(query, model='text-embedding-3-small')
for i in data:
i["similarities"]=cosine_similarity(i["embedding"],embedding)
# 'similarities'の値で降順に並べ替え
sorted_data = sorted(data, key=lambda x: x['similarities'], reverse=True)
# 最も類似度が高いn個の辞書を取得
top_n = sorted_data[0:n]
# 上からn個の辞書を返す
return top_n
# generative text
def get_gpt(data,query,model="gpt-4-turbo-preview"):
# model gpt-4、、gpt-4-turbo-preview,gpt-3.5-turbo gpt-4-vision-preview
system_prompt="与えられた事前情報をもとに、質問に答えてください。適切な事前情報がない場合は、推定および一般的な回答を述べてください"
jizen="\n".join(item["text"] for item in data)
prompt=f"事前情報:{jizen}\n質問:{query}"
response = client.chat.completions.create(
model=model,
messages=[
{"role": "system","content":system_prompt},
{"role": "user", "content":prompt},
#{"role": "assistant", "content": "The Los Angeles Dodgers won the World Series in 2020."},
#{"role": "user", "content": "Where was it played?"}
]
)
#print(response)
#print(type(response))
#print(dir(response))
return response.choices[0].message.content
# main0
# make original data
if os.path.exists('index.json'):
print('json File exists.')
else:
print('json File does not exist.')
# ファイルを読み込む
filename='hyakunin.txt'
with open(filename, 'r') as file:
doc = file.read().replace('\u3000', ' ') # 全角スペース嫌い
lines = text_split(doc) # 元データに合わせてセパレーターを確認しよう
# 各テキスト行から埋め込みを生成
index = [{"text": line, "embedding": get_embedding(line)} for line in lines]
#index = lines
# indexをJSONファイルに書き出す
with open('index.json', 'w') as f:
json.dump(index, f)
# JSONファイルからindexを読み込む
with open('index.json', 'r') as f:
index = json.load(f)
print("事前情報数 : ",len(index))
# main1
chat_history=[]
while True:
query=input('百人一首について質問してください。 : ')
if query == "exit":
break
res = search_reviews(index, query, n=5)
""" #debug
for i in res:
print(i["text"],i["similarities"])
"""
ai = get_gpt(res,query,model="gpt-4-turbo-preview")
chat_history.append(f"user : {query}")
chat_history.append(f"ai : {ai}")
print(ai)
# save history
filename= "rag-chatlog"+datetime.datetime.now().strftime("%Y%m%d-%H%M%S")+".txt"
with open(filename,'w',encoding='utf-8') as f:
for chat in chat_history:
f.write(chat)
print(chat)
exit()
#main0
以下を説明しますね。
まず、オリジナルデータからembeddedデータを含む辞書データを作ります。ファイルからテキストをすべて読みこんで、適当に分断して、リストにします。そのリストをすべてembeddedして、辞書データindexに変換します。
index = [{"text": line, "embedding": get_embedding(line)} for line in lines]
なお、元でたーのテキストはこんな感じ。\n\nで一首のデータを区切っているのでこれを2,3首含めば、十分な粒度と補足的な情報が得られるでしょう。もちろん文学じゃなくて医療データや販売商品データなら1件ずつ完全に分けるようにしたほうがいいかもしれませんし、論文要約なら1ページとか1段落を単位としてあまり短くしないほうがいいでしょう。
出来上がったindexは、json.dumpを使って保存しておきます。毎回変換すると時間もお金もかかりますからね。
次回に実行したときに、index.jsonが存在したら、これを読み出すだけでOKなので楽ちんです。
# main1
ここは基本的に無限ループです。
ユーザーの質問をqueryに入力し、入力がexitならループを終了します。
ループ本体では、
res = search_reviews(index, query, n=5)
queryと関連の深いデータを5つピックアップして、
get_gpt(res,query,model="gpt-4-turbo-preview")
を呼び出します。
実行結果の解析
ザクッと
「秋の歌を紹介してください」と入れてみると、4種ほど紹介してくれました。まあ、結果には満足です。「秋」の言葉が直接入っていないのに関連性を抽出できているのかな。恐るべし。
まとめと今後の展望
今回はOpenAIの新しいEmbedding modelとGPT-4 Turbo Previewを用いて、シンプルながらも実用的なRAGを実装しました。既存のライブラリを使わずに、自分で埋め込み、類似度計算、関連性抽出を行い、期待通りの検索結果を得ることができました。今後、このモデルを用いてさらに実用的なRAGシステムの開発に取り組んでいきます。
今後は、マルチモーダルRAGは一応実装しておきたいですね。
動画の内容を説明してくれるRAGとかを紹介するかもしれません。
おまけ タイトル画の説明 by GPT-4V
この画像は拡大鏡を持ったキャラクターが、何かを一生懸命に探しているような表情で、大きな辞書を見ています。キャラクターの目は大きく驚いた様子で、顔は驚きや焦りを表しているかのようです。両サイドには積み重ねられた書籍があり、カップのコーヒーもテーブルの上に置かれています。文字のブロックが辞書から飛び出し、動きや活気を感じさせる演出がなされています。イラストは、研究や深い学び、あるいは情報の探求を表していると解釈できます。
ハッシュタグ:
#OpenAI #EmbeddingModel #GPT4TurboPreview #RAG #MachineLearning #AI #DeepLearning #PythonProgramming