💡

NotionマニュアルをRAG検索チャットボットを作ってみた

に公開

TL;DR

  • NotionのページをRAG検索
    Notion APIでマニュアルを再帰取得 → 300 文字/30 文字オーバーラップでチャンク化 → multilingual‑e5‑small で埋め込み → FAISS に保存 → Ollama(Qwen 4B) + LangChain で類似検索&回答生成
  • 動作環境
    OllamaのみLocalでその他はDevContainerで動かしています
  • 実装したコード
    https://github.com/yukiyoshimura/notion-rag-search
  • 精度検証
    ページタイトル検索も本文ワード検索も想定どおりヒット。実運用してみないと実際のところはわからないが、Notion側の整理次第で精度UPが見込める

はじめに

Notionでナレッジを蓄積している方は多いと思いますが、日々増え続ける情報を効率的に検索するのは意外と難しいものです。特に社内のドキュメントや個人のメモが膨大になると、欲しい情報にたどり着くまでに時間がかかってしまいます。
そこで、NotionとRAG(Retrieval-Augmented Generation)技術を組み合わせて、自然言語で質問に答えてくれるチャットボットを作ってみました。今回はlocalで動作させるところまでです。

システム全体の設計

大きく分けて6つのレイヤーで構成されています。

レイヤー ツール/ライブラリ 説明
データ取得 notion-client Notion API からページを JSON 取得
テキスト分割 LangChainTextSplitter 長文を適切なサイズのチャンクに分割
埋め込み生成 multilingual-e5-small 多言語に対応した軽量版のテキスト埋め込みモデルで、118の言語をサポートしている
ベクトルDB FAISS in-memory ローカル検索。必要なら永続化も可能
RAG Orchestrator LangChainRetrievalQA 埋め込み→検索→LLM 生成の一連の流れをシンプルに実現
LLM実行環境 Ollama(qwen3:4b) 量子化済み Llama-2 7B モデルをローカルで高速推論
API サーバ FastAPI /chat エンドポイントを数十行で実装
チャット UI Gradio 数行のコードで即チャット画面を立ち上げ、URL 発行も簡単

RAGの基本概念

  • 「意味的な理解」に基づいて情報を検索
    • 従来の全文検索と異なり、RAGは意味的な理解で検索
  • テキストの埋め込み化
    • 文章をベクトルに変換し、意味を数値表現
  • セマンティック検索
    • クエリと類似する意味のテキストを発見
  • コンテキスト補強
    • 関連テキストを材料にLLMが回答を生成

これにより、キーワードマッチでは見落としがちな概念的に関連する文書も取得できます。

事前準備 Notionからデータを取得する方法

Notionの設定

1.ここからインテグレーションを作成し、secretを発行します。
https://www.notion.so/profile/integrations

RAG検索を許可するページに行き、接続 → 作成したインテグレーション

全体の流れ

1.Notionからテキストを取得

今回は、自身で運営しているサービスのヘルプページを作ってその子ページ全てを読み込ませました。
ヘルプページのページIDがあれば、あとは再帰的に子ページを読み込ませることが可能です。

親ページを起点に再帰的に子ページ含めて処理してテキストを抽出します。

1-1.ページの基本情報を取得

GET /v1/pages/{page_id}
ページタイトルを取得するため
https://developers.notion.com/reference/retrieve-a-page

{
  "id": "page_id",
  "properties": {
    "title": {
      "title": [
        {
          "plain_text": "ページタイトル"
        }
      ]
    }
  },
  "parent": {...},
  "url": "https://www.notion.so/..."
}

1-2.ページのコンテンツを取得

GET /v1/blocks/{block_id}/children
page_idを元にそのページのコンテンツを取得
https://developers.notion.com/reference/get-block-children

{
  "type": "paragraph",
  "paragraph": {
    "rich_text": [
      {
        "plain_text": "段落のテキスト"
      }
    ]
  }
}

1-3.ページのコンテンツ(block)からテキストを抽出

https://developers.notion.com/reference/block

blockTypeごとにテキストを抽出する必要があるのでこんな感じで抽出します。

    def extract_text_from_blocks(self, blocks: Dict[str, Any]) -> str:
        """Notionブロックからテキストを抽出"""
        text = ""
        for block in blocks.get("results", []):
            block_type = block.get("type")
            
            if block_type == "paragraph":
                text += self._extract_text_from_rich_text(block.get("paragraph", {}).get("rich_text", []))
            elif block_type == "heading_1":
                text += "# " + self._extract_text_from_rich_text(block.get("heading_1", {}).get("rich_text", []))
            elif block_type == "heading_2":
