LangGraphでは、人間の指示をもとに自律的に判断や行動をするRAGエージェントを構築できます。
この記事では、LangGraphを使ってAdaptive-RAG、CRAG、Self-RAGの手法を組み合わせたエージェントの作り方を解説します。
ざっくり言うと
- LangGraphでは、自律的に判断や行動をするRAGエージェントを構築できる
- Adaptive-RAG、CRAG、Self-RAGを組み合わせたエージェントを実装する
- LLMには無料の日本語モデルのLlama-3-ELYZA-JP-8Bを使用する
LangGraphでRAGエージェントを構築する
LangGraphでは、人間の指示をもとに自律的に判断や行動をするRAGエージェントを構築できます。
この記事では、LangGraphを使ってAdaptive-RAG、CRAG、Self-RAGを組み合わせたエージェントの作り方を解説します。
Adaptive-RAG
質問の内容に応じて動的に最適な検索方法を変える手法
CRAG
RAGで取得したドキュメントが、質問に対して正しいかを評価し、必要に応じて他の情報源を探索する手法
Self-RAG
生成された回答に対して自己評価を行い、必要に応じて修正する手法
Adaptive-RAGについて、別の記事で解説しています。
CRAGについて、別の記事で解説しています。
Self-RAGについて、別の記事で解説しています。
LangGraphのワークフロー
LangGraphのワークフローは大きく次のようなステップになります。
- 質問に応じてベクトルストアを使うか、Web検索するかを選択する(Adaptive-RAG)
- ベクトルストアから取得したドキュメントが質問に関連しているか評価する(CRAG)
- 生成された回答が、事実に基づいているか、質問を解決するのに役に立つかを評価する(Self-RAG)
ワークフローの各ステップには次のような機能があります。
- ルーター:質問に応じてベクトルストアを使うか、Web検索するかを選択する
- Retriver:質問をもとにベクトルストアからドキュメントを取得する
- ドキュメントの関連性評価:ベクトルストアから取得したドキュメントが質問に関連しているか評価する
- Web検索の判定:関連性評価に応じて、回答を生成するか、Web検索に進むかを判定します。
- Web検索:Web検索の判定、有用性の判定に応じて、Web検索を行う
- 回答の生成:取得したドキュメントとWeb検索の結果をもとにRAGで回答を生成する
- ハルシネーションの判定:生成された回答が事実に基づいているかを評価する
- 有用性の判定:生成された回答が質問を解決するのに役に立つかを評価する
LangGraphの実行環境
この記事で用意した実行環境は以下のとおりです。
- GPU:NVIDIA A100 80GB
- GPUメモリ(VRAM):80GB
- OS :Ubuntu 22.04
- Docker
Dockerで環境構築
Dockerを使用してLangChainの環境構築をします
Dockerの使い方は以下の記事をご覧ください。
Ubuntuのコマンドラインで、Dockerfileを作成します。
mkdir langgraph_agent
cd langgraph_agent
nano Dockerfile
Dockerfileに以下の記述を貼り付けます。
# ベースイメージ(CUDA)の指定
FROM nvidia/cuda:12.1.0-cudnn8-devel-ubuntu22.04
# 必要なパッケージをインストール
RUN apt-get update && apt-get install -y python3-pip python3-venv git nano curl pciutils lshw python3-dev graphviz libgraphviz-dev pkg-config
RUN curl -fsSL https://ollama.com/install.sh | sh
# 作業ディレクトリを設定
WORKDIR /app
# アプリケーションコードをコピー
COPY . /app
# Python仮想環境の作成
RUN python3 -m venv /app/.venv
# 仮想環境をアクティベートするコマンドを.bashrcに追加
RUN echo "source /app/.venv/bin/activate" >> /root/.bashrc
# JupyterLabのインストール
RUN /app/.venv/bin/pip install Jupyter jupyterlab
# LangChain関連のインストール
RUN /app/.venv/bin/pip install ollama langchain-ollama langchain langsmith langgraph langchain-chroma faiss-gpu langchain-community langchain_huggingface langchain_core tiktoken pygraphviz
# コンテナの起動時にbashを実行
CMD ["/bin/bash"]
FROM nvidia/cuda:12.1.0-cudnn8-devel-ubuntu22.04
CUDA12.1のベースイメージを指定しています。
RUN apt-get update && apt-get install -y python3-pip python3-venv git nano curl pciutils lshw python3-dev graphviz libgraphviz-dev pkg-config
必要なパッケージをインストールしています。
RUN curl -fsSL https://ollama.com/install.sh | sh
Linux版のOllamaをインストールしています。PythonでOllamaを動かす際にもLinux版Ollamaのインストールが必要になりますのでご注意ください。
RUN /app/.venv/bin/pip install Jupyter jupyterlab
JupyterLabをインストールしています。
RUN /app/.venv/bin/pip install ollama langchain-ollama langchain langsmith langgraph langchain-chroma faiss-gpu langchain-community langchain_huggingface langchain_core tiktoken pygraphviz
LangChainとOllama関連のパッケージをインストールしています。
LLMはOllamaのライブラリを使って動かしますので、PyTorchやTransformerは別途インストール不要です。
docker-compose.ymlでDockerコンテナの設定をします。
docker-compose.ymlのYAMLファイルを作成して開きます。
nano docker-compose.yml
以下のコードをコピーして、YAMLファイルに貼り付けます。
services:
langgraph_agent:
build:
context: .
dockerfile: Dockerfile
image: langgraph_agent
runtime: nvidia
container_name: langgraph_agent
ports:
- "8888:8888"
volumes:
- .:/app/langgraph_agent
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
command: >
bash -c '/usr/local/bin/ollama serve & /app/.venv/bin/jupyter lab --ip="*" --port=8888 --NotebookApp.token="" --NotebookApp.password="" --no-browser --allow-root'
bash -c ‘/usr/local/bin/ollama serve & /app/.venv/bin/jupyter lab –ip=”*” –port=8888 –NotebookApp.token=”” –NotebookApp.password=”” –no-browser –allow-root’
bash -c '/usr/local/bin/ollama serve
Ollama Serverを起動しています。PythonのOllamaを使用する際に、Ollama Serverを起動しておく必要がありますので、ご注意ください。
& /app/.venv/bin/jupyter lab --ip="*" --port=8888 --NotebookApp.token="" --NotebookApp.password="" --no-browser --allow-root'
JupyterLabを8888番ポートで起動しています。
Dockerfileからビルドしてコンテナを起動します。
docker compose up
Dockerの起動後にブラウザの検索窓に”localhost:8888″を入力すると、Jupyter Labをブラウザで表示できます。
localhost:8888
環境変数・LLM・Retrieverの設定
Dockerコンテナで起動したJupyter Lab上でLangChainを使ったRAGの実装をします。
LangChainとTavilyのAPIに関する環境変数を設定します。
import os
from uuid import uuid4
unique_id = uuid4().hex[0:8]
os.environ["LANGCHAIN_TRACING_V2"] = "true"
os.environ["LANGCHAIN_PROJECT"] = f"rag_agent - {unique_id}"
os.environ["LANGCHAIN_ENDPOINT"] = "https://api.smith.langchain.com"
os.environ["LANGCHAIN_API_KEY"] = "***************"
os.environ["TAVILY_API_KEY"] = "***************"
unique_id = uuid4().hex[0:8]
8桁のランダムな一意の識別子unique_id
を生成しています。
os.environ[“LANGCHAIN_TRACING_V2”] = “true”
この設定により、LangChainのトレースが可能になります。
os.environ[“LANGCHAIN_PROJECT”] = f”rag_agent – {unique_id}”
angChainプロジェクトの名前を設定しています。ここでは、生成したunique_id
を使用してプロジェクト名を「rag_agent – {unique_id}」の形式で一意にしています。
os.environ[“LANGCHAIN_ENDPOINT”] = “https://api.smith.langchain.com”
LangChainのAPIエンドポイントを指定しています。
os.environ[“LANGCHAIN_API_KEY”] = “***************”
LangChain APIを利用するためのAPIキーを設定しています。
os.environ[“TAVILY_API_KEY”] = “***************”
Tavily APIを利用するためのAPIキーを設定しています。
日本語LLMモデル「Llama-3-ELYZA-JP-8B-q4_k_m.gguf」をダウンロードします。
!curl -L -o Llama-3-ELYZA-JP-8B-q4_k_m.gguf "https://huggingface.co/elyza/Llama-3-ELYZA-JP-8B-GGUF/resolve/main/Llama-3-ELYZA-JP-8B-q4_k_m.gguf?download=true"
Llama-3-ELYZA-JPについては、別記事で詳しく解説しています。
LLMの実行にはOllamaを使用します。
LLMのモデルがOllama使えるようにプロンプトテンプレートを指定して、モデルを作成します。
import ollama
from langchain_community.chat_models import ChatOllama
modelfile='''
FROM ./Llama-3-ELYZA-JP-8B-q4_k_m.gguf
TEMPLATE """{{ if .System }}<|start_header_id|>system<|end_header_id|>
{{ .System }}<|eot_id|>{{ end }}{{ if .Prompt }}<|start_header_id|>user<|end_header_id|>
{{ .Prompt }}<|eot_id|>{{ end }}<|start_header_id|>assistant<|end_header_id|>
{{ .Response }}<|eot_id|>"""
PARAMETER stop "<|start_header_id|>"
PARAMETER stop "<|end_header_id|>"
PARAMETER stop "<|eot_id|>"
PARAMETER stop "<|reserved_special_token"
'''
ollama.create(model='elyza8b', modelfile=modelfile)
llm = ChatOllama(model="elyza8b", temperature=0)
llm.invoke(("human","ジブリを知っていますか?")).content
FROM ./Llama-3-ELYZA-JP-8B-q4_k_m.gguf
ダウンロードしたモデルのパスが入ります。
TEMPLATE “””{{ if .System }}<|start_header_id|>system<|end_header_id|>…
モデルで使用するプロンプトテンプレートが入ります。
ollama.create(model=’elyza8b’, modelfile=modelfile)
モデルとプロンプトテンプレートを使ってOllama用のモデルを作成します。model
にはOllamaで呼び出す際に使用する名前をつけられます。
ChatOllama(model=”elyza8b”, temperature=0)
ChatOllamaをインスタンス化してLLMモデルを実行できる状態にしています。
確認のため、LLMからテキストを生成しています。
'スタジオジブリは日本のアニメーション制作会社で、多くの名作を生み出しています。宮崎駿監督や高畑勲監督などが有名です。\n\n代表作には、「風の谷のナウシカ」、「天空の城ラピュタ」、「もののけ姫」、「千と千尋の神隠し」、「ハウルの動く城」、「崖の上のポニョ」、「借りぐらしのアリエッティ」などがあります。\n\nジブリ作品は、美しいアニメーションやストーリーが特徴で、世界中で愛されています。'
Ollamaの使い方については、別の記事で詳しく解説しています。
ベクトルストアに格納するデータをWikipediaから取得します。
Wikipediaは、『もののけ姫』、『千と千尋の神隠し』、『風の谷のナウシカ』のページを指定しています。
from langchain_community.document_loaders import WebBaseLoader
from langchain.text_splitter import CharacterTextSplitter
urls = [
"https://ja.wikipedia.org/wiki/%E3%82%82%E3%81%AE%E3%81%AE%E3%81%91%E5%A7%AB",
"https://ja.wikipedia.org/wiki/%E5%8D%83%E3%81%A8%E5%8D%83%E5%B0%8B%E3%81%AE%E7%A5%9E%E9%9A%A0%E3%81%97",
"https://ja.wikipedia.org/wiki/%E9%A2%A8%E3%81%AE%E8%B0%B7%E3%81%AE%E3%83%8A%E3%82%A6%E3%82%B7%E3%82%AB",
]
docs = [WebBaseLoader(url).load() for url in urls]
docs_list = [item for sublist in docs for item in sublist]
text_splitter = CharacterTextSplitter.from_tiktoken_encoder(
separator = "\n",
chunk_size= 1000,
chunk_overlap=100,
)
doc_splits = text_splitter.split_documents(docs_list)
doc_splits[10]
WebBaseLoader(url).load()
指定したWebページのHTMLコンテンツを取得し、HTMLコンテンツを解析し、使いやすいデータ構造に変換し、そのデータを読み込みます。
CharacterTextSplitter.from_tiktoken_encoder…
テキストを指定された条件でチャンクに分割します。
separator = “\n”
チャンクを分割する際の区切り文字を「\n」として指定します。
chunk_size
各チャンクの最大トークン数を設定します。
chunk_overlap
各チャンク間で重複するトークン数を設定します。
確認のため、分割したチャンクを1つ表示しています。
Document(metadata={'source': 'https://ja.wikipedia.org/wiki/%E3%82%82%E3%81%AE%E3%81%AE%E3%81%91%E5%A7%AB', 'title': 'もののけ姫 - Wikipedia', 'language': 'ja'}, page_content='ラスト\n池で月光を浴び、夜の姿に変わろうとするシシ神を見つけたエボシは、気絶したサンを抱えたアシタカが止めるのも構わず、遂にその首を取る。するとシシ神の体から不気味な体液が大量に飛び散り、それに触れた者達は死に、木は枯れてしまう。やがて体液は津波のような勢いで山を埋め尽くし、森は枯れ果てて、タタラ場も壊滅してしまうのであった。\n目覚めたサンは、森を見て森が死んだと絶望し、人間に対する憎しみを爆発させる。しかし、アシタカはまだ望みはあるとサンを説得し、二人は協力して、シシ神の首を持って逃げようとするジコ坊を押し留め、首をシシ神に返す。シシ神は首を取り戻したが、朝日を浴びると同時に地に倒れて消える。その瞬間に風が吹き、枯れ果てた山には僅かながら緑が戻り、アシタカの腕の呪いも消えた。\nエピローグ\nアシタカのプロポーズに対し、サンは「アシタカは好きだが、人間を許す事は出来ない」と答える。アシタカは「それでもいい、サンは森で私はタタラ場で暮らそう、共に生きよう」と語る。エボシもタタラ場の村人達に、「新たに良い村を作ろう」と語りかけるのであった。\n最後に、倒れた一本の大木の上に芽生えた若木の横に、1体のコダマが現れて、頭を動かしカラカラと音を立てる場面で終わる。\n登場人物[編集]\n主要人物[編集]\nアシタカ')
Wikipediaのデータから、ベクトル検索が可能なRetrieverを構築します。
Wikipediaは、『もののけ姫』、『千と千尋の神隠し』、『風の谷のナウシカ』のページを指定しています。
from langchain.vectorstores import FAISS
from langchain_huggingface import HuggingFaceEmbeddings
embedding = HuggingFaceEmbeddings(model_name="intfloat/multilingual-e5-large")
vectorstore = FAISS.from_documents(doc_splits, embedding)
retriever = vectorstore.as_retriever(search_type="similarity", search_kwargs={"k": 3})
question = "王蟲は怒るとどうなりますか"
retriever.invoke(question)
HuggingFaceEmbeddings(model_name=”intfloat/multilingual-e5-large”)
テキストをベクトル表現に変換する埋め込みモデルを読み込みます。
intfloat/multilingual-e5-large
は日本語性能が高く、無料で使える埋め込みモデルです。
FAISS.from_documents(splits, embedding)
ベクトル検索用のインデックスを作成しています。インデックスはDBのテーブルのような概念です。
vector.as_retriever()
インデックスからRetriverを作成しています。
search_type=”similarity”
質問の類似度にもとづいて検索する指定です。
search_kwargs={“k”: 3}
検索結果として上位3件の類似ドキュメントを返す設定です。
retriever.invoke(question)
質問の内容に類似したドキュメントをRetriverで検索します。
Retriverから取得したデータを確認しています。
Document(metadata={'source': 'https://ja.wikipedia.org/wiki/%E9%A2%A8%E3%81%AE%E8%B0%B7%E3%81%AE%E3%83%8A%E3%82%A6%E3%82%B7%E3%82%AB', 'title': '風の谷のナウシカ - Wikipedia', 'language': 'ja'}, page_content='王蟲(別表記:オーム[86][87]、英:Ohmu、NWP:Giant Gorgon)\n最大の蟲。現実世界の等脚類(ワラジムシ目動物)を巨大化したような、十数節の体節からなる濃緑色の体と多数の歩脚をもつ[88]。第3節に6個、第4節に8個の計14個のドーム状の眼があり、普段は青いが怒ると赤く、気絶すると灰色になる[89]。水中でも活動できる地上棲で卵生。卵から孵化した幼生は体長50 cm程で、脱皮を繰り返して成長し成体は体長70 mを超える[83]。体液は青く[注 7][90]、王蟲の血に染まった服は蟲の怒りを鎮める力がある[91]。口腔内には直径数cmの糸状かつ金色の触手が無数にある[92]。また「漿液(しょうえき)」と呼ばれる透明で粘性のある液体を分泌することができ、この漿液を人間が肺に満たす事で液体呼吸が可能となる[93][94]。腐海の“大木”であるムシゴヤシを好んで食べ[55]、王蟲がムシゴヤシを食べ進んだ跡は森の中にトンネル状の空間となって残り「王蟲の道」と呼ばれる。\n表皮は作中で一般的な超硬質セラミックよりも非常に堅牢[95]かつ弾性に富み[96]、軽量[97]であって脱皮殻は装甲板、刃物や甲冑に加工される。特に眼の部分は透明なドーム状で、ゴーグルのレンズ[27]やガンシップの風防[98]に利用される。300年前の大海嘯は、古代エフタル王国の王位継承を巡る内乱によって増大した武器の需要に応える為に王蟲が乱獲された事が原因だったと伝えられている[61]。風の谷の戦士は、戦場に出る際には王蟲の甲皮から作られた胴鎧と手甲を着ける[27]。')
各種Chainの構築
LangGraphのワークフロー内の関数で使用するChainを構築していきます。
- ドキュメントの関連性を評価するChain
- 回答を生成をするChain
- ハルシネーションを評価するChain
- 回答の有用性を評価するChain
- Web検索をするTool
- ルーターに使用するChain
ユーザーの質問と取得したドキュメントがどれだけ関連しているかを評価するChainを構築しています。
評価はバイナリースコア(yes または no)で表され、その結果はJSON形式で返されます。
# retrieval_chain
from langchain_core.output_parsers import JsonOutputParser
from langchain_core.prompts import PromptTemplate
prompt = PromptTemplate(
template="""<|begin_of_text|><|start_header_id|>system<|end_header_id|>
あなたは、「ユーザーの質問」と「取得したドキュメント」の関連性を評価する採点者です。
もしユーザーの質問のキーワードが、ドキュメント含まれる場合は、「関連性がある」と評価してください。
厳密な評価は不要です。
ドキュメントが質問に関連しているかどうかを示すために、バイナリースコア「yes」または「no」を付けてください。
バイナリースコアは、前置きや説明なしで、単一のキー「score」を持つJSONとして提供してください。
<|eot_id|><|start_header_id|>user<|end_header_id|>
取得したドキュメント: \n\n {documents} \n\n
ユーザーの質問: {question} \n <|eot_id|><|start_header_id|>assistant<|end_header_id|>
""",
input_variables=["question", "documents"],
)
llm = ChatOllama(model="elyza8b",format="json",temperature=0)
retrieval_chain = prompt | llm | JsonOutputParser()
question = "カオナシとは?"
docs = retriever.invoke(question)
doc_txt = docs[0].page_content
print(retrieval_chain.invoke({"question": question, "documents": doc_txt}))
print("\nユーザーの質問:\n" + question)
print("\n取得したドキュメント:\n" + docs[0].page_content)
PromptTemplate()
プロンプトのテンプレートを定義するために使用されるクラスです。テンプレート内で、指定された変数に動的に値を埋め込み、LLMに指示を与えるプロンプトを生成します。
template
には、LLMに対してどのような指示を与えるかを記述します。
<|begin_of_text|><|start_header_id|>system<|end_header_id|> ...<|eot_id|>
には、システムプロンプトが入ります。
<|start_header_id|>user<|end_header_id|>...<|eot_id|>
には、ユーザーの質問が入ります。
<|start_header_id|>assistant<|end_header_id|>...
には、LLMの回答が入ります。
input_variables
では、テンプレート内で動的に置き換えられる変数名をリストで指定します。
この場合、question
と documents
の2つの変数が指定されています。
prompt | llm | JsonOutputParser()
プロンプトの作成から、LLMによる応答の生成、JSON形式の出力までを一連の処理として組み合わせたChainを構築しています。
retrieval_chain.invoke({“question”: question, “documents”: doc_txt})
questionとdocumentsをプロンプトに渡して、定義したChainを実行しています。
出力結果の確認
{'score': 'yes'}
ユーザーの質問:
カオナシとは?
取得したドキュメント:
魔法の力ですすから生まれたらしく、働いていないとすすに戻ってしまう。
釜爺の指示で石炭を抱えて運び、ボイラー室の炉に放り込むのが仕事。休憩時間の際は金平糖を食事として与えられている。千尋の服と靴を預かるなど、釜爺と共に千尋を手助けする。千尋に最初に会った時、一匹が自分の体よりも大きい石炭を運ぼうとして千尋の目の前で潰れてしまい、彼女が代わりに運んであげた。彼女が石炭を持ち上げた時、潰れた一匹は手足のない状態で復活したが、彼女の質問を無視して宙を飛び巣穴に戻ってしまった。(以下省略)
回答を生成するChainを構築します。
# generate_chain
from langchain_core.output_parsers import StrOutputParser
prompt = PromptTemplate(
template="""<|begin_of_text|><|start_header_id|>system<|end_header_id|>
あなたは質問応答タスク用のアシスタントです。
取得したコンテキストを使用して質問に回答してください。
答えがわからない場合は、「わからない」と回答してください。
回答は最大3文までとし、簡潔にしてください。
<|eot_id|><|start_header_id|>user<|end_header_id|>
質問: {question}
コンテキスト: {context}
回答: <|eot_id|><|start_header_id|>assistant<|end_header_id|>""",
input_variables=["question", "context"],
)
def format_docs(docs):
return "\n\n".join(doc.page_content for doc in docs)
llm = ChatOllama(model="elyza8b",temperature=0)
generate_chain = prompt | llm | StrOutputParser()
question = "カオナシとは?"
docs = retriever.invoke(question)
generation = generate_chain.invoke({"context": docs, "question": question})
print("\nユーザーの質問:\n" + question)
print("\n生成した回答:\n" +generation)
prompt | llm | StrOutputParser()
プロンプトの作成から、LLMによる応答の生成、文字列形式の出力までを一連の処理として組み合わせたChainを構築しています。
出力結果の確認
ユーザーの質問:
カオナシとは?
生成した回答:
カオナシとは、千と千尋の神隠しに登場する化け物です。黒い影のような体にお面をつけたような姿をしていて、言葉は話せず「ア」や「エ」というか細い声を絞り出します。
生成された回答が事実に基づいているかどうかを判定するChainを構築しています
# hallucination_chain
prompt = PromptTemplate(
template=""" <|begin_of_text|><|start_header_id|>system<|end_header_id|>
あなたは、ある回答が事実に基づいているか、または事実によって裏付けられているかを評価する採点者です
<|eot_id|><|start_header_id|>user<|end_header_id|>
以下は事実です:
\n ------- \n
{documents}
\n ------- \n
以下は回答です: {generation}
回答が事実に基づいているか、または裏付けられているかを示すために、
バイナリスコアを「score」というキーを持つJSON形式で、「yes」または「no」で評価してください。
<|eot_id|><|start_header_id|>assistant<|end_header_id|>""",
input_variables=["generation", "documents"],
)
llm = ChatOllama(model="elyza8b",format="json",temperature=0)
hallucination_chain = prompt | llm | JsonOutputParser()
print(hallucination_chain.invoke({"documents": docs, "generation": generation}))
print("\n回答:\n" + generation)
print("\n事実:\n" + docs[0].page_content)
出力結果の確認
{'score': 'yes'}
回答:
カオナシとは、千と千尋の神隠しに登場する化け物です。黒い影のような体にお面をつけたような姿をしていて、言葉は話せず「ア」や「エ」というか細い声を絞り出します。
事実:
魔法の力ですすから生まれたらしく、働いていないとすすに戻ってしまう。
釜爺の指示で石炭を抱えて運び、ボイラー室の炉に放り込むのが仕事。休憩時間の際は金平糖を食事として与えられている。千尋の服と靴を預かるなど、釜爺と共に千尋を手助けする。千尋に最初に会った時、一匹が自分の体よりも大きい石炭を運ぼうとして千尋の目の前で潰れてしまい、彼女が代わりに運んであげた。彼女が石炭を持ち上げた時、潰れた一匹は手足のない状態で復活したが、彼女の質問を無視して宙を飛び巣穴に戻ってしまった。(以下省略)
生成された回答が質問を解決するのに役に立つかを判定するChainを構築しています
# answer_chain
prompt = PromptTemplate(
template="""<|begin_of_text|><|start_header_id|>system<|end_header_id|>
あなたは、回答が質問を解決するのに役立つかどうかを評価する採点者です。
回答が質問を解決するのに役立つかどうかを示すために、バイナリースコア「yes」または「no」を付けてください。
バイナリースコアは、前置きや説明なしで、単一のキー「score」を持つJSONとして提供してください。
<|eot_id|><|start_header_id|>user<|end_header_id|>
回答:
\n ------- \n
{generation}
\n ------- \n
質問: {question}
<|eot_id|><|start_header_id|>assistant<|end_header_id|>""",
input_variables=["generation", "question"],
)
llm = ChatOllama(model="elyza8b",format="json",temperature=0)
answer_chain = prompt | llm | JsonOutputParser()
print(answer_chain.invoke({"question": question, "generation": generation}))
print("\n回答:\n" + generation)
print("\n質問:\n" + question)
出力結果の確認
{'score': 'yes'}
回答:
カオナシとは、千と千尋の神隠しに登場する化け物です。黒い影のような体にお面をつけたような姿をしていて、言葉は話せず「ア」や「エ」というか細い声を絞り出します。
質問:
カオナシとは?
Tavily APIを利用してWEB検索を行うToolの設定をします。
# web_search_tool
from langchain_community.tools.tavily_search import TavilySearchResults
web_search_tool = TavilySearchResults(max_results=5)
question = "カオナシとは?"
web_search_tool.invoke({"query": question})
TavilySearchResults(max_results=5)
Tavily の検索 API を利用して、指定された検索クエリに対して上位5件の検索結果を取得します。
カオナシについてWeb検索した結果
[{'url': 'https://selvy.jp/chihiro_kaonashi/',
'content': 'カオナシとは、千と千尋の神隠しに登場する謎に包まれたキャラクターです。 黒い胴体をしており、若干透き通っている身体が特徴的でしょう。 顔にはお面のようなものを付けており、作中ではお面の下に口がありました。'},
{'url': 'https://comic-kingdom.jp/ghibli-kaonashi/',
'content': 'カオナシの正体は油屋を訪れた神様とはまた違う「己というものを持たない悲しい存在」です。 己がないからこそ、物をプレゼントをすることでしかコミュニケーションが取れず、油屋の従業員に対して金をばら撒いていたのです。'},
(以下省略)
ユーザーの質問を解析して、適切なデータソース(「ベクトルストア」または「ウェブ検索」)をルーティングするためのChainを構築しています。
# router_chain
prompt = PromptTemplate(
template="""<|begin_of_text|><|start_header_id|>system<|end_header_id|>
あなたは、ユーザーの質問をベクトルストアまたはウェブ検索にルーティングする専門家です。
ジブリに関する質問についてはベクトルストアを使用してください。
これらのトピックに関連する質問のキーワードに厳密である必要はありません。それ以外の場合はウェブ検索を使用してください。
質問に基づいてバイナリーチョイス「web_search」または「vectorstore」を返してください。
前置きや説明なしで、単一のキー「datasource」を持つJSONとして返してください。
ルーティングする質問: {question}
<|eot_id|><|start_header_id|>assistant<|end_header_id|>""",
input_variables=["question"],
)
llm = ChatOllama(model="elyza8b",format="json",temperature=0)
router_chain = prompt | llm | JsonOutputParser()
question = "カオナシとは?"
docs = retriever.get_relevant_documents(question)
doc_txt = docs[0].page_content
print(router_chain.invoke({"question": question}))
print("\n質問:\n" + question)
print("\nベクトルストアから取得した情報:\n" + doc_txt)
ユーザーの質問とベクトルストアから取得した情報
ユーザーの質問:
カオナシとは?
ベクトルストアから取得した情報:
魔法の力ですすから生まれたらしく、働いていないとすすに戻ってしまう。
釜爺の指示で石炭を抱えて運び、ボイラー室の炉に放り込むのが仕事。休憩時間の際は金平糖を食事として与えられている。千尋の服と靴を預かるなど、釜爺と共に千尋を手助けする。千尋に最初に会った時、一匹が自分の体よりも大きい石炭を運ぼうとして千尋の目の前で潰れてしまい、彼女が代わりに運んであげた。彼女が石炭を持ち上げた時、潰れた一匹は手足のない状態で復活したが、彼女の質問を無視して宙を飛び巣穴に戻ってしまった。(以下省略)
Langgraphワークフローで使用する関数の定義
LangGraphのワークフローで使用する各種関数の定義をしていきます。
- GraphStateの定義
- Retriver関数
- 回答を生成する関数
- ドキュメントの関連性を評価する関数
- Web検索の関数
- ルーター関数
- 追加Web検索を判断する関数
- 「ハルシネーション」と「回答の有用性」を判定する関数
必要なライブラリをインポートします。
from pprint import pprint
from typing import List
from langchain_core.documents import Document
from typing_extensions import TypedDict
Graphを初期化したときのノードやエッジの状態を定義します。
class GraphState(TypedDict):
question: str
generation: str
web_search: str
documents: List[str]
Retriverを使用してベクトルストアからユーザーの質問に関連するドキュメントを取得します。
「question(質問)」と「document(ドキュメント)」を次のステップで使用するため、状態 (state) を更新します。
# Retriver関数
def retrieve(state):
print("---RETRIEVE---")
question = state["question"]
documents = retriever.invoke(question)
return {"documents": documents, "question": question}
ユーザーの質問にもとづいた回答を生成します。
「question(質問)」と「document(ドキュメント)」、「generation(回答)」を次のステップで使用するため、状態 (state) を更新します。
# 回答を生成する関数
def generate(state):
print("---GENERATE---")
question = state["question"]
documents = state["documents"]
generation = generate_chain.invoke({"context": documents, "question": question})
return {"documents": documents, "question": question, "generation": generation}
取得したドキュメントが質問に関連しているかどうかを評価します。
関連性の高いドキュメントを選別し、リストに格納します。
関連性が低いドキュメントが存在する場合、Web検索が必要なフラグを立てます。
「question(質問)」、「filtered_docs(関連性リスト)」、「web_search(Web検索フラグ)」を次のステップで使用するため、状態(sate)を更新します。
# ドキュメントの関連性を評価する関数
def grade_documents(state):
print("---CHECK DOCUMENT RELEVANCE TO QUESTION---")
question = state["question"]
documents = state["documents"]
filtered_docs = []
web_search = "No"
for d in documents:
score = retrieval_chain.invoke(
{"question": question, "documents": d.page_content}
)
grade = score["score"]
if grade.lower() == "yes":
print("---GRADE: DOCUMENT RELEVANT---")
filtered_docs.append(d)
else:
print("---GRADE: DOCUMENT NOT RELEVANT---")
web_search = "Yes"
continue
return {"documents": filtered_docs, "question": question, "web_search": web_search}
質問に基づいてウェブ検索を実行します。検索結果を取得して、それをドキュメントに追加します。
「question(質問)」と「document(ドキュメント)」を次のステップで使用するため、状態(state)を更新します。
# Web検索の関数
def web_search(state):
print("---WEB SEARCH---")
question = state["question"]
documents = state["documents"]
docs = web_search_tool.invoke({"query": question})
web_results = "\n".join([d["content"] for d in docs])
web_results = Document(page_content=web_results)
if documents is not None:
documents.append(web_results)
else:
documents = [web_results]
return {"documents": documents, "question": question}
質問をRAGで処理するか、Web検索にルーティングするかを決定します。
この関数は「websearch」または「vectorstore」を返します。この返り値は、後続のステップで利用されます。
# ルーター関数
def route_question(state):
print("---ROUTE QUESTION---")
question = state["question"]
print(question)
source = router_chain.invoke({"question": question})
print(source)
print(source["datasource"])
if source["datasource"] == "web_search":
print("---ROUTE QUESTION TO WEB SEARCH---")
return "websearch"
elif source["datasource"] == "vectorstore":
print("---ROUTE QUESTION TO RAG---")
return "vectorstore"
ドキュメントが質問に適切に関連しているかどうかを基に、生成された回答を使用するか、追加のウェブ検索を行うかを決定します。
この関数は「websearch」または「generate」を返します。この返り値は、後続のステップで利用されます。
# 追加Web検索を判断する関数
def decide_to_generate(state):
print("---ASSESS GRADED DOCUMENTS---")
state["question"]
web_search = state["web_search"]
state["documents"]
if web_search == "Yes":
print(
"---DECISION: ALL DOCUMENTS ARE NOT RELEVANT TO QUESTION, INCLUDE WEB SEARCH---"
)
return "websearch"
else:
print("---DECISION: GENERATE---")
return "generate"
生成された回答がドキュメントに基づいているか(ハルシネーション)を評価します。
ハルシネーションのチェックを通過した場合、回答が質問を解決するのに役に立つか(回答の有用性)を評価します。
# 「ハルシネーション」と「回答の有用性」を判定する関数
def grade_generation_v_documents_and_question(state):
print("---CHECK HALLUCINATIONS---")
question = state["question"]
documents = state["documents"]
generation = state["generation"]
score = hallucination_chain.invoke(
{"documents": documents, "generation": generation}
)
grade = score["score"]
if grade == "yes":
print("---DECISION: GENERATION IS GROUNDED IN DOCUMENTS---")
print("---GRADE GENERATION vs QUESTION---")
score = answer_chain.invoke({"question": question, "generation": generation})
grade = score["score"]
if grade == "yes":
print("---DECISION: GENERATION ADDRESSES QUESTION---")
return "useful"
else:
print("---DECISION: GENERATION DOES NOT ADDRESS QUESTION---")
return "not useful"
else:
pprint("---DECISION: GENERATION IS NOT GROUNDED IN DOCUMENTS, RE-TRY---")
return "not supported"
LangGraphワークフローの構築
下図のLangGraphのワークフローを構築していきます。
- ルーター:質問に応じてベクトルストアを使うか、Web検索するかを選択する
- Retriver:質問をもとにベクトルストアからドキュメントを取得する
- ドキュメントの関連性評価:ベクトルストアから取得したドキュメントが質問に関連しているか評価する
- Web検索の判定:関連性評価に応じて、回答を生成するか、Web検索に進むかを判定します。
- Web検索:Web検索の判定、有用性の判定に応じて、Web検索を行う
- 回答の生成:取得したドキュメントとWeb検索の結果をもとにRAGで回答を生成する
- ハルシネーションの判定:生成された回答が事実に基づいているかを評価する
- 有用性の判定:生成された回答が質問を解決するのに役に立つかを評価する
必要なライブラリをインポートします。
from langgraph.graph import END, StateGraph, START
from IPython.display import Image, display
from langchain_core.runnables.graph import CurveStyle, MermaidDrawMethod, NodeStyles
Graphのワークフローを作成し、各ノードを追加します。
#Graphのワークフローを作成
workflow = StateGraph(GraphState)
#ノードの追加
workflow.add_node("websearch", web_search)
workflow.add_node("retrieve", retrieve)
workflow.add_node("grade_documents", grade_documents)
workflow.add_node("generate", generate)
StateGraph(GraphState)
StateGraph
は、複数の処理ステップ(ノード)をグラフ構造として扱い、それぞれのノード間で状態を渡しながら処理を進めることができます。
workflow.add_node(“websearch”, web_search)
websearch
という名前のノードがワークフローに追加され、web_search
関数がそのノードで実行されます。
ワークフローにおけるエッジ(ノード間の接続)の設定を行います。
エッジは、各ノードをどの順序で実行するか、どのような条件で次のノードに進むかを定義します。
# Edgeの追加
workflow.add_conditional_edges(
START,
route_question,
{
"websearch": "websearch",
"vectorstore": "retrieve",
},
)
workflow.add_edge("retrieve", "grade_documents")
workflow.add_conditional_edges(
"grade_documents",
decide_to_generate,
{
"websearch": "websearch",
"generate": "generate",
},
)
workflow.add_edge("websearch", "generate")
workflow.add_conditional_edges(
"generate",
grade_generation_v_documents_and_question,
{
"not supported": "generate",
"useful": END,
"not useful": "websearch",
},
)
workflow.add_conditional_edges()
LangGraphワークフロー内で条件付きエッジを設定するために使用されます。このメソッドは、特定のノードが実行された後に、次に進むべきノードを条件に基づいて選択します。
workflow.add_conditional_edges(
START,
route_question,
{
"websearch": "websearch",
"vectorstore": "retrieve",
},
)
START
はワークフローの開始点を示します。すべてのワークフローはこのノードから始まります。
route_question
関数の実行結果にもとづいて、ワークフローはwebsearch
またはvectorstore
にルーティングされるべきかを判断します。websearch
にルーティングされた場合、ワークフローはwebsearch
ノードに進みます。vectorstore
にルーティングされた場合、ワークフローはretrieve
ノードに進みます。
workflow.add_conditional_edges(
"generate",
grade_generation_v_documents_and_question,
{
"not supported": "generate",
"useful": END,
"not useful": "websearch",
},
)
generate
ノードは、質問に対して応答を生成するステップです。
grade_generation_v_documents_and_question
関数は次の3つの条件分岐をします。
1)生成された応答がドキュメントに基づいていない、または信頼性が低いと判断された場合、not supported
が返され、ワークフローは再度generate
ノードに戻り、新しい応答が生成されます。
2)生成された応答が質問に対して有用であると評価された場合、useful
が返され、ワークフローはEND
に進み、プロセスが完了します。
3)生成された応答が質問に対して有用でないと判断された場合、not useful
が返され、ワークフローは再度websearch
ノードに戻り、別の情報源から新しいデータを取得して応答を生成し直します。
workflow.add_edge()
LangGraphワークフロー内で、特定のノードから次のノードへ直線的な接続するために使われます。
例えば、workflow.add_edge("retrieve", "grade_documents")
では、retrieve
ノードの処理が完了したあとにgrade_documents
ノードの処理に進みます。
全体のワークフローをコンパイルして、実行可能な形にします。
# コンパイル
app = workflow.compile()
定義したLangGraphワークフローのグラフ構造を、Mermaid形式で可視化します。
# ワークフローの可視化
display(
Image(
app.get_graph().draw_mermaid_png(
draw_method=MermaidDrawMethod.API,
)
)
)
RAGエージェントに『ジブリ』について聞いてみる
RAGエージェントに『ジブリ』について質問をしてみます。
RAGエージェントに質問する(1)
「シシ神様は夜になると何に変わりますか?」と質問してみます。
from pprint import pprint
inputs = {"question": "シシ神様は夜になると何に変わりますか?"}
for output in app.stream(inputs):
for key, value in output.items():
pprint(f"Finished running: {key}:")
pprint(value["generation"])
質問:
シシ神様は夜になると何に変わりますか?
—ROUTE QUESTION—
シシ神様は夜になると何に変わりますか?
{‘datasource’: ‘vectorstore’}
vectorstore
—ROUTE QUESTION TO RAG—
—RETRIEVE—
‘Finished running: retrieve:’
—CHECK DOCUMENT RELEVANCE TO QUESTION—
—GRADE: DOCUMENT RELEVANT—
—GRADE: DOCUMENT RELEVANT—
—GRADE: DOCUMENT RELEVANT—
—ASSESS GRADED DOCUMENTS—
—DECISION: GENERATE—
‘Finished running: grade_documents:’
—GENERATE—
—CHECK HALLUCINATIONS—
—DECISION: GENERATION IS GROUNDED IN DOCUMENTS—
—GRADE GENERATION vs QUESTION—
—DECISION: GENERATION ADDRESSES QUESTION—
‘Finished running: generate:’
‘夜になると、シシ神は頭と背中に無数のとげのようなものがついたディダラボッチに変わります。’
RAGエージェントに質問する(2)
「王蟲はなぜ眼が赤くなったのですか?」と質問してみます。
from pprint import pprint
app = workflow.compile()
inputs = {"question": "王蟲はなぜ眼が赤くなったのですか?"}
for output in app.stream(inputs):
for key, value in output.items():
pprint(f"Finished running: {key}:")
pprint(value["generation"])
質問:
王蟲はなぜ眼が赤くなったのですか?
—ROUTE QUESTION—
王蟲はなぜ眼が赤くなったのですか?
{‘datasource’: ‘vectorstore’}
vectorstore
—ROUTE QUESTION TO RAG—
—RETRIEVE—
‘Finished running: retrieve:’
—CHECK DOCUMENT RELEVANCE TO QUESTION—
—GRADE: DOCUMENT RELEVANT—
—GRADE: DOCUMENT RELEVANT—
—GRADE: DOCUMENT NOT RELEVANT—
—ASSESS GRADED DOCUMENTS—
—DECISION: ALL DOCUMENTS ARE NOT RELEVANT TO QUESTION, INCLUDE WEB SEARCH—
‘Finished running: grade_documents:’
—WEB SEARCH—
‘Finished running: websearch:’
—GENERATE—
—CHECK HALLUCINATIONS—
—DECISION: GENERATION IS GROUNDED IN DOCUMENTS—
—GRADE GENERATION vs QUESTION—
—DECISION: GENERATION ADDRESSES QUESTION—
‘Finished running: generate:’
‘王蟲は普段は青い眼ですが、怒ると赤くなるという特徴があります。
RAGエージェントに質問する(3)
「千尋の親はなぜ豚に変えられたのですか?」と質問してみます。
from pprint import pprint
app = workflow.compile()
inputs = {"question": "千尋の親はなぜ豚に変えられたのですか?"}
for output in app.stream(inputs):
for key, value in output.items():
pprint(f"Finished running: {key}:")
pprint(value["generation"])
質問:
千尋の親はなぜ豚に変えられたのですか?
—ROUTE QUESTION—
千尋の親はなぜ豚に変えられたのですか?
{‘datasource’: ‘vectorstore’}
vectorstore
—ROUTE QUESTION TO RAG—
—RETRIEVE—
‘Finished running: retrieve:’
—CHECK DOCUMENT RELEVANCE TO QUESTION—
—GRADE: DOCUMENT RELEVANT—
—GRADE: DOCUMENT NOT RELEVANT—
—GRADE: DOCUMENT RELEVANT—
—ASSESS GRADED DOCUMENTS—
—DECISION: ALL DOCUMENTS ARE NOT RELEVANT TO QUESTION, INCLUDE WEB SEARCH—
‘Finished running: grade_documents:’
—WEB SEARCH—
‘Finished running: websearch:’
—GENERATE—
—CHECK HALLUCINATIONS—
—DECISION: GENERATION IS GROUNDED IN DOCUMENTS—
—GRADE GENERATION vs QUESTION—
—DECISION: GENERATION ADDRESSES QUESTION—
‘Finished running: generate:’
‘千尋の両親が豚に変えられたのは、八百万の神々に用意された料理に手をつけて食い散らかしたことへの罰です。’
生成AI・LLMのコストでお困りなら
GPUのスペック不足で生成AIの開発が思うように進まないことはありませんか?
そんなときには、高性能なGPUをリーズナブルな価格で使えるGPUクラウドサービスがおすすめです!
GPUSOROBANは、生成AI・LLM向けの高速GPUを業界最安級の料金で使用することができます。
インターネット環境さえあれば、クラウド環境のGPUサーバーをすぐに利用可能です。
大規模な設備投資の必要がなく、煩雑なサーバー管理からも解放されます。