こんにちは、CCCMKホールディングス TECH LABの三浦です。
駅で電車の路線図を眺めていたら、どこかふらっと電車に乗って、今まで行ったことがない駅に行ってみたくなりました。今度休みが取れたら、チャレンジしてみようと思います。
Large Language Models(LLMs)を活用した手法の1つとして、ユーザーの質問(クエリ)に関連する追加情報を外部データから検索し、クエリと一緒にプロンプトに組み込んでLLMsに与えることで、様々なクエリに対応することが出来るRetrieval Augmented Generation(RAG)というものがあります。RAGはLLMsの活用手法の代表的なものの1つと言えます。
RAGの手続きの流れは以下の様になります。
RAGをOpenAIやAzure OpenAIのモデルで試してみたことはあるのですが、一度HuggingFaceのモデルをダウンロードしてローカルな環境で試してみたいな、と考えていました。ローカルであればデータを外部環境に送信する必要がないので、活用できる機会も多そうです。
ところが実際にやってみようとすると、当初想像していたよりも考えなければならないことがたくさんあることが分かりました。特にRAGの中心になるLLMsをどう用意するのか、に関連する課題が多くあります。具体的には
- ChatGPTのように大きなモデルをローカル環境で動かすことが出来ない→そのため小さなモデルを選ぶ必要がある。
- 小さなモデルはそのままだと適切な回答が得られにくい→RAGを想定したデータでチューニングを行う必要がある。
こういった背景から、LLMsを自身で集めたデータでチューニングする必要が出てきます。ではどうやってデータを用意するのか?データを集めた後、限られたリソースでLLMsをどうやってチューニングするのか?この2つの壁を乗り越えなければならないことが分かってきました。
現時点ではLLMsのチューニングがまだあまり上手くいっておらず、今回はデータを用意する部分だけ試したことをご紹介したいと思います。ここではクエリに関連する情報を取得するために必要になる、Embeddingモデルの使い方を知る必要があります。
目標のデータの形
そもそもRAGではどのようなテキストがプロンプトとしてLLMsに渡るのでしょうか。プロンプトのテンプレートは以下の様になります。このテンプレートの中の{question}
にはユーザーのクエリが入り、{context}
にはクエリと関連性が強い、外部データから検索したテキストの断片が入ります。
PROMPT = """以下の情報を参照し、Questionに対するAnswerを日本語で作成してください。 ----------- {context} ----------- Question:{question} Answer:"""
LLMsはこのプロンプトを受け取ると、"Answer:"に続く内容としてクエリに対する回答を作成します。ここで課題になるのが{context}
に入る、クエリに関連する情報を取得するために必要なEmbeddingモデルです。
Embeddingモデル
OpenAIやAzure OpenAIではEmbeddingモデルとして"text-embedding-ada-002"などを利用することが出来ます。Embeddingモデルを使うとテキストデータをベクトルデータに変換でき、外部データを細かく分割したテキストの断片とユーザーが入力したクエリをベクトルデータに変換するのに利用します。Embeddingモデルはクエリとそのクエリに答えるために必要な関連情報が、出来るだけ似た(近い)ベクトルになるよう変換できることが望ましいです。
HuggingFaceでは、Embeddingモデルとしてsentence-transformersからモデルを選択することが出来ます。
各モデルのパフォーマンスはsentence-transformersのドキュメントが参考になると思います。
Vectorstore
Embeddingモデルによってベクトル化されたデータを格納し、検索機能を提供するVectorstoreも必要です。いくつか選択候補はありますが、これまでも利用する機会があったchromadbを使用しました。
RAG用LLMsチューニングデータ生成手順
このブログの記事を使ってRAGに使用するLLMsをチューニングするためのデータの作成をしてみました。最初にブログの記事から本文を取得するための関数を定義します。
import urllib from bs4 import BeautifulSoup from langchain.document_loaders import TextLoader def get_text_content(url,class_name): """ この関数はurlの中のclass名がclass_nameのdiv要素のテキスト情報を取得します。 """ # HTMLの取得 req = urllib.request.Request(url) with urllib.request.urlopen(req) as res: html = res.read().decode() # BeautifulSoupで解析し、記事本文文章を取得 content = BeautifulSoup(html).find("div",attrs={"class":class_name}).text # 一度テキストファイルに書き込む with open("./temp.txt","w") as f: f.write(content) # テキストファイルから読み込み loader = TextLoader("./temp.txt") data = loader.load() return {"url":url, "class_name":class_name, "content":data}
次にテキストデータを分割し、ベクトル化し、Vectorstoreに格納する手順に移ります。Embeddingモデルへのインターフェースがchromadbとlangchainで異なるため、それぞれ個別に用意しました。
import chromadb from chromadb.utils.embedding_functions import SentenceTransformerEmbeddingFunction from langchain.embeddings.sentence_transformer import SentenceTransformerEmbeddings from langchain.text_splitter import RecursiveCharacterTextSplitter # テキスト分割用 spliter = RecursiveCharacterTextSplitter(chunk_size=100, chunk_overlap=10) # chromadbクライアント client = chromadb.PersistentClient() # embeddingモデル embedding_model = "sentence-transformers/all-mpnet-base-v2" # chromadb用 embedding_func = SentenceTransformerEmbeddingFunction(model_name=embedding_model) #langchain用 embedding_func_lng = SentenceTransformerEmbeddings(model_name=embedding_model)
chromadbに分割されたテキストを挿入するための関数です。データにはidを付与する必要があるため、uuid.uuid1
を使って取得しています。
def insert_docs(collection_name, docs, embedding_func): """ collection_nameのcollectionに分割されたテキストリストdocsをembedding_funcを使ってベクトル化して挿入します。 """ import uuid collection = client.get_or_create_collection(collection_name,embedding_function=embedding_func) for doc in docs: collection.add( ids=[str(uuid.uuid1())], metadatas=doc.metadata, documents=doc.page_content)
ブログの記事からテキストを取得してchromadbに挿入します。chromadbではcollectionごとにデータを格納していくのですが、collectionの名前はurl文字列の一部から取るようにしました。(url.split("/")[-1]
)
content = get_text_content(url, class_name)["content"] insert_docs(url.split("/")[-1], spliter.split_documents(content))
chromadbから検索するための準備です。langchainを使用します。
from langchain.vectorstores import Chroma db = Chroma( client=client, collection_name=url.split("/")[-1], embedding_function=embedding_func_lng)
試しに検索をしてみます。対象にした記事はこちらの記事です。
db.similarity_search("はんだごての形状について教えて下さい。")
以下の様に関連する情報を取得することが出来ました。
[Document(page_content='良いはんだと悪いはんだの形状について', metadata={'source': './temp.txt'}), Document(page_content='techblog.cccmk.co.jp\nなんとか電気が通る回路を作ることが出来たのですが、一度はちゃんと詳しい方に教えて頂きたいな、と考えていました。', metadata={'source': './temp.txt'}), Document(page_content='ず、帰り道不安で一杯でした。しかし、家に帰ってインターネットで調べて試してみたら、思っていた以上に簡単に切って食べることが出来ました。', metadata={'source': './temp.txt'}), Document(page_content='なので、手元にあるパーツを繰り返し使って練習回数を増やそうと思います。', metadata={'source': './temp.txt'})]
これを利用して、LLMsに与えるプロンプトを生成してみます。最初にプロンプトのテンプレートを用意します。langchainを利用しました。
from langchain import PromptTemplate PROMPT = """以下の情報を参照し、Questionに対するAnswerを日本語で作成してください。 ----------- {context} ----------- Question:{question} Answer:""" train_prompt = PromptTemplate.from_template(PROMPT)
クエリからプロンプトを生成します。
question = "三浦さんが食べたものを教えて下さい。" context = [doc.page_content for doc in db.similarity_search(question)] train_prompt.format(question=question, context=context)
"以下の情報を参照し、Questionに対するAnswerを日本語で作成してください。\n\n-----------\n['ず、帰り道不安で一杯でした。しかし、家に帰ってインターネットで調べて試してみたら、思っていた以上に簡単に切って食べることが出来ました。', 'こんにちは、技術開発ユニットの三浦です。', '良いはんだと悪いはんだの形状について', 'これからパイナップルが我が家の食卓に並ぶことが増えそうです。こういう出会いをセレンディピティというのかも、と思いました。\\n少し前ですが、はんだ付けにチャレンジした話をさせて頂いたことがありました。']\n-----------\n\nQuestion:三浦さんが食べたものを教えて下さい。\nAnswer:"
このように、関連する情報とクエリが埋め込まれたプロンプトを生成することが出来ました。
Embeddingモデルによる取得される関連情報の違い
HuggingFaceのsentence-transformersには多数のモデルがありますが、選択するモデルによってクエリと関連する情報が異なります。
たとえば
- sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
- sentence-transformers/multi-qa-mpnet-base-dot-v1
という二つのモデルで試してみます。
"三浦さんが食べたいと思ったものは何ですか?"
sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
['この前子どもと近所を歩いていた時に、八百屋さんでパイナップルが丸々一個売られているのを見つけました。「食べてみたい!」というので買ってみたのですが、どうやって切ったらいいのか分からず、帰り道不安で一杯', 'これからパイナップルが我が家の食卓に並ぶことが増えそうです。こういう出会いをセレンディピティというのかも、と思いました。\\n少し前ですが、はんだ付けにチャレンジした話をさせて頂いたことがありました。', 'みをブーストしていきたいと思います!', 'はんだごて\\nはんだ付けに欠かせないはんだごてのコテ先の種類も、色々あることを知りました。\\nこの日使ったコテ先は、先端がとがっていないタイプでした。\\nコテ先の温度は350度です']
sentence-transformers/multi-qa-mpnet-base-dot-v1
['この前子どもと近所を歩いていた時に、八百屋さんでパイナップルが丸々一個売られているのを見つけました。「食べてみたい!」というので買ってみたのですが、どうやって切ったらいいのか分からず、帰り道不安で一杯', 'ず、帰り道不安で一杯でした。しかし、家に帰ってインターネットで調べて試してみたら、思っていた以上に簡単に切って食べることが出来ました。', 'こんにちは、技術開発ユニットの三浦です。', 'で、蛍光灯も目に見えない速度で点滅しているんですよね。']
"三浦さんが学んだことは何ですか?"
sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
['せっかく貴重な体験をしたので、この場を借りて、どんな内容でどんなことを学んだかをお話させて頂きます。\\n「はんだ講習会」の内容\\nいっぱい機器が並んでいる!', '作業台の上にはご覧のように色々な機器が用意されていて、講習を通じて「何をする機器なのか」「どうやって使うのか」を教えて頂きました。自分なりに理解した内容を書いてみます。\\nテスター', 'これからパイナップルが我が家の食卓に並ぶことが増えそうです。こういう出会いをセレンディピティというのかも、と思いました。\\n少し前ですが、はんだ付けにチャレンジした話をさせて頂いたことがありました。', 'AKIBA様で「はんだ講習会」を開いて頂き、メンバー数名と一緒に講習を受けてきました。講習会でははんだ付けのやり方だけでなく、電子工作に必要になる様々な機器の扱い方も教えて頂きました。']
sentence-transformers/multi-qa-mpnet-base-dot-v1
['こんにちは、技術開発ユニットの三浦です。', 'ということで、今回は「はんだ講習会」を通じて色々なことを学んできた話をご紹介させて頂きました。これからははんだ付けすることを恐れることなく、色々なパーツを試してみて、IoTの取り組みをブーストしていき', 'せっかく貴重な体験をしたので、この場を借りて、どんな内容でどんなことを学んだかをお話させて頂きます。\\n「はんだ講習会」の内容\\nいっぱい機器が並んでいる!', 'また、はんだこてをしまう時は、コテ先をスポンジなどできれいにした後に、少しはんだを付けておくことを知りました。使い始める時も同じ手順を行うことで、はんだが乗りやすくなります。\\n糸はんだ']
ちょっと判断に悩むところもありますが、sentence-transformers/multi-qa-mpnet-base-dot-v1
の方が回答に役に立つ情報が得られているかな・・・という印象を受けました。Embeddingモデルの選択は、もう少し色々と試してみたいところです。
ひとまずブログ記事のURLとクエリがあればLLMsに与えるプロンプトが作れるようになりました。さらにクエリに対する望まれる回答データを用意出来ればRAG用のLLMsを学習するためのデータが作れそうです。
ということで、こんな感じのデータセットを現在ちょこちょこと作り始めています。だいたい160件くらい集まったところです。
まとめ
HuggingFaceのモデルを使ってRAGをやってみたい!ということではじめてみて、今回はその中のRetrieval編としてデータの用意に必要になる関連技術について調べたことをまとめてみました。今度はTuning編として、QLoRAという限られたリソースでLLMsのチューニングを可能にする技術について、ご紹介できれば・・・と思っておりますので、そちらもお楽しみにしていてください!