LINE × GAS × OpenAI で社内ドキュメント検索ボットをつくる

目次

はじめに

社内のFAQやマニュアルをAIに答えさせたい。
しかも「社員が普段使っているLINEから質問できる」ようになれば、利用のハードルはぐっと下がります。

この記事では、LINE公式アカウント × Google Apps Script(GAS) × OpenAI を組み合わせて、Drive内の資料を検索して回答する RAG(Retrieval Augmented Generation)ボット を構築した過程を紹介します。
実際に試したコード断片も交えつつ、「読み物+実装ハンドブック」風にまとめました。


先にやること:プロパティ設定 & データ準備(最重要)

RAGボットは プロパティが1つでも欠けると動きません。まず最初にこのチェックリストを埋めてください。

✅ Script Properties(必須項目)

LINE関係のキーはLINEのofficial Account ManagerまたはLINE Developersのサイトで取得してください。
OPEN AIのAPI KEYはAPI プラットフォームで発行してください。
Googleドライブ、スプレッドシートはGoogleアカウントはドライブサイトで作成してください。

キー名用途
LINE_CHANNEL_SECRETWebhook署名検証(LINE → GAS)xxxxxxxxxxxxxxxxxxxxxxx
LINE_CHANNEL_ACCESS_TOKENBot返信(GAS → LINE)xxxxx...(実際はプレーン文字列で保存)
OPENAI_API_KEYOpenAIの埋め込み・生成呼び出しsk-...
DRIVE_FOLDER_ID取り込み対象のGoogle DriveフォルダID1AbCdEf...
フォルダのURLにある文字列
INDEX_SHEET_IDインデックス保存用スプレッドシートID1AbCdEf...
スプレッドシートのURLにある文字列

設定場所:GAS エディタ → 右上「歯車」→「スクリプト プロパティ」


✅ Google側の権限とサービス

  1. GASのデプロイ
    • 「デプロイ → 新しいデプロイ → ウェブアプリ」
    • 実行ユーザー=自分 / アクセス権=全員(匿名含む)
    • 発行されたURLを LINEのWebhook URL に設定
  2. Advanced Google Services
    • GAS左メニュー「サービス」→ 追加 → Drive API(v2) を有効化
    • Google Cloud Console 側でも Drive API が有効化されていることを確認

✅ データ準備(最小構成)

  • DriveフォルダDRIVE_FOLDER_ID)に、次のいずれかを入れる
    • Googleドキュメント、Word(.docx)、PDF(テキストベース推奨)、TXT、CSV、Markdown
  • スプレッドシートINDEX_SHEET_ID)に index シートを用意(空でOK)
    • 初回インデックス時にヘッダを上書きします

PDFがスキャン画像の場合は文字が取れません(OCRが必要)。まずはテキストベース資料でテストしてください。


✅ ワンショット動作確認

最初に sanityCheck() を走らせ、プロパティとAPIキー・I/Oが揃っているか確認します。

function sanityCheck() {
  const prop = PropertiesService.getScriptProperties();
  const required = ['LINE_CHANNEL_SECRET','LINE_CHANNEL_ACCESS_TOKEN','OPENAI_API_KEY','DRIVE_FOLDER_ID','INDEX_SHEET_ID'];
  const missing = required.filter(k => !prop.getProperty(k));
  if (missing.length) throw new Error('❌ Missing: ' + missing.join(', '));
  Logger.log('✅ Properties OK');

  const folder = DriveApp.getFolderById(prop.getProperty('DRIVE_FOLDER_ID'));
  if (!folder.getFiles().hasNext()) Logger.log('⚠️ Driveフォルダは空');

  const ss = SpreadsheetApp.openById(prop.getProperty('INDEX_SHEET_ID'));
  const sh = ss.getSheetByName('index') || ss.insertSheet('index');
  if (sh.getLastRow() === 0) sh.getRange(1,1,1,4).setValues([['chunk_text','vector_json','source','chunk_no']]);
  Logger.log('✅ indexシートOK');

  const res = UrlFetchApp.fetch('https://api.openai.com/v1/chat/completions', {
    method:'post', contentType:'application/json',
    headers:{ 'Authorization':'Bearer '+prop.getProperty('OPENAI_API_KEY') },
    payload: JSON.stringify({ model:'gpt-4o-mini', messages:[{role:'user', content:'OKだけ返して'}], temperature:0 })
  });
  if (res.getResponseCode() !== 200) throw new Error('❌ OpenAI API error');
  Logger.log('✅ OpenAI chat OK');
}

全体像

準備ができたら、以下の流れで構築していきます。

  1. Driveドキュメントをインデックス化(チャンク+埋め込み→シート保存)
  2. ユーザー質問を検索してコンテキスト取得
  3. GPTに渡して回答生成
  4. LINEに返信

ステップ1:エコーボット

まずはLINEとGASのWebhookがつながるか確認。(事前準備チェック)

function doPost(e) {
  const body = JSON.parse(e.postData.contents);
  const event = body.events[0];
  if (event.type === 'message') {
    replyMessage(event.replyToken, "エコー: " + event.message.text);
  }
}

