1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【AWS】ベイズ平均 × Bedrock × Comprehendで実装する「ハズレない」飲食店検索アプリ

1
Last updated at Posted at 2026-07-06

bedrock-comprehend.png

1. 対象読者とこの記事で得られること

こんな方に向けて書きました

  • Google Mapsで美味しい飲食店を探したいエンジニア
  • Bedrockで生成AIアプリを作ってみたいが、最初の一歩が分からない方
  • Comprehendによる感情分析を使ってみたい方

読後に持ち帰れる技術要素

技術要素 本記事での用途 汎用的に使える場面
ベイズ平均
Bayesian average
レビュー信頼度補正 ECサイト評価
推薦エンジン
Bedrock
invoke_model
店舗要約の自動生成 チャットボット
ドキュメント分類
Comprehend
バッチ感情分析
レビューの感情可視化 カスタマーサポート分析
SNS監視
S3 + CloudFront
写真キャッシュ
外部API写真コスト87%削減 画像配信最適化
CDN設計全般
DynamoDB
TTLキャッシュ
API呼び出しコスト削減 外部API連携全般
Lambda
ThreadPoolExecutor
36秒→13秒の並列化 複数外部API呼び出し処理全般

発言は個人の見解に基づくものであり、所属組織を代表するものではありません。

2. きっかけ:星4.5の店で5,000円を失った夜

先月、友達との飲み会で焼肉店を探していました。

Google Mapsで「星4.5」の店を見つけ、迷わず予約しました。しかし、実際に訪問してみると、期待とは大きくかけ離れた体験でした。レビューを改めて確認してみると、たった数件のレビューでした。冷静に見ればレビュー数の少なさに気づけたはずですが、星の数だけで判断してしまいました。

翌週、別の友達に連れて行ってもらった焼肉店は「星4.1」でしたが、レビューは数千件あり、味もサービスも期待以上でした。

この体験が出発点です。

「レビュー件数を考慮した、統計的に信頼できるランキングが欲しい」
AWSの勉強も兼ねて、「ハズレない」飲食店検索アプリを自分で作ることにしました。

                                       
完成したアプリ

3. そもそも何が問題か:数件の星4.5 vs 数千件の星4.1

以下の2店舗を比較してみてください。

店舗 Google評価 レビュー件数
A ★ 4.5 数件
B ★ 4.1 数千件

Google Mapsの標準UIでは店舗Aが上位に表示されます。

しかし統計学的には:

  • 数件:サンプルサイズが極めて小さく、偶然高評価が集中した可能性を排除できない
  • 数千件:これだけの母数で4.1を維持するには、継続的に高品質なサービスが不可欠

レビュー件数によるバイアスを統計的に補正する手法が、次に説明するベイズ平均です。

4. ベイズ平均:30秒で理解する

ベイズ平均は「サンプルサイズが小さい評価を、全体平均に引き寄せる」補正手法です。

