MORE

BLOG

【生成AI】超簡単 & 低コストで作れる RAG on Slack UI x GCP Firestore 

  • Firestore
  • GCP
  • RAG
  • Slack
  • UI
  • 低コスト
  • 生成AI
  • 簡単
25.04.26

1. はじめに

本記事へアクセスいただき、ありがとうございます。

自社で生成AIサービスを導入することを検討中の企業様への検討材料となりましたら幸いです。

以下に冒頭タイトルの背景を記載致します。

超簡単である理由:

Firestoreの手軽さ

 ・サーバーレスでスケーラブルなNoSQL DB。

 ・JSONベースで簡単にデータを投入・検索できる。

 ・ベクトル検索(Vector Search)対応により、RAG向きのデータ検索が手軽。

Slack UIの利便性

 ・UI/UXをSlackに任せられるため、開発工数を大幅に削減。

 ・Slack Appを利用すれば、認証、メッセージング、添付ファイル処理など基本機能が標準で備わっている。

シンプルな構成例

 ・Slackからの質問を受け取り、Firestoreで該当ナレッジを検索。

 ・検索結果をプロンプトに組み込み、OpenAI APIで生成した回答をSlackに返すだけ。

低コストである理由:

GCP Firestoreのコスト効率性

 ・無料枠(読み取り5万回/日、書き込み2万回/日)があり、小規模〜中規模ナレッジ運用であれば無料または低コスト。

 ・従量課金でコスト最適化可能。

Slackの無料プランでも運用可能

 ・Slack App作成やAPI利用に費用がかからない。

 ・小規模チームではSlack無料プランでも十分運用可能。

・OpenAI APIの低コスト

 ・API料金もリーズナブル(例:GPT-4-turboなら$10/100万トークン程度)で、実運用において大きな負担にならない。

ざっくりと上述を背景にタイトルを謳わせていただきました。

スピーディに実現可能で運用管理も楽なため、まずは小規模での社内展開からスタートし、成果が出ましたら徐々に本格化・最適化を進めるという進め方が良いかなと思います。

もっと簡単で低コストな構築方法あるよ!という方いらっしゃいましたらお手数ですがお問い合わせより教えていただけますと幸いです!

1.1 記事について

RAG参照先になるFirestoreへのナレッジデータの登録と、回答の精度については本記事では触れておりません。

弊社にて実際に行った構築方法についてご紹介させていただいております。

1.1 記事の対象読者

・生成AI導入検討中の企業ご担当者様

・プロトタイプ作成から小〜中規模をご検討中

1.2 記事を読み終えるまでの記事

約30分

2. 構築

2.1 完成イメージ

【生成AI】超簡単 & 低コストで作れる RAG on Slack UI x GCP Firestore  - 株式会社テックディレクション

2.2 システム概要図

【生成AI】超簡単 & 低コストで作れる RAG on Slack UI x GCP Firestore  - 株式会社テックディレクション

2.3 初期設定

以下必要な設定内容になります。各サービス(OpenAI、GCP、Slack app)での詳細手順につきましては割愛させてください。

OpenAI APIキー発行:

OpenAI Platform へログインしAPIキーを発行。発行時しかコピーできなかった気がしますので、コピーしてメモります。

【生成AI】超簡単 & 低コストで作れる RAG on Slack UI x GCP Firestore  - 株式会社テックディレクション

Firestore CollectionとIndex作成:

 1. collection を作成。今回はコレクション名は knowledge_demo で作成します。

【生成AI】超簡単 & 低コストで作れる RAG on Slack UI x GCP Firestore  - 株式会社テックディレクション

GCP Cloud SHELL(ターミナル)で以下コマンドを実行し、Index作成。

gcloud firestore indexes composite create --project=<project id> --collection-group=knowledge_demo --query-scope=COLLECTION_GROUP --field-config=vector-config='{"dimension":"1536","flat": "{}"}',field-path=position_vector

Slack app 作成:

slack api画面からslack app(from scratch)を作成。

・OAuth & Permissions設定

 ・Scopes設定

