🧑‍💻

Firebaseで作る生成AI×RAG Slackbot: 実践ハンズオン

2024/06/12に公開


独自の知識を必要とする質問にも答えられる Slackbot を作ります

生成 AI 技術の発展は目覚ましい! エンジニアとして、この生成 AI 技術を自分のスキルセットとして持っておきたい! そんな気持ちをハンズオンにまとめたのがこの記事です。

GPT などの生成 AI を利用したツールはすでに多種多様に存在しますが、いざ自分の手で業務や趣味に組み込むとなると、どうすれば良いか悩むこともあるでしょう。そんな方がこのハンズオンをやってみると、生成 AI 技術の活用についてざっくりとイメージを持ち、引き出しの 1 つとしていつでも使える状態になれるかなと思います。

最終目標として、生成 AI と RAG を用いて、独自の知識を必要とする質問にも答えられる Slackbot を作成する ことを目指します。よくある質問応答システムなどの基礎はこれで構築できますし、ここからいろんなシステムに拡張することも容易です。

このハンズオンは一度同僚向けに実施しており、その際の所要時間は早い人で 1 時間、平均で 2 時間ほどでした。ですので、皆様もお気軽にハンズオンしてみてください!

  • 最終成果物: 独自の知識を必要とする質問にも答えられる Slackbot
  • 必要な環境: Node.js > 18.17.0, Visual Studio Code, CLI 環境(WSL2, Mac 確認済)
  • 技術スタック: TypeScript, Node.js, LangChain.js, Firebase, SlackBolt
  • キーワード: 生成 AI, LLM, RAG(検索拡張生成), ベクトル検索
  • 平均所要時間: 2 時間

1 章: Firebase プロジェクトで GPT-4 を呼び出す

1 章では Firebase プロジェクトを作成し、LangChain という生成 AI ライブラリを通して GPT-4 を呼び出してみます。ここでは以下のステップをハンズオンしていきます。

  • Firebase プロジェクトを新規作成し、ローカルエミュレータを構築
  • LangChain を通して GPT-4 を呼び出し、オススメのお昼ご飯を教えてもらう
  • この処理を Firebase でホスティングし公開する

Firebase プロジェクトの作成

今回のハンズオンでは、クラウドプラットフォームに Firebase を利用します。Firebase は Web アプリを簡単に作成できるサービス群であり、データベース、認証、関数や Web サイトのホスティングなどの機能を、わずかな CLI コマンドだけで高速に構築することができます。

Web Console でプロジェクトを作成

デプロイ先となる Firebase プロジェクトを、Firebase Web Console から作成していきます。

Web Console でプロジェクトを作成

詳細な手順はこちら(Web Console でプロジェクトを作成)
  1. Firebase Web Console にアクセスし、Google アカウントでログインします。
  2. 「プロジェクトを追加」をクリックします。
  3. 任意のプロジェクト名(例: hands-on-llm-yourname)を入力し、利用規約を確認した後、「続行」をクリックします。
  4. 「Google アナリティクスの設定」画面が表示されますが、今回は不要なため「Google アナリティクスを追加しません」を選択し、「プロジェクトを作成」をクリックします。
  5. プロジェクトの作成が完了すると、「コンソールに移動」をクリックしてプロジェクトのダッシュボードに移動します。

Blaze プランに変更

詳細な手順はこちら(Blaze プランに変更)

後述の Firestore を利用するには、プロジェクトを従量制課金(Blaze)プランにする必要があります。いったん課金登録はするのですが、十分な無料枠がありますので、このハンズオンの範囲内では無料で利用できるかと思います。

  1. プロジェクトのダッシュボードで、左側のナビゲーションメニューの下部にある「アップグレード」を選択します
  2. Blaze プランを選択します
  3. 適切な請求先アカウントを選択、あるいは作成します
  4. 「購入」をクリックします

Firestore Database を作成

詳細な手順はこちら(Firestore Database を作成)
  1. プロジェクトのダッシュボードで、左側のナビゲーションメニューから「構築」→「Firestore Database」を選択します。
  2. 「データベースの作成」をクリックします。
  3. 「Firestore を開始」の画面が表示されるので、「ネイティブモードで開始」を選択し、「次へ」をクリックします。
  4. データベース ID は変更せず、そのまま (default) とします。
  5. データベースのロケーションを選択する画面で、「asia-northeast1(東京)」を選択し、「データベースを作成」をクリックします。
  6. セキュリティ ルールの設定画面で、「テストモードで開始」を選択し、「次へ」をクリックします。
  7. Firestore データベースのセットアップが完了するまで待ちます。完了すると、Firestore のダッシュボードに移動します。

