LlamaIndexのNotion Loaderを使って、Notion内の情報についてChatGPTで質問できるようにしてみる

独自のデータをChatGPTで簡単に扱えるLlamaIndexでは、Notionの情報を取り込むデータコネクタ(Notion Loader)が存在します。この記事では、Notion Loaderを利用してNotionの情報を収集・インデックス化し、ChatGPTで質問できる方法についてご紹介します。
2023.03.23

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

LlamaIndexについて

LlamaIndexは、「独自のデータをChatGPT(およびその他LLM)で使う」ことを簡単に実現するOSSです。具体的には、既存のデータをもとにインデックスを作成し、質問に対応する適切な情報を選択。その情報をコンテクストとしてChatGPTに伝えたうえで質問することで、独自のデータをもとにした回答をChatGPTに行わせることができる仕組みです。この基本的な動作については、先日別のブログ記事で簡単に紹介をしていますので、ご興味ある方は参照してください。

Llama HubとNotion Loaderについて

さて、このLlamaIndexの魅力の一つが、独自データを取り込むためのデータコネクタが、Llama Hubというサイトで多数公開されていることです。このおかげで現在持っている情報資産のインデックスを簡単に作成することができます。

このデータコネクタの一つに、Notionの情報を取り込むNotion Loaderがあります。 これにより、Notionの情報をベースにChatGPTでの質問するシステムを用意できるはずなので、試してみたいと思います。

補足しておきますと、NotionはすでにNotion AIという機能を実装しており、Notion内でGPTをベースとしたAI機能を利用することが可能です。ただ、2023年3月現在は、まだこのNotion AIではワークスペース全体の情報を横断して質問に答えてもらう、という機能は提供されていません。その補完として考えられる手段の一つが、このLlamaIndexの利用といえます。

事前に必要な準備

Notion LoaderはNotionのAPIを利用します。 このため、Notionでインテグレーションを作成し、以下を用意する必要があります。

  • APIトークンの取得
  • 取得したいページへのAPIコネクトの追加

詳細は、Notionにおける公式のヘルプページをご参照ください。

また、LlamaIndexでChatGPTを利用するためのOpen AIのAPIキーも当然のことながら別途必要となります。

今回の例ではOpen AIのAPIキーとNotionのAPIトークンいずれも、環境変数として設定している前提です。

$ export OPENAI_API_KEY=XXXX
$ export NOTION_API_KEY=YYYY

Notion Loaderの仕様

Notion Loaderの情報は下記ページにあります。

ページ内の例にあるように、Notionのページを読み込ませるにはそのページのid(のリスト)を指定する必要があります。 そのため、このidをまずNotionから取得する必要があります。

幸い、APIコネクトを設定したページとそれ以下の子ページの情報はNotion APIの search メソッドで取得することができます。 このため、この記事では、search メソッドをまず実行して対象ページ及びそれ以下の小ページのidを取得し、Notion Loaderに渡すことにしてみます。

※ただ、この後お見せするサンプルコードを書いた後に、Notion Loaderでこのsearchを行うメソッドを見つけました。こちらを使ったほうがスマートかもしれません。とりあえず、私が書いたコードでも動きはしたので、そのまま載せておきます。

Notion Loaderで情報を取得するサンプルコード

今回検証のために書いたサンプルコード(Python)は下記となります。

import requests
import os
import json
from llama_index.indices.vector_store import GPTSimpleVectorIndex
from llama_index import download_loader
from llama_index.langchain_helpers.chatgpt import ChatGPTLLMPredictor

notion_api_key = os.environ['NOTION_API_KEY']
NotionPageReader = download_loader('NotionPageReader')
notion_reader = NotionPageReader(integration_token=notion_api_key)
ids = []
# Settings for requests
retry = requests.adapters.Retry(connect=5, read=3)
session = requests.Session()
session.mount("https://", requests.adapters.HTTPAdapter(max_retries=retry))
index = None
index_file = 'notion_index.json'

def load_init_notion_data(ids):
    documents = notion_reader.load_data(page_ids=ids)
    llm_predictor = ChatGPTLLMPredictor()
    index = GPTSimpleVectorIndex(
        documents=documents,
        llm_predictor=llm_predictor
    )
    index.save_to_disk(save_path=index_file)

def add_notion_data(ids):
    documents_insert = notion_reader.load_data(page_ids=ids)
    for doc in documents_insert:
        index.insert(doc)
    index.save_to_disk(save_path=index_file)

