背景
昨今、生成AI技術の進化が著しいですが、その中でも特にRAG (Retrieval-Augmented Generation)は、顕在化したビジネスニーズの大きさを背景に注目を集めています。しかし、技術要素の新規性が高いため、多くのエンジニアにとっては具体的な構築方法がイメージしづらいのが現状です。また、RAGは精度向上のために工夫が必要な箇所が数多く存在し、業務レベルのRAG実装にはこれらを幅広く理解して適切に対応することが求められます。
本記事では、RAGの基礎から最適化の入門まで、概念とコードの両面から解説し、エンジニアが自身のプロジェクトに実践的に活用できるガイドを提供します。
RAGの概要
概要
RAG (Retrieval-Augmented Generation)は、利用者の質問文に関連のある外部情報を都度参照することで、LLM (Large Language Model、大規模言語モデル)によるテキスト生成タスクの精度を向上させる手法です。言い換えると、LLMに検索機能を付与したようなシステムです。
これによってLLM単体の場合、あるいは検索機能単体の場合の課題を効果的に解決しています。
例えばChatGPTのようなLLM単体では、日常会話形式の文章での質問が可能ですが、LLMの保有している知識の範疇でしか回答ができず、最新のデータや会社情報のような非公開の情報については回答できません。
一方で、Webブラウジングや社内ドキュメントのキーワード検索では、特定の情報に直接アクセスできるものの、情報の中から関連したパートを抽出したり、文脈を考慮して情報を統合するのが難しいという問題があります。
そこでRAGを活用することによって、外部の動的な情報をリアルタイムで取得しつつ、それを文脈に応じて統合することで、より正確で一貫性のある回答を得ることができるようになります。
RAGの仕組み
RAGの基本的な仕組みは以下の図のようになっています。
手順をひとつずつ紹介していきます。
1. データの事前登録
検索対象となるデータを、あらかじめデータベースに登録しておきます。
2. 質問の入力
利用者がインターフェースに文章で質問を入力します。
3. 検索クエリの生成
利用者が入力した質問文を基に検索クエリを生成します。クエリの生成にはLLMを活用したり、ルールベースの処理を適用したりします。
データベースの種類に応じて、以下のようなクエリが生成されます:
- ベクターデータベースの場合:質問文をベクトルに変換
- リレーショナルデータベースの場合:SQLクエリを生成
- グラフデータベースの場合:Cypherクエリを生成
4. 検索の実行
生成された検索クエリを使用してデータベースに検索をかけ、関連するデータを取得します。
5. 検索結果のプロンプト化
検索結果を利用者の質問文と事前に設定されたプロンプトと組み合わせて、コンテキスト付きのプロンプトを作成します。
6. 回答生成
コンテキスト付きのプロンプトをLLMに入力し、利用者の質問に対する回答を生成します。
シンプルなRAGの限界
RAGは情報検索に革新的な改善をもたらしましたが、シンプルな構成では依然としていくつかの課題が存在します。
例えば、利用者が入力する質問文の言い回しによって回答が大きく変化してしまう場合や、事前登録した情報に存在しない架空の内容を誤って出力してしまう(いわゆるハルシネーション)といった問題が挙げられます。
こういった問題点はRAGのアーキテクチャの工夫によって解決できることがあります。
RAGの最適化
概要
RAGの精度向上には、LLM自体の精度向上とRAGアーキテクチャの工夫という二つのアプローチがあります。特にアーキテクチャの工夫においては、各プロセスごとに最適化を行ったり、結果をフィードバックして複数のプロセスを繰り返す手法が効果的です。
最適化手法
ここでは、RAGアーキテクチャの工夫に焦点を当て、各プロセスに対して一般的に用いられる最適化手法について解説します。
- データベース
- データベースの種類の選択
- データベースの種類を工夫することで、RAGの精度を向上させることができます。一般的には、テキストをベクトル化して検索を行うベクターデータベースが用いられますが、リレーショナルデータベース(RDB)やグラフデータベースを活用することも可能です。また、これら複数のデータベースに対して並列的に検索を実行し、後段で結果を統合するアプローチも有効です(Hybrid Search)。
- データベースの種類の選択
- データ事前登録
- チャンク化 (Chunk Optimization)
- テキストを適切なサイズに分割(チャンク化)することで、検索効率を向上させます。分割の基準としては、文脈の一貫性を保ちながら、必要最小限の情報量を持つことが求められます。
- チャンク化 (Chunk Optimization)
- 検索クエリ
- 複数クエリの生成 (Multi-query)
- 質問文を基に複数の検索クエリを生成することで、質問文の表現の揺れによる影響を軽減し、精度を安定させます。
- 仮説的な回答による検索 (HyDE – Hypothetical Document Embeddings)
- 質問文に対して仮説的な回答を一時的に生成し、それを基に関連した情報を取得する手法です。文脈が不足している質問やキーワードが欠落している質問に対して特に有効です。
- 複数クエリの生成 (Multi-query)
- 検索実行
- 検索結果のフィルタリングと再検索 (Active Retrieval)
- 初回の検索結果をフィルタリングし、不足している情報を補うために再検索を行います。このプロセスにより、必要情報の不足を解決することができます。
- 検索結果のフィルタリングと再検索 (Active Retrieval)
- 検索結果プロンプト
- 複数クエリの結果の並び替え (RAG Fusion)
- 複数の検索クエリによって得られた結果を並び替えて統合し、重要度の高い情報を優先的に参照して回答を生成するようにします。
- 複数クエリの結果の並び替え (RAG Fusion)
- 回答生成
- 回答生成結果の評価と再検索 (Self-RAG)
- 初回の回答を評価し、不足や誤りがある場合に再検索を実行する手法です。これにより、最終的な回答の精度と信頼性が向上します。
- 回答生成結果の評価と再検索 (Self-RAG)
引用元:RAG from scratch: Overview, LangChain
実装
構成
今回は企業へのRAG導入のような実践的なケースをイメージして、厚生労働省が公開しているモデル就業規則を検索するシステムをLangChainで構築します。なお、実行環境はMacOSのローカル環境です。
実行環境 | MacOSローカルpython3.12.8 |
フレームワーク | LangChain |
データセット | 厚生労働省モデル就業規則 (docファイル) |
Embeddingモデル | Text-embedding-3-small (OpenAI) |
ベクトルデータベース | Chroma (LangChain API) |
LLMモデル / 大規模言語モデル | GPT-4o、 GPT-4o-mini (OpenAI) |
RAGの実装
パッケージのインストール
必要となるPython Packageをインストールします。Jupyter Notebookのセル内やTerminal上でインストールを実行してください。
!pip install langchain-core langchain-openai
!pip install langchain-community langchain-chroma
!pip install unstructured
!pip install langchain-unstructured
!pip install "unstructured[doc]"
!brew install --cask libreoffice
文章の前処理
まずはデータセットをベクトルデータベースに登録するために必要な前処理を行います。
前処理にはデータセットの読み込みと文章の分割が必要となります。
はじめにデータセットを確認してみましょう。
章や条項でセクションを表しており、各セクション間には改行があるなど一定のフォーマットが施されていることが確認できます。
引用元:モデル就業規則
それでは早速データセットを読み込んで文章の分割を行います。厚生労働省のモデル就業規則についてのページからword形式の資料をダウンロードし、実行環境と同じディレクトリに移動してください。またファイル名をemployment_regulations_template.docと変更してください。
文章の読み込みおよび分割は、ファイルの形式ごとに対応したLangChainのdocument loaderを適用することで実行できます。doc形式のファイルはUnstructuredLoaderで処理することができます。
from langchain_unstructured.document_loaders import UnstructuredLoader
from langchain_community.vectorstores.utils import filter_complex_metadata
file_path = "./employment_regulations_template.doc"
loader = UnstructuredLoader(
file_path=file_path,
)
# ベクトルデータベースが対応していないメタデータの削除
documents = filter_complex_metadata(loader.load())
読み込み結果を確認します。読み込みと同時に改行(‘\n’)による文章の分割もデフォルトで実施されていることが確認できます。
改行による文章の分割は、章や条項の塊が細かく分断されてしまっており、文脈が消失してしまう懸念がありそうです。ここではこの点を留意点としつつも、まずはRAG構築まで進めていきます。
def analysis_langchain_documents(documents, break_point=10):
num_documents = len(documents)
lengths = [len(doc.page_content) for doc in documents]
total_length = sum(lengths)
print(f"Number of documents: {num_documents}")
print(f"Total length of text: {total_length} characters")
print(f'Individual document lengths: {lengths[:10]} {"" if len(lengths) <= 10 else "..."}')
for i, doc in enumerate(documents):
print("="*10)
print(f"Document {i+1} (Length: {len(doc.page_content)}): {doc.page_content[:100]}...")
print("="*10+"\n\n")
if i >= break_point:
break
analysis_langchain_documents(documents, break_point=1000)
ベクトルデータベースの構築
次に前処理が完了した文章群をデータベースに登録します。
ここではRAGで一般的に用いられるベクトルデータベースを構築することにします。
ベクトルデータベースの構築には文章のベクトル化とベクトルデータベースへの登録が必要となります。
今回は文章ベクトル化のためのエンベディングモデルとして人気なOpenAI社のtext-embedding-3-smallを採用し、ベクトルデータベースにはLangChain公式ドキュメントで紹介のあるChromaを採用します。
なお、LangChainを通じてのOpenAI APIの実行にはOpenAI APIキーの設定と利用料の支払いが必要となります。詳しくはこちらをご確認ください(OpenAI APIに関する公式ドキュメント、LangChainのOpenAI APIチュートリアル)。
from langchain_chroma import Chroma
from langchain_openai import OpenAIEmbeddings
class VectorDB:
def __init__(self, db_name):
self.embeddings = OpenAIEmbeddings(model='text-embedding-3-small')
self.db = Chroma(persist_directory=db_name, embedding_function=self.embeddings)
def add_documents(self, documents):
self.db.add_documents(documents)
def similarity_search(self, query, **kwargs):
return self.db.similarity_search(query, **kwargs)
def as_retriever(self, **kwargs):
return self.db.as_retriever(**kwargs)
では実行して、ベクトル検索の動作を確認してみます。
質問(「勤務時間の定義は?」)に対して関連のある文章を抽出できていることが確認できます。注目すべき点として、質問の勤務時間という表現に対して、就労規則の労働時間という表現が抽出されています。ナイーブな文字列検索では不一致となるところが、ベクトル検索では意味空間上に文章が配置されることで類似性が見出されるのです。
vector_db = VectorDB(db_name='vector_db')
vector_db.add_documents(documents)
question = '勤務時間の定義は?'
results = vector_db.similarity_search(question)
display([result.page_content for result in results])
RAGの構築
いよいよRAGの構築を行います。
RAG構築には利用者の質問文の取得、関連する情報の検索、検索結果に基づいた回答の生成が必要になります。
これらはLangChainの機能によって非常に簡潔に実装することができます。
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough
from langchain_openai import ChatOpenAI
def create_simple_rag_chain(vector_db):
prompt = ChatPromptTemplate.from_template('''
質問: {question}
情報源: """{context}
"""
''')
llm = ChatOpenAI(model='gpt-4o-mini', temperature=0.1)
retriever = vector_db.as_retriever()
chain = {
'question': RunnablePassthrough(),
'context': retriever
} | prompt | llm | StrOutputParser()
return chain
質問文を入力して実行してみます。
繁忙期の労務上の対応についての質問ですが、回答は概ね的を射ていると評価できそうです。しかし、コミュニケーションの強化など就業規則に記載のない内容まで回答してしまっており、信憑性に欠けると言わざるを得ません。
注意:LLMによる出力はランダム性を伴っているため、ここで示す例とまったく同一の回答が得られない場合があります。
question = '忙しさに波があって繁忙期に人手が足りない。社員に一時的に長く働いてもらうには、会社側はどのような対応をすればよい?'
chain = create_simple_rag_chain(vector_db)
result = chain.invoke(question)
print(result)
なお、今回ベンチマークとして設定した繁忙期の労務上の対応についての質問は回答精度の検証のために意図的に選ばれたものであり、専門知識を要する制度を含む様々な対応方法の返答が期待される質問です。例えば労使協定の締結や変形労働時間制度への言及が期待されます。
このように、シンプルなRAGはベクトル検索による高い検索精度とLLMによる操作性の高いインターフェースという利点があるものの、業務レベルに対応した回答精度を担保することは容易ではありません。
次章ではこの問題に対するアプローチとしていくつかのRAG最適化手法を実装します。
RAGの最適化
概要
前章で構築したシンプルなRAGでは専門的な情報の出力が不十分であったり、データセットにない情報を出力したりしていました。また、ベクトルデータベースに登録された文章は細切れになっており文脈が分断されている懸念もありました。
そこで本章ではチャンク化によるベクトルデータベースの改善とHyDE(Hypothetical Document Embeddings)による質問クエリの改善を行い、RAGの最適化を試みます。
HyDE (Hypothetical Document Embeddings)
まずはHyDEによる質問クエリの改善を行います。
以下の実装では、利用者の入力した質問に対してLLMの一般知識により仮回答(hypothetical answer)を生成し、その回答に近しい文章をベクトルデータベースから取得しています。この仮回答内で重要な専門用語や文脈を生成することができれば、ベクトル検索でも重要な情報を取得できるという仕組みです。そのため、仮回答の生成を指示するプロンプトについてもプロンプトエンジニアリングによるベストプラクティスの適用と試行錯誤が必要となります。
加えて、最終的な回答を指示するプロンプトに対してもプロンプトエンジニアリングを施すことで回答精度の向上を試んでいます。プロンプトエンジニアリングは本記事の主題ではないので紹介までに留めますが、入力構造化(Input Structuring)、役割設定(Role Prompting)、ステップバイステップ指示(Step-by-Step Instructions)のような手法が用いられています。
さらにLLMモデルもgpt-4oに変更しています。
def create_hyde_chain(vector_db):
def _create_hypotherical_answer_chain(vector_db):
hypotherical_answer_prompt = ChatPromptTemplate.from_template('''
以下で提供する文脈を理解して、質問に回答する一文を書いてください。
文脈: 質問者は企業の人事担当者です。就労規則について質問しています。一般的な就労規則の文言を用いて質問に回答してください。
質問: {question}
''')
llm = ChatOpenAI(model='gpt-4o-mini', temperature=0.1)
retriever = vector_db.as_retriever()
hypotherical_answer_chain = (
{'question': RunnablePassthrough()}
| hypotherical_answer_prompt
| llm
| StrOutputParser()
)
return hypotherical_answer_chain
hypotherical_answer_chain = _create_hypotherical_answer_chain(vector_db)
retriever = vector_db.as_retriever(search_kwargs={"k": 10})
prompt = ChatPromptTemplate.from_template('''
あなたは特定企業の就労規則について精通しているアシスタントです。
企業の人事担当者からの質問に対して、当該会社の就労規則にのみ基づいて回答をしてください。
なお回答には以下のステップを踏んでください。
1. 就労規則から関連した内容を抽出
2. 関連した内容を参照しながら質問に対する回答を作成
質問: {question}
就労規則: """{context}
"""
''')
llm = ChatOpenAI(model='gpt-4o', temperature=0.1)
hyde_chain = {
'question': RunnablePassthrough(),
'context': hypotherical_answer_chain | retriever
} | prompt | llm | StrOutputParser()
return hyde_chain
それでは実行してみます。
結果を確認すると労使協定や割増賃金について専門的な回答をしていることがわかります。いずれも就労規則の範囲からまとめられた回答です。
信頼性において目に見えた改善が認められましたが、変形労働時間制についての言及がなく、理想的なRAGには今一歩届かないという印象です。
question = '忙しさに波があって繁忙期に人手が足りない。社員に一時的に長く働いてもらうには、会社側はどのような対応をすればよい?'
hyde_chain = create_hyde_chain(vector_db)
result = hyde_chain.invoke(question)
print(result)
なお、中間生成物である仮回答は以下の通りでした。「労働条件の変更」、「同意」、「法定労働時間」、「時間外労働」、「割増賃金」など利用者からの質問文には登場しなかった重要なキーワードが含まれていることが確認できます。
カスタムChunk
RAGシステムは検索によって外注された文脈を元に回答を生成するため、検索精度がRAG全体の精度に与える影響は甚大です。
ここではデータセットの分割を改善することでベクトルデータベースの精度向上を試みます。
まず現状を整理すると、doc形式ファイルのdocument loaderであるUnstructuredLoaderは文章を改行単位で機械的に分割していました。そのため、章や条項の途中でドキュメントが分割されて文脈が欠損する恐れがありました。
そこで、章や条項の文字列を正規表現で判別し分割するアプローチを採用します。また、分割結果の文字数が多すぎる場合はベクトル検索およびLLMによる理解に悪影響が懸念されることから、一定の文字数でチャンク化します。
import subprocess
def extract_text_from_doc(file_path):
command = ["soffice", "--headless", "--convert-to", "txt:Text", file_path, "--outdir", "."]
subprocess.run(command, check=True)
txt_file = file_path.rsplit('.', 1)[0] + ".txt"
with open(txt_file, "r", encoding="utf-8") as file:
text = file.read()
return text
file_path = "./employment_regulations_template.doc"
documents_text = extract_text_from_doc(file_path)
from langchain.text_splitter import RecursiveCharacterTextSplitter
import re
class CustomTextSplitter(RecursiveCharacterTextSplitter):
def __init__(self, chunk_size: int = 3500, chunk_overlap: int = 500):
super().__init__(chunk_size=chunk_size, chunk_overlap=chunk_overlap)
def split_text(self, text: str):
split_pattern = r'(第[0-90-9]+章|【第[0-90-9]+条[^】]*】)'
matches = re.split(split_pattern, text)
sections = []
for i in range(1, len(matches), 2):
header = matches[i]
content = matches[i + 1].strip() if i + 1 < len(matches) else ''
sections.append(header + content)
chunks = []
for section in sections:
chunks.extend(super().split_text(section))
return chunks
チャンク化を実行すると、章や条項のまとまりで文章が正しく分割できていることが確認できます。
from langchain.schema import Document
custom_splitter = CustomTextSplitter(chunk_size=1000, chunk_overlap=200)
chunks = custom_splitter.split_text(documents_text)
documents_by_chapter = [Document(page_content=chunk) for chunk in chunks]
それでは整理された文章でベクトルデータベースを作成し、HyDEを施したRAGに適用してみます。
労使協定や割増賃金に加えて変形労働時間制についても言及しており、多角的な視点と信頼性のある回答を生成していることが確認できました。
vector_db_by_chapter = VectorDB(db_name='vector_db_by_chapter')
vector_db_by_chapter.add_documents(documents_by_chapter)
hyde_chain_by_chapter = create_hyde_chain(vector_db_by_chapter)
result_by_chapter = hyde_chain_by_chapter.invoke(question)
print(result_by_chapter)
まとめ
RAGの最適化にはエンジニアリング技術はもちろんのこと、プロンプトエンジニアリング、アテンション機構など深層学習に関する知識、自然言語処理に対する知識、評価指標の設計などデータアナリティクスの幅広い知識が必要となります。またコストと精度のバランスや、ユースケースに対するプロンプトやインターフェースのチューニングなどビジネス領域の検討も必要となります。
本記事ではこういった本格的なRAGシステムへの導入を役立てるために、その概要から具体的な実装例までを紹介しました。
参考リンク
投稿者について
機械学習による予測モデルやBIダッシュボードのビジネス活用支援を得意とするデータサイエンティスト。Imperial College London修了後、コンサルティング会社等を経てIndeed Technologies Japanおよびフリーランスとして活動中。