\text{score} = \frac{C \times m + n \times r}{C + n}
記号 意味 本実装での値
C 仮想レビュー数(全店舗レビュー件数の中央値 動的計算
m 母集団平均(全店舗の平均評価) 動的計算
n 対象店舗の実レビュー件数 3件 or 1,200件
r 対象店舗の実評価 4.5 or 4.1
  • レビューが少ない → 全体平均に引き寄せられる(保守的な評価)
  • レビューが多い → 実評価がそのまま反映される(データに基づく評価)

なぜ平均値ではなく中央値か
検索結果にはレビュー5,000件の超人気店と10件の新店舗が混在します。平均値では外れ値に引っ張られるため、中央値を使ってロバストな閾値を算出しています。

4.1 実際に計算してみる

仮に全体平均 = 3.8、中央値(仮想レビュー数)= 200
店舗Aのレビュー件数 = 3件、店舗Bのレビュー件数 = 1200件とすると:

店舗A(星4.5 / 3件)

\frac{200 \times 3.8 + 3 \times 4.5}{200 + 3} = 3.81

店舗B(星4.1 / 1,200件)

\frac{200 \times 3.8 + 1200 \times 4.1}{200 + 1200} = 4.06

結果としてランキングが逆転し、レビュー件数の裏付けがある店舗Bが上位に来ます。これが「統計的に信頼できる店」の発掘につながります。

5. アーキテクチャ:シンプルな単一Lambda構成

                                       
構築したアーキテクチャ

キャッシュの確認

キャッシュMiss時のLambda処理

5.1 技術スタック

レイヤー 技術 選定理由
フロントエンド Next.js + TypeScript 型安全性、SSR対応
スタイリング Tailwind CSS 高速なUI構築
ホスティング Amplify Gen 2 Git Push連動の自動デプロイ
API API Gateway (REST) シンプルな構成
ロジック Lambda (Python) サーバーレス、コールドスタート改善
キャッシュ DynamoDB (TTL付き) フルマネージド、自動失効
キャッシュ(写真) S3 + CloudFront OAI制御、30日ライフサイクル自動削除
AI(要約) Bedrock
(Claude Haiku 4.5)
低コスト、高速、日本語対応
AI(感情分析) Comprehend バッチ処理対応、日本語対応
シークレット SSM Parameter Store SecureString暗号化
IaC CloudFormation 環境の再現性、変更履歴の管理

5.2 なぜ単一Lambdaにしたか

当初はStep Functions + 複数Lambda構成を検討しましたが、以下の理由から採用を中止しました。

観点 Step Functions 単一Lambda(採用)
実装複雑度 高(ステートマシン定義) 低(直線的フロー)
デバッグ ログが複数関数に分散 CloudWatchに集約
レイテンシ Lambda間通信のオーバーヘッド 関数内で完結
コスト Step Functions実行料金が加算 Lambda実行料金のみ

以上の理由から、要件に対してStep Functionsは過剰と判断し、まず動くものを最速で出してスケーラビリティが必要になった段階で分割する方針としました。

5.3 セキュリティ設計

個人開発の範疇ではありますが、最低限のセキュリティ設計を施しました。

シークレット管理

IAM最小権限の原則

S3アクセス制御

  • CloudFront OAI(Origin Access Identity)経由のみ公開アクセスを許可
  • 直接のS3 URLは外部に公開しない

CORS制御

入力検証

  • 駅名・ジャンルはホワイトリスト方式でバリデーション

6. つまずきポイント5選

ここからが本記事の核心です。開発中に遭遇した問題と、その解決過程を共有します。

6.1 つまずき① Google Places APIのレビュー件数制限

問題

  • レビューを数十件を取得して、感情分析の精度を上げたい
  • Google Places APIの検索エンドポイントは、1店舗あたり最大5件のレビューを返却

検討した選択肢

選択肢 コスト影響 判断
Place Details APIを各店舗に追加実行 +$0.34/検索(53%増) ✕ コスト増が大きい
5件で運用 現状維持 〇 採用

判断と根拠

  • 5件のレビューでも感情の傾向は十分に把握できると判断
  • 完璧な精度よりもコストとユーザー体験(レスポンス速度)のバランスを重視

6.2 つまずき② Bedrockのモデルリージョン問題

問題

  • 東京リージョン(ap-northeast-1)でBedrock呼び出しが失敗
# ✕ 動作しない(北米向けプレフィックス)
MODEL_ID = "us.anthropic.claude-haiku-4-5-20251001-v1:0"

# ✕ これも動作しない
MODEL_ID = "ap.anthropic.claude-haiku-4-5-20251001-v1:0"

# 〇 東京リージョンではjpプレフィックスが必要
MODEL_ID = "jp.anthropic.claude-haiku-4-5-20251001-v1:0"

解決策

  • リージョンごとにモデルIDのプレフィックスが異なる
  • 以下のコマンドで利用可能なモデルを確認可能
aws bedrock list-inference-profiles \
  --region ap-northeast-1 \
  --query "inferenceProfileSummaries[?contains(inferenceProfileId, 'haiku')]"

6.3 つまずき③ 写真取得のskipHttpRedirect

問題

  • Google Places APIで取得した写真URLにアクセスすると、302リダイレクト先のセッション付きURLが返却
  • このURLはサーバーサイドでのみ有効で、クライアントブラウザからはアクセスできない
# ✕ 302リダイレクト → セッション付きURL(クライアントからアクセス不可)
url = f"https://places.googleapis.com/v1/{photo_name}/media?key={api_key}"

# 〇 skipHttpRedirect=trueでphotoUriをJSONで直接取得
url = f"https://places.googleapis.com/v1/{photo_name}/media?skipHttpRedirect=true&key={api_key}"

教訓

  • skipHttpRedirect=trueを付与しないと写真が表示されない
  • APIドキュメントの細部まで確認する重要性

6.4 つまずき④ S3 HeadObjectの403エラーとバケットポリシー設計

問題

  • Lambda関数からS3HeadObject(キャッシュ存在確認)を呼び出すと403 Forbidden
  • IAMロールにはS3権限を付与しているはずだが、動作せず
# 403 Forbidden
s3.head_object(Bucket=S3_PHOTO_BUCKET, Key=s3_key)

原因

  • S3のPublic Access Blockが有効な状態では、IAMポリシーだけでなくバケットポリシーにもLambdaロールを明示的に許可する必要がある
  • s3:HeadObjectというアクションはバケットポリシーに存在しない
{
    "Effect": "Allow",
    "Principal": {"AWS": "arn:aws:iam::123456789012:role/lambda-role"},
    "Action": ["s3:PutObject", "s3:GetObject"],
    "Resource": "arn:aws:s3:::bucket-name/*"
},
{
    "Effect": "Allow",
    "Principal": {"AWS": "arn:aws:iam::123456789012:role/lambda-role"},
    "Action": "s3:ListBucket",
    "Resource": "arn:aws:s3:::bucket-name"
}

ポイント

  • HeadObjects3:GetObject権限でカバーされる(バケットポリシーにs3:HeadObjectは存在しない)
  • 404判定に使う場合はs3:ListBucketをバケットレベル(/*なし)で付与する必要がある
  • OAI用のバケットポリシーとLambdaロール用のポリシーは別ステートメントで管理

6.5 つまずき⑤ 処理時間 36秒 → 13秒への改善

問題

  • 20店舗分のBedrock呼び出しを直列処理した結果、レスポンスに36秒もかかった
# ✕ Before: 直列処理(約25秒)
for item in restaurants:
    enrich_item(item)

# 〇 After: 並列処理(約8秒)
with ThreadPoolExecutor(max_workers=5) as executor:
    futures = [executor.submit(enrich_item, item) for item in restaurants]
    for f in as_completed(futures):
        f.result()
処理 Before After 削減率
Bedrock要約生成 25秒 8秒 68%
写真URL取得 8秒 2秒 75%
合計 36秒 13秒 64%

教訓

  • I/Oバウンドな処理(外部API呼び出し)はThreadPoolExecutorによる並列化が効果的
  • Lambda環境でもスレッドプールは問題なく動作

7. 実装の詳細

コードの全体像を把握したい方向けのセクションです。
概要だけ知りたい方はコスト設計まで読み飛ばしてください。

7.1 メイン処理フロー

index.py
def handler(event, context):
    station = event.get("station", "")
    genre = event.get("genre", "")
    force_refresh = event.get("forceRefresh", False)
    
    cache_pk = f"RESULT#{station}#{genre}"
    
    # キャッシュ確認(force_refresh時はスキップ)
    if not force_refresh:
        cached = get_cache(cache_pk)
        if cached:
            result = dict(cached)
            result["costInfo"] = calc_cost(0, 0, 0, cached=True)
            return result  # コスト0円で即返却
    
    # Google API(店舗検索と駅座標の並列取得)
    with ThreadPoolExecutor(max_workers=2) as ex:
        f_station = ex.submit(fetch_station_location, station)
        f_places = ex.submit(fetch_google_places, station, genre)
        station_loc = f_station.result()
        places = f_places.result()
    
    # ベイズ平均スコアリング
    restaurants, score_detail = build_restaurants(places, genre)
    
    # Bedrock並列処理(5並列)
    with ThreadPoolExecutor(max_workers=5) as executor:
        futures = [executor.submit(enrich_item, item) for item in restaurants]
        for f in as_completed(futures):
            f.result()
    
    # 写真キャッシュ確認 + 取得(20並列、S3/CloudFront経由)
    fetch_all_photos_parallel(restaurants)
    
    # キャッシュ保存 + 返却
    save_cache(cache_pk, result)
    return result

7.2 ベイズ平均の計算

index.py
def build_restaurants(places: list, genre: str) -> tuple[list, dict]:
    ratings = [float(p["rating"]) for p in places if p.get("rating")]
    prior_mean = statistics.mean(ratings) if ratings else 3.5
    
    counts = [int(p.get("userRatingCount", 0)) for p in places 
              if p.get("userRatingCount", 0) > 0]
    threshold = float(statistics.median(counts)) if counts else 50.0
    
    for place in places:
        rating = place.get("rating")
        review_count = int(place.get("userRatingCount", 0))
        score = (threshold * prior_mean + review_count * rating) / (threshold + review_count)
        # ...

thresholdを検索結果全体の中央値から動的に算出することで、「渋谷の焼肉」と「地方の定食屋」のように母集団が異なるケースにも自動で適応します。

7.3 S3写真キャッシュ

index.py
def fetch_and_cache_photo(photo_name: str) -> str | None:
    cloudfront_domain = get_cloudfront_domain()
    if not S3_PHOTO_BUCKET or not cloudfront_domain:
        return fetch_single_photo_url_legacy(photo_name)

    # photo_nameのMD5ハッシュをS3キーとして使用
    photo_hash = hashlib.md5(photo_name.encode()).hexdigest()
    s3_key = f"photos/{photo_hash}.jpg"

    # S3キャッシュ確認
    try:
        s3.head_object(Bucket=S3_PHOTO_BUCKET, Key=s3_key)
        return f"https://{cloudfront_domain}/{s3_key}"  # キャッシュHIT
    except ClientError as e:
        if e.response['Error']['Code'] != '404':
            return fetch_single_photo_url_legacy(photo_name)

    # キャッシュMISS: Google APIから取得 → S3に保存
    photo_uri = fetch_photo_uri_from_google(photo_name)
    image_data = download_image(photo_uri)
    
    s3.put_object(
        Bucket=S3_PHOTO_BUCKET, Key=s3_key,
        Body=image_data, ContentType='image/jpeg',
        CacheControl='public, max-age=604800'
    )
    return f"https://{cloudfront_domain}/{s3_key}"

MD5ハッシュキー

  • photo_nameは長い文字列のため、固定長ハッシュをS3キーに使用

フォールバック

接続プール

  • max_pool_connections=25で20並列のHeadObject/PutObjectを効率的に処理

7.4 感情分析(Comprehend

index.py
def analyze_sentiment_batch(reviews: list) -> dict:
    target_reviews = reviews[:5]
    texts = [r.get("text", "") for r in target_reviews if r.get("text")]
    
    response = comprehend.batch_detect_sentiment(
        TextList=texts, LanguageCode="ja"
    )
    
    reviews_by_sentiment = {"POSITIVE": [], "NEGATIVE": [], "NEUTRAL": [], "MIXED": []}
    
    for i, result in enumerate(response.get("ResultList", [])):
        sentiment = result.get("Sentiment", "NEUTRAL")
        if i < len(texts):
            reviews_by_sentiment[sentiment].append(texts[i])
    
    return {
        "sentiment": dominant_sentiment,
        "scores": avg_scores,
        "reviewCount": count,
        "reviewsBySentiment": reviews_by_sentiment
    }

フロントエンド側では、感情分析バーをクリックすると該当カテゴリのレビュー原文を表示する仕組みを実装しています。

                                       
感情バーをクリック → 該当レビュー原文を表示

8. コスト設計:77円/検索から0円へ

8.1 S3キャッシュ導入前後の比較

フェーズ 1検索あたり 写真コスト 月100検索時
最適化前 ¥99 $0.56(87%) ¥9,900
S3キャッシュ導入後
(初回)
¥77 $0.42(60枚)
S3キャッシュ導入後
(2回目〜)
¥12 $0CloudFront配信) ¥1,200
DynamoDBキャッシュHit ¥0 $0 ¥0

8.2 現在の内訳(初回検索時)

項目 小計 割合
Places API Photo Media(60枚) $0.42 84%
Places API Text Search(2回) $0.064 13%
Bedrock Claude Haiku(20店舗分) $0.009 1.8%
Comprehend 感情分析(20店舗分) $0.006 1.2%
合計 $0.50(≈77円) 100%

8.3 S3 + CloudFrontによる写真コスト最適化

写真取得はコストの84%を占める最大のボトルネックでした。そのため、以下の戦略で2回目以降のコストを低減しています。

初回

2回目~

項目 設計
キャッシュキー photo_nameのMD5ハッシュ(photos/{hash}.jpg
ライフサイクル 30日で自動削除(S3 Lifecycle Rule
アクセス制御 CloudFront OAI経由のみ公開
フォールバック S3障害時はGoogle Places APIのURLを直接返却
並列度 max_pool_connections=25で20並列アップロード

月100検索(同一条件50%)の場合

  • Before: $56/月(写真のみ)
  • After: $0.09/月(S3ストレージ + CloudFront転送量)
  • 削減率: 99.8%

8.4 多層キャッシュ戦略

本システムでは3層のキャッシュでコストを段階的に削減しています。

レイヤー 対象 TTL 効果
DynamoDB 検索結果全体 1週間 2回目以降0円
S3 + CloudFront 写真データ 30日 写真コスト99.8%削減
CloudFrontエッジ 配信済み写真 7日 レイテンシ削減

検索結果(1週間)は飲食店のレビュー変動頻度に基づき、写真(30日)はストレージコストとAPI再取得コストのバランスで決定しています。

9. 使ってみる:川崎で焼肉を探す

実際の検索フローを紹介します。

9.1 Step 1: 条件設定

  • 駅:川崎
  • ジャンル:焼肉
  • 絞り込み:2,000〜4,000円 / 10分以内 / 金曜日の19時に営業
駅選択 ジャンル選択
                                       
絞り込み条件

9.2 Step 2: 結果確認

ベイズ補正スコア順に表示されます。レビュー件数が少ない店舗は自動的に順位が下がります。

                                       
絞り込み検索結果

9.3 Step 3: 詳細確認

ベイズスコア

  • 4.51(Google評価: 4.6 / 1315件)

AI要約

  • 「ビールケースの椅子が並ぶ雰囲気あるホルモン居酒屋で、新鮮なホルモンと遊び心のあるサービスが特徴。」

おすすめメニュー

  • [馬肉とホルモンのミックスホルモンユッケ]
  • [鉄板焼大トロホルモン塩]
  • [バカ金ハイ]

感情分析

  • ポジティブ 87% / ネガティブ 0% / 中立 13% / 混合 0%

9.4 Step 4: 予約

「マップ」もしくは「電話」ボタンから直接予約へ。

10. 振り返り:AWSサービス選定で学んだこと

今回の開発を通じて得た、AWSサービス選定に関する知見を共有します。

10.1 Bedrock vs SageMaker

当初はSageMakerでのモデルホスティングも検討しましたが、以下の理由からBedrockを選択しました。

観点 Bedrock SageMaker
初期構築コスト ゼロ
(APIコール課金のみ)
エンドポイント構築が必要
運用負荷 フルマネージド モデル更新・スケーリングの
管理が必要
レイテンシ 十分高速
(1〜2秒/リクエスト)
カスタムチューニングで
最適化可能
ユースケース適合性 要約生成に最適 大規模推論・独自モデル向き

個人開発〜中規模のAIアプリケーションであれば、Bedrockの「APIを叩くだけで推論できる」シンプルさは非常に大きな利点です。

10.2 Comprehend vs Bedrock(感情分析の使い分け)

感情分析をBedrockのプロンプトで行う選択肢もありましたが、以下の理由からComprehendを選択しました。

バッチ処理

  • batch_detect_sentimentで最大25テキストを1回のAPI呼び出しで処理可能

コスト効率

  • プロンプトエンジニアリング不要
  • ComprehendのバッチAPIの方がトークン課金より安い

出力の安定性

  • 感情分析に特化した専用モデル
  • LLMのように出力フォーマットが揺れない

適材適所の原則

  • 生成が必要な処理(要約・メニュー抽出)はBedrock
  • 分類が必要な処理(感情分析)はComprehend

全てをLLMに委ねるのではなく、タスクの性質に応じてサービスを使い分けることがコスト最適化の鍵です。

10.3 DynamoDB vs ElastiCache(キャッシュ層)

以下の理由からキャッシュにElastiCacheではなくDynamoDBを選択しました。

コスト

  • ElastiCacheは最小構成(cache.t3.micro)でも月額約$15〜
  • DynamoDBのオンデマンドモードなら月数円で済む

TTL

  • DynamoDBのTTL機能で自動削除が可能
  • RedisのEXPIREと同等の機能をサーバーレスで実現

運用負荷

  • VPC構成やクラスタ管理が不要

アクセス頻度が低く、レイテンシ要件もミリ秒単位を求めない用途では、DynamoDBで十分にキャッシュ層として機能します。

10.4 S3 + CloudFront vs 外部画像URL直接参照(写真配信)

以下の理由から写真をGoogle Places APIのURLそのまま返す方式から、S3キャッシュ + CloudFront配信に切り替えました。

観点 直接URL参照 S3 + CloudFront(採用)
コスト 毎回Places Photo API課金
($0.007/枚)
初回のみ課金
以降はS3ストレージ費のみ
レイテンシ Google CDN依存 CloudFrontエッジキャッシュで高速配信
可用性 Google API障害に依存 S3の99.99% SLAで安定配信
URLの安定性 セッション付きURL
(有効期限あり)
永続的なCloudFront URL

設計上のポイントとして、S3バケットはCloudFront OAI(Origin Access Identity)のみを許可し、Lambda実行ロールには別途バケットポリシーでPutObject/GetObject権限を付与しています。この2層のアクセス制御により「Lambdaは書き込み可、ユーザーはCloudFront経由の読み取りのみ」を実現しています。

11. 今後の展望

機能 優先度 技術的課題
エリア検索
(渋谷〜恵比寿の範囲指定)
ジオフェンシング実装
レビュー数十件取得による
分析精度向上
Place Details APIコスト(+$0.34/検索)
パーソナライズ推薦 ユーザー行動履歴の蓄積設計
多言語対応 Comprehend多言語対応
フロントエンド国際化

12. まとめ

項目 Before(Google Maps After(本アプリ)
ランキング精度 星4.5(3件)が上位に表示 星4.1(1,200件)が上位に表示
検索時間 約30分(手動調査) 13秒
情報量 評価・件数のみ +AI要約 +感情分析 +おすすめメニュー
コスト 無料(ただし30分の時間コスト) 77円(2回目以降12円、キャッシュHit時0円)

本記事で実装・解説した技術要素

  1. ベイズ平均によるレビュー件数バイアスの統計的補正
  2. Bedrock(Claude Haiku 4.5)による店舗情報の自動要約生成
  3. Comprehendによるレビュー感情分析(バッチ処理)
  4. S3 + CloudFrontによる写真キャッシュ(月間写真コスト99.8%削減)
  5. ThreadPoolExecutorによる処理並列化(36秒→13秒、64%削減)
  6. DynamoDB TTLキャッシュ + S3写真キャッシュの多層キャッシュ戦略
  7. CloudFormationによるインフラのコード化(IaC)

個人開発であっても、AWSのマネージドサービスを適切に組み合わせることで、運用負荷を抑えつつ実用的なAIアプリケーションを構築できることを示せたと考えています。

本記事が、AWSで生成AIを活用したアプリケーション開発に取り組む方の参考になれば幸いです。

この記事が役に立ったら、ぜひいいねやストックをしていただけると嬉しいです!

改めて、最後まで読んでいただき、ありがとうございました。

13. 参考文献

AWS は、米国その他の諸国における Amazon.com, Inc. またはその関連会社の商標です。
その他、記載されている会社名および商品・製品・サービス名は、各社の商標または登録商標です。

1
0
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
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?