
Google Chat BotにFirestoreで会話履歴を持たせてフォローアップ質問に対応する
はじめに
RAGベースのGoogle Chat Botを運用していて、ユーザーから「もう少し詳しく教えて」と聞かれた時にBotが文脈を失ってしまう問題に気づきました。
原因はシンプルで、Botは毎回最新のメッセージのみをLLMに送信しており、スレッド内の過去のやり取りを一切参照していませんでした。つまり「それについてもっと教えて」と言われても、「それ」が何かわからない状態です。
この記事では、Firestoreで会話履歴を保存し、フォローアップ質問にも対応できるようにした実装を紹介します。
前提・環境
- Google Cloud Functions (2nd gen) + Python 3.14
- Gemini 2.5 Flash(google-genai SDK)
- Vertex AI RAG Engine(ナレッジベース検索)
- Google Chat API(Bot応答)
課題の整理
既存のパイプラインは以下の流れです。
メッセージ受信 → KB検索 → 回答生成 → 送信
この構成だと、スレッド内で以下のようなやり取りができません。
ユーザー: VPNの設定方法を教えて
Bot: (VPNの手順を回答)
ユーザー: もう少し詳しく教えて
Bot: 該当する情報が見つかりませんでした ← 「もう少し詳しく」でKB検索してしまう
「もう少し詳しく教えて」という文字列でRAG検索しても当然ヒットしません。必要なのは:
- 会話履歴の保存・取得 — 過去のやり取りを保持する
- クエリの再構成 — 「もう少し詳しく教えて」→「VPNの設定方法の詳細手順」に変換する
- 履歴を含めた回答生成 — LLMに過去の文脈を渡す

ストレージの選定
会話履歴の保存先として3つの選択肢を検討しました。
Google Chat API(spaces.messages.list)
Chat APIでスレッド内のメッセージを取得できれば理想的ですが、spaces.messages.list は chat.bot スコープをサポートしていません。chat.messages.readonly スコープが必要で、これにはWorkspace管理者の承認が必要です。Bot単体では使えないため除外しました。
Cloud Logging
Bot自体が使用量ログとしてクエリと回答をCloud Loggingに記録済みです。しかし、Cloud Loggingはインジェストに30秒〜5分の遅延があり、クエリにも2〜10秒かかります。インタラクティブな会話には不向きです。
Firestore(採用)
読み取りレイテンシが数十ミリ秒と高速で、TTLポリシーによる自動削除もサポートしています。無料枠(1GiBストレージ、5万回読み取り/日)で十分収まり、追加コストもほぼゼロです。

