はじめに
社内のナレッジ(Notion にまとめたマニュアルや手順)を、Slack から一瞬で検索して回答を返す仕組みを構築したので備忘録。
- プロジェクトのフォルダ構成と Python 仮想環境の準備
- Notion API からページテキストを抽出しファイル化
- 抽出したテキストを OpenAI 埋め込み(ベクトル化)して ChromaDB に登録
- 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なったので、引き続き作り続けようと思います!!