【第一回】LlamaIndex に入門してみた

流行りの LLM に関する記事第一弾です。業務で使ったりもしますが、個人的にも勉強して追いついていきたい分野なので色々学んだことをアウトプットして LLM の記事を連載していきたいと思います。

## この記事の内容
この記事は Python のライブラリである `llama_index` の本当に入門の部分だけをまとめます。具体的には、Retrieval Augmented Generation の考え方と具体的な方針を [LlamaIndex のWebページ](https://docs.llamaindex.ai/en/stable/getting_started/concepts/) に則って説明したのち、いくつかの基本的な機能をコードと共に確認します。Embedding など、LLM 関連の基本的な知識を仮定します。
ぶっちゃけた話、この記事に書いてあることは全て LlamaIndex の公式 docs を読んだり、ちょっとググればすぐ出てくるようなことばかりです。ただ、初めて LlamaIndex に触れる方や、LLM のライブラリ操作などに慣れていらっしゃらない方は初めから公式 docs に飛び込んでも情報過多でどこから手をつけていいのかわかりにくいと思います。そのような方に対して最低限これだけは、という情報を示せるように記事を構成するつもりです。

本記事執筆時の Python と `llama_index` のバージョンは以下のとおりです。
Python: 3.11.1
`llama_index`: 0.10.36

:::note warn
本記事は RAG の5つのステップのうち **indexing** までをカバーします。Querying 以降は未執筆です（頑張ってすぐに記事化します）。
:::

## Retrieval Augmented Generation (RAG)
[**Retrieval Augmented Generation**](https://docs.llamaindex.ai/en/stable/getting_started/concepts/) (RAG) とは、大量の自然言語データで学習済みの LLM に対して、追加の（少量の）データを追加で与えることで特定の用途向けに LLM をカスタマイズする手法です。例えば家電メーカーなどの場合、自社プロダクトの説明書やその他様々な資料を LLM に対して与えることで、プロダクトに関する質問に対し回答するチャットボットを作成することが可能になります。
RAG に関する研究は近年継続的に発表されており、アカデミアの世界でもその有効性が確認されているようです（理論ベースか実験ベースかはあまりよくわかりませんが）[^1][^2][^3]。

[^1]: [Improving language models by retrieving from trillions of tokens](https://arxiv.org/abs/2112.04426)
[^2]: [REALM: Retrieval-Augmented Language Model Pre-Training](https://arxiv.org/abs/2002.08909)
[^3]: [Retrieval-Augmented Generation for Large Language Models: A Survay](https://arxiv.org/abs/2312.10997)

LlamaIndex では RAG には5つの段階があるとしています。
1. Loading: LLM に追加で与えるデータを読み込む
1. Indexing: 読み込んだデータを構造化し、LLM から情報を検索しやすい形にする
1. Storing: indexing を経て構造化されたデータを保存する
1. Querying: LLM に対して質問などを投げ、回答を得る
1. Evaluation: LLM の回答を評価する

以下、順に見ていきましょう。

## RAG: 5つのステップ
参考ページは[こちら](https://docs.llamaindex.ai/en/stable/understanding/)。

`llama_index` の内部の動きを見るために、ログの設定を行います。

```python:python
import logging
import sys

logging.basicConfig(stream=sys.stdout, level=logging.INFO)
logging.getLogger().addHandler(logging.StreamHandler(sys.stdout))
```
次に、LLM に与えるデータのソースを用意します。題材はなんでもいいんですが、最近 Whiplash の映画を見直したので J. K. Simmons の Wikipedia をとってくることにします。

```python:python
import requests
import os

response = requests.get("https://en.wikipedia.org/w/api.php",
                        params={
                            "action": "query",
                            "format": "json",
                            "titles": "J._K._Simmons",
                            "prop": "extracts",
                            "explaintext": True
                        }).json()
page = next(iter(response["query"]["pages"].values()))
text = page["extract"]

filepath = os.path.join("input/simmons", "simmons.txt")
os.makedirs(os.path.dirname(filepath), exist_ok=True)

with open(filepath, "w") as f:
    f.write(text)
```

### Loading
```python:python
from llama_index.core import SimpleDirectoryReader

docs = SimpleDirectoryReader(input_files=["input/simmons/simmons.txt"]).load_data()
```

このようにして、`simmons.txt` のデータを読み込むことができます。`SimpleDirectoryReader` は最も基本的なローディングメソッドになっており、今回は直接ファイルのパスを指定することでファイルを読み込みましたが、ディレクトリのパスを与えることでファイルを一括で読み込んだり、特定のファイルは読み込みから除外する、特定の拡張子を持つファイルのみを読み込む、などのコントロールをすることができます。
`Reader` は他にも様々なものが用意されています。[このページ](https://docs.llamaindex.ai/en/stable/api_reference/readers/)には `llama_index` に標準で備わった `Reader` クラスが列挙されています。`WikipediaReader` もあるので先ほどのソースはこちらの `Reader` でも用意することができそうですね。
また [LlamaHub](https://llamahub.ai) では拡張 `Reader` が用意されており、様々なソースから情報をとってこれるようです。

#### Transformation
Loading ステップの中には、厳密には load と transformation の二つの段階があります。Transformation とは、ロードされたドキュメントをいくつかの塊に分割する（node に　parse する）、embedding を与える、などを意味します。Indexing のための前準備、くらいのイメージかと。

Transformation には低レベル API と 高レベル API を利用することができます。低レベル API では transformation の各ステップに対して細かい設定を与えることができます。一方で、高レベル API はtransformation から indexing までがパッケージ化されており、その中身を細かくいじることはできませんが、`llama_index` に設定を任せることができます。

低レベル API の使用例として、ドキュメントのパースを見てみましょう。
```python:python
from llama_index.core.ingestion import IngestionPipeline
from llama_index.core.node_parser import TokenTextSplitter

pipeline = IngestionPipeline(transformations=[TokenTextSplitter()])

nodes = pipeline.run(documents=docs)
```
これによって与えられた documents を nodes に分割しています。
ちなみにここでは `transformations` 引数に与えることができる `TextSplitter` も様々なものが用意されています。

- `TokenTextSplitter`: 決まった token 数ごとに split する
- `SentenceSplitter`: 文の区切れ目を考慮しつつ split していく

次に紹介する高レベル API でのデフォルトは `SentenceSplitter` になっていたと思います。

### Indexing
Index とは（私のイメージでは） LLM が情報検索しやすいように構造化された文章オブジェクトの集合です。ここでいう index は日本語で「目次、目録」ぐらいのニュアンスだと思います。Index は代表的なものに以下の4つがあります（[こちら](https://docs.llamaindex.ai/en/stable/module_guides/indexing/index_guide/)で全て紹介されています）。

- `VectorStoreIndex`: 最も基本的な index で、transformation によって得られた node それぞれに対して embedding を与える
- `SummaryIndex`: node の集合を、順序を持つものとしてそのまま保持する
- `TreeIndex`: node から木を構成する（階層的に構成するとしか書いていないが、意味的な近さを元に構築していると考えられる）
- `KeywordTableIndex`: 各 node からキーワードを抽出し、キーワードから node への写像を定義する

以下のように高レベル API を用いて index を作成することができます。例として `VectorStoreIndex` を使用していますが、他の index も同様です。

```python:python
from llama_index.core import VectorStoreIndex
from llama_index.llms.openai import OpenAI
from llama_index.core import Settings
from llama_index.embeddings.openai import OpenAIEmbedding

Settings.llm = OpenAI(temperature=0, model="gpt-4-0125-preview")
Settings.embed_model = OpenAIEmbedding(model="text-embedding-ada-002")

vector_index = VectorStoreIndex.from_documents(docs)
```

ちなみに低レベル API を利用して生成した nodes からは以下のように index を構成できます。
```python:python
vector_index_from_nodes = VectorStoreIndex(nodes)
```

### Querying、といきたいところですが、、、
さて、いよいよ最も重要な部分である querying をみていきたいのですが、ここまでで記事がそこそこ長くなってしまったのと、異なる index の種類間での比較がまだ十分にできていないのでこの記事はここまでにして、次回の記事で querying 周りを詳しく書いていければと思います。J. K. Simmons の出る幕なかった。。。

## おわりに
ここまでお読みいただきありがとうございました！この記事の続きはもちろん、他の LLM 関連の話題なども記事にしていければと考えています。是非ともコメントなどお寄せいただけると幸いです。