これでクラウド側の Firebase プロジェクトの設定が完了です。次にローカル側のセットアップを行います。具体的には Firebase に CLI からアクセスできるようにし、いくつかのオプションを有効化しておきます。

# firebase コマンド(firebase-tools)のインストール
npm i -g firebase-tools

# 初回だけ必要
firebase login

# 先ほど作成したプロジェクトがリストアップされることを確認
firebase projects:list

# WebFrameworksを有効化(するとNext.jsが使える)
firebase experiments:enable webframeworks

プロジェクトリポジトリの作成

ハンズオン用のプロジェクトリポジトリを作成します。プロジェクトスケルトンは Firebase CLI で対話形式にて自動生成できます。あわせて以下の設定もしておきます;

  1. Firestore, Functions, Hosting, Emulators を利用
  2. 先ほど Web Console で作成した Firebase Project を、このリポジトリに接続
  3. Hosting において Next.js を有効化
# Firebase プロジェクトをローカルで構築
mkdir hands-on-llm-rag-yourname
cd hands-on-llm-rag-yourname

firebase init

=== Project Setup
> Firestore, Functions, Hosting, Emulators
> Use an existing project > hands-on-llm-rag-yourname

=== Firestore Setup
> エンターキーのみでOK

=== Functions Setup
? What language would you like to use to write Cloud Functions? TypeScript
? Do you want to use ESLint to catch probable bugs and enforce style? No
? Do you want to install dependencies with npm now? Yes

=== Hosting
? Do you want to use a web framework? experimental? Yes
? What folder would you like to use for your web application's root directory? hosting
? Please choose the framework: Next.js
? What language would you like to use? TypeScript
? In which region would you like to host server-side content, if applicable? asia-east1

=== Emulators Setup
? Which Firebase emulators do you want to set up? Functions, Firestore, Hosting
> ほかはエンターキーのみでOK

エミュレータの起動

Firebase は優秀なエミュレータを持っており、Functions, Firestore, Hosting などをすべてローカルでエミュレーションすることができます。サーバにデプロイする前にローカルで動作確認を完結することができるため、開発中は大助かりです。

プロジェクトリポジトリにある firebase.json には、今後エミュレータで起動するこれらのサービスのポート番号が記載されています。今回は以下のように変更します。

{
  "emulators": {
    "functions": { "port": 5001 },
    "firestore": { "port": 8080 },
    "hosting": { "port": 3000 }
  }
}

設定が完了したら、エミュレータを起動してみましょう。

firebase emulators:start

エミュレータの起動に成功すると以下のような出力が出てきます。

┌─────────────────────────────────────────────────────────────┐
│ ✔  All emulators ready! It is now safe to connect your app. │
│ i  View Emulator UI at http://127.0.0.1:4000/               │
└─────────────────────────────────────────────────────────────┘

┌───────────┬────────────────┬─────────────────────────────────┐
│ Emulator  │ Host:Port      │ View in Emulator UI             │
├───────────┼────────────────┼─────────────────────────────────┤
│ Functions │ 127.0.0.1:5001 │ http://127.0.0.1:4000/functions │
├───────────┼────────────────┼─────────────────────────────────┤
│ Firestore │ 127.0.0.1:8080 │ http://127.0.0.1:4000/firestore │
├───────────┼────────────────┼─────────────────────────────────┤
│ Hosting   │ 127.0.0.1:3000 │ n/a                             │
└───────────┴────────────────┴─────────────────────────────────┘
  Emulator Hub running at 127.0.0.1:4400
  Other reserved ports: 4500, 9150

http://127.0.0.1:4000 にアクセスすると、エミュレータの UI が表示されます。そこから Functions の Log や Firestore のデータを確認することができますので、ぜひ活用してください。

Firebase Functions の動作確認

Firebase Functions はサーバレスのバックエンドを提供するサービスです。最終的にはここに Slackbot のハンドラを実装していきます。まずはローカルエミュレータで正常に動作するかを確認するために、簡単なコードを動かしてみましょう。

functions/src/index.ts を変更します;

/// functions/src/index.ts

import { onRequest } from 'firebase-functions/v2/https';
import * as logger from 'firebase-functions/logger';

export const helloWorld = onRequest(
  {
    region: 'asia-northeast1',
  },
  (request, response) => {
    // ログ画面で確認できる
    logger.info('Hello logs!', { structuredData: true });

    // レスポンスとして確認できる
    response.send('Hello from Firebase!');
  },
);

TypeScript の watch は別途起動する必要があるため、ターミナル 2 枚目を立ち上げ、watch を動かしておきます。

# 2枚目のターミナルで実行
cd functions/
npm run build:watch

エミュレータを起動して、ログを確認してみましょう。

# 1枚目のターミナルで、project root に戻ってから実行
firebase emulators:start

