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

## はじめに
社内のナレッジ（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 トークン>

:::note info
OPENAI_API_KEY：OpenAI の埋め込みや ChatCompletion に使います
:::
:::note info
NOTION_TOKEN：Notion API を呼び出すためのトークン（Integration を作成して取得）
:::
:::note info
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。
```
例：
🗨️ 質問> 経費精算 手順
上位３件の行番号とその行の冒頭（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なったので、引き続き作り続けようと思います！！