~ 略        
            text += "\n\n"
        
        return text.strip()
    
    def _extract_text_from_rich_text(self, rich_text: List[Dict[str, Any]]) -> str:
        """リッチテキスト配列からプレーンテキストを抽出"""
        return "".join([rt.get("plain_text", "") for rt in rich_text])

2.テキストを分割(chunk)

分割する理由

埋め込みモデルには入力長の制限があるため。
RAGシステムの性能を最大化するためにこういう設計になっていると思われる。(chunkしておかないと、埋め込み処理も当然重くなるはず)

  • どれくらいの文字数で分割するかは、埋め込みモデルのtoken制限を参考に決める。
  • multilingual-e5-smallの制限は512token
  • 日本語は1文字あたり平均すると1.2〜1.5token
    上記から300文字で分割にした。
    overlapは30文字でセット
    文脈のつながりを維持する目的でoverlapは設定する。今回は3chunk_sizeの10%)で指定してみた。

LangChainのRecursiveCharacterTextSplitterを使うと、文字数とoverlapを指定してよしなに分割してくれます。

RecursiveCharacterTextSplitter(
            chunk_size=300,
            chunk_overlap=30,
            separators=["\n\n", "\n", ". ", " ", ""]
        )

3.Embedding(埋め込み)

埋め込みする理由

テキストや画像などのデータをコンピュータが理解できる数値のベクトル(数値の配列)に変換するため
chunkが完了したら、それを埋め込み(ベクトル化)します

texts = chunkしたデータ
HuggingFaceEmbeddings(multilingual-e5-small).embed_documents(texts)

4.ベクトルDBに保存

Embeddedした結果をベクトルDBに保存します

5.検索

5-1.チャットボット上で入力された文字列を、ベクトル化

5-2.ベクトルDBに対して1.の結果で類似検索

埋め込みモデルと、取得したいドキュメント(1chunk)の数を指定。ここでは5を指定。

VectorStore().similarity_search(multilingual-e5-small, k=5)

5-3.検索結果をLLMのプロンプトに渡す

LLMに渡すプロンプトの組み立てはこんな感じ

    def generate_response(self, query: str, contexts: List[str], history: Optional[List[Dict[str, Any]]] = None) -> str:
        """コンテキストを用いてLLMで回答を生成"""
        try:
            # コンテキストを結合
            context_text = "\n\n".join(contexts)
            
            # プロンプトの構築
            prompt = f"""以下は、ユーザーの質問に関連するマニュアルからの情報です:

{context_text}

ユーザーの質問: {query}

上記の情報に基づいて、ユーザーの質問に明確に答えてください。マニュアルに記載されている情報のみを使用し、情報がない場合はその旨を伝えてください。"""

検証

まずはページタイトルに含まれるもので検索してみる


ホームページ作成機能について と入力してみる
検索結果↓

  • 管理画面をAdmin画面という表現で返してしまってますが一応、ドキュメントは正しいものを返せている
  • 回答内容は正しい

ページの中に書かれている情報をちゃんと引っ張ってこれるか

ホームページ作成機能ページの中に、わざとRAGに関する説明文を入れてみます。

検索結果↓

  • 回答内容は正しい

ページを横断的にみた上で回答を返せるか

Notion上はこんな感じで機能ごとのページがあるだけで機能をまとめたページはない。

検索結果↓

含まれていないのもあるが一部は返せているという感じ。

感想

実装面においては、便利なライブラリやフレームワークがあるので、スムーズに進められ理解もしやすかったです。
Notion x RAGの精度については、今回試したものだとドキュメント量がそこまでまだないのと、実運用で試してみないとわからないというのが正直なところです。ただ簡単な質問であればしっかりと答えられていたのでNotionの方を整理しておけば精度を上げることはできるのかなと思いました。

その他

OllamaをDocker上にinstallして動かそうとしてたのですが、DockerだとGPUがないのでパフォーマンスが低くレスポンスが全く返ってこないという結果になり、結局localで動かしました。localだとレスポンスは5-10秒で返ってきました。(M1 ProのMacでメモリ32Mです)
Ollamaを実際にホスティングして使う場合はちゃんとした性能が出せるかは要調査しないとなと思いました。

Discussion