ChatGPTで独自データを利用できるLlamaIndexはどんな仕組みで動いているのか?調べてみました

2023.03.13

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

ChatGPT APIがリリースされて、すでにさまざまな試行錯誤が始まっていますね。

なかでも、「自社の独自のデータを使ってChatGPTに質問を答えさせたい」というのは興味を持っている方が多いのでは、と思います。しかしながら、この記事を書いている2023年3月12日現在では、最新のモデルであるgpt-3.5-turboに、標準機能で追加で独自のデータをユーザーが学習させることはできません。

この「独自のデータをChatGPTで使う」ことを簡単に実現させる手段の1つとして、LlamaIndex(旧GPT Index)に注目が集まっています。

このDevelopersIOでも、LlamaIndexを使ってブログ記事を読み込ませて質問してみる、という試みを紹介する記事がいくつか投稿されています。

上の記事を読むと分かるように、LlamaIndexはユーザーが用意したデータを読みこんでインデックスファイルを作成、そのインデックスファイルの情報を参照して質問に答えてくれる仕組みを実現できます。

しかし、このLlamaIndexはどういう仕組みで動いているのでしょう?ローカルのデータとChatGPTをどう組み合わせて動いているのでしょうか?よく分からなかったので、ドキュメントと実際の動きを確認しつつ調べてみました。

そもそもLlamaIndexとは

まずはざっくりLlamaIndexとはどんなものかを説明しておきます。

公式ドキュメントサイトのTopページでは、概要としてLlamaIndexが作成された背景について書かれています。

ざっくり要約すると、GPTのようなLLMにプライベートなデータを補強するために、in-context learningという枠組みがあり、これを行うには

  • データの取り込み
  • インデックス化が必要

ということです。そこで、このデータの取り込み、インデックス化、またそのインデックスを利用して質問(クエリ)に回答するところまでの機能を一気通貫で提供してくれるのがLlamaIndex、となります。

たとえば既存のデータを取り込む部分では、llamahubというサイトで多数のデータコネクタが公開されています。

集めたデータはインデックス化され、質問があった際はこのインデックスのデータを参照しつつ返答を返します。

おおまかな仕組みのまとめ

いきなりですが、調べた結果として分かったおおまかな仕組みを最初に書いておきます。 公式のReadmeに例がある、SimpleVectorIndexを使った場合の動きです。

インデックスファイルの作成時の動き(SimpleVectorIndexの場合)

  • データを読み込み、細かいチャンクに分割
  • 分割したチャンクごとにOpenAI APIに問い合わせを行い、Embeddings(※)のデータを取得
  • 上記の情報を格納したインデックスファイル(JSON)を作成
    • インデックスファイルには、各チャンクに対応したノードオブジェクトが含まれる

質問(クエリ)に回答する際の動き

  • 質問のEmbeddingsのデータをOpenAI APIを取得
  • インデックスファイル内の各ノードを検索し、関連性の高いノードを抽出
  • 抽出したノード内のテキストを参照しながら、OpenAI APIへ問い合わせ実行
  • OpenAI APIからの返信をもとに、最終的な返信を作成

※Embeddings(埋め込み)は、テキストデータを数列に変換したものです。このデータを利用することで、文章の類似度を求めたり、検索できたりします。

以下、実際の動きも絡めて詳細を見てみましょう。

検証の方法

実行する処理は、上でも述べた公式のReadmeにある例を参考としました。

インデックスの処理:

import os
os.environ["OPENAI_API_KEY"] = 'YOUR_OPENAI_API_KEY'

from llama_index import GPTSimpleVectorIndex, SimpleDirectoryReader
documents = SimpleDirectoryReader('data').load_data()
index = GPTSimpleVectorIndex(documents)

クエリの実行:

index.query("<question_text>?")

インデックスとして読み込ませるデータですが、簡単に試すためmarkdown形式のファイルを1つだけ用意しました。以下のように会社の概要を記した情報です。

# クラスメソッド株式会社の会社概要

## クラスメソッド株式会社について
クラスメソッド株式会社は、日本の株式会社です。
2004年7月に設立しました。
代表取締役は横田聡です。
従業員数は2021年12月現在、グループ全体で600名です。

(以下略)

詳しく動きを見るため、以下のようにデバッグログを有効にしつつ、Jupyter Notebookで各処理の情報を一つ一つ追っていきました。

import logging
import sys
logging.basicConfig(stream=sys.stdout, level=logging.DEBUG, force=True)