ターミナルのログを見ると、途中で helloWorld 関数が起動しているのがわかります。

✔  functions: Loaded functions definitions from source: helloWorld.
✔  functions[asia-northeast1-helloWorld]: http function initialized (http://127.0.0.1:5001/hands-on-llm-rag-yourname/asia-northeast1/helloWorld).

表示されている URL をコピーして、ブラウザからアクセスしてみましょう。Hello from Firebase! と表示されれば成功です!

エミュレータ画面の Logs にも、Hello logs! というログが表示されていることが確認できます。

以上で Firebase プロジェクトのセットアップは完了です。以降はこのプロジェクトをベースに LLM や RAG の機能を載せていきます。

LangChain で OpenAI GPT-4 を動かす

LangChain は、生成 AI 技術を簡単に使えるようにするためのパッケージです。このハンズオンでは、LangChain を通して OpenAI の GPT-4 を Firebase Functions で動かしてみます。

パッケージインストール

エミュレータと watch を一度停止してから、LangChain と OpenAI のパッケージをインストールします。

# エミュレータとwatchをいずれも停止してから実行
cd functions/
npm i @langchain/core @langchain/openai langchain

OPENAI_API_KEY を環境変数に設定

ローカルエミュレータにおいては、functions/.secret.local というファイルに環境変数を env ファイル形式で記述しておくことで、Functions 内で環境変数として利用することができます。

OpenAI platform から API key を新規作成し、クリップボードにコピーしておきます。次に functions/.secret.local を新規作成し、以下のように API キーを設定します。

OPENAI_API_KEY=hogehoge

LangChain で GPT-4 を呼び出す

いよいよ LangChain を通して GPT-4 を呼び出してみます。ここでは今日のお昼ご飯について提案をもらってみましょう。

/// functions/src/index.ts

import { onRequest } from 'firebase-functions/v2/https';
import * as logger from 'firebase-functions/logger';
import { ChatPromptTemplate } from '@langchain/core/prompts';
import { ChatOpenAI } from '@langchain/openai';

// GPTに渡すテンプレート
const promptTemplate = `
あなたは優秀なアシスタントです。
以下のコンテキストを参考に、質問に答えてください。

--------
# context
{context}
--------

質問文: {input}
`;

export const helloWorld = onRequest(
  {
    region: 'asia-northeast1',
    secrets: ['OPENAI_API_KEY'],
  },
  async (_request, response) => {
    logger.info('Hello logs!', { structuredData: true });

    // テンプレートからPromptを生成
    const prompt = ChatPromptTemplate.fromTemplate(promptTemplate);

    // OpenAIのChat APIを使ったLLMを生成
    const llm = new ChatOpenAI({
      model: 'gpt-4o',
      temperature: 0.5,
    });

    // PromptとLLMをパイプラインでつないで
    const chain = prompt.pipe(llm);

    // 質問に対する回答を取得
    const answer = await chain.invoke({
      context: '',
      input: 'お腹が空きました。お昼ご飯のオススメを教えてください。',
    });

    logger.info(answer, { structuredData: true });
    response.send(answer.content);
  },
);

実行する前に functions/tsconfig.json に skipLibCheck を追加しておいてください。

{
  "compilerOptions": {
    "skipLibCheck": true
  }
}

エミュレータと watch を再起動します。

# 2枚目のターミナルで実行
cd functions/
npm run build:watch

# 1枚目のターミナルで、project root に戻ってから実行
firebase emulators:start

エミュレータのログに表示されている Functions の URL をブラウザで呼びだしてみましょう。以下のような感じで、お昼ご飯のオススメが表示されれば成功です!

お腹が空いているとのことですね!お昼ご飯におすすめのメニューをいくつかご紹介します。 1. 和食 - お寿司: 新鮮な魚介類を使ったお寿司はいかがですか? - 天ぷら定食: サクサクの天ぷらとご飯、味噌汁のセットは満足感があります。 (以下略)

Logs には、より詳細なデータが表示されているかと思います。

以上で、LangChain + GPT-4 による生成 AI 呼び出しを、プログラムによって実現することができるようになりました!

2 章: Slack で会話できるように調整

1 章では、Firebase と LangChain を用いて、GPT-4 を呼び出すことに成功しました。しかしこのままでは特定の応答しか行うことができません。会話を成立させるためには、その UI を提供する必要があります。

2 章では、会話の UI として Slack を利用し、Slack でメンションすると会話を開始し継続できるような実装を行います。具体的には、以下の要領で進めていきます。

  1. Functions に SlackApp と接続できるハンドラを実装
  2. SlackApp とエミュレータを接続して動作確認
  3. SlackApp とリモートの Firebase Functions を接続

Functions に SlackApp と接続できるハンドラを実装

Slack が提供している SDK の Slack Bolt には、SlackApp からのリクエストに応えるハンドラを簡単に作成できる関数が備わっています。これを利用して、Functions を使って SlackApp と接続できるハンドラを実装してみましょう。

まずは @slack/bolt をインストールします。

cd functions/
npm i @slack/bolt

先ほど書いた index.ts は hello-world.ts として保存しておきましょう。

mv functions/src/index.ts functions/src/hello-world.ts

そのあと、改めて functions/src/slackbot.ts を作成し、以下のようにコーディングします。

/// functions/src/slackbot.ts

import { ChatPromptTemplate } from '@langchain/core/prompts';
import { ChatOpenAI } from '@langchain/openai';
import { App, ExpressReceiver } from '@slack/bolt';
import { onRequest } from 'firebase-functions/v2/https';

// GPTに渡すテンプレート
const promptTemplate = `
あなたは優秀なアシスタントです。
以下のコンテキストを参考に、質問に答えてください。

--------
# context
{context}
--------

質問文: {input}
`;

/**
 * Slackイベントハンドラを作成する
 * @param signingSecret Slackからのリクエストを検証するための署名シークレット
 * @param token Slackアプリのトークン
 * @returns ExpressReceiverのインスタンス
 */
const createReceiver = (signingSecret: string, token: string) => {
  const receiver = new ExpressReceiver({
    signingSecret,
    endpoints: '/events',
    processBeforeResponse: true,
  });

  const app = new App({
    receiver,
    token,
    processBeforeResponse: true,
  });

  /**
   * メンションイベントをリッスンする
   * @param event app_mention イベント
   * @param context コンテキスト
   * @param client Slack WebClient
   * @param say メッセージ送信関数
   */
  app.event('app_mention', async ({ event, context, client, say }) => {
    const { bot_id: botId, text, user, channel } = event;
    const { retryNum, retryReason } = context;
    const ts = event.thread_ts || event.ts;

    console.log(`app_mention: user=${user}, ts=${ts}`);

    // リトライの場合はスキップ
    if (retryNum) {
      console.log(`skipped: retryNum=${retryNum} ${retryReason}`);
      return;
    }

    // Botのメンションの場合はスキップ
    if (botId) {
      console.log(`skipped: botId=${botId}`);
      return;
    }

    // 考え中
    const botMessage = await say({
      thread_ts: ts,
      text: 'thinking...',
    });
    if (!botMessage.ts) return;

    // テンプレートからPromptを生成
    const prompt = ChatPromptTemplate.fromTemplate(promptTemplate);

    // OpenAIのChat APIを使ったLLMを生成
    const llm = new ChatOpenAI({
      model: 'gpt-4o',
      temperature: 0.5,
    });

    // PromptとLLMをパイプラインでつないで
    const chain = prompt.pipe(llm);

    // 質問に対する回答を取得
    const answer = await chain.invoke({
      context: '',
      input: text,
    });

    await client.chat.update({
      channel,
      ts: botMessage.ts as string,
      text: answer.content as string,
    });
  });

  return receiver;
};

/**
 * Slackbotのエンドポイント
 */
export const slackbot = onRequest(
  {
    region: 'asia-northeast1',
    secrets: ['OPENAI_API_KEY', 'SLACK_BOT_TOKEN', 'SLACK_SIGNING_SECRET'],
  },
  (req, res) => {
    const signingSecret = process.env.SLACK_SIGNING_SECRET;
    const token = process.env.SLACK_BOT_TOKEN;
    return createReceiver(signingSecret!, token!).app(req, res);
  },
);

functions/src/index.ts を新たに作成し、いま作成した slackbot エンドポイントと、1 章で作成した helloWorld エンドポイントとを、それぞれ公開します。

/// functions/src/index.ts

export * from './hello-world';
export * from './slackbot';

エミュレータと watch を再起動します。

# 2枚目のターミナルで実行
cd functions/
npm run build:watch

# 1枚目のターミナルで、project root に戻ってから実行
firebase emulators:start

Logs を見ると、新たに slackbot エンドポイントが生えていることを確認できます。

SlackApp とエミュレータを接続して動作確認

SlackApp の作成

新たに SlackApp を作成し、さきほど作成した slackbot エンドポイントに連結します。まず以下の手順で新しい SlackApp を作成しましょう。

  • https://api.slack.com/apps にアクセス
  • 「Create New App」をクリック → 「From an app manifest」を選択
    1. Pick a workspace: アプリを配置したいワークスペースを選択
    2. Enter app manifest below: 以下の YAML をペースト
    3. 名前の部分を、適切に変更
    4. 内容を確認して「Create」
display_information:
  name: hands-on-llm-rag-yourname
  description: LLM+RAG構成のSlackbotを作成するハンズオン
  background_color: '#0c2b22'
features:
  bot_user:
    display_name: hands-on-llm-rag-yourname
    always_online: false
oauth_config:
  scopes:
    bot:
      - app_mentions:read
      - channels:history
      - chat:write
      - files:read
settings:
  org_deploy_enabled: false
  socket_mode_enabled: false
  token_rotation_enabled: false

アプリの画面に遷移したら、「Basic Information」→「Install your app」→「Install to Workspace」でアプリをインストールします。

成功したら、以下の 2 点をアプリ画面から取得し、それぞれ環境変数として functions/.secret.local に保管します

  • SLACK_SIGNING_SECRET: Basic Information → App Credentials → Signing Secret
  • SLACK_BOT_TOKEN: OAuth & Permissions → Bot User OAuth Token
OPENAI_API_KEY=hogehoge
SLACK_SIGNING_SECRET=hogehoge
SLACK_BOT_TOKEN=hogehoge

エミュレータと watch を再起動します。

# 2枚目のターミナルで実行
cd functions/
npm run build:watch

# 1枚目のターミナルで、project root に戻ってから実行
firebase emulators:start

VSCode のポート転送を使って、エミュレータと SlackApp を接続

vscode の port forwarding を利用して、エミュレータと SlackApp を接続します。

  • vscode ターミナルタブの近くにある「ポート」タブをクリック
    • Mac で開発している場合: このまま読み進めてください
    • Windows WSL2 で開発している場合: 開発中の vdcode からではなく、新たに WSL2 に接続していない vscode ウィンドウ(空っぽで OK)を立ち上げ、ポートタブを開いてください
  • 「ポートの転送」→「5001」を追加
  • リスト上で右クリック →「ポートの表示範囲」→「公開」→「続行」

一度そのアドレスにブラウザなどでアクセスしてみてください。アクセスに成功すると「Not Found」が表示されますが、それで OK です。

では試しに 1 章で作成した「お昼ご飯のオススメ」をトンネル経由で呼んでみましょう。リスト上で右クリック →「ローカルアドレスをコピー」し、そのアドレスを以下のルールに従って編集します。

https://[VSCode Port Forwarding の生成した値]-5001.asse.devtunnels.ms/[Firebase Project ID]/asia-northeast1/helloWorld
 ↓ (作例)
https://hogehoge-5001.asse.devtunnels.ms/hands-on-llm-rag-yourname/asia-northeast1/helloWorld

この URL にアクセスすると、1 章で作成したお昼ご飯のオススメが表示されます。さらにエミュレータの Logs を見ると、トンネル経由でローカルエミュレータに疎通していたことが確認ができると思います。

これを応用して、SlackApp とエミュレータを接続します。以下のルールに従って URL を作成します。

https://[VSCode Port Forwarding の生成した値]-5001.asse.devtunnels.ms/[Firebase Project ID]/asia-northeast1/slackbot/events
 ↓ (作例)
https://hogehoge-5001.asse.devtunnels.ms/hands-on-llm-rag-yourname/asia-northeast1/slackbot/events

そのあと、SlackApp の Web Console に移動し、以下の手順でエミュレータのハンドラを接続する設定を行います。

  • Event Subscriptions →「Enable Events」→「ON」
    • Request URL に上記の編集したアドレスをペースト
    • Verified と表示されれば OK
  • Subscribe to bot events → Add Bot User Event → 「app_mention」
    • 画面下の「Save Changes」をクリック → Success!

これで下準備ができました! あとはその bot を招待して、メンションで呼びかけてみてください!

bot へメンションをすると、スレッドで会話を開始したり、継続したりできます。ぜひ何度か会話を楽しんでみてください!

3 章: RAG を使って独自の知識を持たせる

2 章では Slackbot による LLM との対話を実装しました。一般的な質疑応答はこれで十分対応可能です。ここまででも生成 AI 活用としてのハンズオンはある意味十分なのですが、生成 AI でより具体的なタスクをこなすには、そのタスク独自の知識を持たせることが不可欠になります

3 章では最終目標である「独自の知識を必要とする質問にも答えられる Slackbot」を実現するために、RAG(検索拡張生成)という技術を使用します。そのために、独自の知識を保存するベクトルデータベースと、質問内容から類似検索をできるようにするベクトル検索機能を実装していきます。

  1. RAG(検索拡張生成) と Embeddings(埋め込み表現)
  2. Firebase イベントトリガによる自動ベクトル化
  3. Firestore に登録した独自の知識をベクトル検索

実装に先んじてディレクトリ整理をします。functions/src/http ディレクトリを作成し、2 章までで作った関数をその配下に収めます。それらを export する functions/src/index.ts も作成しましょう。

functions/src/
  http/
    index.ts
    hello-world.ts
    slackbot.ts
  index.ts
/// functions/src/index.ts

export * from './http';

Firestore とベクトル検索

Firestore は Google が提供する NoSQL 型のクラウドデータベースで、リアルタイムのデータ同期やオフラインアクセスが可能です。今回は独自の知識を保存するためのデータベースとして利用します。

この Firestore に最近(2024/4)、ベクトル検索に対応するアップデートが入りました。ベクトル検索は、データを高次元ベクトルとして扱い、類似度に基づいて情報を検索する技術です。今回のユースケースでは「質問内容に類似した知識を使って回答する」ことが求められることから、この技術に優位性があります。

LLM がベクトル検索と親和性が高いのは、この「類似知識」を LLM のクエリのコンテキスト部分に詰め込むことで、クエリの精度と応答の質が向上するためです。これにより、LLM は関連性の高い情報を提供しやすくなり、ユーザーの質問に対してより具体的で正確な回答が可能になります。

ベクトル化には OpenAI の Embeddings API を使用します。この処理も LangChain がラップして提供してくれていますので、使っていきましょう。

Firebase イベントトリガを使って自動ベクトル化

独自の知識を入力するごとにいちいち API をコールするのは大変なので、ここでは Firebase のイベントトリガ機能を使って、Firestore にデータが書き込まれたら、それを自動的にベクトル表現(エンベディング)に変換し、ベクトルデータとして追記する手法を取ります。

functions/src/firestore/knowledge.ts を作成します。

/// functions/src/firestore/knowledge.ts

import { OpenAIEmbeddings } from '@langchain/openai';
import { FieldValue, Timestamp } from 'firebase-admin/firestore';
import { onDocumentWritten } from 'firebase-functions/v2/firestore';

/**
 * knowledges/{id} が書き込まれたときに、content をベクトル化してドキュメントに付与する
 */
module.exports.onwriteknowledge = onDocumentWritten(
  { document: 'knowledges/{id}', secrets: ['OPENAI_API_KEY'] },
  async (event) => {
    console.log(`onDocumentWritten: knowledges/${event.params.id}`);
    const before = event.data?.before.data();
    const after = event.data?.after.data();

    // データがない場合は何もしない
    if (!after) {
      return;
    }

    // before と after で content が等しければ何もしない
    if (before?.content === after.content) {
      return;
    }

    // OpenAI Embeddings API を使ってベクトル化
    const embeddings = new OpenAIEmbeddings({
      openAIApiKey: process.env.OPENAI_API_KEY,
    });
    const vector = await embeddings.embedQuery(after.content);

    // ベクトルデータをドキュメントに付与
    return event.data?.after.ref.update({
      vector: FieldValue.vector(vector),
      updatedAt: Timestamp.now(),
    });
  },
);

http と同様に、functions/src/firestore/index.ts で export しておきましょう。

/// functions/src/firestore/index.ts

export * from './knowledge';

functions/src/index.ts を変更し、イベントを Firebase にトリガしてもらえるようにします。

/// functions/src/index.ts

const { initializeApp } = require('firebase-admin/app');
const { setGlobalOptions } = require('firebase-functions/v2');

initializeApp();
setGlobalOptions({ timezone: 'Asia/Tokyo', region: 'asia-northeast1' });

export * from './firestore';
export * from './http';

これでイベントトリガを設置できたので、実際にデータを書き込んで、それが動くかを確かめてみましょう。エミュレータと watch を再起動します。

# 2枚目のターミナルで実行
cd functions/
npm run build:watch

# 1枚目のターミナルで、project root に戻ってから実行
firebase emulators:start

エミュレータ画面の Firestore から、ドキュメントを追加してみます。

- Start collection
  - Collections ID: knowledges
  - Parent path: (変更なし)
  - Document ID: (変更なし)
  - Field: content
  - Type: string
  - Value: suzukalight の 2024/06/10 の晩ごはんは青椒肉絲でした。

Save すると、バックグラウンドで先ほど作成した Functions がトリガされ、ベクトル化とドキュメントへの付与が自動で実行されます。

Firestore のデータをベクトル検索

Firestore のデータをベクトル検索した結果を GPT のコンテキストとして利用することで、最終目標である「独自の知識を必要とする質問にも答えられる bot」がいよいよ実装可能になりました! functions/src/http/slackbot.ts の実装は以下のようになります。

/// functions/src/http/slackbot.ts

import { ChatPromptTemplate } from '@langchain/core/prompts';
import { ChatOpenAI } from '@langchain/openai';
import { App, ExpressReceiver } from '@slack/bolt';
import { onRequest } from 'firebase-functions/v2/https';
import { Document } from '@langchain/core/documents';
import { createStuffDocumentsChain } from 'langchain/chains/combine_documents';
import { OpenAIEmbeddings } from '@langchain/openai';
import { FieldValue, getFirestore } from 'firebase-admin/firestore';

// Firestore
const db = getFirestore();

// GPTに渡すテンプレート
const promptTemplate = `
あなたは優秀なアシスタントです。
以下のコンテキストを参考に、質問に答えてください。

--------
# context
{context}
--------

質問文: {input}
`;

const createReceiver = (signingSecret: string, token: string) => {
  const receiver = new ExpressReceiver({
    signingSecret,
    endpoints: '/events',
    processBeforeResponse: true,
  });

  const app = new App({
    receiver,
    token,
    processBeforeResponse: true,
  });

  /**
   * メンションイベントをリッスンする
   * @param event app_mention イベント
   * @param context コンテキスト
   * @param client Slack WebClient
   * @param say メッセージ送信関数
   */
  app.event('app_mention', async ({ event, context, client, say }) => {
    const { bot_id: botId, text, user, channel } = event;
    const { retryNum, retryReason } = context;
    const ts = event.thread_ts || event.ts;

    console.log(`app_mention: user=${user}, ts=${ts}`);

    // リトライの場合はスキップ
    if (retryNum) {
      console.log(`skipped: retryNum=${retryNum} ${retryReason}`);
      return;
    }

    // Botのメンションの場合はスキップ
    if (botId) {
      console.log(`skipped: botId=${botId}`);
      return;
    }

    // 考え中
    const botMessage = await say({
      thread_ts: ts,
      text: 'thinking...',
    });
    if (!botMessage.ts) return;

    // テンプレートからPromptを生成
    const prompt = ChatPromptTemplate.fromTemplate(promptTemplate);

    // OpenAIのChat APIを使ったLLMを生成
    const llm = new ChatOpenAI({
      model: 'gpt-4o',
      temperature: 0.5,
    });

    // 質問文をベクトル表現化
    const embeddings = new OpenAIEmbeddings({
      openAIApiKey: process.env.OPENAI_API_KEY,
    });

    // Firestoreをベクトル検索
    const vector = await embeddings.embedQuery(text);
    const searchResults = (await db
      .collection('knowledges')
      .findNearest('vector', FieldValue.vector(vector), {
        limit: 5,
        distanceMeasure: 'COSINE',
      })
      .get()
      .then(({ docs }) => docs.map((doc) => ({ id: doc.id, ...doc.data() })))) as Record<
      string,
      any
    >[];

    // 結果をLangChainが扱いやすい形式(Document)に変換
    const documents = searchResults.map((result) => new Document({ pageContent: result.content }));

    // LangChainを使って、質問に対する回答を取得
    const documentChain = await createStuffDocumentsChain({ llm, prompt });
    const answer = await documentChain.invoke({
      input: text,
      context: documents,
    });

    // メッセージを更新
    await client.chat.update({
      channel,
      ts: botMessage.ts as string,
      text: answer,
    });
  });

  return receiver;
};

export const slackbot = onRequest(
  {
    region: 'asia-northeast1',
    secrets: ['OPENAI_API_KEY', 'SLACK_BOT_TOKEN', 'SLACK_SIGNING_SECRET'],
  },
  (req, res) => {
    const signingSecret = process.env.SLACK_SIGNING_SECRET;
    const token = process.env.SLACK_BOT_TOKEN;
    return createReceiver(signingSecret!, token!).app(req, res);
  },
);

これで独自の知識を必要とする質問にも答えられる bot が出来上がりました。以下のように bot に質問のメンションを飛ばしてみましょう!

成功です!

知らないことを聞いてみると、「わかりません」と正しい返事を返してきます。

ナレッジベースを追加してみましょう! エミュレータ画面の Firestore から、ドキュメントを追加してみます。

- Add a document
  - Document ID: (変更なし)
  - Field: content
  - Type: string
  - Value: suzukalight の 2000/01/01 の晩ごはんはおせち料理でした。

Save すれば自動でベクトル化もしてくれますので、追加された知識について改めて質問してみましょう。

これで完成です!

4 章: Firebase プロジェクトをデプロイし slackbot として常設

3 章では RAG を用いて独自の知識を必要とする質問にも答えられる slackbot を開発しました。これでローカルエミュレータによる開発が完了しましたので、あとはリモートにデプロイして常設の slackbot にしていこうと思います。以下の手順で実現していきます。

  1. 環境変数を secrets としてデプロイ
  2. Firestore をベクトル検索できるデータベースとして設定
  3. Firebase Functions をデプロイ
  4. SlackApp とリモートの Firebase Functions を接続

環境変数を secrets としてデプロイ

OPENAI_API_KEY などの機密情報は、ファイルとして git などへコミットしてしまうとセキュリティリスクになるため、secrets としてサーバに直接展開します。以下のコマンドで secrets を登録できます。

firebase functions:secrets:set OPENAI_API_KEY

? Enter a value for OPENAI_API_KEY [hidden] hogehoge
✔  Created a new secret version projects/818232792394/secrets/OPENAI_API_KEY/versions/1

# 以下同様
firebase functions:secrets:set SLACK_SIGNING_SECRET
firebase functions:secrets:set SLACK_BOT_TOKEN

functions/.secret.local.gitignore で除外管理しておきましょう。

Firestore をベクトル検索できるデータベースとして設定

リモートの Firestore をベクトル検索できるようにするには、gcloud をインストールし、CLI で設定をする必要があります。手順については このインストールガイド を参照してください。

インストールが完了したら、以下のコマンドを実行します。

gcloud auth login

CLI 上にリンクが表示されるので、それをクリックして認証を行います。完了ページが表示されれば成功です。CLI にも You are now logged in ... と表示されます。

続けて、操作対象の Firebase プロジェクト ID を指定します。

gcloud config set project hands-on-llm-rag-yourname

インデックスを作成します。この情報 を参考に、以下のようなコマンドを実行しましょう。インデックス作成には数分ほど時間がかかります。

gcloud alpha firestore indexes composite create \
--collection-group=knowledges \
--query-scope=COLLECTION \
--field-config field-path=vector,vector-config='{"dimension":"1536", "flat": "{}"}' \
--database="(default)"

作成されたインデックス情報を、ローカルの設定ファイルに保存しておきましょう。

firebase firestore:indexes > firestore.indexes.json

これでリモートの Firestore がベクトル検索に対応し、knowledges の vector にインデックスが張られました。

Firebase Functions をデプロイ

Firebase を用いた Functions や Hosting などのデプロイは firebase deploy コマンド 1 つで実現できます。

firebase deploy

実行には数分かかります。成功すると、以下のような URL がコンソール上に表示されます。それぞれアクセスしてみましょう。

✔  functions[helloWorld(asia-northeast1)] Successful create operation.
Function URL (helloWorld(asia-northeast1)): https://helloworld-abcdefghijk-an.a.run.app

✔  Deploy complete!

Project Console: https://console.firebase.google.com/project/hands-on-llm-rag-yourname/overview
Hosting URL: https://hands-on-llm-rag-yourname.web.app
  • Hosting URL を呼び出すと、firebase が自動で作った Next.js の画面が表示されます。
  • Function URL(helloworld) を呼び出すと、GPT-4 を使ったお昼ご飯のサジェストが出てきます。

以上で Firebase プロジェクトのデプロイは完了です。非常に手軽で、ありがたい限りですね。

SlackApp とリモートの Firebase Functions を接続

Function URL として slack エンドポイントがデプロイされますので、これを以下の法則で編集しておきます。

https://slackbot-abcdefghij-an.a.run.app
 ↓ (作例)
https://slackbot-abcdefghij-an.a.run.app/events

Slack App の設定画面に移動し、

  • Event Subscriptions → Request URL
    • 「Change」: URL をペースト
    • 「Save Changes」

完了したら、Slack で bot にメンションして呼びかけてみてください。

まだリモートの Firestore には独自の知識を保存していませんので、欲しい答えが返ってきませんね。なのでローカルエミュレータと同じ要領で追加してみましょう。途中のドキュメント ID は「自動 ID」をクリックすれば OK です。



では改めて質問してみましょう。

成功しました! これで「生成 AI と RAG を用いて、独自の知識を必要とする質問にも答えられる Slackbot を作成する」という最終目標達成です。

🎉 おめでとうございます! お疲れ様でした! 🎉

おわりに

ハンズオンを通じて、生成 AI 技術と Firebase を組み合わせた Slackbot の構築を体験しました。LangChain や OpenAI の API を利用して生成 AI の基礎を学び、RAG を用いて独自の知識を持つ高度な Slackbot を実装する方法までを、自分の技術スタックとして引き出しに入れることができたかと思います。

生成 AI 利用のイメージがふつふつと湧いてきていて、技術スタックとしても得られた今であれば、応用編として以下の実践などもいかがでしょうか?

  • さらに複雑なクエリを組んでみる
  • 管理者用ダッシュボードを Firebase Hosting で開発
  • Firebase Authentication を使って、認証を受けた人だけが bot や知識を操作可能に
  • 画像や音声などに対応
  • 外部 API を使った独自知識のインプット、および最新の知識の自動的な反映

これからの AI プロジェクトに、今回の知識を存分に活かしていただければ幸いです!

CureApp テックブログ

Discussion