1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Claude Code + MCP で社内チャットボットを作ったら、問い合わせ対応が「週8時間→30分」になった

1
Posted at

この記事で得られること

  • Claude Code + MCP を使って、社内ナレッジに答えるチャットボットを1日で作った実体験
  • 「AIが社内ドキュメントを読んで答える」を本番運用に載せるまでの設計判断
  • 問い合わせ対応が週8時間 → 30分に減った具体的な数字と、そこに至るまでのハマりどころ

背景:毎週8時間を「同じ質問への回答」に使っていた

15人の開発チームで、僕はテックリード的な立場にいます。困っていたのがこれ:

  • 「このAPIの認証ってどうやるんでしたっけ?」
  • 「デプロイ手順書ってどこにありますか?」
  • 「このエラー前にも出たんですけど対処法は?」

Notion に全部書いてある。でも聞かれる。ドキュメントを書いても読まれない問題は、どのチームにもあると思います。

Slackで聞かれるたびに該当ページのリンクを返す作業を計測したら、週8時間。丸1営業日がコピペ仕事に消えていた。

「じゃあNotionを検索してくれるボットを作ればいい」——アイデアは簡単。問題は実装コストでした。


最初の失敗:素朴なRAGパイプライン

最初に試したのは教科書どおりのRAG構成:

Notion → エクスポート → テキスト分割 → ベクトル化 → Pinecone → 検索 → Claude API → 回答

2日かけて組んで、動くには動いた。でも3つの問題が出た:

問題 具体的な症状
鮮度 Notionを更新してもベクトルDBに反映されるまでタイムラグ
精度 チャンク分割の境界で文脈が切れて、微妙にズレた回答
運用 エクスポート→分割→埋め込みのパイプライン自体がメンテ対象に

RAGの仕組みを作ること自体が新しい「運用負荷」になっていた。本末転倒です。


転機:MCPという選択肢

その頃ちょうど Claude Code の MCP(Model Context Protocol)対応が話題になっていました。

MCPの核心はシンプルです:AIが外部ツールに直接アクセスできるプロトコル。Notionなら、ベクトルDBを介さず、Notion APIを直接叩いてページを読める。

ユーザーの質問 → Claude(MCPでNotionを検索) → 該当ページを取得 → 回答生成

RAGパイプライン全体がなくなる。ベクトルDB不要、チャンク分割不要、同期バッチ不要。


実装:Claude Code で1日で作った全体像

アーキテクチャ

┌──────────────┐     ┌──────────────────────────┐
│   Slack /     │     │    バックエンド (Node.js)   │
│   Chat UI    │◄───►│                          │
│              │     │  ┌──────────────────────┐│
└──────────────┘     │  │  Claude API           ││
                     │  │  model: claude-opus-4-7││
                     │  │                       ││
                     │  │  MCP Tools:           ││
                     │  │  ├─ notion-search     ││
                     │  │  ├─ notion-read-page  ││
                     │  │  └─ github-search     ││
                     │  └──────────────────────┘│
                     └──────────────────────────┘

ポイント:Claude Code 自体を使って、このボットのコードを書いた。つまり Claude が Claude 用のツール定義を書いている。これが想像以上に速かった。

Step 1:MCPツールの定義

Notion検索とページ読み込みの2つをMCPツールとして定義します。

// src/mcp-tools/notion-tools.ts
import { Client } from '@notionhq/client';
import Anthropic from '@anthropic-ai/sdk';

const notion = new Client({ auth: process.env.NOTION_API_KEY });

export const notionTools: Anthropic.Tool[] = [
  {
    name: 'notion_search',
    description:
      '社内Notionワークスペースからページを検索します。' +
      'キーワードで関連ドキュメントを見つけます。',
    input_schema: {
      type: 'object' as const,
      properties: {
        query: {
          type: 'string',
          description: '検索キーワード(日本語OK)',
        },
      },
      required: ['query'],
    },
  },
  {
    name: 'notion_read_page',
    description:
      '指定されたNotionページの本文を取得します。' +
      '検索結果のpage_idを渡してください。',
    input_schema: {
      type: 'object' as const,
      properties: {
        page_id: {
          type: 'string',
          description: 'NotionページのID',
        },
      },
      required: ['page_id'],
    },
  },
];

