[RAGカスタマイズ] LlamaIndexで情報元のPDFのメタデータを含めた回答を生成する

# 背景
社内資料の検索をスムーズにするRAGシステムをLlamaIndexを使用して開発している際にある問題に当たった．

「情報の出所がどのPDFの何ページ目かを知りたい」

営業やカスタマーセンターの方が顧客から質問され，RAGを使用して回答を得て，顧客に回答するという使用方法を想定する場合，間違った情報を伝えてはならない．従って，情報源となるPDFのページを示すことで，簡単にファクトチェックを行えるようになる．

今回の記事では，このRAGシステムを構築する際によく遭遇する問題をLlamaIndexで解決していく．対象読者は以下の通りである．

* RAGの基本（Retriever，Generator）がわかっている人
* Document，Node，IndexなどのLlamaIndexの基本がわかっている人
* RAGを細かくカスタマイズしたい人

なお，RAGのデータ構造に関するまとめは別記事で公開しているので，データ構造に詳しくない人はぜひこちらもご覧ください．

https://qiita.com/paypay-1126/items/11ce9a7881fb5ad323cb


# チャンク取得後の処理の手順
Llama index では，Retrieverによって，複数のNodeが取得され，そのNodeの情報を元に回答を生成する．手順は以下の通りである．

1. Retrieverを使用してIndex（Nodeオブジェクトの塊）からスコアの高い複数Nodeを取得
1. Node Postprocessor でフィルタリングなどの後処理
1. Response Synthesizer でNodeの情報をLLMに渡して回答を生成

これらの処理をLlamaIndexのパイプライン処理によって行うプログラムは以下の様になる．
```python
from llama_index.core import (
    StorageContext,
    load_index_from_storage,
    get_response_synthesizer
)
from llama_index.core.retrievers import VectorIndexRetriever
from llama_index.core.query_engine import RetrieverQueryEngine
from llama_index.core.postprocessor import SimilarityPostprocessor

# 保存済みのindexを読み込み
index_path = "path/to/storage"
storage_context = StorageContext.from_defaults(persist_dir=index_path)

index = load_index_from_storage(storage_context=storage_context)

# Retrieverを準備
retriever = VectorIndexRetriever(
    index=index,
    similarity_top_k=5
)

# NodePostProcessorの準備
# スコアが0.75以下のNodeを削除
score_filtter_processor = SimilarityPostprocessor(similarity_cutoff=0.75)
node_postprocessors=[score_filtter_processor]

# Synthesizerの作成
response_synthesizer = get_response_synthesizer(
    output_cls=ResponseModel
)

# QueryEngineの作成
query_engine = RetrieverQueryEngine(
    retriever=retriever,
    response_synthesizer=response_synthesizer,
    node_postprocessors=[AddMetadataNodePostprocessor()]
)

output = query_engine.query("質問を入力")
```

それぞれについてまとめる．

## Retriever
Retrieverは，クエリとNodeのスコアを計算し，そのスコアの高い上位のNodeを取得する機構である．
LlamaIndexでは，以下のような基本的なRetrieverが提供されている．

* BM25
* ベクトル検索

また，特殊な処理を行うものもあり，LlamaIndexでは，Metadata Replacement + Node Sentence Window として紹介されている．これは，ヒットしたチャンクの周りのチャンクをコンテキスト情報として一緒に取得する方法であり，今回のユースケースに適したものである．後に詳しく記述する．

https://docs.llamaindex.ai/en/stable/examples/node_postprocessor/MetadataReplacementDemo/

## Node Postprocessor
検索により，取得したNodeになんらかの変形やフィルタリングを加える機構である．具体例として，以下の処理が挙げられる．

* フィルタリング：閾値以下のスコアのNodeを全て切り捨てなど
* リランク：取得したNodeの順位を別のモデルを使用して再度ランク付け
* ヒットしたNodeの加工

