記事の要点
- ベクトルデータベースの構築・管理をGoogleのマネージドサービスに一任することで、インフラ整備の手間を最小限に抑えつつ、高度な検索機能を実装。
- 自前サーバを立てず、既存の社内ツールを活用することで、運用の手間とコストを抑えながら全社員が使いやすいインターフェースを実現。
- 回答に参照元ドキュメントへのURLリンクを添える工夫により、AIのハルシネーション対策と情報の信頼性確保を同時に達成。
はじめに
こんにちは、株式会社エスタイルのニーチェです!
「あの社内規定、どこに書いてあったっけ?」「経費精算のルール、前と変わった気がする……」
社内ルールの問い合わせ対応は、聞く側にとっても答える側にとっても、意外と時間と労力を削られる業務です。こうした課題を解決するために「社内文書の情報を検索できる(RAG)AIチャットボット」の導入を検討される方も多いと思いますが、実際やろうとするとベクトルデータベースの構築やサーバの準備など、環境整備のハードルが高く感じられるのではないでしょうか。
そこで今回、Googleの Gemini API で提供されている File Search Toolを活用し、非常にシンプルな構成で社内問い合わせボットを開発しました。
この方法の最大のメリットは、面倒な「ベクトルデータベースの管理」をすべてGoogle側に任せられる点にあります。専門的なインフラ構築の知識がなくても、手軽に精度の高いRAGを実装することが可能です。
本記事では、ユーザーインターフェースに Slack、実行環境に Google Apps Script(GAS) を採用した具体的な実装の流れを紹介します。あわせて、実際に作ってみて分かった工夫点や、解決するのに苦労した点などの実践的な知見も共有します。
アーキテクチャ
今回のシステムを構築するにあたり、重視したのは「運用の手軽さ」と「ユーザーへの定着」です。これを実現するために選定した、主要な3つの技術とその理由は以下の通りです。
各技術の選定理由
File Search Tool
RAGの実装において最も手間に感じるのは、ドキュメントを分割・ベクトル化し、データベースへ登録・管理するプロセスです。今回、これらを一手に引き受けてくれる File Search Tool を採用しました。RAGのナレッジベースであるストアの作成や削除、ストアに対してドキュメントのアップロードや削除が簡単にできるのも魅力の一つです。
- RAGプロセスの自動化:ファイルをアップロードするだけで、内部的なベクトル化や検索処理をGoogle側が自動で管理してくれます。
- サーバレスな運用:自前でベクトルデータベースをホスティング・運用する必要がありません。
- 多様な形式に対応:PDFやテキストファイルなど、幅広いファイル形式をそのままサポートしているため、既存の社内資料をスムーズに活用できました。
Google Apps Script
中継役となる実行環境には、Googleのサービスとの親和性が高い Google Apps Script(GAS) を選定しました。
- サーバレス:サーバのセットアップや保守が不要で、スクリプトを書くだけですぐに動かせる点が最大の魅力です。
- 豊富なナレッジ:「AI×GAS×Slack」という組み合わせは開発事例が多く、実装時に発生する課題を解決しやすいという安心感がありました。
Slack
ユーザーインターフェースには、社内で利用していて全社員が使い慣れている Slack を採用しました。
- 導入障壁の排除:新たに専用のWebUIなどを構築する手間を省き、開発リソースを機能面に集中させました。
- 日常への定着:ツールを増やすのではなく、普段から業務で使っているプラットフォーム上に構築することで、心理的なハードルを下げ、日常的に使ってもらえるツールを目指しました。
システム構成
今回のシステムは、Google Driveに保存されたドキュメントを「知識源」とし、ユーザーの質問に対してGeminiが適切な情報を探し出して回答する仕組みです。

