LINE API Expertのヤマタケです。今八王子で子育てしているのですが、週末は公園で遊ぶのがルーティンになっています。
そこで、八王子市内の公園情報を教えてくれる簡易なRAGのLINE botをGoogle Apps Script(GAS)で試作してみました。
週末の休みは娘と八王子市内の公園へ
今八王子に住んでいて2歳児の子育てをしているのですが、八王子はたくさんの公園があります。
ただ公園といっても千差万別です。
駐車場がついている大型の公園もあれば、こじんまりした公園もあります。
また幼児でも遊べる遊具か、娘が好きなブランコがあるか、さらにはトイレの充実ぶりも公園によってまちまちです。
八王子の公園情報を教えてくれるRAGがあると便利
今はGoogleマップなどの情報や口コミである程度の公園の情報も調べられるようになりましたが、遊具の種類やトイレの情報まで調べるのは大変です。
そこで八王子の公園情報を教えてくれるRAGのサービスがあると便利だなと考えました。
やりたいのは 「◯◯から徒歩△分で、ブランコがある公園」 や 「駐車場付きで走り回れる広さの公園」 などです。
こうした形で調べるならRAGのサービスがピッタリと考えました。
GAS×LINE botで簡易的な八王子公園RAGを構築
そこでプロトタイプとして自分で八王子公園RAGを作ってみました。
ただ、実際にRAGを構築するのは大変です。
公園情報をGoogle CloudのCloud Storageなどに配置する、またはその内容とともにベクトルDBを構築するなどすると、かなりの費用が発生します。
そこでスプレッドシートに公園のデータを用意して、それを都度質問とともに投げる簡易なRAGの構成にしました。
RAGを使うプラットフォームは、八王子市も採用しているLINEです。
LINE Messaging APIで八王子公園情報を教えてくれるLINE公式アカウントを、Google Apps Script(GAS)で作成しました。
// スクリプトプロパティを取得
const PROPS = PropertiesService.getScriptProperties();
const CHANNEL_ACCESS_TOKEN = PROPS.getProperty('CHANNEL_ACCESS_TOKEN');
const GEMINI_API_KEY = PROPS.getProperty('GEMINI_API_KEY');
// 定数:シート名と読み込む範囲
const SHEET_NAME = '公園リスト';
const DATA_RANGE = 'A1:N27';
function doPost(e) {
// エラー処理
if (typeof e === 'undefined' || !e.postData) {
return ContentService.createTextOutput(JSON.stringify({content: 'postData is empty'})).setMimeType(ContentService.MimeType.JSON);
}
const json = JSON.parse(e.postData.contents);
const events = json.events;
events.forEach(function(event) {
if (event.type === 'message' && event.message.type === 'text') {
const userMessage = event.message.text;
const replyToken = event.replyToken;
try {
// 1. スプレッドシートから公園データを取得してテキスト化する
const parkDataText = getParkDataFromSheet();
// 2. Geminiへの命令文(プロンプト)を作成する
// 公園データ、役割設定、ユーザーの質問を組み合わせます
const prompt = `
あなたは八王子市南大沢駅周辺の公園に詳しい「公園ガイド」です。
以下の[公園データ]のみを情報源として、[ユーザーの質問]に対して親切に回答してください。
以下のルールを守ってください:
- リストにない情報は捏造しないでください。
- 条件に合う公園が見つかった場合は、その公園名と特徴、なぜおすすめなのかを具体的に教えてください。
- 複数の候補がある場合はいくつか提示してください。
- 該当する公園がない場合は、正直に「条件に合う公園は見当たりませんでした」と答えてください。
[公園データ]
${parkDataText}
[ユーザーの質問]
${userMessage}
`;
// 3. Gemini APIを呼び出す
const botResponse = callGemini(prompt);
// 4. LINEに返信
reply(replyToken, botResponse);
} catch (error) {
console.error(error);
reply(replyToken, '申し訳ありません。現在情報を取得できませんでした。');
}
}
});
return ContentService.createTextOutput(JSON.stringify({content: 'ok'})).setMimeType(ContentService.MimeType.JSON);
}
/**
* スプレッドシートから公園データを読み込み、テキスト形式に整形する関数
*/
function getParkDataFromSheet() {
// スプレッドシートを取得(スクリプトが紐付いているシート)
const ss = SpreadsheetApp.getActiveSpreadsheet();
const sheet = ss.getSheetByName(SHEET_NAME);
if (!sheet) {
throw new Error(`シート「${SHEET_NAME}」が見つかりません。シート名を確認してください。`);
}
// データを取得 (2次元配列)
const values = sheet.getRange(DATA_RANGE).getValues();
// Geminiが理解しやすいテキスト形式(CSV風)に変換
// 例:
// 公園名: 鑓水公園 | 住所: ... | 特徴: ...
// 公園名: ...
// 1行目はヘッダーなのでそのまま取得
const headers = values[0];
// 2行目以降のデータ行を処理
const textRows = values.slice(1).map(row => {
// 各列の「項目名: 値」の形にして結合する
return row.map((cell, index) => {
// 空白や「-」の項目は省略してトークンを節約するか、そのまま送るか。
// ここでは情報の正確性のためにそのまま送りますが、視認性よくパイプ区切りにします。
return `${headers[index]}: ${cell}`;
}).join(' | ');
});
// 全行を改行で結合して返す
return textRows.join('\n');
}
/**
* Gemini APIにリクエストを送る関数
* @param {string} promptText - 完成されたプロンプトテキスト
* @param {string} model - モデル名(デフォルト: gemini-1.5-flash)
*/
function callGemini(promptText, model = 'gemini-flash-latest') {
if (!GEMINI_API_KEY) {
throw new Error('Script Property "GEMINI_API_KEY" is not set.');
}
const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${GEMINI_API_KEY}`;
const payload = {
'contents': [
{
'parts': [
{
'text': promptText
}
]
}
]
};
const options = {
'method': 'post',
'contentType': 'application/json',
'payload': JSON.stringify(payload),
'muteHttpExceptions': true
};
const response = UrlFetchApp.fetch(apiUrl, options);
const responseCode = response.getResponseCode();
const responseBody = JSON.parse(response.getContentText());
if (responseCode !== 200) {
throw new Error(`Gemini API Error: ${responseCode} - ${JSON.stringify(responseBody)}`);
}
if (responseBody.candidates && responseBody.candidates.length > 0) {
return responseBody.candidates[0].content.parts[0].text;
} else {
return 'Geminiからの応答がありませんでした。';
}
}
// LINEへ返信する関数
function reply(replyToken, text) {
const url = 'https://api.line.me/v2/bot/message/reply';
const payload = {
'replyToken': replyToken,
'messages': [{
'type': 'text',
'text': text
}]
};
const options = {
'method': 'post',
'headers': {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + CHANNEL_ACCESS_TOKEN
},
'payload': JSON.stringify(payload)
};
UrlFetchApp.fetch(url, options);
}
あくまでプロトタイプなので、できるだけ無料にしようとGemini flashを使いました。
仕組みとしてはシンプルで、以下の3つの処理を実装しています。
- LINE公式アカウントのトーク画面で、ユーザーが投稿
- ユーザー投稿+スプレッドシートの公園情報をプロンプトとしてGemini flashにリクエスト
- Geminiから応答結果をユーザーに返信
実際に動かしてみると、きちんと投稿した条件に沿った公園情報を返してくれます。
一番大変なのは公園情報の収集
今回の八王子公園RAGの構築で大変なのはRAG自体の構築ではなく、八王子市内の公園情報を集めることです。
プロトタイプでは自分が住んでいる周辺の公園情報のみを対象としましたが、これだけでも実は自力でデータを集めることに断念しました。
前述したスプレッドシートの公園データでは、生成AIによるダミーデータを入力しています。
八王子市は市内の公園を網羅した「公園マップ」を配布していますが、あくまで公園の一覧は確認できるものの、公園の詳細までは掲載されていません。
公園情報を集めるという点でRAG構築の難しさを感じました。
このあたりのベースとなる情報を集める作業は、自分が運営している地域メディアを使って解決できないかネクストアクションで試す予定です。
2025年12月にGemini flashの無料枠が減少
八王子公園RAGを構築している間に、GoogleのGemini flashに変化がありました。
なんとGemini flashの1日あたりの無料枠の実行回数が20回までとなっています。
- gemini-3-flash
- gemini-2.5-flash-lite
- gemini-2.5-flash
Google AI Studioのレート制限ページを見ると、それぞれにつき1日20回までとなっているようです。
上限まで実行すると、次のモデルに切り替えるという運用しても1日60回までです。
月に換算すると、1800回。
八王子に限定すれば大丈夫そうですが、多くの人に使ってもらえた場合には、枯渇しそうです。
生成AI周りの利用コストをどう低減していくかも課題になりそうです。
終わりに
今回、八王子市の公園情報を調べられる簡易RAGをLINE Messaging APIとGoogle Apps Scriptで試作してみました。
スプレッドシートに記録した公園情報とユーザーからの問い合わせを生成AIのGemini flashにリクエストして、応答結果をLINE公式アカウントに出力する形です。
RAGを構築するのはコストがかかるため、スプレッドシートの公園情報をそのまま投げていますが、プロトタイプでは自分の住んでいるエリアの公園のみなので、問題ないですが、八王子市内全域だと事前に入力条件に合致するものを抽出するなど、工夫が必要そうです。
さらにGemini flashの無料枠が1日上限が3つのモデル合計で60回までなので、上限に達しそうです。
八王子の人に使ってもらえるレベルにするには、さらに公園情報を集めたり、上記課題を解決する必要はありますが、自分もほしいサービスなので、引き続き来年も開発を進めていきたいと思います。
メリークリスマス!





