見出し画像

RAGの精度を高めるためにまずはLlamaindexの構造を理解する!入門編

フクロウラボの若杉です!
最近では、社内のナレッジなどを参照してLLM(Large Language Model:大規模言語モデル)が回答を生成するチャットボットやアプリ作る機会が増えてきているのではないでしょうか。また、RAG(Retrieval-Augmented Generation)という言葉も一般的なってきました。

そこで、今回は、(Llamaindexにおける)RAGについてお話したいと思います。

RAG(Retrieval-Augmented Generation)とは

RAGは、一般的な質問応答や文章生成のタスクにおいて、外部のナレッジを活用することで、より豊富で適切な回答や文章を生成することが期待されます。また、モデルは自己学習によってテキストデータベースを更新することも可能であり、新しい情報に迅速に適応することができます。

Retrieval-Augmented Generation(RAG)は、LLMに対する一般的な質問応答や文章生成のタスクにおいて、検索と文書生成を組み合わたアプローチを指します。この方法は、LLMが学習できていない領域に対する質問など、LLM単体では回答が困難な場合に用いられます。

図1

RAGでは、まず、質問に対して最も関連性のある文章やドキュメントをKnowledge Baseから情報を検索("retrieve")します。この検索ステップは、LLMが生成を行う前に有用な情報を得るためのもので、これによりLLMは役立つ情報を短時間で見つけることができ、この結果をインプットとしてより詳細で明確な回答を提供できるようになります。

① UserがAI Appに対して質問
② AI AppがKnowledge DBへ情報を検索
③ Knowledge Baseから検索結果を取得
④ AI Appが検索結果を元に回答の生成をリクエスト
⑤ LLMからのレスポンス
⑥ Userに対する最終的な回答

このようにして、RAGは外部知識の利用と生成能力を組み合わせることで、より高度な文章生成や質問応答ができます。

RAGのアーキテクチャ

上記の図1だけを見るととてもシンプルに見えますが、実際の構成はもう少し複雑になります。まず、Knowledge Baseに関してですが、最近のトレンドとして、OpenAIのEmbedding(text-embedding-ada-002)を利用してテキストデータを予めベクトル化します。
2024年1月にアップデートがありまして、text-embedding-3-small および text-embedding-3-largeを利用してテキストデータをベクトル化すると良いと思います。

図2-1

図2-1のようにKnowledge BaseをLlamaindexやLangchainなどEmbedding用のアプリを介してOpenAIのAPI(text-embedding-ada-002 text-embedding-3-small および text-embedding-3-large)へ投げVector DBを予め作成します。

図2-2

そうすると、図2-2のようにUserからの質問クエリも同様にtext-embedding-ada-002 text-embedding-3-small および text-embedding-3-largeを使いベクトル化してから、AI AppがVector DB対して検索を行うようにする必要があります。
OpenAIのEmbeddingを使った場合、大体このような構成になるのではないでしょうか。

Llamaindexを使用したシンプルな実装

図2-1の①〜④におけるEmbedding Appの処理で、例えば任意のディレクトリに格納されているPDFファイルをベクトル化する場合、下記のようなシンプルなコードで実現できます。

import os
import openai
from llama_index import (
    GPTVectorStoreIndex,
    SimpleDirectoryReader,
)

openai.api_key = os.environ["OPENAI_API_KEY"]

persist_dir = "path_to/vector_data"
documents_dir = "path_to/documents"

documents = SimpleDirectoryReader(documents_dir).load_data()
index = GPTVectorStoreIndex.from_documents(documents)
index.storage_context.persist(persist_dir)

また、図2-2の①〜⑧におけるAI Appでも、シンプルにLlamaindexのみした場合、下記のコードでも図2-2の①〜⑧の処理を実現できます。

from llama_index import (
    StorageContext,
    load_index_from_storage,
)

storage_context = StorageContext.from_defaults(persist_dir=persist_dir)
index = load_index_from_storage(storage_context)

def print_response(prompt: str, index):
    print(index.as_query_engine().query(prompt))