// ツール実行ハンドラ
export async function executeNotionTool(
  name: string,
  input: Record<string, string>
): Promise<string> {
  switch (name) {
    case 'notion_search': {
      const res = await notion.search({
        query: input.query,
        filter: { property: 'object', value: 'page' },
        page_size: 5,
      });
      return JSON.stringify(
        res.results.map((page: any) => ({
          id: page.id,
          title:
            page.properties?.title?.title?.[0]?.plain_text ||
            page.properties?.Name?.title?.[0]?.plain_text ||
            'Untitled',
          url: page.url,
          last_edited: page.last_edited_time,
        }))
      );
    }
    case 'notion_read_page': {
      const blocks = await notion.blocks.children.list({
        block_id: input.page_id,
        page_size: 100,
      });
      // ブロックからテキストを抽出
      const text = blocks.results
        .map((block: any) => {
          const type = block.type;
          const content = block[type];
          if (content?.rich_text) {
            return content.rich_text
              .map((t: any) => t.plain_text)
              .join('');
          }
          if (content?.text) {
            return content.text
              .map((t: any) => t.plain_text)
              .join('');
          }
          return '';
        })
        .filter(Boolean)
        .join('\n');
      return text || '(ページの内容を取得できませんでした)';
    }
    default:
      return `Unknown tool: ${name}`;
  }
}

Step 2:エージェントループ

Claude APIのツール呼び出しをループで処理します。Claudeが「Notionを検索したい」と判断したら自動でAPIを叩き、結果をもとに回答を生成します。

// src/agent/chat-agent.ts
import Anthropic from '@anthropic-ai/sdk';
import {
  notionTools,
  executeNotionTool,
} from '../mcp-tools/notion-tools';

const anthropic = new Anthropic();

const SYSTEM_PROMPT = `あなたは社内のテクニカルサポートAIです。

## ルール
- 質問に答えるとき、まずnotion_searchで関連ドキュメントを検索してください
- 検索結果が見つかったらnotion_read_pageでページの内容を読んでください
- ドキュメントの内容に基づいて回答してください。ドキュメントにない情報は推測せず「ドキュメントに記載がありません」と答えてください
- 回答にはNotionページのURLを引用として含めてください
- 日本語で簡潔に回答してください`;

export async function askAgent(
  question: string,
  conversationHistory: Anthropic.MessageParam[] = []
): Promise<{ answer: string; sources: string[] }> {
  const messages: Anthropic.MessageParam[] = [
    ...conversationHistory,
    { role: 'user', content: question },
  ];

  let response = await anthropic.messages.create({
    model: 'claude-opus-4-7',
    max_tokens: 2048,
    system: SYSTEM_PROMPT,
    tools: notionTools,
    messages,
  });

  const sources: string[] = [];

  // ツール呼び出しループ
  while (response.stop_reason === 'tool_use') {
    const toolBlock = response.content.find(
      (b): b is Anthropic.ToolUseBlock => b.type === 'tool_use'
    );
    if (!toolBlock) break;

    const result = await executeNotionTool(
      toolBlock.name,
      toolBlock.input as Record<string, string>
    );

    // 検索結果からURLを収集
    if (toolBlock.name === 'notion_search') {
      try {
        const pages = JSON.parse(result);
        pages.forEach((p: any) => {
          if (p.url) sources.push(p.url);
        });
      } catch {}
    }

    messages.push({ role: 'assistant', content: response.content });
    messages.push({
      role: 'user',
      content: [
        {
          type: 'tool_result',
          tool_use_id: toolBlock.id,
          content: result,
        },
      ],
    });

    response = await anthropic.messages.create({
      model: 'claude-opus-4-7',
      max_tokens: 2048,
      system: SYSTEM_PROMPT,
      tools: notionTools,
      messages,
    });
  }

  const textBlock = response.content.find(
    (b): b is Anthropic.TextBlock => b.type === 'text'
  );

  return {
    answer: textBlock?.text || '回答を生成できませんでした。',
    sources: [...new Set(sources)],
  };
}

