0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Slack と Notion をつなぐ AI ナレッジ検索ボットを作る

Posted at

はじめに

社内のナレッジ(Notion にまとめたマニュアルや手順)を、Slack から一瞬で検索して回答を返す仕組みを構築したので備忘録。

  1. プロジェクトのフォルダ構成と Python 仮想環境の準備
  2. Notion API からページテキストを抽出しファイル化
  3. 抽出したテキストを OpenAI 埋め込み(ベクトル化)して ChromaDB に登録
  4. Flask サーバー + ngrok + Slack アプリ(/faq コマンド)で質問を受け取り、VectorDB 検索結果を返す

完成後は、Slack の任意のチャンネルで /faq ◯◯ と打つだけで、Notion に登録された情報の該当箇所を瞬時に提示できます。

前提・準備

  • Python 3.8 以上 がインストール済み
  • Homebrew (macOS) または apt (Linux) などで必要ツールをインストールできる
  • Notion に社内マニュアルをまとめており、Notion API トークンを発行済み
  • Slack ワークスペースに管理者権限でアクセスできる

必要な環境変数

プロジェクトルートに .env ファイルを置き、以下を設定しておきます。

  • OPENAI_API_KEY=<あなたの OpenAI API キー>
  • NOTION_TOKEN=<あなたの Notion Integration Token>
  • SLACK_BOT_TOKEN=<あとで使う場合がある Slack Bot トークン>

OPENAI_API_KEY:OpenAI の埋め込みや ChatCompletion に使います

NOTION_TOKEN:Notion API を呼び出すためのトークン(Integration を作成して取得)

SLACK_BOT_TOKEN:Slack Bot 操作用(今回の実装では応答時に使わないため必須ではありませんが、Bot 返信を Block Kit で拡張する際に必要になることがあります)

1. プロジェクトセットアップ

~/qa_mvp をプロジェクトルートとします。

1) プロジェクトフォルダを作成して移動

mkdir -p ~/qa_mvp
cd ~/qa_mvp

2) Python 仮想環境を作成・有効化

python3 -m venv venv
source venv/bin/activate  # Windows では venv\Scripts\activate

3) 必要パッケージをインストール

pip install --upgrade pip
pip install openai chromadb python-dotenv requests Flask

完成後のディレクトリ構成イメージは以下のとおりです。

qa_mvp/
├─ data/                ← Notion コーパスなどを置くフォルダ
├─ chroma_db/           ← ChromaDB が自動生成する永続化フォルダ
├─ src/                 ← 以下の .py ファイルを置く
│   ├─ fetch_notion.py
│   ├─ embed_notion.py
│   ├─ query_notion.py
│   └─ app.py
└─ venv/                ← Python 仮想環境
└─ .env                 ← 環境変数(APIキーなど)

Notion からテキストを抽出してファイル化

Notion Integration の準備

  • ブラウザで Notion を開き、「Settings & Members」→「Integrations」→「Develop your own integrations」→「New integration」を選択
  • Integration 名(例:Slack-Notion-Search)、ワークスペース、Read content 権限を付与して作成
  • 発行された Internal Integration Token を .env の NOTION_TOKEN に登録
  • 検索対象の Notion ページ(あるいはデータベース)の「Share」→「Invite」→先ほど作った Integration を招待

src/fetch_notion.py の作成

src/fetch_notion.py を作成し、Notion ページ(ブロック)から子ブロックを取得してプレーンテキストに変換し、data/notion_corpus.txt に保存します。

以下のコードをコピーして src/fetch_notion.py として保存してください。

# src/fetch_notion.py

import os
import requests

# ① 環境変数から Notion API トークンを読み込み
NOTION_TOKEN = os.getenv("NOTION_TOKEN")
if not NOTION_TOKEN:
    raise ValueError("環境変数 NOTION_TOKEN が設定されていません。")

# ② Notion のページID(ハイフンなし32文字)
NOTION_PAGE_ID = "aaabbbccc"  # ← ここを自分のページIDに書き換える