全体の処理フローは以下の通りです。
- データ準備 (Google Drive):社内規定やマニュアルなどのPDFファイルを、Google Drive上の指定フォルダに格納。
- インデックス化 (Gemini File Search):Gemini APIの「File Search」ツールが、Drive上のファイルを読み込み、検索可能な状態(インデックス)に管理。
- 質問の送信 (Slack):ユーザーがSlackでチャットボットに対して質問を投稿。
- リクエスト中継 (GAS):SlackからのWebhookを受け取ったGASが、Gemini APIに対して「File Search」を用いた回答生成リクエストを送信。
- 回答生成と参照:Geminiがインデックスから関連情報を検索し、参照元ドキュメントの情報を含めた回答を生成。
- 回答の投稿 (Slack):GASが生成された回答を受け取り、Slackのスレッドへ返信。
構成のポイント:ステートレスな運用
この構成の肝は、「情報の保管」をGeminiのFile Searchに、「実行環境」をGASに任せている点です。
自前でデータベースや常時稼働のサーバを管理する必要がないため、環境構築が非常にシンプルで済むだけでなく、メンテナンスコストも最小限に抑えられています。また、Google Cloudのプロジェクト内で完結するため、管理がしやすいというメリットがあります。
実装方法の紹介
GeminiのAPIキーを取得する
ここでは、GeminiのAPIキーの取得方法を説明します。GeminiのAPIキーはGoogle Cloud Platform(GCP)のプロジェクトと紐づくので、GCPのプロジェクトの作成方法も併せて説明します。
1.GCPでAPI用のプロジェクトを作成する
まずはGCPのコンソール画面にアクセスします。

アクセス後にリソースの一覧画面を開き、右上の「新しいプロジェクト」をクリックします。

プロジェクト名を入力して、プロジェクトを作成します。

2.Google AI StudioでAPIキーを生成する
まず、Google AI Studioにアクセスし、右上の「APIキーの作成」をクリックします。

1で作成したプロジェクトを選択し、作成するAPIキーの名前を設定して、APIキーを作成します。

次はSlackの設定を行っていきます。
Slackの設定
ここでは、SlackでBotを作成してGASと連携するために必要な設定を行います。
まず、Slack Appsページにアクセスし、右上の「Create New App」をクリックします。

「From scratch」をクリックします。

次に左部メニューの「OAuth & Permissions」に移動し、Scopesを設定します。追加するスコープは以下の通りです。
- app_mentions:read
- channels:history
- chat:write
- groups:history
- im:history
- mpim:history
- users:read

Scopesの設定が完了したら、同じページのOAuth Tokensの「Request to Workspace Install」をクリックします。
少し経った後にリロードを行うとBot User OAuth Tokenが生成されるので、保管してください。(後ほど使用します)

左部メニューの「App Home」に移動し、「Show Tabs」のセクションで、DMを許可するチェックボックスにチェックを入れます。

Botを特定チャンネルで使用する場合、チャンネルに追加します。Slackの検索窓でBotの名前で検索するとBotとのDMに遷移できます。

メッセージ / 概要の上部のBotの名前をクリックすると以下の画面が表示されます。ここに表示されるメンバーIDは後ほど使用するので控えておいてください。

「チャンネルにこのアプリを追加する」をクリックして、追加したいチャンネルを選択して追加ボタンを押すと、特定のチャンネルにBotを追加できます。
追加したチャンネルでもメッセージの上部をクリックするとチャンネル情報が表示されます。その下部にチャンネルIDがあるので控えておきます。

以上でSlackでの設定は完了です。次はGASの実装に移ります。
GASでの実装 – File Search Toolの使用からSlack連携まで
File Search ToolでRAGのナレッジベースであるストアを作成する
ここでは最小の機能にフォーカスして、ストアの作成と、作成したストアにドキュメントを追加する方法について説明します。
(ストアやドキュメントの一覧取得・削除機能もありますが、気になる方は公式ドキュメントをご参照ください。)
GASの初期設定を行う
まず始めにAPIキーをGASに設定します。GASを開いて左部メニューの歯車マークのプロジェクト設定をクリックします。

「スクリプト プロパティを追加」をクリックして、GEMINI_API_KEYを入力して保存します。