Step 3:Slackとの接続

Slack Bolt で受けて、エージェントに投げるだけ。

// src/slack/app.ts
import { App } from '@slack/bolt';
import { askAgent } from '../agent/chat-agent';

const app = new App({
  token: process.env.SLACK_BOT_TOKEN,
  signingSecret: process.env.SLACK_SIGNING_SECRET,
});

// ボットへのメンション or DM を処理
app.event('app_mention', async ({ event, say }) => {
  const question = event.text
    .replace(/<@[A-Z0-9]+>/g, '')
    .trim();

  if (!question) {
    await say('質問を入力してください。');
    return;
  }

  // 「考え中...」のリアクション
  await app.client.reactions.add({
    channel: event.channel,
    timestamp: event.ts,
    name: 'hourglass_flowing_sand',
  });

  const { answer, sources } = await askAgent(question);

  let reply = answer;
  if (sources.length > 0) {
    reply += '\n\n📎 参照ドキュメント:\n';
    reply += sources.map((url) => `• ${url}`).join('\n');
  }

  await say({ text: reply, thread_ts: event.ts });

  // リアクションを完了に変更
  await app.client.reactions.remove({
    channel: event.channel,
    timestamp: event.ts,
    name: 'hourglass_flowing_sand',
  });
  await app.client.reactions.add({
    channel: event.channel,
    timestamp: event.ts,
    name: 'white_check_mark',
  });
});

(async () => {
  await app.start(Number(process.env.PORT) || 3000);
  console.log('Bot is running');
})();

ここまででSlackで質問 → Notionを検索 → 回答生成の基本フローが動きます。


Step 4(任意):Slack以外にも展開する場合

うちのチームはSlack完結だったのでこれで十分でした。もしモバイルアプリや自社プロダクトのサポートチャットにも展開するなら、メッセージ配信層を別途用意する必要があります。

僕は検討だけして今回は見送りましたが、メモとして残しておきます:

  • 自前WebSocket → 構築・再接続・永続化・スケーリングが重い
  • Chat SDK & API → 無料枠(1,000MAU、同時接続無制限、Push通知込み)で配信層を丸投げできる。特にオフライン通知まで含めて無料なのは調べた限りここだけだった → trtc.io/ja/free-chat-api
  • Firebase Realtime → リアルタイムDBとしては優秀だがChat UIは自前構築

社内ツールなら Slack Bot で十分。外部ユーザー向けに展開するフェーズになったら Chat SDK & API を入れると思います。


運用してみた結果

定量

指標 Before After (4週間平均)
週あたりの問い合わせ対応時間 8時間 30分
平均回答時間(ユーザー体感) 2-3時間(僕が見るまで待つ) 8秒
Notion検索ヒット率 87%(残り13%は未ドキュメント化の知識)
月間API コスト(Claude Opus 4.7) 約$12

定性

  • 「Notionに書いてあるから検索して」が不要になった。ボットに聞けばURLごと返ってくる
  • ドキュメントの穴が可視化された。ボットが「ドキュメントに記載がありません」と答えたログ = 書くべきドキュメントのリスト
  • 新メンバーのオンボーディングが爆速化。入社初日から質問し放題で、先輩の時間を奪わない

ハマりどころと対処

1. Notionの検索精度が低い

Notion APIの search は全文検索の精度がイマイチ。対処:

