💬

Genkit・Agent Builder(Vertex AI Search)・Google Chatで作るお問い合わせ返信AIシステム

に公開

はじめに

生成AIが出てきた頃の花形といえばRAG(Retrieval Augmented Generation) だった思い出があります。LangChainで色々なデータソースに連携しながら、チャットアシスタントを作ってワイワイしていた頃...

それから月日が経って、RAGにも色々な選択肢が出てきました。特に当時課題に感じていたObservabilityも、大きな手間なく用意できるようになりました。

この記事では、よりどりみどりの多様なRAGの選択肢から、

  • Genkit
  • Vertex AI Agent Builder(Vertex AI Search)
  • Google Chat(UI)

というGoogleズブズブの構成でのRAG構築例をシェアします。

この記事の流れ

この記事では大きく3つの処理を実装します。

0. 前提

この記事では以下のようなお問い合わせフローを前提としています。

  • Google Forms経由
  • 回答はGmail返信で行なっている

すなわち、過去のお問い合わせのやり取りがGmailの返信として蓄積されていることを前提とします。

1. ナレッジの用意(Google Forms -> Vertex AI Agent Builder)

まずRAGの前提となるナレッジを用意し、Vertex AI Agent Builderを作成するデータパイプラインの作成を行います。

  • Gmailから過去のメールと返信文を取得(Apps Script)
  • Geminiを用いてFAQ化(Google Colab)
  • VertexAI用のJSONL生成
  • Vertex AI Agent Builderの作成

2. GenkitでのAI Agentの構築

GenkitでのRAG実装を取り上げます。Genkitのツールとして、1で作成したVertex AI Agent Builderを用います。

3. Google Chatの実装(Apps Script)

最後にUIとしてApps Scriptを使ったGoogle Chatアドオンを作成します。

Architecture

本記事で構成するシステム構成図を鳥瞰しておきます。

Flow 1. ナレッジの用意

Vertex AI Agent Builderを作成するまでのフロー。


ナレッジの作成

Flow 2. Google Chatでの返信案作成

実際にお問い合わせを受けてGoogle Chatで返信案を提示するまでのフロー。

Agent BuilderはPart 1で作成したものです。

以下がGoogle Chatの一例です。

問い合わせをChatに投稿 Genkitの回答

技術選定

今回用いるものについて、選定理由と合わせて簡単に概観しておきます。

Apps Script

このシステムではApps Scriptが要になっています

  • 過去の問い合わせメールの抽出・加工
    • GmailAppを用いて過去の問い合わせを取得
  • Googleフォームの提出時の処理
    • 受領メール送信
    • AI回答の作成
      • Cloud Runバックエンド(Genkit)への問い合わせ
      • Google Chat用Apps ScriptへのPOST
  • Google Chatアドオン(Web Appとしてデプロイ)
    • Chat UIの作成
    • Googleフォームから呼ばれる

採用理由は言うまでもなく、Googleサービスとの連携のしやすさです。

LLMアプリでは重要な考慮点になる認証・認可についても、Apps ScriptはWeb Appとしてデプロイする際に実行ユーザーを限定できます。さらに、Cloud Runと同じGoogleプロジェクトに紐づけておけば、ScriptApp.getIdentityToken()で得られるtokenを付けることで、IAM認証付きのCloud Runを呼び出すことができます。

今回のシステムでも

  • GoogleフォームApps Script -> Google ChatのApps Script(実行アカウントは「自分のみ」)
  • GoogleフォームApps Script -> (identityToken) -> GenkitのCloud Run(IAM保護)

という呼び出しになっています。


Web AppとしたApps Scriptには3通りの認証がある。Google Workspaceの場合はドメインによる制限も可能

本記事で書かれるApps Scriptは全てclaspを用いてTypeScriptで書かれています。

Google Colab

過去のお問い合わせからFAQ形式に加工するためのLLM処理にGoogle Colabを用いています。
Apps Scriptでも実装可能ですが、長時間実行が可能なこと、ライブラリで簡素に実装できること(python-genai)から、採用しました。

Agent Builder

Agent BuilderはCloud Storageのデータを取り込んで検索エンジンを作成できるサービスで、RAG用のデータベースとして使用します。

Pineconeなどのベクトルデータベースを用いても良いですが、セットアップからデータ取り込みまでGUIで出来ること、Geminiと連携しやすいことからから採用しました。費用を考えるなら自前でPineconeを用意する選択肢もあると思いますが、チャンキング戦略など世話することが増えます。(ちなみにGenkitにはindexingの機構も提供されています。)

Genkit on Cloud Run

LLMは仕様やモデルが日進月歩、留まることがないので、筆者は緩衝材として何らかのラッパーを使うようにしています。とりわけ、ツールエージェントを作る場合、ツールやロギングなど追うべきインターフェースが多岐に渡ることもあって、ラッパーライブラリに依存したとしても、各ベンダーの変更に振り回されないメリットが大きいと考えています。

筆者が使い慣れているVercelのAI SDKを最初に検討していましたが、今回はGenkitを選択しました。理由としては、

  • Cloud Runにデプロイしやすい
    • といってもAI SDKもデプロイ容易性はそこまで変わらないですが、公式ドキュメントに手順があります。
  • トレースがデフォルトで完備

特にマルチステップエージェントでは必需品と言える、モニタリングの充実が決め手になりました。
ローカルだけでなく、デプロイ後もenableFirebaseTelemetry();を入れるだけでtraceが取れるようになります。


Trace

デフォルトのデプロイ先はFirebase Functions 2nd Gen.のcallable functionとなっていますが、Apps ScriptからIAM認証で呼び出したかったこと、また実装し慣れているので、Cloud Runとしました。

Google Chat

AIの回答をどこで受け取るか。最初はSlackを選択していました。
しかし、SlackとGmailで2画面を開く不便さが拭えず、最終的にGoogle Chatを選びました。


Gmailと同じ画面で操作できる