アーキテクチャ
改修後のパイプラインです。
メッセージ受信 → 履歴取得 → クエリ再構成 → KB検索 → 回答生成(+履歴) → 送信 → 履歴保存
パイプラインの可視ステップ数は既存の4ステップのまま変えず、履歴の取得・保存はステップの前後に、クエリ再構成は「検索クエリを作成中」ステップに組み込みました。
Firestoreのデータモデル
コレクション chat_threads に、スレッドごとに1ドキュメントを保存します。
doc_id: "spaces_ABC_threads_XYZ"
{
turns: [
{ role: "user", text: "VPNの設定方法を教えて", timestamp: ... },
{ role: "model", text: "VPN接続の手順は以下の通りです...", timestamp: ... },
...
],
expired_at: <現在時刻 + 24時間> // TTL用
}
doc_idはスレッド名(spaces/ABC/threads/XYZ)の/を_に置換したもの(FirestoreのドキュメントIDに/は使えないため)expired_atにTTLアンカーを設定し、24時間後に自動削除される- ターン数の上限は設けない — TTLにより自然に制限される(ITサポートの会話は2〜4ターン程度)
実装
1. Firestore CRUD(bot/history.py)
Firestoreクライアントはシングルトンパターンで初期化します。これは既存のChat APIクライアントやgenaiクライアントと同じパターンです。
import logging
import threading
from datetime import datetime, timedelta, timezone
from google.cloud import firestore
logger = logging.getLogger(__name__)
_COLLECTION = "chat_threads"
_TTL_HOURS = 24
_client = None
_lock = threading.Lock()
def _get_client() -> firestore.Client:
global _client
if _client is None:
with _lock:
if _client is None:
_client = firestore.Client()
return _client
def _encode_doc_id(thread_name: str) -> str:
return thread_name.replace("/", "_")
def get_history(thread_name: str) -> list[dict]:
if not thread_name:
return []
try:
doc = _get_client().collection(_COLLECTION).document(
_encode_doc_id(thread_name)
).get()
if not doc.exists:
return []
turns = doc.to_dict().get("turns", [])
return [{"role": t["role"], "text": t["text"]} for t in turns]
except Exception:
logger.warning("Failed to read history for %s", thread_name, exc_info=True)
return []
def save_turn(thread_name: str, user_text: str, model_text: str) -> None:
if not thread_name:
return
try:
doc_ref = _get_client().collection(_COLLECTION).document(
_encode_doc_id(thread_name)
)
doc = doc_ref.get()
turns = []
if doc.exists:
turns = doc.to_dict().get("turns", [])
now = datetime.now(timezone.utc)
turns.append({"role": "user", "text": user_text, "timestamp": now})
turns.append({"role": "model", "text": model_text, "timestamp": now})
doc_ref.set({
"turns": turns,
"expired_at": now + timedelta(hours=_TTL_HOURS),
})
except Exception:
logger.warning("Failed to save turn for %s", thread_name, exc_info=True)
ポイントは グレースフルデグラデーション です。get_history も save_turn もFirestoreのエラー時にはログを出力するだけで例外を上げません。履歴が取れなくても、Botは通常通り(履歴なしで)動作します。
2. クエリ再構成(Query Reformulation)
フォローアップ質問をそのままRAG検索に使うと、意味のある結果が返りません。LLMを使って自己完結型のクエリに書き換えます。
REFORMULATE_TEMPLATE = """\
以下は社内ITサポートチャットの会話履歴です。
## 会話履歴
{history}
## 最新の質問
{question}
## タスク
最新の質問が会話履歴の文脈に依存している場合(例:「それ」「その方法」「他には?」など)、\
ナレッジベース検索に適した自己完結型の質問文に書き換えてください。
すでに自己完結している質問はそのまま返してください。
書き換えた質問文だけを返してください。説明や前置きは不要です。
"""
def _format_history_text(history: list[dict]) -> str:
lines = []
for turn in history:
role_label = "ユーザー" if turn["role"] == "user" else "アシスタント"
lines.append(f"{role_label}: {turn['text']}")
return "\n".join(lines)
def reformulate_query(question: str, history: list[dict]) -> str:
if not history:
return question
history_str = _format_history_text(history)
prompt = REFORMULATE_TEMPLATE.format(history=history_str, question=question)
try:
client = _get_genai_client()
response = client.models.generate_content(
model=MODEL_ID,
contents=prompt,
)
reformulated = response.text.strip()
return reformulated if reformulated else question
except Exception:
logger.warning("Query reformulation failed, using original", exc_info=True)
return question
再構成されたクエリはRAG検索にのみ使います。回答生成にはユーザーの元の質問文をそのまま渡します。これにより、LLMが「もう少し詳しく教えて」という文脈を理解しつつ、検索には適切なクエリが使われます。
3. 履歴を含めた回答生成
会話履歴がある場合は、履歴を含むプロンプトテンプレートに切り替えます。
GENERATION_TEMPLATE_WITH_HISTORY = """\
以下のナレッジベースの情報と会話履歴を参考に、ユーザーの質問に回答してください。
## 会話履歴
{history}
## ナレッジベース検索結果
{context}
## ユーザーの質問
{question}
## 回答
"""
def generate_answer(question: str, contexts: list[dict],
history: list[dict] | None = None) -> RagAnswer:
# ... 省略 ...
if history:
history_str = _format_history_text(history)
prompt = GENERATION_TEMPLATE_WITH_HISTORY.format(
history=history_str, context=context_str, question=question,
)
else:
prompt = GENERATION_TEMPLATE.format(context=context_str, question=question)
# ... 以降は同じ(Gemini呼び出し + structured output) ...
システムプロンプトにも会話履歴の扱い方を追加しています。
- スレッド内での会話では、前のやり取りの文脈を踏まえて回答してください
- ただし、前の回答を繰り返さず、新しい質問に対する回答に集中してください
- 過去の回答を参照する場合でも、必ずナレッジベースの情報に基づいてください
4. パイプラインへの組み込み
worker.py の主な変更箇所です。
from bot.history import get_history, save_turn
from bot.rag import reformulate_query
# パイプライン本体(主要部分のみ抜粋)
history = get_history(thread_name)
# Step 2: 検索クエリ作成(履歴があれば再構成)
if history:
search_query = reformulate_query(user_text, history)
else:
search_query = user_text
# Step 3: KB検索(再構成後のクエリで検索)
contexts = retrieve_context(search_query)
# Step 4: 回答生成(元の質問文 + 履歴を渡す)
result = generate_answer(user_text, contexts, history=history or None)
# フォローアップのヒント(初回応答時のみ)
if not history and not is_fallback:
state.content_paragraphs.append(FOLLOWUP_HINT)
# 成功時のみ履歴を保存
if answer_text is not None:
save_turn(thread_name, user_text, answer_text)
初回応答時のみ「このスレッドで追加の質問もできます(24時間以内)」というヒントを表示し、ユーザーにフォローアップが可能なことを知らせています。2回目以降の応答では表示しません(ユーザーは既にフォローアップしているので不要です)。
GCPセットアップ
Firestoreのセットアップは3つのコマンドで完了します。
# 1. Firestore APIを有効化
gcloud services enable firestore.googleapis.com --project=YOUR_PROJECT_ID
# 2. データベースを作成(Nativeモード)
gcloud firestore databases create \
--location=asia-northeast1 \
--type=firestore-native \
--project=YOUR_PROJECT_ID
# 3. TTLポリシーを設定(24時間後に自動削除)
gcloud firestore fields ttls update updated_at \
--collection-group=chat_threads \
--enable-ttl \
--project=YOUR_PROJECT_ID
IAMについては、Cloud Functionsのデフォルトサービスアカウントが Editor ロールを持っていれば、Firestoreの読み書きに必要な datastore.user 権限が含まれているため追加設定は不要です。
なお、データベースの新規作成には datastore.owner ロールが必要です。Editor ロールでは権限不足なので、プロジェクトオーナーに依頼するか、一時的に datastore.owner を付与してもらう必要があります。
動作確認
デプロイ後、Google Chatで実際にフォローアップ質問を試しました。
ユーザー: VPNの設定方法を教えて
Bot: VPN接続の手順は以下の通りです...(詳細な手順を回答)ユーザー: もう少し詳しく教えて
Bot: VPN設定についてさらに詳しく説明します...(前の回答を踏まえた補足情報)
プログレッシブカードの「検索クエリを作成中」ステップでは、再構成されたクエリ(例:「VPN設定の詳細手順」)が表示され、適切にクエリが書き換えられていることが確認できます。
Firestoreコンソールでは、chat_threads コレクションにドキュメントが作成されていることも確認できました。
まとめ
Google Chat BotにFirestoreベースの会話履歴を追加することで、スレッド内のフォローアップ質問に対応できるようになりました。
実装のポイントをまとめます。
- ストレージはFirestore — 低レイテンシ、TTLによる自動クリーンアップ、無料枠で十分
- クエリ再構成 — フォローアップ質問をLLMで自己完結型クエリに変換してからRAG検索する
- 元の質問文で回答生成 — 再構成クエリはRAG検索のみに使い、回答生成にはユーザーの元の質問を渡す
- グレースフルデグラデーション — Firestore障害時もBotは通常動作する(履歴なしにフォールバック)
- ターン数上限なし — TTL(24時間)が自然なリミット。ITサポートの会話は短いので上限管理は不要
フォローアップ質問のたびにRAG検索を実行するのは一見無駄に見えますが、ユーザーが新しいトピックに切り替える可能性を考えると、常にRAGを通すほうがシンプルで安全です。意図分類を入れて「clarification」と「new topic」を分岐させる設計も検討しましたが、分類ミスのリスクと複雑さに対してメリットが見合わないため、シンプルな「常にRAG」方式を採用しました。