インデックス作成時の動き

index = GPTSimpleVectorIndex(documents)

上記でSimpleVectorIndexを作成した際のデバッグログの出力の抜粋です。 上記mdファイル内のテキストをチャンクに分割して、そのチャンク単位でOpenAI APIへembeddingsのデータを返すようリクエストしていることが分かります。

DEBUG:root:> Adding chunk: 

クラスメソッド株式会社の会社概要

DEBUG:openai:message='Request to OpenAI API' method=post path=https://api.openai.com/v1/engines/text-embedding-ada-002/embeddings
DEBUG:openai:api_version=None data='{"input": ["  \\u30af\\u30e9\\u30b9\\u30e1\\u30bd\\u30c3\\u30c9\\u682a\\u5f0f\\u4f1a\\u793e\\u306e\\u4f1a\\u793e\\u6982\\u8981  "], "encoding_format": "base64"}' message='Post details'
DEBUG:urllib3.util.retry:Converted retries value: 2 -> Retry(total=2, connect=None, read=None, redirect=None, status=None)

インデックスファイルは下記でJSONファイルとしてローカルに保存が可能です。

index.save_to_disk('index.json')

JSONファイルの中身は以下のような感じです。Unicodeがエンコードされているので分かりづらいですが、上の出力結果と併せてみると、text部分のデータは上の例のチャンクのテキストと同じことが分かるでしょう。