## Response Synthesizer
検索でヒットしたノードは，バラバラの情報であるため，LLMに効率的に情報を渡すには，これらをうまく加工してやる必要がある．"Synthesize"とは「合成する」という意味であり，文字通り複数のNodeの情報を合成する役割を持つ．

LlamaIndexでは，複数のSynthesizerが公開されている．代表例をいくつか列挙すると以下のようなものが挙げられる．

1. **refine（洗練）**：
検索されたテキストチャンクを順番に処理しながら、回答を洗練していく．チャンクごとにLLMを呼び出すため，LLM呼び出し回数が多くなる．スコアの高い検索結果をベースにしてそこから順々に情報を洗練していく手法
1. **compact（圧縮，LlamaIndexのデフォルト）**：
refine と似ているが，事前にテキストチャンクを**圧縮（連結）**することで、LLM呼び出し回数を減らす．refine よりもLLM呼び出し回数が少なく，効率的な一方，
refineyよりも回答の質が劣る可能性がある
1. **tree_summarize（ツリー要約）**:
動作: 検索されたチャンクを階層的に要約し、最終的にツリー状に構造化された要約を生成．（今回は詳細には触れない．）
1. **context_only（コンテキストのみ）**
検索でヒットしたNodeのテキストをただ連結するだけの合成方法．検索されたコンテキスト (文脈) をそのまま確認したい場合に使用
1. **no_text（テキストなし）**
ただRetrieverの挙動を確認したい場合にのみ使用．

用途に合わせて適したResponseSynthesizerを使用することで，RAGによって効率的に情報を集めることができる．

# RAGの回答にメタデータを含める
それでは，これらの方法を応用して，RAGで生成する回答に出所となるPDFファイルとそのページを含める方法についてまとめる．大きく3つの手順が必要になる．

1. PDFからDocumentオブジェクトを生成する際に所望のメタデータを含める
1. Retrieverで検索をする際にPostNodeProcessorによりNodeのテキストにメタデータを追加する
1. Synthesizerの出力形式をpydanticで指定し，メタデータが含まれるようにする

## Nodeオブジェクトに所望のメタデータを含める
LlamaIndexでは，PDF → Documentオブジェクト → Nodeオブジェクト の順でデータが変換されていく．DocumentオブジェクトのメタデータはNodeオブジェクトにも継承される．つまり，PDFのファイルパスやページ数をメタデータとして含める場合は，PDF → Documentの部分でそれぞれのDocumentオブジェクトにメタデータを持たせる．

LlamaIndexが提供しているSimpleDirectoryReaderでは，PDFをDocumentに変換する際，デフォルトでメタデータを付与してくれる．従って，メタデータを付与するための処理は以下の処理だけで良い．

```python
input_dir = "RAGの情報源となるファイルをまとめたディレクトリへのパス"
data_reader = SimpleDirectoryReader(input_dir=input_dir)
documents = data_reader.load_data()
```
あとは，`documents`からIndexを作り，永続化する．Document → Node → Indexの順にデータは変換されるが，以下のプログラムは，それを抽象化してNodeを飛ばして記述することができる．しかし，作成されるIndexに含まれるNodeはしっかりと`documents`のメタデータを継承している．

```python
index = VectorStoreIndex.from_documents(documents=documents)
# データの保存
index.storage_context.persist("indexの保存先パス")
```

## PostNodeProcessorによりNodeのテキストにメタデータを追加する
情報源となるデータの準備ができたので，続いてRetriever側の実装に入る．Retrieverは，ユーザーの入力クエリからスコアの高いNodeオブジェクトを複数取得する．Nodeオブジェクトは，テキスト情報とメタデータの情報を持っている．ここでは，メタデータの情報をテキストに加えることで，この後の処理でLLMがメタデータを使用できる様にする．

Retrieverが取得したNodeに後処理を加えるPostNodeProcessorをカスタマイズして自分で作成することで，これを実現する．