def get_notion_page(cursor):
    pagenate_num = 10
    post_data =  {'page_size': pagenate_num}
    if not cursor == "":
        post_data['start_cursor'] = cursor
    url = 'https://api.notion.com/v1/search'
    headers = {"Authorization": f"Bearer {notion_api_key}",
           "Content-Type": "application/json",
           "Notion-Version": "2022-06-28"}
    res  = session.post(url=f"{url}",headers=headers,json=post_data, timeout=5)
    res.raise_for_status() 
    res_data=json.loads(res.text)
    print(res_data)

    for page in res_data['results']:
        ids.append(page['id'])

    if index == None:
        load_init_notion_data(ids)
    else:
        add_notion_data(ids)

    if res_data['has_more'] == True:
        next_cursor =  res_data['next_cursor']
        get_notion_page(next_cursor)

get_notion_page('')

コードの解説

以下、かいつまんで解説をしていきます。

  • 関数 get_notion_page では、再帰処理を行っています。Notion APIの search メソッドでは、一度に取得するページ情報の個数をパラメータ page_size で指定、情報がまだ続く場合はレスポンス内の has_more の値が True となり、カーソルの情報が next_cursor にセットされて返ってきます。情報が続く限り再帰呼び出しして search メソッドを呼び続けています。
  • Notion APIの search メソッドで取得したページ情報からページIDを抽出し、Notion LoaderにIDを送っています。多数のページを処理する場合を想定し、このサンプルコードでは一度search メソッドで情報を取得するたび、都度Notion LoaderにIDを送り、インデックスへ順次追加する処理としてみました。読み込む予定のページ数がそれほど多くなければ、手間をかけずにすべてのIDをまとめて一気に送ってしまっても問題ないかと思います。が、後述のように筆者の環境ではページの読み込みに非常に時間がかかる場合があったので、順次処理にしておいたほうが確実かもしれません。
  • 上記の関係から、LlamaIndexのインデックスは、初回処理では新規作成(関数 load_init_notion_data )を実施、以降は作成済みインデックスへ追加(関数 add_notion_data)する仕様としました。これまた、一気にページIDを送って一気に処理する場合には必要とならない処理です。

利用したインデックスでQ&Aできるかの検証

上記コードで、Notion記事を取り込んだインデックスを作成できました。問題なく動作するか、簡単にテストしてみたいと思います。

検証用に取り込んだNotionページでは、下記のようにクラスメソッドの沿革を記載してみました。

これをもとに質問して回答してもらえるかテストしたいと思います。

import openai
from llama_index.indices.vector_store import GPTSimpleVectorIndex
from llama_index import SimpleDirectoryReader

index = GPTSimpleVectorIndex.load_from_disk('notion_index_sandbox.json')
response = index.query("クラスメソッドがDevelopesIOをはじめたのは何年か教えてください。")
print(response.response)

返ってきた答え:

2011年

味気ない回答ですが、正解が返ってきているのでよしとします!

(回答については、ChatGPTに聞く部分のテンプレートを調整するともっと良くなるのかもしれませんが、それは別の検討課題としたいと思います)

おわりに:課題とこれから

ということで、LlamaIndexのNotion Loaderを使う例をご紹介しました。ご参考になれば幸いです。

ただ、実際にNotion Loaderを利用してみた感想として、実運用を考えた場合、以下のような課題があると感じました。

速度

実際に多くの情報を記載したNotionのページを複数Notion Loaderで読み込んでみたところ、ページによっては非常に時間がかかる現象が発生しました。原因を完全に追求できておらず、またプログラムを実行している環境の問題の可能性もありそうですが、極端なケースでは1ページの読み込みに数十分を要しました。実際のコードを読む限り、ブロック単位で情報を取得する仕様になっているので、ブロックが多いと処理が重くなるのかもしれません。またデータベースの場合はデータベース内の各エントリを読み込むようなので、データの多いデータベースでも読み込みに時間がかかる可能性がありそうです。

更新

これはNotion Loaderの問題というわけではありませんが、Notionは当然ながら内容の追加・削除・変更が継続的に行われるため、実運用に際してはその変更を継続的インデックスに反映させていく必要があります。このメンテナンスをどう行っていくか、が運用上は課題になるかと思いました。データが小規模であれば、インデックスを毎回すべて作り直すという力技も可能とは思いますが、そうすると上記の速度の問題がネックになってきます。

このため、場合によってはNotion Loaderを利用するのではなく、Notionから直接ページをエクスポートしてLlamaIndexにロードしてしまったほうが環境によっては効率が良いケースもあるかと思いました。ただ、現状ではNotionのエクスポートは自動では行えないので、今度はその点が運用上の課題となります。

実運用を想定して、これらの課題をどうクリアしていくか、引き続き検討していきたいと思います。

ではでは。