ChatをGmailに表示させるには、下記から設定が必要です。
https://mail.google.com/mail/u/0/?tab=rm&ogbl#settings/chat)

少し長くなりました。実装していきましょう。

実装編

Part 1. ナレッジの用意

0. 前提

  • Google Formsからお問い合わせの受領メールを送信(Apps Script
  • お問い合わせメールには特定のラベルがついている

ことは前提とし、前者は極めて一般的なApps Scriptの用途であり、後者もタイトル一致等で簡単に実現できるため、本記事では扱いません。

1. Gmailの抽出

ラベル付きのメールを取得するApps Scriptを作成します。

ソースは下記です。Claspを用いてTypeScriptで書いています。[1]
https://github.com/HosakaKeigo/gmail-fetcher

主な処理はGmailFetcherにまとめられています。フッターを削除したり、返信の引用を消したり、とLLMに無駄なtokenを与えないような小細工をしています。

  private removeQuote(emailBody: string): string {
    return emailBody
      .split("\n")
      .filter(line => {
        return !line.startsWith(">"); // delete quoted lines
      })
      .join("\n")
      .trim();
  }

実行すると以下のような形でGmailが取得されます。

2. Google Colabでの学習用FAQ生成

生のメールのままだと学習データに使えないため、一度LLMを通して学習用FAQに加工します。

前述の通り、python-genaiライブラリが使えること、実行時間の制約がApps Scriptより緩いため、Colabを用いています。

コード例は下記です。

https://github.com/HosakaKeigo/gmail-fetcher/blob/main/colab/faqMaker.ipynb

LLM部分は下記です。実行するGoogleのユーザーにVertex AI Userのロールを付与しておくと、auth.authenticate_userをしておけばVertex AIを使用できます。

出力はpydanticを使ったStructured Outputsを使って制御します。

import os
from google import genai
from google.colab import userdata, auth
from pydantic import BaseModel
import json

class FAQ(BaseModel):
    question: str
    todo: str
    reply: str

# Require Vertex AI User Role
auth.authenticate_user(
    project_id=GOOGLE_CLOUD_PROJECT_ID
)

def extract_faq(contents: str) -> str:
    client = genai.Client(
        vertexai=True,
        project=GOOGLE_CLOUD_PROJECT_ID,
        location='us-central1'
    )

    try:
        response = client.models.generate_content(
          model=GEMINI_MODEL,
          contents=mask_email(contents),
          config=genai.types.GenerateContentConfig(
              system_instruction=SYSTEM_PROMPT,
              temperature=0,
              response_mime_type='application/json',
              response_schema=FAQ,
          ),
        )
        return json.loads(response.text)
    except Exception as e:
        print(e)
        return

データの利用を考慮し、Geminiを使う場合は無料版のGoogle Developer APIではなく、有料のGoogle Developer APIまたはVertex AI APIを使うことをお勧めします。


https://ai.google.dev/gemini-api/terms?hl=ja#unpaid-services

結果として、以下のように問い合わせ事項、対応方法、返信文例が作成されます。

3. Vertex AI Agent Builder用JSONLファイルの作成

このシートをソースにVertex AI Agent Builderへのデータ取り込みを行います。

Agent Builderのデータストアは多彩な形式の構造化/非構造化データをサポートしていますが、今回はFAQに向いている構造化JSONL形式でデータを作成します。

https://cloud.google.com/generative-ai-app-builder/docs/prepare-data

作成するデータの一例を示します。

{"faq_id":1,"question":"ピアノステップ参加申込の演奏曲目コード(7桁)の確認方法。郵送での申し込みの場合。","action":"- 問い合わせ内容が、郵送申込の曲目コード(7桁)についてであることを確認する。\n- 郵送申込の場合、7桁の曲目コード欄は空欄で良いことを伝える。\n- 楽譜名、出版社名の記入が必要なことを伝える。","answer":"◆◆◆さま\n\nお世話になっております。\nこの度はお問い合わせをいただきましてありがとうございます。\n\n課題曲コードにつきまして、紛らわしく恐れ入ります。\n7桁の部分につきましては空欄で構いませんので、楽譜名や楽譜出版社名などを\n忘れずにご記入くださいませ。\n\n他にもご不明点等ございましたら、お手数ですが再度ご連絡をいただけますと幸いです。\n◆◆◆さまのお申込みをお待ちしております。\nどうぞよろしくお願いいたします。"}
{"faq_id":2,"question":"継続表彰の対象者が、過去に継続表彰回数をスキップしている場合、スキップされた分の記念品と継続表彰シールを請求できるか。","action":"- 参加者の継続表彰の履歴を確認する。\n  - 過去にスキップされた継続表彰回数を確認する。\n- 該当する継続表彰回数の記念品とシールを確認する。\n  - 在庫を確認する。\n  - 発送手配を行う。","answer":"◆◆◆様\n\nお世話になっております。\nこの度は、<地区名>地区ステップへのご参加、およびお問い合わせをいただきありがとうございます。\n\n先日のステップで<回数>回の継続表彰になったかと存じますので、\nスキップされた<回数>回、<回数>回の記念品をお送りいたします。\n\n・継続表彰シール<回数>回\n・継続表彰シール<回数>回\n・<回数>回記念<記念品名>\n・<回数>回記念<記念品名>\n\n上記<個数>点をお送りしますので、到着まで少々お待ちください。\n今後とも、ピティナ・ピアノステップをどうぞよろしくお願いいたします。\n\nピティナ ステップ担当"}
{"faq_id":3,"question":"ステップ申し込み時にギフトカード決済でエラーが発生し、ギフトカードが使用済みになったが、申し込みは完了していない場合の対応について。","action":"- 申込者の情報を確認する\n  - 参加者氏名\n  - 地区名\n  - ステップレベル\n  - 問い合わせ内容(エラーの詳細、ギフトカード番号など)\n- 該当のギフトカードが使用済みになっているか、システム上で確認する。\n- 該当のギフトカードが使用済みの場合、再度利用可能な状態に設定を変更する。\n- 申込者に、エラーのあったギフトカードが再度利用可能になった旨を連絡する。","answer":"◆◆◆さま\n\nお世話になっております。\nこの度は、<地区名>地区ステップへのお申込み、およびお問い合わせをいただきましてありがとうございます。\n\nエラーとなった下記のギフトカードの設定を変更し、再度ご利用いただけるよう修正いたしました。\n<ギフトカード番号>\n\nご迷惑をおかけしてしまい申し訳ございませんでした。\n\nピティナ ステップ担当"}

JSONLの作成関数は先ほどのApps Scriptで以下のように定義されていました。

/**
 * FAQデータをJSONL形式に変換する
 * @param values FAQデータの配列
 * @returns JSONL形式のテキスト
 */
function formatFAQDataToJSONL(values: FAQRow[]): string {
  return values.map((row, index) => {
    // 各行をJSONオブジェクトに変換
    const faqItem = {
      faq_id: index + 1,
      question: row[0]?.trim() || "",
      action: row[1]?.trim() || "",
      answer: row[2]?.trim() || ""
    };

    return JSON.stringify(faqItem);
  }).join('\n');
}

exportFAQDataToJSONLを実行するとJSONLデータがGoogle Driveに書き出されるので、これをAgent Builderに登録していきます。

4. Vertex AI Agent Builderの構築

Cloud Storageへのデータ保存

作成したJSONLデータを任意のCloud Storageへアップロードします。方法については省略します。

データストアの作成

課金設定済みのGoogle CloudプロジェクトでAI Applicationsで検索。

「アプリを作成する」

その後、データストアを作成します。

Cloud Storageを選択。

構造化データ(JSONL) を選択。

スキーマは自動で検出されます。

アプリの作成

RAGと相性の良い「抜粋された回答」(Extractive answers)を使いたいので、Enterprise エディションの機能は必要です。「高度な LLM 機能」を使うとGeminiで回答を生成することもできますが、今回はこの箇所をGenkitで作るため不要です。

先ほど作成したデータストアを選択。

すると以下のように取り込みが開始されます。

ここからは時間がかかるので、しばしコーヒーブレイクを...☕️


さて、しばらくしたら「プレビュー」から検索をしてみます。

学習データに含まれていそうなワードを入れて返ってくればセットアップ完了です。

Part 1のまとめ

Part 1のまとめ

1. Gmailの抽出

  • ラベル付きメールを取得するApps Scriptを作成
  • ClaspとTypeScriptを使用して実装
    • Clasp v3からTypeScript非対応のため、v2を明示的にインストール必要
  • GmailFetcherクラスで主要処理を実装
    • フッター削除
    • 返信引用の除去
    • LLMへの無駄なトークン送信を回避

2. Google Colabでの学習用FAQ生成

  • 生のメールデータをLLMで学習用FAQに加工
  • python-genaiライブラリを使用
  • Vertex AI APIを利用。無料版は機密データは送信不可
  • pydanticを使用したStructured Outputsで出力制御

3. Vertex AI Agent Builder用JSONLファイルの作成

  • FAQ形式に適した構造化JSONL形式でデータ作成
  • 各レコードに以下を含む:
    • faq_id
    • question(質問内容)
    • action(対応方法)
    • answer(返信文例)

4. Vertex AI Agent Builderの構築

  • Cloud Storageへのデータアップロード
  • データストアの作成(構造化データJSONL形式)
  • Enterpriseエディションで「抜粋された回答」機能を使用
  • RAGと相性の良い設定を選択

Part 2. GenkitでのAI Agentの構築

ナレッジベースが完成したので、それを用いて回答を行うAI AgentをGenkitを用いて実装していきます。

https://github.com/firebase/genkit

セットアップ

Firebase CLIを使ってscaffoldします。

$npm install -g genkit-cli
$npm install -g firebase-tools
$firebase init genkit

interactive promptが続きますが、基本的にはYesで進みます。サンプルフローも作成するようにしてください。

ProviderはVertex AIを選択。

以下のようにプロジェクトが作成されます。

サンプルを動かしてみます。

$npm run genkit:start

http://localhost:4000/flows/menuSuggestionFlowにアクセス。

実行します。

回答が返ってくればセットアップ完了です。もし権限の問題が出る場合は、ADC(Application Default Credentials)の設定が必要です。

$gcloud auth application-default login

http://localhost:4000/tracesからはトレースが確認できます。

マルチステップのエージェントでは、ツールの入出力も見やすくなるため、便利です。

Cloud Runへのデプロイ

https://genkit.dev/docs/cloud-run/

startコマンドを以下のように修正します。

package.json
-     "start": "npm run shell",
+     "start": "node lib/index.js",
$npm install @genkit-ai/express express@^4.21.1

続いて、genkit-sample.tsmenuSuggestionFlowindex.tsに移動し、genkit-sample.tsは削除します。Cloud Runにデプロイするためのindex.tsは以下の通り。

index.ts
import { startFlowServer } from "@genkit-ai/express";
import { enableFirebaseTelemetry } from "@genkit-ai/firebase";
import { gemini20Flash, vertexAI } from "@genkit-ai/vertexai";
import { genkit, z } from "genkit";

enableFirebaseTelemetry();

const ai = genkit({
  plugins: [
    vertexAI({
      location: "us-central1",
    }),
  ],
});

const menuSuggestionFlow = ai.defineFlow(
  {
    name: "menuSuggestionFlow",
    inputSchema: z.string().describe("A restaurant theme").default("seafood"),
    outputSchema: z.string(),
    streamSchema: z.string(),
  },
  async (subject, { sendChunk }) => {
    // Construct a request and send it to the model API.
    const prompt = `Suggest an item for the menu of a ${subject} themed restaurant`;
    const { response, stream } = ai.generateStream({
      model: gemini20Flash,
      prompt: prompt,
      config: {
        temperature: 1,
      },
    });

    for await (const chunk of stream) {
      sendChunk(chunk.text);
    }

    return (await response).text;
  },
);

startFlowServer({
  flows: [menuSuggestionFlow],
});

Cloud Run仕様にするには下記が重要。

startFlowServer({
  flows: [menuSuggestionFlow],
});

デプロイします。--no-allow-unauthenticatedでエンドポイントは保護します。

$cd functions
$gcloud run deploy genkit-assistant --source . --region=us-central1 --no-allow-unauthenticated 

テストします。保護しているのでCloud Run Proxyを使います。

https://cloud.google.com/sdk/gcloud/reference/run/services/proxy

$gcloud run services proxy genkit-assistant --region=us-central1

これで、http://127.0.0.1:8080へのリクエストはCloud Runにプロキシされます。

curl -X POST http://127.0.0.1:8080/menuSuggestionFlow \
  -H "Authorization: Bearer $(gcloud auth print-identity-token)" \
  -H "Content-Type: application/json" -d '{"data": "banana"}'

レスポンスが返ってくればCloud Runへのデプロイは完了です。

Agentフローの構築

Agent Buiderに接続したFlowを作成していきます。まずプロンプトから。下記は一例です。

プロンプト(/src/prompts/faqAssistant.ts)
export const FAQ_ASSISTANT_SYSTEM_PROMPT = `あなたはお問い合わせメール応対アシスタントです。

## 行うこと
お問い合わせに、groundingを必ず用いて、最適な回答をしてください。
わからない場合は「わかりません」と回答してください。決して推測に基づいた回答をしてはいけません。

## 回答形式
回答は以下の形式で行なってください。

\`\`\`json
{
  "canAnswer": boolean, // 提供された情報で正確な回答ができるか。正確な回答が難しい場合は、falseとし、"note"に必要な情報、アクションを記載せてください。
  "reply": string // 顧客への回答。フォーマットは下記を参照,
  "reason": string // 参考にしたファイル名と、参考箇所を抜粋(参考箇所は一字一句そのまま引用すること)
  "note": string // スタッフへの確認事項。情報やアクションを求めることができます。
}
\`\`\`

replyは顧客へのメール返信文で、以下の形式としてください。


\`\`\`
<顧客の苗字。不明な場合は省略。>様

この度はお問い合わせいただきありがとうございます。

<返信文。1行50文字程度で区切りの良いところで改行すること。>

どうぞよろしくお願いいたします。
\`\`\`

"reason"は以下が一例です。groundingに用いた資料のファイル名またはツール名と、参考箇所を抜粋してください。

\`\`\`
<資料名>: 「複数人で演奏する場合、全員を < 参加者 > とすることも、一部を < 賛助出演者 > として参加いただくこともできます。」
\`\`\`

もし、一度スタッフによる申込状況等の確認が必要な場合は、確認します、という繋ぎの返信を作成し、"note"にスタッフが行うべきことを記載してください。

## 現在の時刻
現在の時刻は${new Date().toLocaleString("ja-JP", { timeZone: "Asia/Tokyo" })}です。

----

それでは、顧客からのお問い合わせに正確に回答してください。回答は必ず資料にもとづいてください。

>[!important]
>必ずRetrieval Toolを用いて回答をしてください。推測や憶測に基づいた回答は絶対にしないでください。
>なお、顧客には資料の内容を参照させてはいけません。必要な情報はreplyに盛り込んでください。
`;

Vertex Agent Builderを組み込む方法は何通りかあります。それぞれ見ていきます。

A. 組み込みツール(Native Tool)として使用

以下のようにgenerate関数のオプションとして、vertexRetrievalを渡すことができます。
datastoreにはAI Applicationsのデータストアのダッシュボードで取得できるIDが入ります。(ドキュメントではcollectionになっていましたが、型エラーになります。ドキュメントの間違い?)

await ai.generate({
  model: vertexAI.model('gemini-2.0-flash'),
  prompt: '...',
  config: {
    googleSearchRetrieval: {
      disableAttribution: true,
    }
    vertexRetrieval: {
      datastore: {
        projectId: 'your-cloud-project',
        location: 'global',
        datastore: 'datastoreId',
      },
      disableAttribution: true,
    }
  }
})

https://genkit.dev/docs/plugins/vertex-ai/#generative-ai-models

ただし、筆者はこの方法はあまりお勧めしません。ツールの利用をLLMに委ねるので、必ずGroundingしてほしいお問合せフローには都合が悪いためです。[2]

B. 自前ツールを作成する

Genkitではツールの定義をdefineToolで型付きで行うことができます。マルチターンTool useもmaxTurnsプロパティを指定すれば実現できます。(この辺りはVercel AI SDKなどと変わりません。)

https://genkit.dev/docs/tool-calling/#tool-calling-with-genkit

組み込みツールよりはコントロールは効きますが、どのみちGroundingは必須なので、別の方法を採ります。

C. 自分で呼び出してContextとして与える

今回採用する手法です。自分でVertex Agent Builderを呼び出して、結果を後続のLLM処理に渡します。素朴ですが、定型的なフローでは無理にLLMに任せない方が安定します。

実装例は以下です。データストアの種類(構造化/非構造化)などで戻り値が分岐するため、型ガード等がややこしくなっています。あまり実装サンプルも多くなく、より良い書き方がありそうです。

src/tools/retrievalTool.ts
import { SearchServiceClient } from "@google-cloud/discoveryengine";
import type { google } from "@google-cloud/discoveryengine/build/protos/protos.js";
const apiEndpoint = "discoveryengine.googleapis.com";
const client = new SearchServiceClient({ apiEndpoint: apiEndpoint });

/**
 * 解析タイプを定義
 */
export type ParseType = "extractiveAnswer" | "structData";

/**
 * extractive_answers フィールドから内容をパースする関数
 *
 * @param extractiveAnswers - extractive_answersフィールドの値
 * @returns パースされた抽出回答の配列
 */
function parseExtractiveAnswers(extractiveAnswers: google.protobuf.IValue) {
  const values = extractiveAnswers.listValue?.values;
  if (!values) return [];

  return values.map((value) => {
    const pageNumber = value.structValue?.fields?.pageNumber.stringValue;
    return {
      pageNumber,
      content: value.structValue?.fields?.content?.stringValue,
    };
  });
}

/**
 * structData フィールドから内容をパースする関数
 *
 * @param fields - structDataフィールドの値
 * @returns パースされた構造化データの配列
 */
function parseStructData(fields: Record<string, google.protobuf.IValue>) {
  if (!fields || !fields.question || !fields.answer) return [];

  const faqId = fields.faq_id?.numberValue?.toString() || "unknown";
  const question = fields.question.stringValue || "";
  const answer = fields.answer.stringValue || "";
  const action = fields.action?.stringValue || "";

  // questionとanswerを組み合わせてコンテンツとして返す
  const content = `質問: ${question}\n回答: ${answer}${action ? `\nアクション: ${action}` : ""}`;

  return [
    {
      pageNumber: faqId,
      content: content,
    },
  ];
}

/**
 * 検索結果から抽出回答を処理する関数
 *
 * @param searchResults - 検索APIから返された結果の配列
 * @param parseType - 解析タイプ
 * @returns 処理された抽出回答の配列
 */
function processSearchResults(
  searchResults: google.cloud.discoveryengine.v1.SearchResponse.ISearchResult[],
  parseType: ParseType,
) {
  if (!searchResults || searchResults.length === 0) {
    return [];
  }

  const docs = [];

  for (const result of searchResults) {
    if (parseType === "extractiveAnswer") {
      const fields = result.document?.derivedStructData?.fields;
      if (fields && "extractive_answers" in fields) {
        const extractiveAnswers = fields.extractive_answers;
        const extracts = parseExtractiveAnswers(extractiveAnswers);
        if (extracts.length > 0) {
          docs.push(...extracts);
        }
      }
    } else if (parseType === "structData") {
      const fields = result.document?.structData?.fields;
      if (fields) {
        const parsed = parseStructData(fields);
        if (parsed.length > 0) {
          docs.push(...parsed);
        }
      }
    }
  }

  return docs;
}

export async function vertexAIRetrieval(
  query: string,
  projectId: string,
  collectionId: string,
  dataStoreId: string,
  parseType: ParseType = "extractiveAnswer",
) {
  // The full resource name of the search engine serving configuration.
  // Example: projects/{projectId}/locations/{location}/collections/{collectionId}/dataStores/{dataStoreId}/servingConfigs/{servingConfigId}
  // You must create a search engine in the Cloud Console first.
  const name = client.projectLocationCollectionDataStoreServingConfigPath(
    projectId,
    "global",
    collectionId,
    dataStoreId,
    "default_serving_config",
  );

  const request: google.cloud.discoveryengine.v1.ISearchRequest = {
    pageSize: 10,
    query: query,
    servingConfig: name,
    contentSearchSpec: {
      extractiveContentSpec: {
        maxExtractiveAnswerCount: 5,
      },
    },
  };

  const IResponseParams = {
    ISearchResult: 0,
    ISearchRequest: 1,
    ISearchResponse: 2,
  };

  // Perform search request
  const response = await client.search(request, {
    // Warning: Should always disable autoPaginate to avoid iterate through all pages.
    autoPaginate: false,
  });
  const searchResponse = response[
    IResponseParams.ISearchResponse
  ] as google.cloud.discoveryengine.v1.ISearchResponse;
  const searchResults = searchResponse.results;

  // 検索結果から抽出回答を処理
  return processSearchResults(searchResults || [], parseType);
}

最終的なflowは以下になります。

import { gemini20Flash } from "@genkit-ai/vertexai";
import { type Genkit, type Role, z } from "genkit";
import { env } from "../env";
import {
  FAQ_ASSISTANT_SCHEMA,
  FAQ_ASSISTANT_SYSTEM_PROMPT,
} from "../prompts/faqAssistant";
import { vertexAIRetrieval } from "../tools/retrievalTool";

export const createFaqAssistantFlow = (ai: Genkit) =>
  ai.defineFlow(
    {
      name: "faqAssistantFlow",
      inputSchema: z.object({
        userPrompts: z.array(z.string()),
      }),
      outputSchema: z
        .object({
          completion: z.object({
            canAnswer: z.boolean(),
            reply: z.string().describe("顧客への返信文"),
            reason: z
              .string()
              .describe("回答の根拠。資料から該当箇所を詳細に抜き出すこと。"),
            note: z.string().nullable(),
          }),
          citations: z.array(z.string()),
          model: z.string(),
          usage: z.object({
            prompt_tokens: z.number(),
            completion_tokens: z.number(),
            total_tokens: z.number(),
          }),
        })
        .strict(),
    },
    async (input) => {
      const model = gemini20Flash;

      const { userPrompts } = input;
      const messages = userPrompts.map((prompt) => ({
        role: "user" as Role,
        content: [{ text: prompt }],
      }));

      // 関連FAQの取得
      const faqResults = await vertexAIRetrieval(
        userPrompts.join("\n"),
        env.GCLOUD_PROJECT_ID,
        "default_collection",
        env.DATASTORE_ID,
        "structData",
      );
      console.log("FAQ Result:", faqResults);

      if (faqResults && faqResults.length > 0) {
        messages.push({
          role: "model" as Role,
          content: [
            {
              text: `FAQから関連情報が見つかりました。\n${faqResults.map((item) => item.content).join("\n\n")}`,
            },
          ],
        });
      }

      const { output: completion, usage } = await ai.generate({
        model,
        system: FAQ_ASSISTANT_SYSTEM_PROMPT,
        messages: messages,
        config: {
          temperature: 0,
        },
        output: {
          schema: FAQ_ASSISTANT_SCHEMA,
        },
      });
      if (!completion) {
        throw new Error("No completion returned from the model.");
      }

      const response = {
        model: model.name,
        completion,
        citations: faqResults.map(
          (item) => `FAQ ID: ${item.pageNumber}\n${item.content}`,
        ),
        usage: {
          prompt_tokens: usage.inputTokens || 0,
          completion_tokens: usage.outputTokens || 0,
          total_tokens: usage.totalTokens || 0,
        },
      };

      return response;
    },
  );

npm run genkit:startでローカルでテストすると、以下のようにAgent Builderの結果を踏まえた回答が得られました。

{
  "model": "vertexai/gemini-2.0-flash",
  "completion": {
    "canAnswer": true,
    "note": "",
    "reason": "質問: 参加票が届かない場合の再送依頼について\n回答: ◆◆◆...",
    "reply": "様\n\nこの度はお問い合わせいただきありがとうございま..."
  },
  "citations": [
    "FAQ ID: 808\n質問: 参加票の再送依頼(開催日...",
    "FAQ ID: 381\n質問: ステップ参加票が届かな...",
    "FAQ ID: 820\n質問: 参加票が届かない。プロ...",
    "FAQ ID: 154\n質問: 締切後、開催日が近い時...",
    "FAQ ID: 753\n質問: ステップ当日が間近に迫っ...",
    "FAQ ID: 322\n質問: 参加票が届かない。開催日...",
    "FAQ ID: 717\n質問: ステップ参加票(ハガキ)...",
    "FAQ ID: 163\n質問: 参加票が届かない場合の再...",
    "FAQ ID: 807\n質問: 参加票が届かない場合の再...",
    "FAQ ID: 909\n質問: 海外在住などの理由で、指..."
  ],
  "usage": {
    "prompt_tokens": 3298,
    "completion_tokens": 269,
    "total_tokens": 3567
  }
}

これをdeployすればLLMのバックエンドは完成です。

Part 2のGenkitプロジェクトのソースは以下を参照ください。

https://github.com/HosakaKeigo/genkit-agent-with-agent-builder

Apps ScriptからCloud Runの呼び出し

Apps Scriptから保護されたCloud Runを呼び出すには以下の手順が必要です。[3]

  • マニフェスト(appsscript.json)のoauthScopesに下記を追加
    • openid
    • https://www.googleapis.com/auth/script.external_request
    • https://www.googleapis.com/auth/cloud-platform
  • Apps ScriptをCloud Runと同じプロジェクトに紐付け
  • Apps Scriptの実行アカウントにCloud Run Invokerを付与


設定から紐付け

その上で、ScriptApp.getIdentityToken()で取得したTokenをリクエストに付与します。

function runGeminiAssistant(userPrompts: string[]) {
  const endpoint = getGeminiEndpoint();

  const headers = {
    "Authorization": `Bearer ${ScriptApp.getIdentityToken()}`
  }

  try {
    const response = UrlFetchApp.fetch(endpoint, {
      method: 'post',
      contentType: 'application/json',
      headers,
      payload: JSON.stringify({
        data: {
          userPrompts
        }
      }),
      muteHttpExceptions: true,
    });

    const { result } = JSON.parse(response.getContentText());
    if (!result) {
      throw new Error('Assistant response not found');
    }
    return result as GeminiAssistantResponse;
  } catch (e) {
    throw new Error(`Failed to run Gemini Assistant: ${e.message}`);
  }
}
Part 2 のまとめ

1. セットアップ & ローカル検証

  • genkit initFirebase/Genkit プロジェクトを scaffold
  • Provider に Vertex AI を指定
  • npm run genkit:startmenuSuggestionFlow が動作し、/traces で実行ログを確認

2. Cloud Run へのデプロイ

  • @genkit-ai/express + startFlowServer()HTTP サーバ
  • npm run build && gcloud run deploy ... --no-allow-unauthenticated
  • gcloud run services proxycurl -H "Authorization: Bearer $(gcloud auth print-identity-token)" … で疎通テスト完了

3. FAQ Assistant Flow の実装

  • System PromptFAQ_ASSISTANT_SYSTEM_PROMPT に定義

  • ai.defineFlow()faqAssistantFlow

    • 入力: userPrompts: string[]
    • 出力: canAnswer / reply / reason / note を強制スキーマ化 (zod)
    • temperature: 0 で determinism を確保

4. Retrieval 手法の比較

手法 メリット デメリット 採用判断
ネイティブ Tool (vertexRetrieval) 実装最小 Gemini がツール利用を選ばない場合あり
defineTool で自作 制御しやすい Gemini がツール利用を選ばない場合あり
検索→コンテキスト注入 Groundingを完全に制御できる 呼び出しコード増 ◎ 採用

5. Apps Script から呼び出し

  • 同一プロジェクトで Cloud Run Invoker 付与
  • openid / script.external_request / cloud-platformoauthScopes に追加
  • ScriptApp.getIdentityToken()Authorization: Bearer で付与し、保護されたエンドポイントを実行

Part 3. Google Chatの実装(Apps Script)

最後にUIとしてApps ScriptでGoogle Chatアドオンを作成します。

処理の流れを以下に示します。

Google Chatアドオン作成の全体像

Google Chatアドオンの設定方法は以下のドキュメントに書かれています。

https://developers.google.com/workspace/add-ons/chat/quickstart-apps-script?hl=ja

  • Google Cloudプロジェクト作成
  • Chat APIの有効化
  • OAuth 同意画面の構成
  • Apps Scriptでアドオンの作成
  • Apps ScriptのデプロイIDを登録
  • Google Chatスペースの作成&アプリのインストール

この記事ではApps Scriptでアドオンの作成の部分のみ取り上げます。

以下の記事でも丁寧に説明されていましたので合わせてご覧ください。

https://zenn.dev/hogeticlab/articles/214847b429a07c

マニフェストの構成

Google ChatのアドオンとしてApps Scriptを構成するためにはappsscript.jsonを適切に設定する必要があります。

appsscript.json
{
  "dependencies": {
    "enabledAdvancedServices": [
      {
        "userSymbol": "Chat",
        "version": "v1",
        "serviceId": "chat"
      }
    ],
    "libraries": [
      {
        "userSymbol": "OAuth2",
        "version": "43",
        "libraryId": "1B7FSrk5Zi6L1rSxxTDgDEUsPzlukDsi4KGuTMorsTQHhGBzBkMun4iDF"
      }
    ]
  },
  "chat": {},
  "webapp": {
    "executeAs": "USER_DEPLOYING",
    "access": "MYSELF"
  }
}

ポイントは

  • enabledAdvancedServicesでChatを指定
  • "chat": {}でアドオンであることを指定

です。

https://developers.google.com/apps-script/manifest?hl=ja

エントリーポイント

今回のGoogle Chatシステムは

  • お問い合わせのチャット表示
  • LLMの回答の表示

の2つの責務があります。これをPostするデータにmessageTypeを持たせることで対応しています。

function doPost(e: GoogleAppsScript.Events.DoPost): GoogleAppsScript.Content.TextOutput {
  const postData: PostData = JSON.parse(e.postData.contents);

  let card: Card;
  if (postData.messageType === "inquiry") {
    // お問い合わせカードを作成
    card = createInquiryCard(postData.subject, postData.content)
  }
  else if (postData.messageType === "gemini" || postData.messageType === "openai") {
    // AI返信カードを作成
    card = createReplyCard({
      provider: postData.messageType,
      subject: postData.subject,
      reply: postData.content,
      reason: postData.reason,
      note: postData.note,
      citations: postData.citations
    })
  }

  // カードをGoogle Chatに投稿
  postCardMessage({ card, threadKey: postData.subject })
}

カードの作成

Google Chatは「カード」でUIを構築します。「カード」はJSON形式で構築できますが、Card Builderを使うことでプレビューできます。

https://addons.gsuite.google.com/uikit/builder


カードの一例

たとえば以下のように作成します。

/**
 * お問い合わせ内容を表示するカードを生成する
 *
 * @param title - カードのタイトル
 * @param mailBody - メール本文
 */
function createInquiryCard(title: string, mailBody: string): Card {
  return {
    header: {
      title: title,
      imageUrl: INQUIRY_HEADER_IMAGE,
      imageType: "CIRCLE"
    },
    sections: [
      {
        header: "お問い合わせ内容",
        collapsible: true,
        uncollapsibleWidgetsCount: 1,
        widgets: [
          {
            decoratedText: {
              text: mailBody,
              wrapText: true
            }
          }
        ]
      }
    ]
  }
}

チャットの投稿

Google Chatではサービスアカウントでの認証が必要です。認証のセットアップについては関連記事に譲ります。

https://zenn.dev/hogeticlab/articles/214847b429a07c#2.5-google-chat-アプリの開発-[apps-script]

簡単にまとめるとサービスアカウント作成、キーの発行、スクリプトプロパティに保存します。その後、OAuth2ライブラリで認証することでチャットの投稿ができるようになります。

src/cards/postCard.ts
  const response = Chat.Spaces.Messages.create(
    message, parent, parameters, getHeaderWithAppCredentials()
  );
src/utils/auth.ts
const APP_AUTH_OAUTH_SCOPES = ['https://www.googleapis.com/auth/chat.bot'];

function getServiceAccount() {
  try {
    const strJSON = PropertiesService.getScriptProperties().getProperty("SERVICE_ACCOUNT_KEY")
    return JSON.parse(strJSON)
  }
  catch (e) {
    Logger.log("Fail to fetch valid Service Account Key. Make sure to set the SERVICE_ACCOUNT_KEY property in the script properties.")
    throw new Error("Failed to get Service Account Key˝")
  }
}

/**
 * Authenticates the app service by using the OAuth2 library.
 *
 * @return {Object} the authenticated app service
 */
function getService_() {
  const SERVICE_ACCOUNT = getServiceAccount()
  return OAuth2.createService(SERVICE_ACCOUNT.client_email)
    .setTokenUrl(SERVICE_ACCOUNT.token_uri)
    .setPrivateKey(SERVICE_ACCOUNT.private_key)
    .setIssuer(SERVICE_ACCOUNT.client_email)
    .setSubject(SERVICE_ACCOUNT.client_email)
    .setScope(APP_AUTH_OAUTH_SCOPES)
    .setCache(CacheService.getUserCache())
    .setLock(LockService.getUserLock())
    .setPropertyStore(PropertiesService.getScriptProperties());
}

/**
 * Generates headers with the app credentials to use to make Google Chat API calls.
 *
 * @return {Object} the header with credentials
 */
function getHeaderWithAppCredentials() {
  return {
    'Authorization': `Bearer ${getService_().getAccessToken()}`
  };
}

イベントリスナ

「Gmailで返信」をクリックすると返信が作成される仕組みを実装しています。

これは以下のようにonCardClickで受け、actionMethodNameで分岐することで実現できます。

/**
 * Responds to a CARD_CLICKED event triggered in Google Chat.
 * 
 * @param {CardClickedEvent} event - the event object from Google Chat
 * @return {TextResponse} - the response to send back to Google Chat
 */
function onCardClick(event: CardClickedEvent): TextResponse {
  if (event.action?.actionMethodName === 'draftMail') {
    const params = event.action.parameters || [];
    const subject = params.find(param => param.key === 'subject')?.value || 'Default Subject';
    const content = params.find(param => param.key === 'content')?.value || 'Default Content';
    const provider = params.find(param => param.key === 'provider')?.value as Provider;

    return draftMail(subject, content, provider);
  }

  return { text: "エラーが発生しました: Unknown Action" };
}

アクション側ではactionResponseを返す必要があります。

https://developers.google.com/workspace/chat/api/reference/rest/v1/spaces.messages?hl=ja#Message.ActionResponse

/**
 * 下書きを追加する。追加するスレッドは、件名で検索。
 */
function draftMail(subject: string, reply: string, provider: Provider) {
  ...
  try {
    ...

    return {
      actionResponse: { type: "NEW_MESSAGE" },
      text: `下書きを作成しました。下書きを編集するにはリンクをクリックしてください。\n\n${draftUrl}`,
    }
  } catch (e) {
    return {
      actionResponse: { type: "NEW_MESSAGE" },
      text: `下書きの作成に失敗しました: ${e.message}`
    }
  }
}

ソースコードは以下にアップしています。なお、config.tsにインストール先のGoogle Chat Spaceのスペース名を指定する必要があります。

https://github.com/HosakaKeigo/google-chat-sample

Google FormsのApps Scriptからの呼び出し

最後に2つのApps Scriptの統合を確認します。

Google Chatアドオンは保護付きでデプロイしています。

  "webapp": {
    "executeAs": "USER_DEPLOYING",
    "access": "MYSELF"
  }

これを呼び出すための呼び出し側の設定は以下です。

  • Chat側と同じ実行アカウントであること。
  • https://www.googleapis.com/auth/drive.readonlyoauthScopesに追加すること
    • 忘れがち。これがないとApps Script自体にアクセスできない。
  • ScriptApp.getOAuthToken()を指定。
    const options: GoogleAppsScript.URL_Fetch.URLFetchRequestOptions = {
      method: "post",
      headers: {
        "Authorization": `Bearer ${ScriptApp.getOAuthToken()}` // 重要
      },
      muteHttpExceptions: true,
      contentType: "application/json",
      payload: JSON.stringify(payload)
    };

保護付きWeb Appの呼び出しについては以下も参考にしてください。

https://stackoverflow.com/questions/29229361/calling-a-google-apps-script-web-app-with-access-token

https://zenn.dev/trans/articles/d9c5d3f2accfbe

これで以下のようなpayloadでFormsのApps ScriptからdoPostを呼び出せば、チャットへの投稿ができるようになります。

お問合せの投稿

{
  messageType: "inquiry",
  subject: "ピアノステップの申込について",
  content: "参加票が届きません。開催日が近いので確認をお願いします。"
}

AIの回答案の投稿

{
  messageType: "gemini",
  subject: "ピアノステップの申込について",
  content: "◆◆◆様\n\nこの度はお問い合わせいただきありがとうございます。\n\n参加票につきまして、システムで確認させていただきます。\n開催日が近いとのことですので、至急確認いたします。\n\nどうぞよろしくお願いいたします。",
  reason: "FAQ ID: 808\n質問: 参加票が届かない場合の再送依頼について",
  note: "参加者の申込状況を確認し、参加票の発送状況を確認してください。",
  citations: [
    "FAQ ID: 808\n質問: 参加票の再送依頼(開催日...)",
    "FAQ ID: 381\n質問: ステップ参加票が届かな..."
  ]
}
Part 3のまとめ

1. Google Chatアドオンの作成

  • Google Workspaceでのみ利用可能(要注意)
  • Apps ScriptをWeb Appとしてデプロイ
    • 実行アカウントは「自分のみ」で保護
    • appsscript.json"chat": {}を追加してアドオンとして構成
  • サービスアカウント認証が必要
    • OAuth2ライブラリを使用
    • サービスアカウントキーをスクリプトプロパティに保存

2. カードUIの実装

  • Google Chatは「カード」形式でUIを構築
    • Card Builderでプレビュー可能
    • JSON形式で定義
  • 2種類のカードを実装
    • お問い合わせ内容表示カード
    • AI回答表示カード(引用・根拠付き)
  • messageTypeでカードの種類を判別

3. Apps Script間の連携

  • Google Forms側Apps Script → Cloud Run(Genkit)
    • ScriptApp.getIdentityToken()でIAM認証
    • Cloud Runと同じGCPプロジェクトに紐付けが必要
  • Google Forms側Apps Script → Google Chat側Apps Script
    • ScriptApp.getOAuthToken()で認証
    • https://www.googleapis.com/auth/drive.readonlyスコープが必要
    • 同じ実行アカウントであることが前提

4. イベント処理の実装

  • onCardClickでカード内ボタンのクリックイベントを処理
  • 「Gmailで返信」ボタンで下書き作成機能を実装
    • actionMethodNameで処理を分岐
    • actionResponseで新規メッセージを返す

終わりに

長くなりましたが、Googleサービスを組み合わせてRAGを構築する流れの要点を取り上げました。

すべてGoogleサービスで揃えることはベンダーロックインのリスクがありますが、一方で保護されたサービス間のやりとり、UIの統合(Google Forms/Gmail/Google Chat)の面でメリットもあります。

コードは全てではありませんが、下記に公開していますので、参考にしていただければ幸いです。

Part 1

Apps Script/Clasp

https://github.com/HosakaKeigo/gmail-fetcher

Part 2

Genkit

https://github.com/HosakaKeigo/genkit-agent-with-agent-builder

Part 3

Google Chatアドオン (Apps Script/Clasp)

https://github.com/HosakaKeigo/google-chat-sample

脚注
  1. Claspはversion 3(執筆時点でα版)からTypeScript非対応になりました。使用する場合はversion 2を明示的にinstallしてください。(https://github.com/google/clasp?tab=readme-ov-file#drop-typescript-support) ↩︎

  2. 強制する方法があるかもしれませんが、筆者の調べた限りではわかりませんでした。 ↩︎

  3. https://stackoverflow.com/questions/79552464/http-post-from-apps-script-to-google-cloud-run-function ↩︎

Discussion