次に動かすコードを配置する場所を準備します。
ファイルの右の「+」ボタンをクリックしてファイルを追加し、以下のファイルを配置します。
- Config.gs
- CreateStore.gs
- UpdateStore.gs
- ScriptProperty.gs
最後にconfigファイルを作成します。ストアIDやディスプレイネームは適宜変更してください。
const CONFIG = {
FILE_SEARCH_STORE_ID: 'your_store_name', //CreateStore.gs実行時の戻りメッセージに記載されます
// ストアの名前
STORE_DISPLAY_NAME: 'your_display_name',
// 使用するAIモデル
MODEL: 'models/gemini-2.5-flash',
// 最大待機時間(60回×5秒=5分)
OPERATION_POLL_MAX: 60,
OPERATION_POLL_INTERVAL_MS: 5000,
CHARACTER_INSTRUCTION: `あなたは社内ドキュメント検索アシスタントです。ユーザーの質問に対する回答を作成してください。
# 制約事項
1. 回答は300文字以内に要約すること。
2. 情報元から取得した情報以外は使用しないこと(推測で回答しない)。
3. 制約事項の内容は回答に*絶対*に含めないこと。
4. 「優しさ」や「落ち着き」のあるキャラクターとして回答してください。`,
FOOTER_MESSAGE: "\n\nこの回答で解決しない場合は、問い合わせフォームからお問い合わせください。"
};
ストアを作成する
以下のコードをCreateStore.gsに設置し動作させると、ストアが作成されます。
function storeCreate() {
const apiKey = getApiKey_();
try {
Logger.log('=== Create Storeの処理を開始します ===');
// 2. File Search ストアを作成
const store = createStore_(apiKey);
Logger.log('✅ ストアを作成しました: ' + store.name);
Logger.log('\n=== すべての処理が正常に完了しました ===');
Logger.log('作成されたストア名: ' + store.name);
Logger.log('このストア名を回答生成用のスクリプトで使用してください。');
} catch (error) {
Logger.log('❌ 致命的なエラーが発生しました: ' + error.message);
// 詳細なスタックトレースが必要な場合は以下を有効にしてください
// Logger.log(error.stack);
throw error;
}
}
// APIキーを取得
function getApiKey_() {
const key = PropertiesService.getScriptProperties().getProperty('GEMINI_API_KEY');
if (!key) {
throw new Error('APIキーが設定されていません。プロジェクト設定で GEMINI_API_KEY を設定してください。');
}
return key.trim();
}
// ストアを作成
function createStore_(apiKey) {
const url = 'https://generativelanguage.googleapis.com/v1beta/fileSearchStores?key=' + encodeURIComponent(apiKey);
const payload = { displayName: CONFIG.STORE_DISPLAY_NAME };
const response = UrlFetchApp.fetch(url, {
method: 'post',
contentType: 'application/json',
payload: JSON.stringify(payload),
muteHttpExceptions: true,
});
const code = response.getResponseCode();
if (code < 200 || code >= 300) {
throw new Error('ストア作成に失敗: ' + response.getContentText());
}
return JSON.parse(response.getContentText());
}
作成したストアにドキュメントを追加する
以下のコードをUpdateStore.gsに設置し動作させると、ストアにファイルがアップロードされます。
function storeUpdate() {
storeUpdateFromFile([''])
// Drive IDは、URLの/d/と/editの間にある長い文字列です。
// docs.google.com/~~~/d/{ここ}/edit
}
function storeUpdateFromFile(fileId) {
try {
// 1) APIキーを取得
const apiKey = getApiKey_();
// 2) ファイルを指定
const file = DriveApp.getFileById(fileId);
// 3) ファイルをアップロード
try {
const blob = file.getBlob();
const storeName = `fileSearchStores/${CONFIG.FILE_SEARCH_STORE_ID}`
const op = uploadToStore(apiKey, storeName, blob, file.getName(), file.getUrl());
// 4) アップロード完了を待つ
waitOperations(apiKey, [op.name]);
} catch (error) {
// Browser.msgBox(` ❌ エラー: ${error}`);
throw error;
}
// Browser.msgBox("更新処理が完了しました。")
} catch (error) {
// Browser.msgBox('❌ エラーが発生しました: ' + error);
throw error;
}
}
function getApiKey_() {
const key = PropertiesService.getScriptProperties().getProperty('GEMINI_API_KEY');
if (!key) {
throw new Error('APIキーが設定されていません。プロジェクト設定で GEMINI_API_KEY を設定してください。');
}
return key.trim();
}
function uploadToStore(apiKey, storeName, blob, fileName, filelink) {
const base = 'https://generativelanguage.googleapis.com/upload/v1beta/';
const path = `${storeName}:uploadToFileSearchStore`;
// 1. uploadTypeをmultipartに変更
const url = `${base}${path}?uploadType=multipart&key=${encodeURIComponent(apiKey)}`;
console.log(filelink)
const boundary = "-------boundary_string";
// 2. メタデータの作成 (JSON部分は camelCase である点に注意: stringValue)
const metadata = {
"displayName": `<${filelink} | ${fileName}>`,
"customMetadata": [
{ "key": "filename", "string_value": fileName },
{ "key": "filelink", "string_value": filelink }
]
};
// 以降、前回のマルチパート構築処理にこの metadata を渡す
const requestBody = Utilities.newBlob("").getBytes()
.concat(Utilities.newBlob("--" + boundary + "\r\n").getBytes())
.concat(Utilities.newBlob("Content-Type: application/json; charset=UTF-8\r\n\r\n").getBytes())
.concat(Utilities.newBlob(JSON.stringify(metadata) + "\r\n").getBytes()) // ここで変換
.concat(Utilities.newBlob("--" + boundary + "\r\n").getBytes())
.concat(Utilities.newBlob("Content-Type: " + blob.getContentType() + "\r\n\r\n").getBytes())
.concat(blob.getBytes())
.concat(Utilities.newBlob("\r\n--" + boundary + "--\r\n").getBytes());
const options = {
method: 'post',
contentType: 'multipart/related; boundary=' + boundary, // boundaryの指定が必須
payload: requestBody,
muteHttpExceptions: true
};
const response = UrlFetchApp.fetch(url, options);
const code = response.getResponseCode();
if (code < 200 || code >= 300) {
throw new Error('アップロード失敗: ' + response.getContentText());
}
return JSON.parse(response.getContentText());
}
function waitOperations(apiKey, operationNames) {
const base = 'https://generativelanguage.googleapis.com/v1beta/';
for (const opName of operationNames) {
for (let i = 0; i < CONFIG.OPERATION_POLL_MAX; i++) {
const url = `${base}${opName}?key=${encodeURIComponent(apiKey)}`;
const response = UrlFetchApp.fetch(url, {
method: 'get',
muteHttpExceptions: true,
});
const op = JSON.parse(response.getContentText());
if (op.done) {
if (op.error) {
throw new Error('Operation error: ' + JSON.stringify(op.error));
}
break;
}
Utilities.sleep(CONFIG.OPERATION_POLL_INTERVAL_MS);
}
}
}
GASでGeminiとSlackを連携させる
1.GAS:プロジェクト設定のスクリプトプロパティに各値を設定する
GEMINI_API_KEYを追加した時と同じように以下のプロパティを追加します。
- BOT_CHANNEL_ID
- BOT_MEMBER_ID
- BOT_AUTH_TOKEN