function replyMessage(token, text) {
  const url = 'https://api.line.me/v2/bot/message/reply';
  UrlFetchApp.fetch(url, {
    method: 'post',
    contentType: 'application/json',
    headers: { 'Authorization': 'Bearer ' + PropertiesService.getScriptProperties().getProperty('LINE_CHANNEL_ACCESS_TOKEN') },
    payload: JSON.stringify({ replyToken: token, messages: [{ type: 'text', text }] })
  });
}

これでLINEから送ったメッセージが「エコー: ○○」と返ってくればOK。


ステップ2:ドキュメントのインデックス化

Driveフォルダ内のファイルを読み取り、チャンクに分割して埋め込み→シートに保存します。

抽出関数(抜粋)

function extractText(fileId, mimeType, name) {
  if (mimeType === MimeType.GOOGLE_DOCS) return DocumentApp.openById(fileId).getBody().getText();
  if (mimeType === 'text/plain') return DriveApp.getFileById(fileId).getBlob().getDataAsString();
  if (mimeType === MimeType.MICROSOFT_WORD || mimeType === MimeType.PDF) return extractViaTempGoogleDoc_(fileId, name);
  throw new Error("未対応: " + mimeType);
}

チャンク化

function chunkText_(text, size=1200, overlap=200) {
  const chunks = [];
  for (let i=0; i<text.length; i+=(size-overlap)) {
    chunks.push(text.slice(i, i+size));
  }
  return chunks;
}

インデックス構築

function buildIndexFromDrive() {
  const folder = DriveApp.getFolderById(PropertiesService.getScriptProperties().getProperty('DRIVE_FOLDER_ID'));
  const files = folder.getFiles();
  const rows = [['chunk_text','vector_json','source','chunk_no']];

  while (files.hasNext()) {
    const f = files.next();
    try {
      const text = extractText(f.getId(), f.getMimeType(), f.getName());
      const chunks = chunkText_(text);
      chunks.forEach((c,i)=>{
        const vec = embedTextOpenAI(c);
        rows.push([c, JSON.stringify(vec), f.getName(), i]);
      });
    } catch (e) {
      Logger.log("SKIP: " + f.getName() + " -> " + e.message);
    }
  }

  const sh = SpreadsheetApp.openById(PropertiesService.getScriptProperties().getProperty('INDEX_SHEET_ID')).getSheetByName('index');
  sh.clear();
  sh.getRange(1,1,rows.length,rows[0].length).setValues(rows);
}

ステップ3:検索と回答生成

ユーザー質問を埋め込みにして、シート内のチャンクと比較します。

類似度計算

function cosineSimilarity(a,b) {
  let dot=0, na=0, nb=0;
  for (let i=0; i<a.length; i++) { dot += a[i]*b[i]; na += a[i]*a[i]; nb += b[i]*b[i]; }
  return dot / (Math.sqrt(na)*Math.sqrt(nb));
}

応答生成

function generateAnswer(userText, hits) {
  const context = hits.map((h,i)=>`[${i+1}] ${h.text}`).join("\n");
  const system = "あなたは社内FAQに基づいて回答するアシスタントです。回答は簡潔に、出典番号を最後に示してください。";
  const user = `# コンテキスト\n${context}\n\n# ユーザーの質問\n${userText}`;

  const key = PropertiesService.getScriptProperties().getProperty('OPENAI_API_KEY');
  const res = UrlFetchApp.fetch("https://api.openai.com/v1/chat/completions", {
    method:"post", contentType:"application/json",
    headers:{ "Authorization":"Bearer "+ key },
    payload: JSON.stringify({
      model: "gpt-4o-mini", temperature: 0.2,
      messages:[{ role:"system", content: system }, { role:"user", content: user }]
    })
  });
  return JSON.parse(res.getContentText()).choices[0].message.content;
}

動作例

Driveに「会社概要」「FAQ」「料金表」を置いて、いったんインデックスを生成。
GASの画面から関数を指定して実行。今回の場合はbuildIndexFromDrive
でLINEから質問。


精度を左右するもの

実装してわかったのは、精度はコードよりドキュメント整備で決まるということ。

  • 見出しを正しくつける(H2/H3単位)
  • FAQは必ずQ/A形式で
  • 用語を統一する
  • ページ番号や目次は削除
  • PDFはテキストベースで

これを徹底するだけで検索ヒット率が大きく変わります。


チューニングの方向性

コード側でできる工夫もあります。

  • チャンクサイズ(小さめ=正確、大きめ=説明力)
  • オーバーラップ
  • 隣接チャンクを一緒に提示
  • プロンプト設計(文体・出典記載など)

ただ、最初に手をつけるべきはやはり ドキュメントの調整 です。


まとめ

  • LINE × GAS × OpenAI でDrive資料検索ボットを構築
  • インデックスはスプレッドシートで軽量実装
  • 精度はコード調整よりもドキュメント整備がカギ
  • 運用ルール(ドキュメント登録指示書)を作ると効果大
  • 将来的にはベクトルDBやOCRで拡張可能

小さなチームでもすぐに始められる仕組みなので、ぜひ試してみてください。

目次