【生成AI】超簡単 & 低コストで作れる RAG on Slack UI x GCP Firestore  - 株式会社テックディレクション

 ・Redirect URLs設定(https:// <Slack Workspace URL>)

【生成AI】超簡単 & 低コストで作れる RAG on Slack UI x GCP Firestore  - 株式会社テックディレクション

 ・OAuth Tokens ( Install to your Workspace )

インストール完了後に発行される Bot User OAuth Token を Copyボタンからコピーしてメモります。

【生成AI】超簡単 & 低コストで作れる RAG on Slack UI x GCP Firestore  - 株式会社テックディレクション

・App Home Messages Tab へチェック

【生成AI】超簡単 & 低コストで作れる RAG on Slack UI x GCP Firestore  - 株式会社テックディレクション

・Basic Information > App Credentials から Signing Secret をコピーしメモります。

【生成AI】超簡単 & 低コストで作れる RAG on Slack UI x GCP Firestore  - 株式会社テックディレクション

2.4 デプロイ

Cloud Run サービス作成:

 1. Cloud Run画面ヘッダーメニュー「関数を作成」からサービス作成を行います。

【生成AI】超簡単 & 低コストで作れる RAG on Slack UI x GCP Firestore  - 株式会社テックディレクション

 2.以下設定をします。

  a.サービス名、リージョン、ランタイム(今回はPythonで実装しますので)を設定。

【生成AI】超簡単 & 低コストで作れる RAG on Slack UI x GCP Firestore  - 株式会社テックディレクション

  b.未認証の呼び出しを許可。(今回必要最低限の内容で実装しますので、こちらは適宜設定をお願いいたします。)

【生成AI】超簡単 & 低コストで作れる RAG on Slack UI x GCP Firestore  - 株式会社テックディレクション

  c. 変数とシークレットを設定。先ほどメモした値を入れてください。環境変数名は今回は以下としています。

SLACK_BOT_TOKEN, OPENAI_API_KEY, SLACK_SIGNING_SECRET

【生成AI】超簡単 & 低コストで作れる RAG on Slack UI x GCP Firestore  - 株式会社テックディレクション

  d. 下記のソース main.py, requirements.txt 作成(コピペ)し、デプロイ実行してみましょう。

補足:ソースのベースイメージは Python 3.12(Ubunts 22 Full)* Ubunts 22 だと sqlite3が無いってbuildエラー(ImportError: libsqlite3.so.0: cannot open shared object file: No such file or directory)になりましたので Full にしています。

【生成AI】超簡単 & 低コストで作れる RAG on Slack UI x GCP Firestore  - 株式会社テックディレクション
main.py

import json
import logging
import os
import re
import functions_framework
import google.cloud.logging
from openai import OpenAI
from box import Box
from flask import Request
from slack_bolt import App
from slack_bolt.adapter.google_cloud_functions import SlackRequestHandler
from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError
from google.cloud import firestore
from google.cloud.firestore_v1.base_vector_query import DistanceMeasure
from google.cloud.firestore_v1.vector import Vector

# Google Cloud Logging クライアント ライブラリを設定
logging_client = google.cloud.logging.Client()
logging_client.setup_logging(log_level=logging.DEBUG)

# 環境変数からシークレットを取得
slack_token = os.environ.get("SLACK_BOT_TOKEN")
slack_signing_secret = os.environ.get("SLACK_SIGNING_SECRET")
openai_api_key = os.environ.get("OPENAI_API_KEY")

# Slackの設定
slack_client = WebClient(token=slack_token)

# OpenAIクライアントの初期化
client = OpenAI(api_key=openai_api_key)

# Firestoreクライアントの初期化
db = firestore.Client()

# FaaS で実行する場合、応答速度が遅いため process_before_response は True でなければならない
app = App(
    token=slack_token,
    signing_secret=slack_signing_secret,
    process_before_response=True
)
handler = SlackRequestHandler(app)

# Bot アプリにダイレクトメッセージしたイベントに対する応答
@app.event("message")
def handle_app_message_events(body: dict,say):
    try:
        logging.debug(type(body))
        logging.debug(body)
        event = body["event"]

        # メッセージが変更(リンクのプレビュー含む)された場合はむしする。
        subtype = event.get("subtype")
        if subtype in ["message_changed", "message_deleted", "channel_join"]:
            logging.debug(f"Message change or deletion event ignored: {subtype}")
            return

        #DMメッセージからのみ受付し、メッセージ処理を実行。
        if body["event"]["channel_type"] == "im":
            process_message_event(body)
        
    except Exception as e:
        error_message = f"Error processing message event: {str(e)}"
        logging.error(error_message)
        say(error_message)  # エラーをDMに通知
        raise

def process_message_event(body):
    try:
        box = Box(body)
        user_id = box.event.user
        text = box.event.text

        # slack投稿からテキスト抽出
        only_text = re.sub("<@[a-zA-Z0-9]{11}>", "", text)
        logging.debug(f"query: {only_text}")
        
        dm_response = slack_client.conversations_open(users=[user_id])
        dm_channel_id = dm_response["channel"]["id"]

        initial_message = slack_client.chat_postMessage(
            channel=dm_channel_id,
            text=f"お疲れさまです<@{user_id}> さん:exclamation:\n"
                ":book: ご質問をありがとうございます。\n"
                ":hourglass_flowing_sand: 回答生成が終わるまでしばらくお待ちください。"
        )
        ts = initial_message["ts"] 
        
        embedded_query = embed_text(only_text)
        search_results = search_firestore(embedded_query, neighbor_count=10)
        final_response = create_chat_completion(only_text, search_results)
        post_message_with_markdown(dm_channel_id, final_response, ts)

    except Exception as e:
        logging.error(f"Unexpected error: {str(e)}")
        slack_client.chat_postMessage(
            channel=dm_channel_id,
            text="処理中にエラーが発生しました。システム管理者にお問い合わせください。"
        )

def search_firestore(embedded_query, neighbor_count):
    try:
        # コレクションを参照
        collection_ref = db.collection_group('knowledge_demo')
        vector = Vector(embedded_query)

        #近傍検索処理を実行
        resp = collection_ref.find_nearest(
            vector_field="position_vector",
            query_vector=vector,
            distance_measure=DistanceMeasure.EUCLIDEAN,
            limit=neighbor_count
        ).stream()
    except Exception as e:
        logging.error(f"Unexpected error during Firestore search: {e}")
        raise e

    # レスポンスからネイバーを取得
    neighbors_result = []
    if resp:
        for doc in resp:
            if doc.exists:
                doc_data = doc.to_dict()
                logging.debug(f"Document ID: {doc.id}, Data: {doc_data}")
                neighbors_result.append({
                    'id':doc.id,
                    'original_text': doc_data.get('original_text', ''),
                    'created_at': doc_data.get('created_at', '')
                })
            else:
                logging.warning(f"Document {doc.id} does not exist.")

        logging.debug(f"neighbors resp text:{neighbors_result}")
    else:
        logging.debug("クエリの取得に失敗しました。")

    return neighbors_result

def embed_text(text):
    try:
        response = client.embeddings.create(
            model="text-embedding-3-small",  # 埋め込みモデルを指定
            input=text
        )
        embedding = response.data[0].embedding
        return embedding
    except Exception as e:
        logging.error(f"OpenAI API呼び出し中にエラーが発生しました: {e}")
        raise e

def get_data_from_firestore(neighbor_id):
    try:
        doc_ref = db.collection("knowledge_demo").document(neighbor_id)
        doc = doc_ref.get()
        if doc.exists:
            data = doc.to_dict()
            return data.get('original_text', '')
        else:
            logging.error(f"Firestore ドキュメント {neighbor_id} が見つかりませんでした。")
            return '', '', ''
    except Exception as e:
        logging.error(f"Firestore からデータ取得中にエラーが発生しました: {e}")
        raise e

def call_openai_api(model, prompt, temperature=0.7):
    try:
        response = client.chat.completions.create(
            model=model,
            messages=[{"role": "system", "content": prompt}],
            stop=None,
            temperature=temperature
        )
        return response
    except Exception as e:
        logging.error(f"OpenAI API呼び出し中にエラーが発生しました: {e}")
        raise e

def create_chat_completion(only_text, search_results):
    related_texts = []
    for i,neighbor in enumerate(search_results):
        text, url, created_at = get_data_from_firestore(neighbor["id"])
        logging.debug(f"neighbor no.{i}: {text} \n {url} \n {created_at}")
        if text:
            related_texts.append(f"ナレッジ: {text}")

    combined_related_texts = "\n\n".join(related_texts)
    logging.debug(f"combined_related_texts: {combined_related_texts}")

    prompt = f"""
    - 以下は株式会社テックディレクションの社内文書と質問者からの質問内容です。
    - 適宜、インデント、太字、箇条書きをSlack向けのマークダウン方式を活用してください。
    - 推測に基づいた回答を避け、不足している情報がある場合や次のアクションを質問にて提案してください。
    - 少しウィットに富んだニュアンスを加えることで親しみやすさを持たせてください。
    - プログラムコードや特記事項に関してはslackのコードブロックの形式にしてください。

    {combined_related_texts}
    質問: {only_text}
    """

    logging.debug(f"summary_prompt:{prompt}")

    try:
        summary_response = call_openai_api("gpt-4o", prompt, 0.3)
        final_response = summary_response.choices[0].message.content.replace("**","*")
        return final_response

    except Exception as e:
        logging.error(f"OpenAI API呼び出し中にエラーが発生しました: {e}")
        raise e

def post_message_with_markdown(channel_id, text, thread_ts):
    try:
        max_length = 3000
        text_chunks = [text[i:i+max_length] for i in range(0, len(text), max_length)]
        for i, chunk in enumerate(text_chunks):
            blocks = [
                {
                    "type": "section",
                    "text": {
                        "type": "mrkdwn",
                        "text": chunk
                    }
                }
            ]

            if i == 0 and thread_ts:
                # 最初のメッセージを更新
                result = slack_client.chat_update(
                    channel=channel_id,
                    ts=thread_ts,
                    blocks=blocks
                )
            else:
                # 2つ目以降のメッセージはスレッドに投稿
                result = slack_client.chat_postMessage(
                    channel=channel_id,
                    thread_ts=thread_ts,
                    blocks=blocks
                )
            logging.debug(f"Message sent: {result}")
    except SlackApiError as e:
        logging.error(f"Error posting message: {e}")
        raise e

# Cloud Functions で呼び出されるエントリポイント
@functions_framework.http
def hello_http(request: Request):
    """slack のイベントリクエストを受信して各処理を実行する関数

    Args:
        request: Slack のイベントリクエスト

    Returns:
        SlackRequestHandler への接続
    """
    header = request.headers
    logging.debug(f"header: {header}")
    body = request.get_json()
    logging.debug(f"body: {body}")

    # URL確認を通すとき
    if body.get("type") == "url_verification":
        logging.info("url verification started")
        headers = {"Content-Type": "application/json"}
        res = json.dumps({"challenge": body["challenge"]})
        logging.debug(f"res: {res}")
        return (res, 200, headers)
    # 応答が遅いと Slack からリトライを何度も受信してしまうため、リトライ時は処理しない
    elif header.get("x-slack-retry-num"):
        logging.info("slack retry received")
        return {"statusCode": 200, "body": json.dumps({"message": "No need to resend"})}

    # handler への接続 class: flask.wrappers.Response
    return handler.handle(request)
requirements.txt

functions-framework
OpenAI
slack-bolt
slack-sdk
google-cloud-logging
flask == 3.*
python-box == 7.0.0
google-cloud-aiplatform>=1.29.0
google-cloud-firestore

  e. デプロイに成功しましたら、作成した Cloud Run サービスのリクエストURLをコピーしメモります。

【生成AI】超簡単 & 低コストで作れる RAG on Slack UI x GCP Firestore  - 株式会社テックディレクション

2.5 Slack App アプリ追加

 1.Slack App管理画面へアクセスし、Event subscriptionの設定。

  a. Reuqest URLへ先ほどコピーしたCloud RunサービスのURLを入力し、疎通確認。Verifiedと表示されればOK

【生成AI】超簡単 & 低コストで作れる RAG on Slack UI x GCP Firestore  - 株式会社テックディレクション

  b. Subscribe to bot events の設定

【生成AI】超簡単 & 低コストで作れる RAG on Slack UI x GCP Firestore  - 株式会社テックディレクション

  c. Slackワークスペースへ再インストール bの手順を踏むと画面上部に以下のようなバナーが表示されます。バナー内リンク reinstall your app から再インストールを実施してください。

【生成AI】超簡単 & 低コストで作れる RAG on Slack UI x GCP Firestore  - 株式会社テックディレクション

Slack Workspace へ作成した Slack app を追加します。

【生成AI】超簡単 & 低コストで作れる RAG on Slack UI x GCP Firestore  - 株式会社テックディレクション

さぁお疲れ様でした、いよいよ作成した Slack AppとのDMで質問してみましょう。

3. まとめ

世の中的に特段新しい技術ではないかと思いますが、改めて整理も兼ねて記事投稿させていただきました!

元々GCPのVertex AIを社内POCで利用していたのですが、比較的コストが高く、週末はベクトル検索のエンジンを停止する対応をしていました。

2024年7月あたりにFirestoreでのベクトル検索が使える様になった(発表は2024年4月?)とネットで見て導入したところ

検索精度も気にならず(変わらず)、とにかく費用の安さに感動しました!

今回、データの登録はされていない状態でのRAG実装でしたので、実質 gpt-4o の要約回答となっていましたが、 実際に Firestore へコードに記載のあるフィールド名でデータを登録いただきましたら参照回答も可能ですのでぜひお試しください。

今後はデータの登録方法や、回答の精度のことについても書いていきたいと思います。またその時はよろしくお願いいたします。

著者:アイランドホッパー深澤

弊社では、ECサイトのリプレース案件から、Shopifyカスタムアプリ開発、保守案件に至るまで、EC中心にプロジェクトの質にこだわり、お客様に笑顔になってもらえるよう日々邁進しております。

皆様からのお問い合わせ・ご相談をお待ちしております。

お問い合わせはこちら