// 複数クエリで検索して結果をマージ
async function broadSearch(question: string) {
  // Claude にキーワード抽出させる
  const keywords = await extractKeywords(question);
  const results = await Promise.all(
    keywords.map((kw) =>
      notion.search({ query: kw, page_size: 3 })
    )
  );
  // 重複排除してマージ
  const seen = new Set<string>();
  return results
    .flatMap((r) => r.results)
    .filter((page: any) => {
      if (seen.has(page.id)) return false;
      seen.add(page.id);
      return true;
    });
}

2. ページが長すぎてコンテキストに入らない

社内の設計書は1ページ10,000字超えもザラ。対処:

  • ページ全文を取得後、Claude に「この質問に関連する部分だけ抽出して」と2段階で処理
  • Opus 4.7 はコンテキスト長が十分なので、よほどの長文でなければ全文投入で問題なし

3. 回答の幻覚(ハルシネーション)

システムプロンプトに「ドキュメントにない情報は推測しない」と明記しても、たまに推測する。対処:

  • 回答の末尾に「このページから回答しました:[URL]」を必ず付けさせる
  • ユーザーが元ページで検証できるようにする(完全防止は無理、検証可能性で担保)

Claude Code で開発して感じたこと

このボット、コードの8割はClaude Codeに書かせた

やったこと:

  1. 「Notion APIを叩くMCPツールを定義して」→ ツール定義が出てくる
  2. 「これをClaude APIのtool_useで呼び出すエージェントループを書いて」→ ループ処理が出てくる
  3. 「Slack Boltで受けるエントリポイントを書いて」→ Slack連携が出てくる

手で書いたのはシステムプロンプトのチューニングと、Notion検索精度を上げるハック部分だけ。

設計判断は人間、コーディングはClaude Code。 この分業が一番速い。


まとめ

  1. 社内ナレッジBot ≠ RAGパイプライン。MCPでNotionに直接アクセスすれば、ベクトルDBもチャンク分割も不要
  2. Claude Code で Claude 用のツールを書くのが最速の開発体験
  3. 運用コストは月$12。浮いた週7.5時間のほうが圧倒的に価値がある
  4. 副次効果としてドキュメントの穴が可視化される

全コードは GitHub に置いてあります:github.com/xxx/internal-knowledge-bot


FAQ

Q. Claude APIのコストはどのくらいですか?

うちの実績で月$12(Opus 4.7、1日平均30問)。コストを抑えたければ claude-sonnet-4-7 に変えるだけで半額以下になります。社内ボットの品質要件ならSonnetで十分なケースも多いです。

Q. Notion以外のツール(Confluence、Google Docs等)にも使えますか?

MCPツールを追加するだけで対応できます。GitHub、Jira、Google Drive用のMCPサーバーも公開されています。うちはNotionとGitHubの2つを接続しています。

Q. セキュリティは大丈夫ですか?社内ドキュメントがClaudeに送られるのでは?

はい、質問と関連ドキュメントはClaude APIに送信されます。Anthropicのデータポリシー上、APIで送信されたデータはモデルのトレーニングには使われません。それでも機密度が高い場合は、送信前にフィルタリングする層を挟む、またはAWS Bedrock経由で利用する選択肢があります。

Q. 既存のRAG構成から移行する価値はありますか?

ドキュメントの更新頻度が高いならMCPの方が運用が楽です。常に最新のNotionページを直接読むので同期処理が不要。一方、数万ページ規模で高精度な意味検索が必要ならRAGの方が向いています。うちは約200ページだったのでMCPで十分でした。

Q. 15人以上の大きなチームでもスケールしますか?

Claude APIは従量課金なので人数に比例してコストは増えますが、技術的な制約はありません。100人規模なら月$50-80程度の見込み。それでも1人のエンジニアの週8時間(= 年間約200万円)と比べれば誤差です。


チームで似たようなことを試した方がいたら、ぜひコメントで教えてください。特にConfluence連携やGitHub Issues連携の実装例があれば聞きたいです。

1
1
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
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?