{"index_struct_id": "da57a925-84f5-45ac-950a-bd63f7360f3c", "docstore": {"docs": {"da57a925-84f5-45ac-950a-bd63f7360f3c": {"text": null, "doc_id": "da57a925-84f5-45ac-950a-bd63f7360f3c", "embedding": null, "doc_hash": "08a14830cef184731c6b6a0bdd67fa351d923556941aa99027b276bd839a07a4", "extra_info": null, "nodes_dict": {"5607013499045663284": {"text": "\n\n\u30af\u30e9\u30b9\u30e1\u30bd\u30c3\u30c9\u682a\u5f0f\u4f1a\u793e\u306e\u4f1a\u793e\u6982\u8981\n\n", "doc_id": "1c58f36d-0708-4668-bd96-c60831b488e0", 
(略)

インデックスのデータの下の方にはembeddingsの情報が格納されています。

 "embeddings_dict": {"1a1927b6-1ee7-4801-83eb-0fb395c3509a": [-0.0017202143790200353, 0.004639721475541592, -0.010204036720097065, -0.025821639224886894, -3.4206430427730083e-05, 0.018411485478281975, 0.006096962373703718, -0.010498834773898125, -0.00018089887453243136, -0.010264336131513119, -0.
(略)

質問(クエリ)に回答する際の動き

index.query("クラスメソッドの創立年は?")

上記質問を実行した際のデバッグログの抜粋です。 まず、質問のembeddingsデータを取得して、インデックスデータの中から類似するノードを抽出しています。 今回データが少ない(かつ、類似度の高い情報がある)からか一つのノードだけが選択されましたが、類似度が高い複数のノードTop Kが抽出されるようです。

DEBUG:openai:message='Request to OpenAI API' method=post path=https://api.openai.com/v1/engines/text-embedding-ada-002/embeddings
DEBUG:openai:api_version=None data='{"input": ["\\u30af\\u30e9\\u30b9\\u30e1\\u30bd\\u30c3\\u30c9\\u306e\\u5275\\u7acb\\u5e74\\u306f\\uff1f"], "encoding_format": "base64"}' message='Post details'
DEBUG:urllib3.util.retry:Converted retries value: 2 -> Retry(total=2, connect=None, read=None, redirect=None, status=None)
DEBUG:urllib3.connectionpool:Starting new HTTPS connection (1): api.openai.com:443
DEBUG:urllib3.connectionpool:https://api.openai.com:443 "POST /v1/engines/text-embedding-ada-002/embeddings HTTP/1.1" 200 8419
DEBUG:openai:message='OpenAI API response' path=https://api.openai.com/v1/engines/text-embedding-ada-002/embeddings processing_ms=17 request_id=c521d3ee40bad22871e70af11d9b405f response_code=200
DEBUG:llama_index.indices.utils:> Top 1 nodes:
> [Node 2a8f5de8-56db-45e8-a88b-3104d7385774] [Similarity score:             0.862449] 

クラスメソッド株式会社について
クラスメソッド株式会社は、日本の株式会社です。
2004年7月に設立しました。
代表取締役は横田聡です。
従業員数は2021年12月現在、グループ全体で600...
DEBUG:root:> Searching in chunk: 

クラスメソッド株式会社について
クラスメソッド株式会社は、日本の株式会社です。
2004年...

上記ではSimilarity score 0.862449という類似性の高いノードが選択されました。 次に、このノードの情報を利用して、OpenAIに質問を行います。

DEBUG:openai:message='Request to OpenAI API' method=post path=https://api.openai.com/v1/completions
DEBUG:openai:api_version=None data='{"prompt": ["Context information is below. \\n---------------------\\n\\n\\n\\u30af\\u30e9\\u30b9\\u30e1\\u30bd\\u30c3\\u30c9\\u682a\\u5f0f\\u4f1a\\u793e\\u306b\\u3064\\u3044\\u3066\\n\\u30af\\u30e9\\u30b9\\u30e1\\u30bd\\u30c3\\u30c9\\u682a\\u5f0f\\u4f1a\\u793e\\u306f\\u3001\\u65e5\\u672c\\u306e\\u682a\\u5f0f\\u4f1a\\u793e\\u3067\\u3059\\u3002\\n2004\\u5e747\\u6708\\u306b\\u8a2d\\u7acb\\u3057\\u307e\\u3057\\u305f\\u3002\\n\\u4ee3\\u8868\\u53d6\\u7de0\\u5f79\\u306f\\u6a2a\\u7530\\u8061\\u3067\\u3059\\u3002\\n\\u5f93\\u696d\\u54e1\\u6570\\u306f2021\\u5e7412\\u6708\\u73fe\\u5728\\u3001\\u30b0\\u30eb\\u30fc\\u30d7\\u5168\\u4f53\\u3067600\\u540d\\u3067\\u3059\\u3002\\n\\t\\n\\n---------------------\\nGiven the context information and not prior knowledge, answer the question: \\u30af\\u30e9\\u30b9\\u30e1\\u30bd\\u30c3\\u30c9\\u306e\\u5275\\u7acb\\u5e74\\u306f\\uff1f\\n"], "model": "text-davinci-003", "temperature": 0.0, "max_tokens": 256, "top_p": 1, "frequency_penalty": 0, "presence_penalty": 0, "n": 1, "best_of": 1, "logit_bias": {}}' message='Post details'

上記では肝心のプロンプトの日本語部分がエンコードされていて読みにくいので、以下デコードしたプロンプト部分の情報を示します。コンテキストとしてノードのデータを与えたうえで、そのコンテクストをもとに最初に受けた質問を返すよう指定しています。

Context information is below. 
---------------------


クラスメソッド株式会社について\nクラスメソッド株式会社は、日本の株式会社です。\n2004年7月に設立しました。\n代表取締役は横田聡です。
従業員数は2021年12月現在、グループ全体で600名です。

---------------------
Given the context information and not prior knowledge, answer the question: クラスメソッドの創立年は?"
---

このデフォルトのプロンプト内では「not prior knowledge」、すなわちもともとの知識に基づかず、コンテクストの情報のみに基づいて回答するよう指定があるので、たとえば以下のようにインデックス内の情報と全然関係のない質問をすると回答不能である旨が返ってきます。

index.query("おいしいカレーの作り方教えて")
I'm sorry, I don't know how to make delicious curry.

プロンプトを変えたい場合は、自分でテンプレートを書いて以下のようにクエリ時に渡してあげれば良いようです。公式ドキュメント、Defining Promptsという記事に具体的な方法が載っているので、必要な場合は参考にしてください。

OpenAIへのクエリを類似度が高いと判定された各ノードについて実施し、Response synthesisという処理でユーザーに最終的に返す回答を生成するようです。ただ今回は単一のノードのみであったため、処理は単純でした。クエリに対してインデックスとどう働くかについては、公式のドキュメントに概要図付きで説明がありますので、詳しくはそちらを確認いただければと思います。

おわりに

というわけで、ざっくりとLlamaIndexの動きを確認してみました。 OpenAI APIとどう連携して動くのか?という点が分かり個人的にはスッキリしました。

また、重要な点として、データをローカルで持つからといってそのデータをOpenAI側に送らない、というわけではなく、必要に応じてローカルのデータをOpenAIへ送って情報を得るということを認識する必要があります(インターフェイスとしてChatGPTを利用する以上当然ではあるのですが・・・)。仕様を理解して、適切に利用しましょう。

ではでは。