headers = {
    "Authorization": f"Bearer {NOTION_TOKEN}",
    "Notion-Version": "2022-06-28",
    "Content-Type": "application/json",
}

def fetch_page(page_id: str) -> dict:
    """
    指定された Notion ページ(block_id)の子ブロック一覧を取得する。
    """
    url = f"https://api.notion.com/v1/blocks/{page_id}/children"
    response = requests.get(url, headers=headers)
    response.raise_for_status()
    return response.json()

def extract_texts(notion_json: dict) -> list[str]:
    """
    Notion API の結果(JSON)から plain_text 部分だけを抜き出す。
    """
    texts: list[str] = []
    for block in notion_json.get("results", []):
        block_type = block.get("type")
        if block_type in (
            "paragraph",
            "heading_1", "heading_2", "heading_3",
            "bulleted_list_item"
        ):
            for rich_text in block[block_type].get("rich_text", []):
                plain = rich_text.get("plain_text", "")
                if plain:
                    texts.append(plain)
    return texts

if __name__ == "__main__":
    # 1) ページを取得
    data = fetch_page(NOTION_PAGE_ID)
    # 2) テキスト抽出
    lines = extract_texts(data)

    # 3) 'data/notion_corpus.txt' に保存
    output_path = "data/notion_corpus.txt"
    os.makedirs(os.path.dirname(output_path), exist_ok=True)
    with open(output_path, "w", encoding="utf-8") as f:
        for line in lines:
            f.write(line + "\n")

    print(f"✅ {output_path} を作成しました。行数: {len(lines)}")

実行方法

cd ~/qa_mvp
source venv/bin/activate
python src/fetch_notion.py

正常に動くと、data/notion_corpus.txt が生成され、Notion ページ内のテキスト行が改行区切りで入ります。

NOTION_PAGE_ID は Notion ページの URL 末尾にある 32 文字のハイフンなし UUID です。

Notion コーパスを AI 埋め込みして VectorDB(ChromaDB)に登録

src/embed_notion.py の作成

data/notion_corpus.txt にまとめたテキストを一行ずつ読み込み、OpenAI で埋め込み(1536 次元)→ ChromaDB に upsert します。永続化のために PersistentClient("chroma_db") を使用します。

# src/embed_notion.py

import os
import chromadb
from openai import OpenAI
from dotenv import load_dotenv

load_dotenv()
openai_client = OpenAI()

# ── 1. Chroma を永続化クライアントで初期化 ──
chroma_client = chromadb.PersistentClient("chroma_db")
collection = chroma_client.get_or_create_collection(name="notion_docs")

# ── 2. テキストファイルを行単位で読み込む ──
corpus_path = "data/notion_corpus.txt"
if not os.path.exists(corpus_path):
    raise FileNotFoundError(f"{corpus_path} が見つかりません。")

with open(corpus_path, "r", encoding="utf-8") as f:
    lines = [line.strip() for line in f if line.strip()]

# ── 3. 各行を OpenAI で埋め込み & Chroma に登録 ──
for idx, text in enumerate(lines, 1):
    resp = openai_client.embeddings.create(
        model="text-embedding-3-small",
        input=text[:8191]
    )
    emb = resp.data[0].embedding  # 1536 次元

    doc_id = f"notion_line_{idx}"
    metadata = {"line": idx}

    collection.upsert(
        ids=[doc_id],
        embeddings=[emb],
        metadatas=[metadata],
        documents=[text]
    )
    print(f"✅ line {idx} embedded")

print(f"👍 全 {len(lines)} 行を埋め込み&登録しました。")

実行方法

cd ~/qa_mvp
source venv/bin/activate
python src/embed_notion.py

正常に動作すると、ターミナルに「✅ line 1 embedded 〜」のように全行分が表示され、最後に「全〇行を埋め込み&登録しました。」と出ます。

実行後、プロジェクトルートに chroma_db/ フォルダが自動作成され、中に永続化されたベクトルが保存されます。

ターミナルで検索テストを行う