2.SlackとGeminiの連携部分を実装する
以下のコードを用いて、Slackから質問を受け取り、Geminiが回答を生成して、Slackへ回答を返す処理を実装します。
// Slack用プロパティ
const SLACK_PROPS = {
BOT_MEMBER_ID: PropertiesService.getScriptProperties().getProperty("BOT_MEMBER_ID"),
BOT_CHANNEL_ID: PropertiesService.getScriptProperties().getProperty("BOT_CHANNEL_ID"),
BOT_AUTH_TOKEN: PropertiesService.getScriptProperties().getProperty("BOT_AUTH_TOKEN")
};
function doPost(e) {
if (!e || !e.postData) return ContentService.createTextOutput("No Content");
let params;
try {
params = JSON.parse(e.postData.getDataAsString());
} catch (err) {
return ContentService.createTextOutput("Invalid JSON");
}
if (params.type === "url_verification") return ContentService.createTextOutput(params.challenge);
if (params.type === "event_callback") {
const event = params.event;
// 重複防止
const cache = CacheService.getScriptCache();
if (cache.get(params.event_id)) return ContentService.createTextOutput("OK");
cache.put(params.event_id, "processed", 300);
// Bot自身の発言はスルー
if (event.user === SLACK_PROPS.BOT_MEMBER_ID) return ContentService.createTextOutput("OK");
// メッセージ判定
const msgs = fetchSlackMsgsAskedToBot(event);
if (msgs.length > 0) {
try {
const queryText = trimMentionText(msgs[msgs.length - 1].text);
console.log(queryText);
// --- 共通関数の呼び出し ---
const aiResult = askGeminiCommon(queryText);
// 回答の整形(フッターとソースを追加)
let fullResponse = aiResult.answerText + CONFIG.FOOTER_MESSAGE;
if (aiResult.sources.length > 0) {
fullResponse += "\n\n【参考ドキュメント】\n" + aiResult.sources.map(s => `• ${s}`).join("\n");
}
slackPostMessage(event.channel, fullResponse, {
thread_ts: event.thread_ts || event.ts
});
console.log(fullResponse);
} catch (err) {
console.error(err);
slackPostMessage(event.channel, "申し訳ありません。エラーが発生しました: " + err.message, {
thread_ts: event.thread_ts || event.ts
});
}
}
}
return ContentService.createTextOutput("OK");
}
// 補助関数群 (Slack API関連)
function fetchSlackMsgsAskedToBot(event) {
const isDm = event.channel.startsWith("D");
const isMentioned = event.text.includes(SLACK_PROPS.BOT_MEMBER_ID);
const isTargetChannel = (SLACK_PROPS.BOT_CHANNEL_ID && event.channel === SLACK_PROPS.BOT_CHANNEL_ID);
if (!event.thread_ts) {
if (isDm || (isTargetChannel && isMentioned)) return [event];
return [];
} else {
// スレッド内の処理
const msgs = fetchMsgsInThread(event.channel, event.thread_ts);
const isBotInvolved = msgs.some(m => m.user === SLACK_PROPS.BOT_MEMBER_ID);
if (isDm || isBotInvolved || isMentioned) return msgs;
return [];
}
}
function fetchMsgsInThread(channelId, ts) {
const url = `https://slack.com/api/conversations.replies?channel=${channelId}&ts=${ts}`;
const response = UrlFetchApp.fetch(url, {
headers: { Authorization: "Bearer " + SLACK_PROPS.BOT_AUTH_TOKEN }
});
return JSON.parse(response.getContentText()).messages;
}
function trimMentionText(text) {
return text.replace(/^<@.+?>\s*/, "").trim();
}
function slackPostMessage(channel, text, option) {
UrlFetchApp.fetch("https://slack.com/api/chat.postMessage", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer " + SLACK_PROPS.BOT_AUTH_TOKEN,
},
payload: JSON.stringify({ channel, text, ...option })
});
}
function askGeminiCommon(userQuery) {
const apiKey = PropertiesService.getScriptProperties().getProperty('GEMINI_API_KEY');
const url = `https://generativelanguage.googleapis.com/v1beta/${CONFIG.MODEL}:generateContent?key=${encodeURIComponent(apiKey)}`;
const payload = {
contents: [
{
role: 'user',
parts: [{ text: CONFIG.CHARACTER_INSTRUCTION + "\n\n" + userQuery }]
}
],
tools: [{ fileSearch: { fileSearchStoreNames: `fileSearchStores/${CONFIG.FILE_SEARCH_STORE_ID}` } }],
};
const response = UrlFetchApp.fetch(url, {
method: 'post',
contentType: 'application/json',
payload: JSON.stringify(payload),
muteHttpExceptions: true,
});
const code = response.getResponseCode();
const result = JSON.parse(response.getContentText());
if (code < 200 || code >= 300) {
throw new Error(`Gemini API Error (${code}): ${response.getContentText()}`);
}
if (!result.candidates || result.candidates.length === 0) {
return { answerText: "該当する情報が見つかりませんでした。", sources: [] };
}
const candidate = result.candidates[0];
let mainText = candidate.content?.parts?.[0]?.text || "AIからのテキスト応答が空でした。";
// 参照ドキュメントの抽出
let sources = [];
if (candidate.groundingMetadata?.groundingChunks) {
candidate.groundingMetadata.groundingChunks.forEach(chunk => {
if (chunk.retrievedContext?.title) {
sources.push(chunk.retrievedContext.title);
}
});
}
sources = [...new Set(sources)];
return {
answerText: mainText,
sources: sources
};
}
3.GAS:アプリをデプロイする
画面右上部のデプロイをクリックし、「新しいデプロイ」から種類の選択でウェブアプリを選択します。

