はじめに
能登半島地震の報道をきっかけに、防災情報の分散という問題を感じました。
- ハザードマップ → 国交省のサイト
- 避難場所 → 自治体のサイト
- 気象警報 → 気象庁のサイト
- 地域防災計画 → PDF
これらをまとめて、住所を 1 つ入力するだけで一括取得できるシステムを作りました。月額コストは約 $0.50(Route 53 のドメイン費用のみ)です。
デモサイト: https://disaster-rag.eggsystems.jp(大阪府内の住所で利用可能)
GitHub: https://github.com/kojiman55/disaster-rag
提供する情報
| 機能 | 内容 |
|---|---|
| ハザードリスク評価 | 洪水・土砂・津波・高潮を高/中/低で評価 |
| 避難経路表示 | 最寄り避難場所への徒歩ルートを地図で表示(所要時間・距離付き) |
| 気象警報連携 | 気象庁のリアルタイム警報・注意報 |
| AI 解説 | 地域防災計画を参照した Gemini による回答 |
| チャット | 追加質問に対応 |
全部 1 回の API コールでまとめて処理して返しています。
技術スタック
| レイヤー | 技術 |
|---|---|
| フロントエンド | Next.js 15 (App Router) / TypeScript / Tailwind CSS |
| ホスティング | S3 + CloudFront (OAC) |
| バックエンド | AWS Lambda (TypeScript) / API Gateway |
| AI | Gemini 2.0 Flash(回答生成・テキスト埋め込み) |
| ベクトル検索 | Qdrant(地域防災計画 PDF の RAG インデックス) |
| 地図 | MapLibre GL JS / 国土地理院タイル(無料) |
| インフラ | AWS SAM / EventBridge |
| 外部 API | 国土地理院 / 国交省不動産情報ライブラリ / 気象庁 / OpenRouteService |
利用データソース(すべて無料)
| データソース | 提供元 | 登録 | 用途 |
|---|---|---|---|
| アドレス検索 API | 国土地理院 | 不要 | 住所 → 緯度経度 |
| 地図タイル | 国土地理院 | 不要 | ベースマップ |
| ハザードタイル API | 国交省 不動産情報ライブラリ | 要申請(無料) | 洪水・土砂・津波・高潮 |
| 警報・注意報 | 気象庁 | 不要 | リアルタイム気象情報 |
| 避難場所データ | 国土地理院 | 規約同意のみ | 避難場所の位置・対応種別 |
| 徒歩経路 | OpenRouteService | 不要(無料枠) | 避難経路計算 |
国土交通省の不動産情報ライブラリ API は申請が必要ですが、審査に数日かかるだけで費用はかかりません。
実装で詰まったポイントと解決策
1. Reinfolib のデータ構造を誤解していた
初期設計では市区町村コードを使ってハザードデータをあらかじめ S3 に保存しておく設計でした。しかし実際の API は z/x/y タイル座標方式でした。市区町村コード単位のデータは存在しません。
設計を変更し、緯度経度からタイル座標を計算して 4 種の API を Promise.all で並行取得する実装に修正しました。
function toTile(lat: number, lng: number, z: number) {
const x = Math.floor((lng + 180) / 360 * Math.pow(2, z));
const yRad = lat * Math.PI / 180;
const y = Math.floor(
(1 - Math.log(Math.tan(yRad) + 1 / Math.cos(yRad)) / Math.PI) / 2 * Math.pow(2, z)
);
return { x, y };
}
// ズームレベル 14 で 1タイル ≒ 200〜300m 四方
const [floodCount, stormCount, tsunamiCount, landslideCount] = await Promise.all([
fetchTileCount("XKT026", lat, lng, apiKey), // 洪水
fetchTileCount("XKT027", lat, lng, apiKey), // 高潮
fetchTileCount("XKT028", lat, lng, apiKey), // 津波
fetchTileCount("XKT029", lat, lng, apiKey), // 土砂
]);
| API ID | 用途 |
|---|---|
| XKT026 | 洪水浸水想定区域(想定最大規模) |
| XKT027 | 高潮浸水想定区域 |
| XKT028 | 津波浸水想定 |
| XKT029 | 土砂災害警戒区域 |
ズームレベル 14 を使うと、丁目・番地レベルのピンポイント評価ができるようになりました。
2. 座標順序の逆転バグ
GeoJSON の仕様は [lng, lat](経度, 緯度)の順ですが、一般的な [lat, lng] と逆です。
// NG: lat, lng の順で展開していた
const [lat, lng] = features[0].geometry.coordinates;
// OK: GeoJSON は [lng, lat]
const [lng, lat] = features[0].geometry.coordinates;
混同したせいで、地図上のピンが日本海の沖に表示されるバグが発生しました。MapLibre も [lng, lat] なのでそろえると解決します。
3. 避難場所の最寄り計算漏れ
初期実装ではデータの先頭要素をそのまま使っていたため、どの住所で検索しても同じ公園が返ってきました。
// NG: データの先頭を返していた
const nearest = shelters[0];
// OK: ユークリッド距離で最寄りを選ぶ
const nearest = shelters.reduce((a, b) =>
distance(lat, lng, a.lat, a.lng) < distance(lat, lng, b.lat, b.lng) ? a : b
);
function distance(lat1: number, lng1: number, lat2: number, lng2: number) {
return Math.sqrt(Math.pow(lat2 - lat1, 2) + Math.pow(lng2 - lng1, 2));
}
4. API Gateway スロットリング時の CORS エラー
レート制限(バースト 5 req/s・定常 2 req/s)を設定した後、「Failed to fetch」エラーが出るようになりました。
原因は「Lambda 実行前の 429 応答には CORS ヘッダーが付かない」ことでした。API Gateway がレート超過で 429 を返すとき、Lambda は実行されず、Lambda がセットするはずの CORS ヘッダーが返りません。ブラウザはそれを CORS エラーとして処理します。
解決策: Gateway Response に CORS ヘッダーを追加
aws apigateway put-gateway-response \
--rest-api-id YOUR_API_ID \
--response-type THROTTLED \
--response-parameters '{
"gatewayresponse.header.Access-Control-Allow-Origin":"'"'"'*'"'"'"
}'
THROTTLED だけでなく DEFAULT_4XX・DEFAULT_5XX にも設定しておくと安心です。
SAM テンプレートで管理する場合:
GatewayResponseThrottled:
Type: AWS::ApiGateway::GatewayResponse
Properties:
ResponseType: THROTTLED
RestApiId: !Ref Api
ResponseParameters:
gatewayresponse.header.Access-Control-Allow-Origin: "'*'"
GatewayResponseDefault4XX:
Type: AWS::ApiGateway::GatewayResponse
Properties:
ResponseType: DEFAULT_4XX
RestApiId: !Ref Api
ResponseParameters:
gatewayresponse.header.Access-Control-Allow-Origin: "'*'"
RAG による地域防災計画の統合
大阪府内 10 市区の地域防災計画 PDF をテキスト抽出・チャンク化して、Gemini の埋め込みモデルでベクトル化し Qdrant に格納しました。
防災計画 PDF
↓ pdfminer でテキスト抽出
↓ チャンクサイズ 500 文字・オーバーラップ 50 文字で分割
↓ Gemini Embeddings でベクトル化(1536 次元)
↓ Qdrant に格納
質問が来るたびにベクトル類似検索で関連箇所を取得して、Gemini へのプロンプトに差し込みます。
const queryVector = await embedText(question);
const ragChunks = await searchDisasterPlans(queryVector, areaName);
const prompt = `
以下の地域防災計画の内容を参照して、質問に回答してください。
【参照資料】
${ragChunks.map(c => c.text).join('\n\n')}
【質問】
${question}
`;
「避難勧告が出たらどうすればいい?」という質問に対して、単なる知識ベースではなくその市の防災計画に書いてある内容を引用して回答できるようになりました。
気象警報の S3 キャッシュ設計
気象庁の警報・注意報は EventBridge で定期的に Lambda を起動して S3 にキャッシュする設計にしました。クエリのたびに気象庁サーバーに問い合わせるより、S3 から取得する方が安定します。
大阪府の警報情報は以下の URL から JSON で取得できます。
https://www.jma.go.jp/bosai/warning/data/warning/270000.json
EventBridge(5 分ごと)
↓
Lambda → 気象庁 API → S3 (weather/270000.json)
ユーザーリクエスト時
↓
Lambda → S3 キャッシュを読む(気象庁は叩かない)
システム全体の構成
住所入力
↓
API Gateway(スロットリング設定済み)
↓
Lambda (disaster-query)
├─ 国土地理院 API(住所 → 緯度経度)
├─ 不動産情報ライブラリ API × 4(ハザードタイル並行取得)
├─ S3(気象庁キャッシュ・避難場所・市区町村マスタ)
├─ OpenRouteService(最寄り避難場所への徒歩経路)
├─ Qdrant(地域防災計画 RAG 検索)
└─ Gemini 2.0 Flash(回答生成)
↓
Next.js(静的エクスポート)→ S3 + CloudFront
外部 API が多くてどれかが一時的に落ちることを想定し、各処理を try/catch で包んでスキップする設計にしています。気象庁が落ちていても、ハザード評価と避難場所は返せます。
月額コスト
| サービス | 費用 |
|---|---|
| Lambda + API Gateway | $0(無料枠内) |
| S3 × 2 | $0(無料枠内) |
| CloudFront | $0(無料枠内) |
| Gemini API | $0(無料枠内) |
| Qdrant Cloud | $0(無料プラン) |
| EventBridge | $0(無料枠内) |
| Route 53 | $0.50 |
| 合計 | 約 $0.50/月 |
まとめ
外部 API が多いシステムは、各 API の仕様と実際のレスポンス構造を手で確認してから実装に入ることが重要です。
Reinfolib のタイル座標の件は、最初に API を 1 エンドポイント叩いていれば設計をやり直すことにはなりませんでした。仕様をざっと読んで実装するより、まず手で叩いてレスポンスを確認してから実装に入る方が結果的に早いです。


