Building Agentic RAG with Llamaindex の内容を解説してみた

# はじめに
こんにちは、GxPの森下です！
この記事は[グロースエクスパートナーズ Advent Calendar 2024](https://qiita.com/advent-calendar/2024/gxp)の6日目の記事です。

弊社では日常的に勉強会が開催されており、一部の勉強会はサイドプロジェクトとしても推進されています。今回は、私が参加しているAIに関する勉強会で学んだ内容を紹介したいと思います。

この記事では、[Building Agentic RAG with LlamaIndex](https://learn.deeplearning.ai/courses/building-agentic-rag-with-llamaindex/lesson/1/introduction)で紹介されている、LlamaIndexを活用したエージェントRAGの構築方法について紹介します。RAGやLlamaIndex、AIエージェントの基本的な内容については触れません。

以下の1~5は、講座の各Lessonと対応するように記載してあります。

**- 全体の概要 -**
1. 導入
2. Router Query Engine
    - QueryEngineToolによるQueryEngineからのツールの作成
    - RouterQueryEngineによる回答の生成
3. Tool Calling
    - FunctionToolによる独自の関数からのツールの作成
    - predict_and_call()による回答の生成
4. Building An Agent Reasoning Loop
    - AgentWorkerとAgentRunnerによるタスクの管理と推論
5. Building A Multi Document Agent
    - ObjectIndexによる作成したツール群のインデックス化

:::note warn
以下のコードは説明のため、重要な内容のみ記載しています。動作確認や詳細な内容は、[Building Agentic RAG with LlamaIndex](https://learn.deeplearning.ai/courses/building-agentic-rag-with-llamaindex/lesson/1/introduction) をご確認ください。(講座のNotebook上であればOpenAI APIキーの取得を行わずに動作確認出来ます。)
:::

# 1. 導入
通常のRAGでは、1つのドキュメントにある情報から回答を取得するため、単純な質問の回答には適しています。しかし、何かのテーマについて調べていて、他のドキュメントから追加の情報を得たい場合は、何度もやり取りをする必要があります。[Building Agentic RAG with LlamaIndex](https://learn.deeplearning.ai/courses/building-agentic-rag-with-llamaindex/lesson/1/introduction)では、このような場合にあらかじめ複数のドキュメントからツールを作成し、LLMに渡すことで、複雑な質問に対する回答を得られるようになります。

# 2. Router Query Engine
参照 : [Router Query Engine](https://learn.deeplearning.ai/courses/building-agentic-rag-with-llamaindex/lesson/2/router-query-engine)
**- 概要 -**
この章では、基本的なquery engineのルーターを作成する方法について紹介しています。
まず、読み込んだドキュメントを元に、要約されたインデックスと通常のインデックスを作成し、それぞれのインデックスからquery engineを作成、query engineからツールの作成を行います。その後、作成した2つのツールをLLMに渡すことで、回答を生成する際にプロンプトの内容を元にどちらのツールを使用するべきかを判断し、適切なインデックスの内容を元に回答を生成してくれます。
- モデルの設定
```python
from llama_index.core import Settings
from llama_index.llms.openai import OpenAI

# Settingsを使用することでグローバルに設定
# デフォルトのLLM: gpt-3.5-turbo
Settings.llm = OpenAI(model="gpt-4o")
# デフォルトのEmbeddingsモデル: text-embedding-ada-002
Settings.embed_model = OpenAIEmbedding(model="text-embedding-3-small")
```
- Query Engineツールの作成
```python
from llama_index.core import SimpleDirectoryReader, SummaryIndex, VectorStoreIndex
from llama_index.core.node_parser import SentenceSplitter
from llama_index.core.tools import QueryEngineTool

# ドキュメントの読み込み
documents = SimpleDirectoryReader(input_files=["./data/sample.pdf"]).load_data()

# ドキュメントをノードに分割
splitter = SentenceSplitter() # デフォルト: chunk_size=1014, chunk_overlap=200
nodes = splitter.get_nodes_from_documents(documents)

# ノードからSummaryIndexとVectorStoreIndexの2種類のインデックスを作成
summary_index = SummaryIndex(nodes)
vector_index = VectorStoreIndex(nodes)

# 作成したインデックスを元にquery_engineをそれぞれ作成
summary_query_engine = summary_index.as_query_engine(
    response_mode="tree_summarize",
    use_async=True,
)
vector_query_engine = vector_index.as_query_engine()

# 作成したquery_engineからツールを作成
summary_tool = QueryEngineTool.from_defaults(
    summary_query_engine, # または、query_engine=summary_query_engine
    name="summary_tool",
    description=("Used to obtain a summary of sample document."),
)
vector_tool = QueryEngineTool.from_defaults(
    vector_query_engine, # または、query_engine=vector_query_engine
    name="vector_tool",
    description=("Used to obtain the specific context of sample document."),
)
```
:::note
QueryEngineToolのdescriptionの内容からどちらのツールを使用するべきか判断されるため、より適切な説明を記載する必要があります。
:::

- 作成したツールをLLMに渡し、回答を生成
```python
from llama_index.embeddings.openai import OpenAIEmbedding
from llama_index.core.query_engine.router_query_engine import RouterQueryEngine
from llama_index.core.selectors import LLMSingleSelector

# 作成したツールをRouterQueryEngineに渡すことで、プロンプトの内容を元にデータを取得する際に
# 要約したインデックスか、通常のインデックスのどちらからデータを取得すればよいかを
# 判断して取得してくれる
query_engine = RouterQueryEngine(
    selector=LLMSingleSelector.from_defaults(),
    query_engine_tools=[summary_tool,vector_tool,]
)

# 以下の"XXX"には質問したい内容を記載
response = query_engine.query("What is XXX?")
print(str(response))
```

# 3. Tool Calling
参照 : [Tool Calling](https://learn.deeplearning.ai/courses/building-agentic-rag-with-llamaindex/lesson/3/tool-calling)
**- 概要 -**
この章では、query engine以外の独自の関数を使用したい場合の方法について紹介しています。
まず、関数を独自に作成し、FunctionToolを使用して作成した関数からLLMが扱えるようなツールを作成します。その後、作成したツールをLLMに渡し、predict_and_callを使用することで、回答を生成する際にプロンプトの内容を元にどのツールを使用するべきかを判断し、適切なツールの使用、回答の生成をしてくれます。また、関数内で使用する変数の値もプロンプトを元に予測し、使用してくれます。

- 関数 (以下の関数の定義は、[Tool Calling](https://learn.deeplearning.ai/courses/building-agentic-rag-with-llamaindex/lesson/3/tool-calling) から引用) とFunctionToolによるツールの作成
```python
from typing import List
from llama_index.core.vector_stores import FilterCondition
from llama_index.core.tools import FunctionTool

def vector_query(
    query: str,
    page_numbers: List[str]
) -> str:
    """Perform a vector search over an index.

    query (str): the string query to be embedded.
    page_numbers (List[str]): Filter by set of pages. Leave BLANK if we want to perform a vector search
        over all pages. Otherwise, filter by the set of specified pages.

    """

    # page_numbersもプロンプトから予測
    metadata_dicts = [
        {"key": "page_label", "value": p} for p in page_numbers
    ]

    # FilterCondition.ORによって、指定された複数のページ番号のうち、いずれかに該当するデータが検索対象になる
    # →複数のページ番号、2, 3, 5が指定された場合これらのページすべてが検索対象になる
    # FilterCondition.ANDの場合、指定された条件すべてに一致するデータが検索対象になる
    # →metadata_dictsにpage_labelが2, authorがJohnが指定された場合、このどちらの条件にも当てはまるデータが検索対象になる
    query_engine = vector_index.as_query_engine(
        similarity_top_k=2,
        filters=MetadataFilters.from_dicts(
            metadata_dicts,
            condition=FilterCondition.OR
        )
    )
    response = query_engine.query(query)
    return response

# 定義した関数を元にFunctionToolでツールを作成
vector_query_tool = FunctionTool.from_defaults(
    vector_query, # または、fn=vector_query
    name="vector_tool", # デフォルトでは関数の名前になる
    # デフォルトでは関数のdocstringが使用されるが、description="" で上書きすることも可能
)
```
:::note
FunctionToolで指定した関数のdocstring内容からどちらのツールを使用するべきか判断されるため、より適切な説明を記載する必要があります。
:::

- predict_and_callによる回答の生成
```python
response = llm.predict_and_call(
    [vector_query_tool], # LLMが使用するツールをリスト形式で渡す
    "How is XXX described on page 5?" # "XXX"には質問したい内容を記載
)
```

# 4. Building An Agent Reasoning Loop
参照 : [Building An Agent Reasoning Loop](https://learn.deeplearning.ai/courses/building-agentic-rag-with-llamaindex/lesson/4/building-an-agent-reasoning-loop)
**- 概要 -**
この章では、プロンプトに対してすぐに回答を生成するのではなく、会話の履歴や実行結果を元にもう一度考えるべきか、どのツールを使用するべきかなどを判断し、適切な回答を生成する方法を紹介しています。
AgentWorkerとAgentRunnerを使用することで、次のような流れで回答を生成することが出来ます。
回答生成までの流れ : プロンプト → タスクを作成 → タスクのステップを1つ進める → どのtoolを使えば良いのか判断・呼び出し → 会話の履歴を確認 → 次のステップ(場合によっては質問を追加) → どのtoolを使えば良いのか判断・呼び出し → ... → 最終的な回答
**AgentWorkerとAgentRunnerの役割**
- AgentWorker : タスクの推論と実行を行う
- AgentRunner : タスクの管理と会話履歴の保存を行う

:::note
回答生成までの流れでは、1つずつステップを進めることが出来るので、回答を生成するまでの間にデバッグを行うことが可能です。
:::

- AgentWorkerとAgentRunnerの作成
```python
from llama_index.core.agent import FunctionCallingAgentWorker, AgentRunner

# FunctionCallingAgentWorkerを使用して、AgentWorkerを作成
# 1. Router Query Engineで作成したvector_tool, summary_toolを使用
agent_worker = FunctionCallingAgentWorker.from_tools(
    [vector_tool, summary_tool],
    llm=llm
)

# 上記で作成したagent_workerを使用して、AgentRunnerを作成
agent = AgentRunner(agent_worker)
```

- 回答の生成 (queryとchatの比較)
```python
# "XXX"には質問したい内容を記載
# agent.queryは単発の回答を行う(会話の履歴は保持されない代わりに情報検索に特化している)
response = agent.query("What is XXX?")

# agent.chatは会話の履歴と使用するツールを持った状態での回答を行う
response = agent.chat("What is XXX?")
```
- タスクの作成と実行
```python
# create_taskでタスクを作成
task = agent.create_task("What is XXX?")

# タスクのステップの実行
# agent.run_step(task.task_id)でタスクのステップを1つ進めることが出来る
step_output = agent.run_step(task.task_id)

# タスクの実行中に質問を追加する
# input=""は、エージェントがタスクを進めている途中に、新たな質問をするための追加質問
step_output = agent.run_step(task.task_id, input="Tell me about YYY")
```

- 最終的な回答の取得
```python
# 出力が完了したかどうかの確認
step_output = agent.run_step(task.task_id)
# step_output.is_last: True/False
print(step_output.is_last)

# agent.finalize_response(task.task_id) で最終的なレスポンスを確認可能
# → step_output.is_lastがTrueの場合のみ確認可能
# → step_output.is_lastがFalseの場合に実行するとエラーになる
response = agent.finalize_response(task.task_id)
print(str(response))
```

# 5. Building A Multi Document Agent
参照 : [Building A Multi Document Agent](https://learn.deeplearning.ai/courses/building-agentic-rag-with-llamaindex/lesson/5/building-a-multi-document-agent)
**- 概要 -**
この章では、ツールが多くなった場合に、より適切なツールを選択させるためにツール自体をインデックス化して使用する方法を紹介しています。
まず、複数のツールの配列を用意し、その配列からインデックスを作成します。その後、ツールを取得するためのリトリーバーを作成し、プロンプトの内容を元に適切なツールを取得します。
- ツール群からインデックス、リトリーバーの作成・ツールの選択
```python
# ツール群からインデックスの作成
# from llama_index.core import VectorStoreIndex
from llama_index.core.objects import ObjectIndex

# ObjectIndexは任意のオブジェクトのインデックスを作成するクラス
# ツールの配列をObjectIndex.from_objects()でインデックス化させる
tools_index = ObjectIndex.from_objects(
    tools, # 作成したツールの配列を指定
    # デフォルトで index_cls=VectorStoreIndex が使用される
)

# リトリーバーの作成
# tools_index.as_retrieverでインデックス化されたデータを検索・取得するためのリトリーバーを作成する
# similarity_top_k=3で関連性の高い上位3件を返すように設定している
tools_retriever = tools_index.as_retriever(similarity_top_k=3)

# ツールの選択
# tools_retriever.retrieve()で、インデックスに格納されたデータから、
# 指定したクエリに基づいて最も関連性の高いデータを検索・取得する
tools = tools_retriever.retrieve("Tell me about XXX") # "XXX"には質問したい内容を記載
```

- AgentWorker・AgentRunnerと共に使用する場合
```python
from llama_index.core.agent import FunctionCallingAgentWorker, AgentRunner

agent_worker = FunctionCallingAgentWorker.from_tools(
    tool_retriever=tools_retriever, # ツールの配列を直接渡すのではなく、tool_retrieverにインデックス化したツールのリトリーバーを渡す
    llm=llm
)
agent = AgentRunner(agent_worker)

response = agent.query("Tell me about XXX")
print(str(response))
```

# Tips
上記のコードでは説明のために、可能な限りシンプルなつくりにしています。そのため各関数の詳細な使用方法は各自で確認していただけたらと思います。
その際に、各クラスや関数にどのような値が設定できるのか、また、デフォルトでどんな値が設定されているのか調べるのがけっこう面倒な場合多いと思います。そのような場合には`help()`を使用することで簡単に調べることが出来ますので、ぜひ参考にしてみてください。
```python
from llama_index.core.agent import FunctionCallingAgentWorker
help(FunctionCallingAgentWorker)
```

<details><summary>help(FunctionCallingAgentWorker) の出力結果</summary>

```
Help on class FunctionCallingAgentWorker in module llama_index.core.agent.function_calling.step:

class FunctionCallingAgentWorker(llama_index.core.agent.types.BaseAgentWorker)
 |  FunctionCallingAgentWorker(tools: List[llama_index.core.tools.types.BaseTool], llm: llama_index.core.llms.function_calling.FunctionCallingLLM, prefix_messages: List[llama_index.core.base.llms.types.ChatMessage], verbose: bool = False, max_function_calls: int = 5, callback_manager: Optional[llama_index.core.callbacks.base.CallbackManager] = None, tool_retriever: Optional[llama_index.core.objects.base.ObjectRetriever[llama_index.core.tools.types.BaseTool]] = None, allow_parallel_tool_calls: bool = True) -> None
 |  
 |  Function calling agent worker.
 |  
 |  Method resolution order:
 |      FunctionCallingAgentWorker
 |      llama_index.core.agent.types.BaseAgentWorker
 |      llama_index.core.prompts.mixin.PromptMixin
 |      abc.ABC
 |      builtins.object
 |  
 |  Methods defined here:
 |  
 |  __init__(self, tools: List[llama_index.core.tools.types.BaseTool], llm: llama_index.core.llms.function_calling.FunctionCallingLLM, prefix_messages: List[llama_index.core.base.llms.types.ChatMessage], verbose: bool = False, max_function_calls: int = 5, callback_manager: Optional[llama_index.core.callbacks.base.CallbackManager] = None, tool_retriever: Optional[llama_index.core.objects.base.ObjectRetriever[llama_index.core.tools.types.BaseTool]] = None, allow_parallel_tool_calls: bool = True) -> None
 |      Init params.
 |  
 |  async arun_step(self, step: llama_index.core.agent.types.TaskStep, task: llama_index.core.agent.types.Task, **kwargs: Any) -> llama_index.core.agent.types.TaskStepOutput
 |      Run step (async).
 |  
 |  async astream_step(self, step: llama_index.core.agent.types.TaskStep, task: llama_index.core.agent.types.Task, **kwargs: Any) -> llama_index.core.agent.types.TaskStepOutput
 |      Run step (async stream).
 |  
 |  finalize_task(self, task: llama_index.core.agent.types.Task, **kwargs: Any) -> None
 |      Finalize task, after all the steps are completed.
 |  
 |  get_all_messages(self, task: llama_index.core.agent.types.Task) -> List[llama_index.core.base.llms.types.ChatMessage]
 |  
 |  get_tools(self, input: str) -> List[llama_index.core.tools.types.AsyncBaseTool]
 |      Get tools.
 |  
 |  initialize_step(self, task: llama_index.core.agent.types.Task, **kwargs: Any) -> llama_index.core.agent.types.TaskStep
 |      Initialize step from task.
 |  
 |  run_step(self, step: llama_index.core.agent.types.TaskStep, task: llama_index.core.agent.types.Task, **kwargs: Any) -> llama_index.core.agent.types.TaskStepOutput
 |      Run step.
 |  
 |  stream_step(self, step: llama_index.core.agent.types.TaskStep, task: llama_index.core.agent.types.Task, **kwargs: Any) -> llama_index.core.agent.types.TaskStepOutput
 |      Run step (stream).
 |  
 |  ----------------------------------------------------------------------
 |  Class methods defined here:
 |  
 |  from_tools(tools: Optional[List[llama_index.core.tools.types.BaseTool]] = None, tool_retriever: Optional[llama_index.core.objects.base.ObjectRetriever[llama_index.core.tools.types.BaseTool]] = None, llm: Optional[llama_index.core.llms.function_calling.FunctionCallingLLM] = None, verbose: bool = False, max_function_calls: int = 5, callback_manager: Optional[llama_index.core.callbacks.base.CallbackManager] = None, system_prompt: Optional[str] = None, prefix_messages: Optional[List[llama_index.core.base.llms.types.ChatMessage]] = None, **kwargs: Any) -> 'FunctionCallingAgentWorker'
 |      Create an FunctionCallingAgentWorker from a list of tools.
 |      
 |      Similar to `from_defaults` in other classes, this method will
 |      infer defaults for a variety of parameters, including the LLM,
 |      if they are not specified.
 |  
 |  ----------------------------------------------------------------------
 |  Data and other attributes defined here:
 |  
 |  __abstractmethods__ = frozenset()
 |  
 |  ----------------------------------------------------------------------
 |  Methods inherited from llama_index.core.agent.types.BaseAgentWorker:
 |  
 |  set_callback_manager(self, callback_manager: llama_index.core.callbacks.base.CallbackManager) -> None
 |      Set callback manager.
 |  
 |  ----------------------------------------------------------------------
 |  Data and other attributes inherited from llama_index.core.agent.types.BaseAgentWorker:
 |  
 |  Config = <class 'llama_index.core.agent.types.BaseAgentWorker.Config'>
 |  
 |  ----------------------------------------------------------------------
 |  Methods inherited from llama_index.core.prompts.mixin.PromptMixin:
 |  
 |  get_prompts(self) -> Dict[str, llama_index.core.prompts.base.BasePromptTemplate]
 |      Get a prompt.
 |  
 |  update_prompts(self, prompts_dict: Dict[str, llama_index.core.prompts.base.BasePromptTemplate]) -> None
 |      Update prompts.
 |      
 |      Other prompts will remain in place.
 |  
 |  ----------------------------------------------------------------------
 |  Data descriptors inherited from llama_index.core.prompts.mixin.PromptMixin:
 |  
 |  __dict__
 |      dictionary for instance variables
 |  
 |  __weakref__
 |      list of weak references to the object
```

</details>


▽出力結果を一部整形したもの
```python
from_tools(
    tools: Optional[List[llama_index.core.tools.types.BaseTool]] = None,
    tool_retriever: Optional[
        llama_index.core.objects.base.ObjectRetriever[
            llama_index.core.tools.types.BaseTool
        ]
    ] = None,
    llm: Optional[
        llama_index.core.llms.function_calling.FunctionCallingLLM
    ] = None,
    verbose: bool = False,
    max_function_calls: int = 5,
    callback_manager: Optional[
        llama_index.core.callbacks.base.CallbackManager
    ] = None,
    system_prompt: Optional[str] = None,
    prefix_messages: Optional[
        List[llama_index.core.base.llms.types.ChatMessage]
    ] = None,
    **kwargs: Any
)
```

# さいごに
いかがでしたでしょうか。
AIに関する情報は日々急速に増えており、追いつくのが難しいと感じる方も多いかと思います。少しでも皆さまのお役に立てていれば幸いです。