下記の呼び出しで、RAGでの回答を得られます。

print_response("【質問文】", index)

RAGの精度を上げるための主な要素

さて、ここからが本題なのですが、実際に上記のコードで簡単にRAGによる回答生成はできますが、実際には様々な最適化や調整をしないと理想的な回答が得られないことが多いと思います。
Llamaindexにおいては、(あげればきりがないのですが)ざっくり下記のような調整要素があると思います。

  • Indexingレイヤーでの調整

    • Embeddingに使うモデルの選択

    • チャンクサイズの調整

  • Storingレイヤーでの調整

    • メタデータフィルター

    • Indexの保存ストレージ

  • Queryingレイヤーでの調整

    • もろもろ…


Indexingレイヤーというのは、図2-1の②〜④のフローに該当します。
Indexingレイヤーでの調整は主に下記の2点があります。

・Embeddingに使うモデルの選択

Embeddingに使うモデルですが、基本的にはOpenAIのtext-embedding-ada-002 text-embedding-3-small および text-embedding-3-largeを使用するのが最も無難な選択ではあります。HuggingFaceで公開されているEmbedding用のモデルで、multilingual-e5-largeというモデルも使ってみましたが、自分が利用した範囲では実用的と感じました。その他、Embedding用のモデルはたくさん発表されているので、Embeddingを行う量やコスト(お金と時間)によって適宜、選択すればよいかと思います。

・チャンクサイズの調整

最も気軽に変更できるパラメータであり最も効果があるパラメータかもしれません。
チャンクサイズは、text2vecにおいて、どのくらいのテキスト量毎でベクトル化を行うかというベクトル化する際の粒度を調整するパラメータです。大きくすると、大きく外さないながらも大雑把な結果が返ってきます。逆に、チャンクサイズは小さめに設定し、top_k(検索結果として取得したNodeの数)を増やしてみるという調整もありかと思いますが、固定でこの値が良いというのではなく、ドキュメントの内容や量によっても変わってくるので、おおよそのレンジの当たりはつけつつも、常に調整は必要そうです。


Storingレイヤーでの調整としては、下記の2点です。

・メタデータフィルター

このメタデータフィルターは使用したことはないのですが、Llamaindexのサイトでは最適化の手法の1つとして紹介されています。
検索対象をVector Store層でメタデータフィルターによって絞って結果取得することができるようです。

from llama_index import VectorStoreIndex, Document
from llama_index.vector_stores import MetadataFilters, ExactMatchFilter

documents = [
    Document(text="text", metadata={"author": "LlamaIndex"}),
    Document(text="text", metadata={"author": "John Doe"})
]

filters = MetadataFilters(filters=[
    ExactMatchFilter(key="author", value="John Doe")
])

index = VectorStoreIndex.from_documents(documents)
query_engine = index.as_query_engine(filters=filters)

ただし、この機能を使うためには、Vector Storeがこの機能(Metadata Filtering)に対応している必要があるようです。下記のページで各Vector Store毎の対応表が掲載されています。

ちなみに、Queryingレイヤーのところでも説明しますが、Nodeのフィルターに関しては、Node Postprocessorでもフィルタリングはできます。Node Postprocessorは、検出したNode群に対してフィルタリングを行うもので、それに対し、メタデータフィルターはVector Storeレイヤーでフィルタリングするので、Node Postprocessorよりも低レイヤーでフィルタリングできていると思われます。

※ここで言うNodeとは、Queryingレイヤーにおいてレトリーバーでのもろもろの処理による検索結果として渡される塊の1つを指しています。ちなみに、レトリーバー(Retriever)というのは、ざっくりいうと図2-2の主に④〜⑤でのVector DBに対する検索ロジックです。

基本的にはEmbedding時に指定したチャンクサイズ毎に分割されたドキュメント郡になります。

・Indexの保存ストレージ

まずIndexのストレージを考える上で、Index自体が基本的には3つのストア(DocumentStore, IndexStore, VectorStore)で構成されているということを理解した上で、それぞれのストアでどのストレージを使用するかを考えなければなりません。
Llamaindexの下記のページに掲載されている図がわかりやすいです。