```python
from llama_index.core import QueryBundle
from llama_index.core.postprocessor.types import BaseNodePostprocessor
from llama_index.core.schema import NodeWithScore

from typing import List, Optional

class AddMetadataNodePostprocessor(BaseNodePostprocessor):
    """
    ノードにメタデータを追加するノード後処理器
    """
    def _postprocess_nodes(
        self, nodes: List[NodeWithScore], query_bundle: Optional[QueryBundle]
    ) -> List[NodeWithScore]:
        # Add node's meta data to its own text
        for node in nodes:
            text_with_metadata = f"""
            -----------------------
            * Text: {node.node.text}
            
            * Metadata: {node.node.metadata}
    
            """
            node.node.text = text_with_metadata

        return nodes
```

LlamaIndexでは，`BaseNodePostprocessor`クラスを継承したクラスを作成することで，自作のNodePostprocessorを作ることができる．この様にすることで，各Nodeのテキストは以下のようになる．

```
Chunk1
* Text: 昔々あるところにおじいさんとおばあさんが住んでいました．おじいさんは．．．
* Metadata: {"file_name": "桃太郎", "file_label": 1, ...}

...

Chunk10
* Text: 桃太郎さん桃太郎さん．お腰につけたきびだんご一つ私に．．．
* Metadata: {"file_name": "桃太郎", "file_label": 6, ...}

```
この様にすることで，テキスト側にメタデータを追加することができた．

## Synthesizerの出力形式をpydanticで指定してメタデータが含まれるようにする
Nodeのテキストにメタデータが含まれたからといって，Sysnthesizerがメタデータを出力してくれる保証はどこにもない．もしかしたら不要な情報と判断され，要約によって削除されてしまう可能性もある．

したがって，pydanticを使用してSynthesizerの自由度を制限し，メタデータが含まれる様にする．pydanticは，Pythonのデータバリデーションとシリアライゼーションを簡単かつ効果的に行うためのライブラリである．これを使用することで，比較的型が自由で保守性の高いプログラムを書くのが難しいPythonで，データの整合性を保つことができる．

```python
class RAGResponseModel(BaseModel):
    """
    RAGを実行するLLMが返すデータの形式
    """
    answer: str
    page_label: str
    file_name: str
    file_path: str

index_path = "indexの保存先パス"
storage_context = StorageContext.from_defaults(persist_dir=index_path)

index = load_index_from_storage(storage_context=storage_context)
retriever = VectorIndexRetriever(
    index=index,
    similarity_top_k=5
)

# Synthesizerの作成
response_synthesizer = get_response_synthesizer(
    output_cls=ResponseModel
)

# QueryEngineの作成
query_engine = RetrieverQueryEngine(
    retriever=retriever,
    response_synthesizer=response_synthesizer,
    node_postprocessors=[AddMetadataNodePostprocessor()]
)

output = query_engine.query("APIの使い方を教えて．参考にしたファイル名も一緒に")
print(output.get_response().response)
```

上記のようにすると，以下のような回答が得られる．わかりやすさのため，ファイル名だけ変更している．

```
'{"answer":"APIの使い方については、提供されたファイル内に詳細な情報が記載されています。ファイル名は「fileA.pdf」です。","file_name":"fileA.pdf","file_page":"1, 3, 5"}'
```

狙い通り，回答にPDFのメタデータを含めることができた．

# まとめ
本記事では，PDFのメタデータを含めた回答を生成する方法をまとめた．実現はできたが，もっと便利なクラスやメソッドがあり，少し遠回りをしている様な気もする．もし，本記事の読者の中にもっと良い方法を知る方がいらっしゃいましたら，コメントいただけますと幸いです．

# 参考資料
https://docs.llamaindex.ai/en/stable/module_guides/querying/response_synthesizers/#custom-response-synthesizers

https://qiita.com/paypay-1126/items/11ce9a7881fb5ad323cb