はじめに
今年も始まりました、Advent Calendar。
こんにちは!AI ShiftのAIチーム所属の栗原健太郎 (@kkurihara_cs)です!
この記事はAI Shift Advent Calendar 2024の1日目の記事です!
Advent Calendarが始まったということは、もう年末なわけですが、とにかくOpenAI系のリリースが今年も多かったなと感じる1年でしたね(GPT-4o, GPT-4o-mini, o1など)。我々に関して言えば、LLMの事業応用に昨年以上に向き合った1年だったなぁと感じます。
記念すべきAdvent Calendar1日目では、多岐に渡るLLMに関する領域の中でも、とりわけ向き合う機会の多かったRAG (Retrieval-Augmented Generation)の性能改善についての記事をお届けします。
本記事の執筆を契機に(事業応用の観点も交えつつ)RAG改善周りの知識を整理できればと思います。
RAGの基本的な処理を簡単なコードと共に振り返りつつ、本書では触れられていないRAGの性能改善のための考え方や策を整理できればと思います。(大変恐縮ですが、改善項目の網羅性などは保証できておりません)
今回RAGを振り返るにあたっては、「大規模言語モデル入門Ⅱ」を活用させていただきました!親会社サイバーエージェントの研究組織AI Lab所属の山田康輔さんから献本いただいたものになります!(山田さん、ありがとうございます。)
本書13-2章のサンプルコードをベースに、RAGにおいて改善すべきポイントについて整理していきます。
RAGの基本的な処理の流れに学ぶ改善ポイント
はじめに、RAGは、情報検索技術とLLMの生成技術を組み合わせたLLMの生成に関する拡張の手法で、LLMが外部知識をもとにテキストを生成することを可能とします。
RAGのシステムは、大きく以下の二つのモジュールで構成されています。(細かくは、Graph RAG, SQL RAGなどの存在しますが、今回は一般的なドキュメント検索のRAGについて記述します。)
Retriever
入力クエリに関連した外部知識を獲得するためのモジュールです。典型的には、外部知識は、検索元となるドキュメントの集合をデータストアに検索可能な状態で置いておくことが一般的です。
Generator
Retrieverで獲得した情報をもとに、入力クエリに対する応答を返すモジュールです。基本的には一般的にLLMを用いて生成を行う事と大きな違いはありません。
では、早速、13-2章のサンプルコードをベースに記事を展開します。適宜リンク先のサンプルコードを触りながらお読みください。(本記事ではコードを一部抜粋しております。)
RAGの性能改善は、大きくRetrieverとGeneratorのそれぞれの改善に大別できそうです。ポイントごとに改善の考え方を整理できればと思います。
Retrieverの改善
1. クエリの前処理
from langchain_core.messages import HumanMessage, SystemMessage
# Chat Modelに入力する会話データ
chat_messages = [HumanMessage(content="四国地方で一番高い山は?")]
# Chat Modelによるチャットテンプレート適用後の入力文字列を確認
chat_prompt = chat_model._to_chat_prompt(chat_messages)
print(chat_prompt)
# 出力
<s>
ユーザ:四国地方で一番高い山は?</s
><s>
アシスタント:
上記コードで、LLMに入力するクエリを定義しています。
RAGの性能を上げるための手段として、まずRetireverの精度を上げる(つまり適切なドキュメントを獲得できるようにする)ことが挙げられます。
適切なドキュメントの獲得を実現するための考え方の一つに、入力クエリを事前に前処理するという考え方があります。具体的には、入力クエリを別の文字列などに変換してRetrieverに入力をするという処理になります。
クエリの変換にはいくつか方法があり、どの変換方法が有効かは、適用するシチュエーションによって変わると考えております。
- 「四国 高い山」→「四国で高い山は?」のような、雑なクエリの自然な文章への変換
- 「四国で高い山は?」→ [検索用の擬似的な文章]を生成する変換
- 「四国で山登りをしたいので、おすすめの高い山を教えて。」→「Q1: 四国で山登りにおすすめの山は? Q2: 四国の高い山は?」など、複数の意図を整理する変換
など
参考: QueryRewriting, HyDE, MultiQueryRetriever, StepBackPrompt
2. 検索手法の検討
from langchain_huggingface.embeddings import HuggingFaceEmbeddings
# Hugging Face Hubにおけるモデル名を指定
embedding_model_name = "BAAI/bge-m3"
# モデル名からEmbedding Modelを初期化
embedding_model = HuggingFaceEmbeddings(
model_name=embedding_model_name,
model_kwargs={"model_kwargs": {"torch_dtype": torch.float16}},
)
# 二つのテキストの文埋め込みから類似度を計算
sample_texts = [
"日本で一番高い山は何ですか?",
"日本で一番高い山は富士山です。",
]
# 二つのテキストに対して文埋め込みを実行し、結果を確認
sample_embeddings = embedding_model.embed_documents(sample_texts)
print(sample_embeddings)
similarity = torch.nn.functional.cosine_similarity(
torch.tensor([sample_embeddings[0]]),
torch.tensor([sample_embeddings[1]]),
)
print(similarity)
# 出力
tensor([0.7743])
上記コードでは、RAGにおける「入力クエリ」と「ドキュメント」の関連度合いを計算するための考え方として、「Embedding」を用いた二つの文字列の類似度の算出をしています。
「関連度合い」には異なる二つの考え方があり、コードに記載されている「Embedding」を含め、それぞれに基づいた検索手法が二つ存在します。それぞれの検索手法については、以下のように概説できます。
Embeddingを用いた意味的類似度による検索
Embeddingは文(文章)をベクトルに変換する技術です。このベクトルは文の意味を反映しているため、「二つのベクトルが類似している = 二つの文の意味が似ている」つまり関連しているとみなすことができます。一般的にEmbeddingを用いて表現される意味のベクトルは、汎用的な表現については適切に表現できる一方で、専門用語や固有名詞などの、世の中の文章頻出しない単語の意味をうまく表現できないと考えられています。ベクトルへの変換方法は多数存在します。
キーワードなどに着目した表層的な検索
表層的な検索は、二つの文(文章)それぞれを構成する文字や単語・フレーズの一致度合いの高さに基づいた検索技術です。文章の意味に着目するEmbeddingとは異なり、文字の一致度合いで関連度合いを決めていくため、固有名詞や専門用語がクエリやドキュメントに頻出するようなケースにおいては、Embeddingよりも高い性能を発揮する可能性があると考えられています。
また、これらをを組み合わせた「ハイブリッド検索」という手法も出ています。各検索手法の妥当性については、ドメインやユースケースに依存します。
参考: OpenAI-Vector Embeddings, BM25
3. ドキュメントのindexingの設定
from langchain_community.document_loaders import JSONLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.vectorstores import FAISS
# JSONファイルから文書を読み込むためのDocument Loaderを初期化
document_loader = JSONLoader(
file_path="./docs.json", # 読み込みを行うファイル
jq_schema=".text", # 読み込み対象のフィールド
json_lines=True, # JSON Lines形式のファイルであることを指定
)
# 文書の読み込みを実行
documents = document_loader.load()
# 文書を指定した文字数で分割するText Splitterを初期化
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=400, # 分割する最大文字数
chunk_overlap=100, # 分割された文書間で重複させる最大文字数
add_start_index=True, # 元の文書における開始位置の情報を付与
)
# 文書の分割を実行
split_documents = text_splitter.split_documents(documents)
# 分割後の文書数を確認
print(len(split_documents))
# 分割後の文書の長さ(文字数)を確認
print(len(split_documents[0].page_content))
# 分割後の文書と文埋め込みモデルを用いて、Faissのベクトルインデックスを作成
vectorstore = FAISS.from_documents(split_documents, embedding_model)
# ベクトルインデックスに登録された文書数を確認
print(vectorstore.index.ntotal)
# ベクトルインデックスを元に文書の検索を行うRetrieverを初期化
retriever = vectorstore.as_retriever(search_kwargs={"k": 3})
# 文書の検索を実行
retrieved_documents = retriever.invoke("四国地方で一番高い山は?")
# 検索された文書を確認
print(retrieved_documents)
# 出力
>print(len(split_documents))
1475
>print(len(split_documents[0].page_content))
221
>print(vectorstore.index.ntotal)
1475
> print(retrieved_documents)
Document(metadata={'source': '/content/docs.json', 'seq_num': 26, 'start_index': 0}, page_content='... 石鎚山(いしづちさん、いしづちやま)は、四国山地西部に位置する標高1,982 mの山で、...'),
Document(metadata={'source': '/content/docs.json', 'seq_num': 1, 'start_index': 0}, page_content='富士山(ふじさん)は、静岡県(富士宮市、富士市、裾野市、御殿場市、駿東郡小山町)と山梨県(富士吉田市、南都留郡鳴沢村)に跨る活火山...'),
Document(metadata={'source': '/content/docs.json', 'seq_num': 96, 'start_index': 0}, page_content='四阿山(あずまやさん)は、長野県と群馬県の県境に跨る山。標高2,354 m。...')
本コードでは、RAGの検索元となるドキュメント群をデータストアに登録(indexing)する処理を記述しています。indexingにおいて特に我々が検討するのは、「登録する各ドキュメントの長さ」です。
「登録する各ドキュメントの長さ」について、生成AIへの入力の長さを考慮して、基本的に長いドキュメントはある程度短く区切ってデータストアに登録します。区切られたドキュメントのことをチャンクと呼び、考慮する際には以下の二つについて留意する必要があります。
チャンクサイズ
データストアに登録するドキュメントのチャンクの長さを表します。あまりに短すぎる場合ですと、各ドキュメントが持つ情報が少なくなり、関連性の高いドキュメントの発見が難しくなります。また、一般的にLLMの生成能力は、インプットの長さが長すぎる場合に制御性の低下などの性能低下を招くことが知られています。そのため、あまりにドキュメントのチャンクサイズが長いと、生成結果の品質悪化を招く可能性が高くなってしまいます。
チャンクオーバーラップ
オーバーラップは連続したチャンクの前後の重なり具合を定義する値です。この数字があまりに小さい場合、各ドキュメントが前後の文脈をほとんど把握できず情報の欠損を招くリスクが上がります。数字が大きすぎる場合、ドキュメント数が不必要に大きくなり、検索の精度に影響が出てしまいます。
この他、単に固定された長さで区切るのではなく、文の意味に応じてチャンクを決めていくsemantic chunkingや、markdownやwebページなどの階層構造を考慮したchunkingも存在します。
参考: 各chunkingに関するAWSのdocument
4. 取得したドキュメント群へのリランキング処理
# 文書の検索を実行
retrieved_documents = retriever.invoke("四国地方で一番高い山は?")
上記コードによるドキュメントのretrieve後に工夫を施す方法の一つとしてリランキングが知られています。
リランキングでは、最終的にk件のドキュメントを獲得したい場合に、初めにn (> k)件のドキュメントを取得します。その後n件のドキュメントと入力クエリとの類似度をリランカーモデルを用いて算出し、類似度の上位k件を最終的なドキュメントとして獲得します。
リランカーモデルを用いて算出した類似度算出には、以下のメリットとデメリットが存在します。
メリット
リランカーモデルによって算出された類似度は「2. 検索手法の検討」にて紹介したEmbedding同士の類似度と比較して正確である。
デメリット
リランカーモデルによる類似度計算にかかる時間はEmbedding同士の類似度の計算と比較してかなり長い。
本性質ゆえに、リランキングの適用を検討する上では、リランカーの計算時間分だけRAGの実行時間が伸びることについて議論する必要性があります。
参考: Cohere-reranker, bge-reranker-large
Generatorの改善
5. より良い生成の獲得
from langchain_core.documents import Document
from langchain_core.runnables import RunnablePassthrough
# 任意のqueryからメッセージを構築するPrompt Templateを作成
rag_prompt_text = (
"以下の文書の内容を参考にして、質問に答えてください。nn"
"---n{context}n---nn質問: {query}"
)
rag_prompt_template = ChatPromptTemplate.from_messages(
[("user", rag_prompt_text)]
)
def format_documents_func(documents: list[Document]) -> str:
"""文書のリストを改行で連結した一つの文字列として返す"""
return "nn".join(
document.page_content for document in documents
)
# 定義した関数の処理を行うRunnableを作成
format_documents = RunnableLambda(format_documents_func)
from langchain_core.runnables import RunnablePassthrough
# RAGの一連の処理を行うChainを作成
rag_chain = (
{
"context": retriever | format_documents,
"query": RunnablePassthrough(),
}
| rag_prompt_template
| chat_model_resp_only
)
# Chainを実行し、結果を確認
rag_chain_output = rag_chain.invoke("四国地方で一番高い山は?")
# 出力
四国地方で一番高い山は、愛媛県と高知県の県境にある石鎚山です。標高は1,982メートルで、四国地方で最も高い山です。
上記コードは、ドキュメントのretrieveからGeneration(生成)まで実施するコードです。
Generation部分の改善の手段の一つとして、回答を生成するまでにLLMの推論処理を複数回実行するSelf-RAGと呼ばれるフレームワークが提案されています。
しかし、Generation部分の改善にあたっては以下の難しさが挙げられます。
生成時間の長さ
複数回生成を行う場合には、その分だけ最終的な出力を得る時間も伸びます。RAGの主な適用先の一つであるchatbotなど様々なアプリケーションにおいて、出力の遅延はUXを大きく下げる懸念があります。
Streaming出力の取り扱い
生成AIを用いたテキスト生成の結果を出力させる方法として、「生成結果を全文一括で出力するか」「トークン単位(文字や単語などの小さい単位)で出力(Streaming出力)するか」の二つがあります。OpenAIのAPIやtransformersで提供されているモデルにおけるstreamerオプションの活用など、の場面でStreaming出力をさせることが可能です。
例えばchatbotなどのユースケースにおいては、生成されるまでじっと待つか、生成されている様子を視認できるかという違いが生まれます。基本的に後者の方がUXは良く、弊社でもstreaming出力の活用の場面は非常に多いです。一方で、streamingで出力した結果に対して(UXを落とさずに)修正・変更を実施するのは現状煩雑です。
以上の理由より、現状では事業応用観点でRAGの改善を検討する場合、Generation部分よりもRetriever部分の改善をまず考えることが多い印象を受けます。
しかし、GeneratorはLLMの大きな課題として挙げられるHallucinationをまさに引き起こす部分です。そのため、Generator部分の抜本的な課題解決に向けてアンテナを貼り続ける必要があります。
以上ここまででRAGの一連の流れを追うことができました!
まとめ
今回は大規模言語モデル入門Ⅱのサンプルコードをベースに、RAGの改善ポイントについて整理しました。動くシンプルなコード等々のおかげで、RAGについて包括的に学習し直す良い機会となりました。
RAGの仕組み自体はシンプルである一方で、「Retrieverの改善」と「Generatorの改善」、特に「Retrieverの改善」は「クエリの前処理、検索手法の検討、ドキュメントのindexingの設定、取得したドキュメント群への処理」と多数の改善方針があることを改めて確認することができました。
今回紹介した多くの改善ポイントに対して、ドメインに応じて1つずつ対応し続けているうちは事業としてはなかなかスケールさせづらいのでは?この部分まで自動化できたりしたら面白いのでは?など想いを馳せている次第です。
今後もLLMや生成AIに関する最新情報にできるだけ追従していければと思います!
明日は同じくAIチームから東が第15回対話シンポジウムの参加報告記事をお届けする予定です。