まずは、GUI を介さずターミナルだけで動作確認してみます。
src/query_notion.py を作成し、「質問文をベクトル化 → ChromaDB を検索 → 上位3件を返す」ロジックを実装します。

# src/query_notion.py

import chromadb
from openai import OpenAI
from dotenv import load_dotenv

load_dotenv()
openai_client = OpenAI()

# ── 1. Chroma 永続化クライアントを初期化 ──
chroma_client = chromadb.PersistentClient("chroma_db")
collection = chroma_client.get_or_create_collection(name="notion_docs")

# ── 2. 対話的にクエリを受け取り、検索結果を返す ──
while True:
    query_text = input("🗨️ 質問> ").strip()
    if query_text.lower() in ("exit", "quit"):
        break

    # 2-1. 質問文を OpenAI でベクトル化
    q_resp = openai_client.embeddings.create(
        model="text-embedding-3-small",
        input=query_text
    )
    q_emb = q_resp.data[0].embedding

    # 2-2. Chroma にクエリを投げる
    results = collection.query(
        query_embeddings=[q_emb],
        n_results=3,
        include=["documents", "metadatas"]
    )

    docs = results.get("documents", [[]])[0]
    metas = results.get("metadatas", [[]])[0]
    if not docs:
        print("(候補が見つかりませんでした)\n")
        continue

    # 2-3. 上位3件を表示
    for rank, (doc, meta) in enumerate(zip(docs, metas), 1):
        line_num = meta.get("line", "unknown")
        snippet = doc[:200].replace("\n", " ")
        print(f"{rank}. line {line_num}\n---\n{snippet}...\n")

実行方法

cd ~/qa_mvp
source venv/bin/activate
python src/query_notion.py

プロンプトが出たら好きな質問語句を打ち込んで Enter。

例:
🗨️ 質問> 経費精算 手順
上位3件の行番号とその行の冒頭(200文字程度)が返ってくれば成功です。

終了するときは exit または quit を入力します。

Slack 連携部分の構築

ここからは、Slack から /faq コマンドを叩くと Flask サーバーが受け取り、VectorDB の検索結果を返すボットを作ります。

