はじめに
社内のアンケートデータとか、溜まる一方で誰も見返さないですよね。
毎週コツコツ蓄積されているのに、「あの人の趣味なんだっけ?」と思っても結局Slackで直接聞いてしまう。
このもったいなさをどうにかできないか、と作り始めたのが今回紹介する 社内RAGチャットボットです。
特徴は「サーバーもDB不要」な点。Google Apps Script(GAS)とスプレッドシートとGemini APIで、RAGチャットボットが動きます。
Collaとは
Colla はSlackと連携して使えるパルスサーベイサービスです。管理者が定期的に「今週の質問」をチャンネルに投げかけ、メンバーがSlack上でそのまま回答できる仕組みです。
毎週こんな質問が届きます。
- 「好きな食べ物は?」
- 「趣味を教えてください」
- 「最近ハマっていることは?」
- 「出身地はどこですか?」
回答はスプレッドシートにエクスポートできます。蓄積されるデータのイメージはこんな感じです。
| 投稿ID | タイムスタンプ | ユーザー名 | 質問 | 回答 |
|---|---|---|---|---|
| 1 | 2024-01-08 | 田中太郎 | 好きな食べ物は? | カレーです |
| 2 | 2024-01-08 | 佐藤花子 | 好きな食べ物は? | ラーメンです |
| 3 | 2024-01-15 | 田中太郎 | 趣味は? | 釣りです |
| 4 | 2024-01-15 | 佐藤花子 | 趣味は? | 読書です |
週を重ねるごとにデータが積み上がっていきます。
「あの人ってどんな人だっけ?」と思っても、このスプレッドシートを掘り返す人はほぼいません。
この眠ったデータを知識源として活用します。
技術的なポイント
| ポイント | 内容 |
|---|---|
| Colla連携 | アンケートのQ&Aデータを知識源として活用 |
| RAG検索 | 質問と意味的に近いデータを自動で引っ張ってくる |
| Gemini API | Embeddingと回答生成を同一APIで統一 |
GASで動いているのでサーバー管理は一切不要。スプレッドシートをそのままベクトルDBとして使うのが最大の変わり種です。
アーキテクチャ全体像
システムは大きく 2つのフロー に分かれます。
【フロー1: データ格納】(データ更新時に実行)
Colla_情報シート(アンケートデータ)
└─> チャンク生成(person / question)
└─> Gemini Embedding API でベクトル化
└─> Embeddingsシートに保存
【フロー2: 質問応答】(ユーザーが質問するたびに実行)
ユーザーの質問
└─> クエリをベクトル化
└─> Embeddingsシートをコサイン類似度で検索
└─> 関連チャンクをコンテキストに構築
└─> Gemini 2.5 Flash で回答生成
└─> sources付きでユーザーへ返す
このフロー全体がGASで動いているので、EC2もCloud Runも不要。
スプレッドシートが「ベクトルデータベース」の役割を担います。
Embeddingでテキストをベクトルに
Embeddingとは、テキストを数値の配列(ベクトル)に変換する技術です。
今回は gemini-embedding-001 を使用。
outputDimensionality: 768を指定し、1テキストを768次元のベクトルに変換します。(デフォルトは3072次元)
// embedding.ts
function getEmbedding(text: string, taskType: string): number[] {
const url = `${CONFIG.GEMINI_API_BASE}/models/${CONFIG.EMBEDDING_MODEL}:embedContent?key=${apiKey}`;
const payload = {
model: `models/${CONFIG.EMBEDDING_MODEL}`,
content: { parts: [{ text }] },
taskType,
outputDimensionality: CONFIG.EMBEDDING_DIMENSIONS, // 768
};
// ...レスポンスをパースして正規化
return normalizeVector(data.embedding.values);
}
重要なのは 「意味が近い文章はベクトルも近くなる」 という性質です。
「好物は?」 → [0.11, -0.31, 0.85, ...] ←─┐ 近い
「好きな食べ物は?」 → [0.12, -0.34, 0.87, ...] ←─┘
「明日の天気は?」 → [-0.92, 0.44, 0.03, ...] ←── 遠い
L2正規化
ベクトルの長さを1に揃える処理です。
これにより文章の長短に関係なく、純粋に「方向の近さ = 意味の近さ」だけで比較できるようになります。
gemini-embedding-001 はMatryoshka Representation Learning(MRL)で訓練されており、3072次元のフルベクトルを truncate して768次元にすると、ベクトルの大きさが変わるため手動で再正規化する。
// ゼロベクトルでも元の配列を変更しないようにコピーを返す(イミュータビリティ)
function normalizeVector(vec: number[]): number[] {
const norm = Math.sqrt(vec.reduce((sum, v) => sum + v * v, 0));
if (norm === 0) return [...vec];
return vec.map((v) => v / norm);
}
また、クエリ時とドキュメント保存時でタスクタイプを分けています。
// ドキュメント保存用
const EMBEDDING_TASK_TYPE_DOCUMENT = "RETRIEVAL_DOCUMENT";
// 検索クエリ用
const EMBEDDING_TASK_TYPE_QUERY = "RETRIEVAL_QUERY";
同じEmbedding APIでも検索用に最適化されます。
2種類のチャンク戦略
生のアンケートデータをそのままベクトル化するのではなく、意味のまとまりに整形してからベクトル化します。
チャンクは2種類。
personチャンク(人物プロファイル型)
「この人はどんな人か」を1テキストにまとめます。
田中太郎: 好きな食べ物=カレー, 趣味=釣り, 出身地=東京, ...
佐藤花子: 好きな食べ物=ラーメン, 趣味=読書, 出身地=大阪, ...
→ 「田中さんの趣味は?」のような 特定人物への質問 に強い。
questionチャンク(質問サマリー型)
「この質問にみんなはどう答えたか」を1テキストにまとめます。
好きな食べ物は?: 田中太郎=カレー, 佐藤花子=ラーメン, 鈴木一郎=寿司, ...
趣味は?: 田中太郎=釣り, 佐藤花子=読書, ...
→ 「カレーが好きな人は誰?」のような傾向・横断検索に強い。
// dataLoader.ts
function buildPersonChunks(rows: SurveyRow[]): Chunk[] {
const byPerson = new Map<string, Array<{ question: string; answer: string }>>();
for (const row of rows) {
const existing = byPerson.get(row.userName) ?? [];
byPerson.set(row.userName, [...existing, { question: row.question, answer: row.answer }]);
}
// ...
}
function buildQuestionChunks(rows: SurveyRow[]): Chunk[] {
const byQuestion = new Map<string, Array<{ userName: string; answer: string }>>();
// ...
}
この2種類のチャンクを組み合わせることで、「特定の人について聞く」クエリにも「傾向を横断的に聞く」クエリにも幅広く対応できます。
スプレッドシートにベクトルデータを保存する
社内アンケートデータはスプレッドシートに保存してます。
なのでベクトルデータもシートで保存しておきます。
Embeddingsシートの列構成
A列: id (例: "person_田中太郎")
B列: type ("person" or "question")
C列: text (チャンクのテキスト)
D列: metadata (JSONシリアライズ)
E列: embedding (768次元ベクトルをJSONシリアライズ)
F列: textHash (差分ビルド用MD5ハッシュ)
// vectorStore.ts
function saveEmbeddings(chunks: StoredChunk[]): void {
const sheet = ss.getSheetByName(CONFIG.SHEET_EMBEDDINGS);
// ...
const rows = chunks.map((chunk) => [
chunk.id,
chunk.type,
chunk.text,
JSON.stringify(chunk.metadata),
JSON.stringify(chunk.embedding),
chunk.textHash ?? "",
]);
sheet.getRange(2, 1, rows.length, 6).setValues(rows);
}
コサイン類似度検索
L2正規化済みベクトル同士のコサイン類似度は内積で計算できます。
function cosineSimilarity(a: number[], b: number[]): number {
if (a.length !== b.length) throw new Error("ベクトル次元が一致せぬ");
return a.reduce((sum, ai, i) => sum + ai * b[i], 0);
}
ハイブリッド検索
personチャンクとquestionチャンクを 別レーンで検索してマージ します。
function hybridSearch(queryVec: number[], chunks: StoredChunk[]): SearchResult[] {
const personResults = searchSimilar(queryVec, chunks, CONFIG.TOP_K_PERSON, "person");
const questionResults = searchSimilar(queryVec, chunks, CONFIG.TOP_K_QUESTION, "question");
// 重複を除いてスコア降順でマージ
const seen = new Set<string>();
const merged: SearchResult[] = [];
for (const r of [...personResults, ...questionResults]) {
if (!seen.has(r.chunk.id)) {
seen.add(r.chunk.id);
merged.push(r);
}
}
return merged.sort((a, b) => b.score - a.score);
}
類似度が 0.5未満のチャンクは除外。無関係な情報をLLMに渡さないためです。
RAGパイプライン
chat.tsが全体のオーケストレーターです。
// chat.ts
function handleChat(userMessage: string, history: ChatHistory[]): ChatResponse {
// 1. クエリをベクトル化
const queryVec = getQueryEmbedding(userMessage);
// 2. キャッシュからチャンクを取得
const chunks = getChunksCache();
// 3. ハイブリッド検索
const results = hybridSearch(queryVec, chunks);
// 4. コンテキスト構築
const context = buildContext(results);
const systemPrompt = buildSystemPrompt(context);
// 5. 会話履歴を直近N件に制限
const trimmedHistory = history.slice(-CONFIG.MAX_HISTORY_TURNS * 2);
// 6. Gemini 2.5 Flash で生成
const answer = generateResponse(systemPrompt, trimmedHistory, userMessage);
return { answer, sources: results.map(...) };
}
差分ビルドでAPIコストを最小化
毎回全チャンクをre-embeddingすると、APIコストも処理時間も膨らみます。
なので2段階の差分ビルドでこれを解決しています。
グローバルハッシュ(高速スキップ)
データ全体に変更がなければ処理ごとスキップします。
// main.ts
function shouldSkipEmbedding(rows: SurveyRow[], props): boolean {
const newHash = computeDataHash(rows); // 全行を連結してMD5
const savedHash = loadDataHash(); // 前回保存済みハッシュ
if (newHash === savedHash) {
Logger.log("データに変更なし。エンベディング更新をスキップする。");
return true;
}
return false;
}
チャンク単位の差分検知
変更されたチャンクだけをAPI呼び出し対象にします。F列のtextHashが鍵です。
function detectChangedChunks(
allChunks: Chunk[],
existingById: Map<string, StoredChunk>
): { chunksToEmbed: Chunk[]; storedById: Map<string, StoredChunk> } {
const chunksToEmbed = allChunks.filter((chunk) => {
const existing = existingById.get(chunk.id);
if (!existing?.textHash) return true; // 新規 or 旧データ → 再embedding必要
return existing.textHash !== computeChunkHash(chunk.text); // テキスト変更あり?
});
// ...
}
Collaのデータは毎週追加はされますが、既存回答はほぼ変わりません。
この差分検知で大部分の再embeddingをスキップできます。
GAS 6分制限 → タイムトリガーで自動継続
GASは1回の実行が6分まで。大量チャンクは複数回に分けて処理します。
function processEmbeddingBatch(/* ... */): { completed: boolean } {
for (let i = startIndex; i < chunksToEmbed.length; i++) {
// 5分30秒を超えたら一時中断
if (Date.now() - startTime > 5.5 * 60 * 1000) {
saveEmbeddings([...storedById.values()]); // 処理済み分を保存
return { completed: false, nextIndex: i };
}
// ...
}
return { completed: true, nextIndex: chunksToEmbed.length };
}
// 中断時は1分後に自動再実行
function scheduleContinuation(): void {
ScriptApp.newTrigger("rebuildEmbeddings")
.timeBased()
.after(60 * 1000)
.create();
}
バッチ再開:中断時に処理済みチャンクをtextHash付きでシートに保存しておきます。次回起動時に detectChangedChunks がtextHashを見て未処理チャンクを自動特定するため、「どこまで処理したか」のインデックス管理が不要です。
ハマりどころと工夫
会話履歴のスキーマ検証(注入防止)
クライアントから送られてくる会話履歴JSONは必ずスキーマ検証します。不正な role や不正な形式のエントリは除外します。
// main.ts
function parseAndValidateHistory(historyJson: string): ChatHistory[] {
// ...
for (const item of parsed) {
if (
item.role !== "user" && item.role !== "model" || // roleは2値のみ許可
!Array.isArray(item.parts) ||
!item.parts.every((p) => typeof p?.text === "string")
) {
continue; // 不正なエントリは除外
}
valid.push(item as ChatHistory);
}
return valid;
}
入力バリデーション
空メッセージと長すぎるメッセージをはじきます(DoS・API過剰消費防止)。
if (!userMessage || userMessage.trim() === "") {
return JSON.stringify({ success: false, error: "メッセージが空じゃ" });
}
if (userMessage.length > CONFIG.MAX_MESSAGE_LENGTH) { // 2000字上限
return JSON.stringify({ success: false, error: "メッセージが長すぎる..." });
}
エラーメッセージの隠蔽
APIエラーの詳細をユーザーに見せるとシステム構成が漏れます。詳細はGASのLogger、ユーザーには汎用メッセージだけ返します。
Logger.log(`handleChatMessage エラー: ${error.message}\n${error.stack}`); // 詳細を記録
return JSON.stringify({
success: false,
error: "エラーが発生した。管理者にお問い合わせくだされ。", // 詳細は隠す
});
APIキー管理
APIキーはGASのScript Properties(スクリプトプロパティ)で管理。
ソースコードには一切ハードコーディングしない。
const apiKey = PropertiesService.getScriptProperties()
.getProperty(CONFIG.GEMINI_API_KEY_PROP); // "GEMINI_API_KEY"
if (!apiKey) throw new Error("GEMINI_API_KEY が Script Properties に設定されておらぬ");
まとめ
| 要素 | 採用内容 |
|---|---|
| 実行環境 | Google Apps Script |
| ベクトルストア | Googleスプレッドシート |
| Embedding | Gemini Embedding API(gemini-embedding-001) |
| 生成モデル | Gemini 2.5 Flash |
| チャンク戦略 | personチャンク + questionチャンクのデュアル構成 |
| コスト最適化 | グローバルハッシュ + チャンク単位差分ビルド |
| 開発環境 | TypeScript + clasp |
GAS + スプレッドシートというシンプルな組み合わせでRAGが成立しました。
制限事項としては、スプレッドシートへのアクセスはDBほど高速でないため、チャンク数が増えるほど応答時間は伸びます。また GASの無料枠には実行時間・トリガー数の上限もあります。大規模なデータや高頻度アクセスが必要な場合はCloud RunやCloud Storageへの移行を検討するのがよいでしょう。
「まず小さく動かしてみる」という用途にはGAS + スプレッドシートのRAGはかなり手軽です。
RAGをちょっと知れた。