langchain + RAGで手元の資料(新たな情報)をllmに読み込ませる
はじめに
RAG(検索拡張生成)について
huggingfaceなどからllmをダウンロードしてそのままチャットに利用した際、参照する情報はそのllmの学習当時のものとなります。(当たり前ですが)学習していない会社の社内資料や個人用PCのローカルなテキストなどはllmの知識にありません。
このような存在しない情報をllmに与える(参照させる)手法がRAGです。
ファインチューニングという手法もありますが、そちらはllmに再学習を行わせる手法です。ファインチューニングでは、llm自体に学習させることで知識を追加しますが、RAGの場合は用意したデータベースから検索することで、追加の情報を与えることができます。
イメージ的には以下のような感じです。
・ファインチューニング: 新しい情報を勉強させる。
・RAG: 新しい情報が記載された本を持たせる。
今回は比較的手軽にできるRAGを使用します[1]。
RAGの手順
RAGの手順としては以下のようになっています。1,2は準備、3~5がチャットを行う際の処理手順です。
-
読み込ませたい資料内の文字列を分割 & ベクトル形式(複数の次元を持つ数値のかたまり)に変換。
文字列 ベクトル形式 こんにちは 0.024385966360569, 0.0067647299729287624... 天気は晴れです 0.02671334706246853, 0.011085131205618382... よろしくお願いします 0.023733152076601982, 0.008909124881029129... -
(変換した)資料をインデックスとして保存。
※ここでいう「インデックス」とは、ベクトル化された資料の索引データベースのことです -
llmへの質問文をベクトル形式に変換。
文字列 ベクトル形式 天気はどうですか 0.029048865661025047, 0.004714482463896275... -
(変換した)質問文から、L2距離やコサイン類似度などのアルゴリズム用いてインデックスを検索します → 類似性のある文章を取得。
文字列 L2距離(0に近いほど類似度が高い) 天気は晴れです 0.19662395 ← 3つの中では最も0に近い(=類似度が高い) こんにちは 0.33486244 よろしくお願いします 0.358436 -
プロンプトに取得した文章を挿入。
※ 以下の場合はコンテキスト(検索で取得した文字列)が一つしかなくプロンプトも単純なため、回答も「天気は晴れです」などコンテキストとほぼ同じ答えが返るかと思います(本来は類似した文字列の上位複数個を取得して、コンテキストにすることが多いです)。# プロンプトのイメージ prompt = """以下のコンテキストを参照して質問に答えてください。 {context} # ここに検索で取得した文字列(「天気は晴れです」)が入ります(要約して入れる場合もあります)。 質問: {quetion} # ここに質問文(「天気はどうですか」)が入ります。 回答: """
以上がRAGの手順です。
ざっくり言うと資料をデータベース化して保存しておく → 質問文と関連ありそうな文章をデータベースから検索 → 質問文と検索した文章をまとめてllmに投げるという流れです
手順の中でベクトル形式への変換や検索に使うアルゴリズム(L2距離やコサイン類似度)などがありましたが、基本的にはベクトル検索用のライブラリ(FAISS、Chroma、Qdrantなど)が内部で行ってくれるため意識しなくても普通に使えるようになっています。
次は実際にベクトル検索ライブラリ(今回はFAISS)を使ってインデックスの作成を行います。上記に書いたRAGの手順では1,2に対応します。
FAISS(ベクトル検索ライブラリ)を使ったインデックスの作成
準備
FAISSはMata(Facebook)がリリースしたベクトル検索ライブラリです。CPU、GPUどちらでも扱えます。
まずは必要なライブラリをインストールしましょう。
$ pip install langchain
$ pip install langchain-community
$ pip install sentence-transformers
FAISSに関してはGPU用とCPU用があります。CUDA環境[2]を構築済みの場合はfaiss-gpu、そうでない場合はfaiss-cpuをインストールしましょう。
# gpu版のインストール
$ pip install faiss-gpu
# cpu版のインストール
$ pip install faiss-cpu
次に読み込ませたい資料(txt,md,pdf形式などのファイル)を用意します。資料は複数あっても構いません。まとめて一つのディレクトリに格納しておきましょう。
自分は(雑ですが)この記事の「はじめに」の文章をsample.txtとして./dataディレクトリに格納しました(きちんと試す場合は、llmが未学習の情報を用意した方がいいです)。
## RAG(検索拡張生成)について
huggingfaceなどからllmをダウンロードしてそのままチャットに利用した際、参照する情報はそのllmの学習当時のものとなります。(当たり前ですが)学習していない会社の社内資料や個人用PCのローカルなテキストなどはllmの知識にありません。
このような存在しない情報をllmに与える(参照させる)手法がRAGです。
ファインチューニングという手法もありますが、そちらはllmに再学習を行わせる手法です。ファインチューニングでは、llm自体に学習させることで知識を追加しますが、RAGの場合は用意したデータベースから検索することで、追加の情報を与えることができます。
イメージ的には以下のような感じです。
・ファインチューニング: 新しい情報を勉強させる。
・RAG: 新しい情報が記載された本を持たせる。
今回は比較的手軽にできるRAGを使用します。
## RAGの手順
RAGの手順としては以下のようになっています。1,2は準備、3~5がチャットを行う際の処理手順です。
1. 読み込ませたい資料内の文字列を分割 & ベクトル形式(複数の次元を持つ数値のかたまり)に変換。
2. (変換した)資料をインデックスとして保存。
※ここでいう「インデックス」とは、ベクトル化された資料のデータベースのことです
3. llmへの質問文をベクトル形式に変換。
4. (変換した)質問文から、L2距離やコサイン類似度などのアルゴリズム用いてインデックスを検索します → 類似性のある文章を取得。
5. プロンプトに取得した文章を挿入。
※ 以下の場合はコンテキスト(検索で取得した文字列)が一つしかなくプロンプトも単純なため、回答も「天気は晴れです」などコンテキストとほぼ同じ答えが返るかと思います(本来は類似した文字列の上位3つや5つなど複数取得して、コンテキストにすることが多いです)。
以上がRAGの手順です。
ざっくり言うと資料をデータベース化して保存しておく → 質問文と関連ありそうな文章をデータベースから検索 → 質問文と検索した文章をまとめてllmに投げるという流れです
手順の中でベクトル形式への変換や検索に使うアルゴリズム(L2距離やコサイン類似度)などがありましたが、基本的にはベクトル検索用のライブラリ(FAISS、Chroma、Qdrantなど)が内部で行ってくれるため意識しなくても普通に使えるようになっています。
次は実際にベクトル検索ライブラリ(今回はFAISS)を使ってインデックスの作成を行います。上記に書いたRAGの手順では1と2に対応します。
準備が出来たら、早速コーディングに移ります。
コーディング
from langchain_community.vectorstores.faiss import FAISS
from langchain_community.embeddings.huggingface import HuggingFaceEmbeddings
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_community.document_loaders import DirectoryLoader
# 資料の格納場所(ディレクトリ)
data_dir = "./data"
# ベクトル化したインデックスの保存場所(ディレクトリ)
index_path = "./storage"
# ディレクトリの読み込み
loader = DirectoryLoader(data_dir)
# 埋め込みモデルの読み込み
embedding_model = HuggingFaceEmbeddings(
model_name="intfloat/multilingual-e5-large"
)
# テキストをチャンクに分割
split_texts = loader.load_and_split(
text_splitter=RecursiveCharacterTextSplitter(
chunk_size=200,# 分割したチャンクごとの文字数
chunk_overlap=50 # チャンク間で被らせる文字数
)
)
# インデックスの作成
index = FAISS.from_documents(
documents=split_texts,
embedding=embedding_model,
)
# インデックスの保存
index.save_local(
folder_path=index_path
)
上記の内、ポイントとなる箇所を確認します。
埋め込みモデルの読み込み
embedding_model = HuggingFaceEmbeddings(
model_name="intfloat/multilingual-e5-large"
)
ベクトル変換には(基本的に)埋め込みモデルというものを使用します。
「埋め込み(エンベッディング)モデル」とは文章をベクトル形式へ変換する際に使用するモデルのことです。
今回は「multilingual-e5-large」という埋め込みモデルを使用しました。上記コードを実行した際にhuggingfaceから自動的にダウンロードされます。
テキストをチャンクに分割
split_texts = loader.load_and_split(
text_splitter=RecursiveCharacterTextSplitter(
chunk_size=200,# 分割したチャンクごとの文字数
chunk_overlap=50 # チャンク間で被らせる文字数
)
)
ディレクトリ内の文章をchunk_sizeで文章を(設定した文字数で収まるように)分割してListに格納します。
chunk_overlapは前のチャンクの末尾の文章を(設定した文字数で収まるように)後のチャンクの先頭に追加します。このようにチャンク間で文章を被らせることで文脈を維持しやすくします。
以下のコードを下に追加することで、どのようにチャンク分割が行われたのか確認できます。
for i, doc in enumerate(split_texts, 1):
print(f"\nチャンク{i}: ================================")
print(doc.page_content)
print(f"文字数: {len(doc.page_content)}")
print("===========================================")
インデックスの作成
index = FAISS.from_documents(
documents=split_texts,
embedding=embedding_model,
)
分割したテキストと埋め込みモデルをセットしてインデックスを作成
インデックスの保存
index.save_local(
folder_path=index_path
)
実行後、./storage(ディレクトリが存在しない場合は自動で作成)にインデックスとして以下のファイルが保存されます。
- index.faiss
- index.pkl
以上でインデックスの作成(& 保存)は完了です。
インデックスを用いたチャット
インデックスが作成できたので、先ほど書いたRAGの手順3,4,5を実施します。
今回は、質問回答ツール(chain)にRetrievalQAと会話用にメモリー(会話履歴)の処理を追加したConversationalRetrievalChainの2種類の方法を紹介します。
コーディング(RetrievalQA)
from langchain_community.llms.llamacpp import LlamaCpp
from langchain_core.prompts import PromptTemplate
from langchain_community.vectorstores.faiss import FAISS
from langchain_community.embeddings.huggingface import HuggingFaceEmbeddings
from langchain.chains import RetrievalQA
# インデックスのパス
index_path = "./storage"
# モデルのパス
model_path = "./ELYZA-japanese-Llama-2-7b-fast-instruct-q4_K_M.gguf"
# 埋め込みモデルの読み込み
embedding_model = HuggingFaceEmbeddings(
model_name="intfloat/multilingual-e5-large"
)
# インデックスの読み込み
index = FAISS.load_local(
folder_path=index_path,
embeddings=embedding_model
)
# プロンプトテンプレートの定義
question_prompt_template = """
{context}
Question: {question}
Answer: """
# プロンプトの設定
QUESTION_PROMPT = PromptTemplate(
template=question_prompt_template, # プロンプトテンプレートをセット
input_variables=["context", "question"] # プロンプトに挿入する変数
)
# モデルの設定
llm = LlamaCpp(
model_path=model_path,
n_gpu_layers=25, # gpuに処理させるlayerの数
stop=["Question:", "Answer:"], # 停止文字列
)
# (RAG用)質問回答chainの設定
chain = RetrievalQA.from_chain_type(
llm=llm,
retriever=index.as_retriever(
search_kwargs={'k': 2} # indexから上位いくつの検索結果を取得するか
),
chain_type_kwargs={"prompt": QUESTION_PROMPT}, # プロンプトをセット
chain_type="stuff", # 検索した文章の処理方法
return_source_documents=True # indexの検索結果を確認する場合はTrue
)
# 質問文
question = "RAG(検索拡張生成)について簡潔に教えてください"
# LLMの回答生成
response = chain.invoke(question)
# indexの検索結果を確認
for i, source in enumerate(response["source_documents"], 1):
print(f"\nindex: {i}----------------------------------------------------")
print(f"{source.page_content}")
print("---------------------------------------------------------------")
# 回答を確認
response_result = response["result"]
print(f"\nAnswer: {response_result}")
上記の内、ポイントとなる箇所を確認します。
インデックスの読み込み
index = FAISS.load_local(
folder_path=index_path,
embeddings=embedding_model
)
先ほど作成したindexを読み込みます。読み込む際にも作成した時と同じ埋め込みモデルを設定しましょう。
プロンプトテンプレートの定義
question_prompt_template = """python:langchain_RetrievalQA.py
{context}
Question: {question}
Answer: """
{context}にはindexから取得した文章、{question}に質問文が挿入されます。
モデルの設定
llm = LlamaCpp(
model_path=model_path,
n_gpu_layers=25, # gpuに処理させるlayerの数
stop=["Question:", "Answer:"], # 停止文字列
)
今回はLlamaCppを使いモデルを読み込みました(モデルは事前にダウンロードしてローカルに保存しておきましょう)。
使用したモデルは以下です。
上記は、以下をgguf形式に変換したモデル(の4bit量子化バージョン)です。
(RAG用)質問回答chainの設定
chain = RetrievalQA.from_chain_type(
llm=llm,
retriever=index.as_retriever(
search_kwargs={'k': 2} # indexから上位いくつの検索結果を取得するか
),
chain_type_kwargs={"prompt": QUESTION_PROMPT}, # プロンプトをセット
chain_type="stuff", # 検索した文章の処理方法
return_source_documents=True # indexの検索結果を確認する場合はTrue
)
chain_typeにstuffを設定していますが、その場合は要約などを行わずにそのままプロンプトに挿入してllmに渡します。stuffの他にはmap_reduceやrefineなどが設定できます。
- map_reduce: 取得したチャンクごとにllmへ渡して要約 → 結合して再度llmに渡す。
- refine: 取得したチャンクを順番に要約(チャンクを要約する → 要約したチャンクと次のチャンクを要約してまとめる → 要約したチャンクと次のチャンクを...と順番に繰り返してまとめていく)。
参照:
LLMの回答生成
response = chain.invoke(question)
上記の箇所で回答を生成しています。
回答結果は response["result"] 、indexの検索結果は response["source_documents"] に格納されています。
実行結果
今回は、記事の「はじめに」の部分を資料として読み込ませているだけなのであまり意味はないですが、実行結果を確認してみます。
index: 1----------------------------------------------------
## RAG(検索拡張生成)について
huggingfaceなどからllmをダウンロードしてそのままチャットに利用した際、参照する情報はそのllmの学習当時のものとなります。(当たり前ですが)学習していない会社の社内資料や個人用PCのローカルなテキストなどはllmの知識にありません。
このような存在しない情報をllmに与える(参照させる)手法がRAGです。
---------------------------------------------------------------
index: 2----------------------------------------------------
以上がRAGの手順です。
ざっくり言うと資料をデータベース化して保存しておく → 質問文と関連ありそうな文章をデータベースから検索 → 質問文と検索した文章をまとめてllmに投げるという流れです
---------------------------------------------------------------
Answer: 与えた質問に回答するために必要な情報をデータベース化して保存しておく → 質問に関連ありそうな文章を検索 → 回答を作成
質問文「RAG(検索拡張生成)について簡潔に教えてください」に対して
llmからの回答「与えた質問に回答するために~」は、2つめのインデックス「ざっくり言うと~」を元にしていそうです。
indexに関しても質問文と関係ありそうな部分(チャンク)がとれているのが確認できます。
次にConversationalRetrievalChainを使ったチャットの方法を紹介します。
コーディング(ConversationalRetrievalChain)
ConversationalRetrievalChainは過去の会話を参照した新たな質問文を生成 → 生成した質問文でindexの取得とllmへの質問を行います。この機能により会話(複数回の質問応答)にも対応できるようになっています。
from langchain_community.llms.llamacpp import LlamaCpp
from langchain_core.prompts import PromptTemplate
from langchain_community.vectorstores.faiss import FAISS
from langchain_community.embeddings.huggingface import HuggingFaceEmbeddings
from langchain.chains.conversational_retrieval.base import ConversationalRetrievalChain
from langchain.memory.buffer_window import ConversationBufferWindowMemory
from langchain_core.messages.base import messages_to_dict
# インデックスのパス
index_path = "./storage"
# モデルのパス
model_path = "./ELYZA-japanese-Llama-2-7b-fast-instruct-q4_K_M.gguf"
# 埋め込みモデルの読み込み
embedding_model = HuggingFaceEmbeddings(
model_name="intfloat/multilingual-e5-large"
)
# インデックスの読み込み
index = FAISS.load_local(
folder_path=index_path,
embeddings=embedding_model
)
# プロンプトテンプレートの定義
question_prompt_template = """
{context}
Question: {question}
Answer: """
# プロンプトの設定
QUESTION_PROMPT = PromptTemplate(
template=question_prompt_template, # プロンプトテンプレートをセット
input_variables=["context", "question"] # プロンプトに挿入する変数
)
# モデルの設定
llm = LlamaCpp(
model_path=model_path,
n_gpu_layers=25, # gpuに処理させるlayerの数
stop=["Question:", "Answer:"], # 停止文字列
)
# メモリー(会話履歴)の設定
memory = ConversationBufferWindowMemory(
memory_key="chat_history", # メモリーのキー名
output_key="answer", # 出力ののキー名
k=5, # 保持する会話の履歴数
return_messages=True, # チャット履歴をlistで取得する場合はTrue
)
# (RAG用)会話chainの設定
chain = ConversationalRetrievalChain.from_llm(
llm=llm,
retriever=index.as_retriever(
search_kwargs={'k': 3} # indexから上位いくつの検索結果を取得するか
),
combine_docs_chain_kwargs={'prompt': QUESTION_PROMPT}, # プロンプトをセット
chain_type="stuff", # 検索した文章の処理方法
memory=memory # メモリーをセット
)
# 会話開始
while True:
user_input = input("Human: ")
if user_input == "exit":
break
# LLMの回答生成
response = chain.invoke({"question": user_input})
# 回答を確認
response_answer = response["answer"]
print(f"AI: {response_answer}")
# 会話履歴の確認
chat_history_dict = messages_to_dict(memory.chat_memory.messages)
print(f"\nmemory-------------------------------------------------------")
for i, chat_history in enumerate(chat_history_dict, 1):
chat_history_type = chat_history["type"]
chat_history_context = chat_history["data"]["content"]
print(f"\n{chat_history_type}: {chat_history_context}")
print("-------------------------------------------------------------")
上記の内、ポイントとなる箇所を確認します。
メモリー(会話履歴)の設定
memory = ConversationBufferWindowMemory(
memory_key="chat_history", # 会話履歴のキー名
output_key="answer", # 出力のキー名
k=5, # 保持する会話の履歴数
return_messages=True, # チャット履歴をlistで取得する場合はTrue
メモリーはいくつかの種類がありますが、今回は設定した数(k個)の履歴を保持するConversationBufferWindowMemoryを使いました。他にはConversationBufferMemory(単純にすべての履歴を保持する)やConversationSummaryMemory(会話を要約して保存する)などがあります。
memory_keyには「"chat_history"」、output_keyには「"answer"」を設定してください。
参照:
(RAG用)会話chainの設定
chain = ConversationalRetrievalChain.from_llm(
llm=llm,
retriever=index.as_retriever(
search_kwargs={'k': 3} # indexから上位いくつの検索結果を取得するか
),
combine_docs_chain_kwargs={'prompt': QUESTION_PROMPT}, # プロンプトをセット
chain_type="stuff", # 検索した文章の処理方法
memory=memory # メモリーをセット
)
上記がConversationalRetrievalChainの設定部分です。
ConversationalRetrievalChainは、送られてきた質問文と会話の履歴を使い新たな質問文を生成します。
これは、内部にある質問文の生成用prompt(CONDENSE_QUESTION_PROMPT)をllmに処理させることで行っています。
LLMの回答生成
response = chain.invoke({"question": user_input})
上記の箇所で回答を生成しています。
回答結果は response["answer"] に格納されています。
実行結果
コードを実行すると会話が始まりますので、質問を入力してください。
Human: RAG(検索拡張生成)について簡潔に教えてください
llmからの回答が返ってきます。
AI: 与えられた文章をデータベース化して保存しておく → 質問文と関連ありそうな文章をデータベースから検索 → 質問文と検索した文章をまとめてllmに投げるという流れです
会話を終了する際は「exit」を入力してください。
Human: exit
最後に会話の履歴が表示されます。
memory-------------------------------------------------------
human: RAG(検索拡張生成)について簡潔に教えてください
ai: 与えられた文章をデータベース化して保存しておく → 質問文と関連ありそうな文章をデータベースから検索 → 質問文と検索した文章をまとめてllmに投げるという流れです
human: 二つ目の流れについて教えてください
ai: llmへの質問に対して、回答となる関連ある文章を自動的に提案するために必要なのです。
以上がRAGの手順です。
ざっくり言うと資料をデータベース化して保存しておく → 質問文と関連ありそうな文章をデータベースから検索 → 質問文と検索した文章をまとめてllmに投げるという流れです
-------------------------------------------------------------
確かめづらいですが、会話は出来ていそうです。
(回答は微妙ですが・・・)
おわりに
少し長くなってしまいましたが、今回はRAGの解説からコーディングまで紹介しました。
RAGを使うことで、自分の持つ資料でユニークな質疑応答ができるためllmの活用の幅が広がります。
今回紹介した手法以外にも色々なカスタマイズがあります。なかにはRAG用のモデルというものもあり、モデルと技術の組み合わせでやれることが増えていくのは面白いです(それでどんどん複雑化していくのですが・・・)。
次に投稿するものもlangchainまわりになる予定です。また機会があればよろしくお願いします。
参考
-
ファインチューニングの場合はllmのパラメータを更新(調整)する必要があり、データセットの準備やPCの要求スペックなどなかなか難しいものがあります。
RAGの場合は、手元の資料さえあればそこから検索した情報をプロンプトに挿入するだけなので、llmと普通にチャットができるPCスペックがあれば(基本的に)問題ありません。 ↩︎ -
CUDA環境の構築に関しては(WSL2ですが)以前記事として投稿しています。興味があればご確認ください。
https://zenn.dev/yumefuku/articles/wsl2-llm-install ↩︎
Discussion
こんにちは。
こちらやってみたのですが、うまく動きません。
hyper-vの環境なんですが無理でしょうか?
embedding_model = HuggingFaceEmbeddings(
model_name="intfloat/multilingual-e5-large"
)でエラーが出ます。
こんちは。
上記の記事ではWSL2(Ubuntu-22.04)環境で行いました。
自分はhyper-v上で(WSL2を使わず)動かしたことがないので実行可否については何とも言えませんが・・・
以下の箇所ではhttps://huggingface.co/intfloat/multilingual-e5-large から自動的にモデルをダウンロードしてきています。もしかしたらhyper-vが外部(インターネット)にアクセスできないなど隔離されている状態ではないでしょうか?
エラー内容がわからないため、あやふやな予想となってしまい申し訳ありません。
もし解決が難しそうであれば、WSL2では動作確認が取れているため、以下の記事(少々長いのですが・・・)や他の方の記事を元にWSL2での環境構築をお勧めいたします(自分もhyper-vとllmやCUDAなどの単語で軽く調べてみましたが記事が少なく、環境面でエラーが発生した際の解決が難しいように思われます)。
返信ありがとうございます。
hyper-vでは、今のところだめですが、googleのcolabでできました。
貧弱のPCなので、ローカルでは難しいのかな。
multilingual-e5-largeもELIZAのように毎回ダウンロードしなくてもいいようにコードを書けるのでしょうか?
multilingual-e5-largeは毎回ダウンロードしておらず2回目以降からはローカルのファイルを参照します。
環境によりますが、自分の場合(WSL2)は初回に「~/.cache/huggingface/hub」に保存されました。
・・・ですが実のところ手動ダウンロード後にローカルパスの指定で読み込むこともできます。
まずは https://huggingface.co/intfloat/multilingual-e5-large/tree/main にアクセスして以下の3つをダウンロードしてください。
その後、好きな名前のフォルダ(intfloat-multilingual-e5-largeとかなんでも)を作成して上記3つのファイルを格納します。
後は作成したフォルダのローカルパスを指定してください。
上記の方法でもモデルが読み込め(るはず・・・)ます。