Slack アプリの作成とスラッシュコマンド登録

  • Slack API 管理画面 にアクセス
    https://api.slack.com/apps

  • 「Create New App」→「From scratch」を選択

  • App Name:Notion AI Search

  • ワークスペース:自分のワークスペースを選ぶ

  • 左メニューの「Slash Commands」→「Create New Command」

  • Command: /faq

  • Request URL:あとで ngrok で発行される URL + /slack/events
    (例:https://abcd1234.ngrok.io/slack/events)

  • Short description: Search Notion from Slack

  • Usage hint: [your question]

→ Save

「OAuth & Permissions」→Scopes(Bot Token Scopes)に次を追加

  • commands
  • chat:write

右上「Install to Workspace」をクリックし、権限を許可 → OAuth Access Token が発行される

必要であれば .env の SLACK_BOT_TOKEN に設定しておく(Bot を拡張する際に使用)。

Flask サーバーの作成 (src/app.py)

Slack からのリクエストを受け取り、先ほどの query_notion.py の検索処理と同じ流れで結果を返すようにします。

# src/app.py

import os
from flask import Flask, request, make_response
from openai import OpenAI
import chromadb
from dotenv import load_dotenv

app = Flask(__name__)

# ── 0. 環境変数ロード ─────────────────
load_dotenv()
openai_client = OpenAI()

# ── 1. Chroma 永続化クライアントを初期化 ──
chroma_client = chromadb.PersistentClient("chroma_db")
collection = chroma_client.get_or_create_collection(name="notion_docs")

@app.route("/slack/events", methods=["POST"])
def slack_events():
    data = request.form
    text = data.get("text", "").strip()  # /faq の後の質問文

    if not text:
        return make_response("質問文が空です。`/faq 質問内容` のように入力してください。", 200)

    # 2. 質問文を OpenAI で埋め込み
    try:
        q_resp = openai_client.embeddings.create(
            model="text-embedding-3-small",
            input=text
        )
        q_emb = q_resp.data[0].embedding
    except Exception as e:
        return make_response(f"⚠️ Embedding 生成エラー: {e}", 500)

    # 3. VectorDB で検索
    try:
        results = collection.query(
            query_embeddings=[q_emb],
            n_results=3,
            include=["documents", "metadatas"]
        )
    except Exception as e:
        return make_response(f"⚠️ 検索エラー: {e}", 500)

    docs = results.get("documents", [[]])[0]
    metas = results.get("metadatas", [[]])[0]
    if not docs:
        return make_response("(候補が見つかりませんでした)", 200)

    # 4. 結果を整形して返す
    response_lines = ["*🔍 検索結果(上位3件)*"]
    for rank, (doc, meta) in enumerate(zip(docs, metas), 1):
        line_num = meta.get("line", "unknown")
        snippet = doc[:100].replace("\n", " ")
        response_lines.append(f"{rank}. line {line_num}: {snippet}...")

    response_text = "\n".join(response_lines)
    return make_response(response_text, 200)

if __name__ == "__main__":
    port = int(os.getenv("PORT", 3000))
    app.run(host="0.0.0.0", port=port)

ngrok で公開し、Slack に Request URL を設定

Flask サーバー起動

cd ~/qa_mvp
source venv/bin/activate
python src/app.py

ポート 3000 で動作します。

別ターミナルで ngrok を起動

ngrok http 3000
Forwarding https://abcd1234.ngrok.io -> http://localhost:3000 のように表示されるので、https://abcd1234.ngrok.io/slack/events の形でメモする。

Slack アプリの Slash Commands 設定を更新

Slack API 管理画面の「Slash Commands」→/faq の「Request URL」に、
https://abcd1234.ngrok.io/slack/events
を貼り付けて Save。

Slack で /faq を実行して動作確認

Slack の任意チャンネルで以下を入力して送信します:

/faq 経費精算

Bot から以下のような返信が数秒後に返ってくれば成功です:

🔍 検索結果(上位3件)
1. line 3: 経費精算手順 このドキュメントでは、社内で発生した経費を精算するための標準フローをまとめています...
2. line 7: Slack 利用ベストプラクティス 基本 ・公開チャンネルを優先して質問・共有...
3. line 12: 新入社員オンボーディングチェックリスト [ ] PC 受領 [ ] アカウント発行...

Flask のターミナルには 200 ステータスのログが出るはずです。

exit を入力しない限り、Flask サーバーと ngrok は動いたままになります。

今後の展望・追加改良ポイント

Slack Block Kit による返信フォーマットの改善

現在はプレーンテキスト返信ですが、Block Kit を使って「リンク付きカード一覧」「ボタン付き返信」など、より見やすくする。

ChatCompletion による要約回答の追加

上位3件を取得後、該当行を ChatGPT に渡して「◯◯についての回答を簡潔にまとめてください」と要約してから Slack に返す。

Notion データベース全体の定期自動再インデックス

毎晩や週次で fetch_notion.py → embed_notion.py を自動実行し、最新情報を VectorDB に反映するバッチスクリプトを用意する。

権限管理・閲覧制限

“機密ドキュメントにはアクセスさせたくない” といった場合、メタデータに「機密フラグ」や「部門タグ」を付与し、検索結果を返す前にフィルタリングして制御する。

おわりに

以上で「Slack からのスラッシュコマンド → Notion からテキスト抽出 → AI 埋め込み → VectorDB 検索 → Slack に結果返却」という一連の仕組みが完成しました。

社内のマニュアルや Q&A を横断的に検索し、「必要な情報がどこに書かれているか」をワンストップで返せるため、ユーザーはキーワード検索でファイルを探す手間を省けます。

作ってみて思ったのは、マニュアルやQ&Aのまとめ方を工夫すれば、そもそも必要ないbotな気がしました。。。
なんだかんだいい練習にhなったので、引き続き作り続けようと思います!!

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?