1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

GAS+Geminiで作る社内RAGチャットボット

1
Posted at

はじめに

社内のアンケートデータとか、溜まる一方で誰も見返さないですよね。

毎週コツコツ蓄積されているのに、「あの人の趣味なんだっけ?」と思っても結局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をちょっと知れた。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?