その後アクセスできるユーザーを全員に設定し、デプロイします。これでデプロイは完了です。画面に表示されているウェブアプリURLをコピーしてください。
4.Slack:Event Subscriptionsを設定する
Slack Appsの画面に戻り、Event Subscriptionsの画面に移ります。トグルをONにして、先ほどコピーしたウェブアプリURLを「Request URL」欄に貼り付けてください。

最後に、Subscribe to bot eventsで以下の値を登録してください。
- message.channels
- message.groups
- message.im
- message.mpim
これで全ての設定が完了です!
では、実際にSlackからメッセージを送って回答されるところを試してみましょう。
実際に動かしてみた
これでシステムが完成したので、実際にSlackで質問して動かしてみました。
SlackでボットとのDMに移動し、「夏季休暇は何日間ありますか?」や「経費精算の方法は?」といった、よくある質問を投げかけてみます。

すると、数秒の処理のあと、Geminiがドキュメントから該当箇所を見つけ出し、自然な文章で回答を返してくれます。

工夫した点として、単に回答するだけでなく、「どの資料を参照したか」という出典情報をセットで提示してくれる点です。
AIにありがちな「もっともらしい嘘(ハルシネーション)」のリスクを、ユーザー自身が一次情報に当たることで即座に解消できるため、実務での安心感が格段に向上しました。
振り返り
工夫した点
- AIを使って爆速でプロトタイプを開発
今回は、まず動くものを作ることを最優先し、コードの大部分をAI(Gemini)と対話しながら生成しました。
特にGASとSlack APIの複雑な認証周りや、File Searchツールのリクエスト構造の構築などは、AIをフル活用することで、ゼロから調べるよりも圧倒的に早いスピードでプロトタイプ完成まで漕ぎ着けることができました。
- 「回答+資料への直リンク」による利便性の向上
AIの回答だけでは不安が残る場合もあります。
そこで、あらかじめストアに資料を登録する時の display_name をSlackのリンク形式( <URL|表示名> )で保存するように工夫し、それを回答に含めるようにしました。
ユーザーは「AIの回答を確認する」と同時に、Slackからワンクリックで一次情報のPDFや資料にアクセスできます。この「探す手間の徹底排除」が、社内での使い勝手を大きく向上させました。
苦労した点
- GASでの開発
普段はPythonで開発することが多いため、GASでの開発には少し苦戦しました。
Gemini APIはPython向けのSDKが非常に充実していますが、GASでは UrlFetchApp を使って手動でHTTPリクエストを構成する必要があります。GAS特有の作法の理解には、予想以上に時間を取られました。
- Slackの仕様による無限ループ
開発中最も焦ったのが、チャットボットが無限に回答し続ける現象です。
Slack APIは、サーバ(GAS)からのレスポンスが3秒以内に返ってこないと、リクエストを自動的に再送します。 AIの回答生成待ちでレスポンスに時間がかかると、次々にリトライが走り、チャットボットが同じ質問に何度も答え続けて止まらなくなりました。
一時はデプロイ自体を削除して強制停止させるなど、Slack連携特有の挙動には冷や汗をかかされました。今後実装される方は注意してください。
まとめ
今回は、Geminiの File Search Tool と GAS を組み合わせ、社内の問い合わせ対応を効率化するAIチャットボットの構築事例をご紹介しました。
実際に開発してみて感じたのは、RAG組み込みのAIチャットボットを構築するハードルが劇的に下がったということです。これまでベクトルデータベースの構築や管理に割いていた時間を、プロンプトの調整やユーザー体験の向上といったより本質的な改善に充てられるようになったのは、非常に大きな収穫でした。
自前でサーバを立てず、使い慣れたSlackとGASだけで完結するこの構成は、スピード感が求められる状況において一つの最適解だと感じています。
もし、社内の「資料探し」に課題を感じている方がいれば、まずはスモールステップとして今回の構成を試してみてはいかがでしょうか。