source: https://gpt-index.readthedocs.io/en/stable/module_guides/storing/storing.html#concept

デフォルトでは、特殊なストアは使用せず全てメモリ(in-memory)を使用します。容量がさほど大きくない場合は適当なファイルストレージに保存しておいて毎回メモリに展開して検索するでも問題ありませんが、Indexの容量が大きい場合はそうは行きません。上図のように、Document StoreとIndex Storeには、任意のKey Value Storeを使用できます。Vector Storeについては、先程、メタデータフィルターでも紹介したVector Storesのページを参照し、利用用途に合わせてVector Storeを選択してもらえればと思います。


さて、Queryingレイヤーの調整ですが、これは図2-2の②〜⑦のの挙動に作用するので、Llamaindexで中心部です。このレイヤーでの調整要素は多く、組み合わせは無限大といっても過言ではないです。ですので、まるっとまとめてしまうのは忍びないのですが、書き出すときりがないという事もあり、ざっくりまとめさせていただきます。

詳細はLlamaindexのドキュメントを見ていただければと思いますが、おおよそ要素をピックアップすると下記になります。

・Retriever(レトリーバー)
・Node Postproseccor(ノードポストプロセッサー)
・Response Synthesizer(レスポンスシンセサイザー)
・上記をQueryEngine/ChatEngine/Agentのいずれかに渡す

source: https://gpt-index.readthedocs.io/en/v0.8.52/getting_started/concepts.html

・Retriever(レトリーバー)
レトリーバーは、どう検索結果のNodeを取得するかという部分になります。
下記に、Retriever Modeという名目でモードの種類が記載されています。
・Vector Index
・Summary Index
・Tree Index
・Keyword Table Index
・Knowledge Graph Index
・Document Summary Index
レトリーバーは上記のIndexに対してどうNodeを取得するか、場合によっては複数のIndexからNodeを取得したりを定義する部分になります。

下記のレトリーバーモジュールの項目にあるように、様々な手法があります。

・Node Postproseccor(ノードポストプロセッサー)
ノードポストプロセッサーは、レトリーバーにより抽出されたNode群に対して、何らかの条件を加えて、フィルタリングやソートを行い、さらに絞り込む事ができます。

・Response Synthesizer(レスポンスシンセサイザー)
レスポンスシンセサイザーは、その名の通りもろもろの過程から抽出したNodeのテキストチャンク群をどのように合成しLLMが回答するかを決める部分です。
詳細は、ResponseModesという項目にまとめられています。

・上記をQueryEngine / ChatEngine / Agentのいずれかに渡す

source: https://gpt-index.readthedocs.io/en/v0.8.52/getting_started/concepts.html

これらのRetriever+Node Postproseccor+Response SynthesizerQueryEngine / ChatEngine / Agentのいずれかへ渡します。
QueryEngine / ChatEngine / Agentというのは、このRAGアプリケーションがどのようなコミュニケーション形態で会話/回答するかを選択する部分です。
詳細は下記。

ここでどれを選択するかでもやれることが大きく異なります。これらのエンジン/エージェントの全容を把握するのは骨の折れる作業だと思います。
奥が深い世界です。


本当はもっとシンプルに的をしぼって記事を書こうと思っていたのですが、なんだかんだで長々と書いてしまいました。実際にはLangchainとLlamaindexを組み合わせて使用することが多いと思いますが、Llamaindexだけでも意外と奥が深いのです。弊社でもいろいろと試行錯誤しながら、社内向けのソリューションを展開しています。
生成AIを使ったボットを作るだけでも、こだわろうと思えば、無限にこだわることができるのが生成AIアプリの面白いところかもしれませんね^^;

今後は、今回ご紹介したRAGの精度を上げるための要素を試行錯誤の結果をもう少し具体的なノウハウとしてを共有していければと考えています。
引き続きよろしくお願いします!

(執筆:若杉)

この記事が気に入ったらサポートをしてみませんか?