改善優先度がいつまでも決まらない問題から始めた話
個人でアプリ開発を続けて12年、累計5,000万ダウンロードを超えたあたりから、レビュー欄が「玉石混淆の鉱脈」になってきました。役に立つ改善要望もあれば、星1だけ残して立ち去る怒気、機能名すら間違えたまま投稿される報告もあります。週末ごとにまとめて眺めて、頭の中で優先度を並べていた時期もありましたが、複数アプリを並行運用するようになってからは、そのやり方が破綻していきました。
「来週はどの修正に手を入れるか」を決める材料が、いつも数百件のレビューを眺めた疲れだけになってしまい、結果として優先度が曖昧なまま機能追加に流れる週が続きます。AdMob 収益も DAU の山にぶら下がっているので、改善着手を間違えると、その月の生活が直接打撃を受けます。個人開発者として一番怖いのは、要望が大きい順に手を入れたつもりが、実は単発の怒気を多く反映していて、肝心のサイレントマジョリティの不満を後回しにしていた、というケースです。
Gemini の Embedding API が安定して使えるようになってから、月末にレビューをまとめてベクトル化してクラスタリングし、改善優先度を機械的に算出する仕組みに切り替えました。約1年運用したいま、固まった3層パイプラインを記録として残しておきます。アーティスト・クリエイターの廣川政樹が、自分の事業を畳まずに続けるために組んだ実装の話です。
なぜキーワード集計ではなく意味クラスタリングなのか
最初は素朴に App Store Connect API と Google Play Developer API からレビュー本文を抜き、形態素解析にかけて頻度順に並べていました。それなりに動くのですが、3つの限界に当たります。
ひとつ目は、同じ要望が違う言葉で書かれていると統合されないことです。「もっとシンプルに」「ごちゃごちゃしている」「アイコンが多すぎる」は別カウントになり、本当に多数派の声が埋もれます。形態素解析ベースでは語形ゆれを救えても、語彙そのものを横断できません。レビューを書く側はその場の感情で言葉を選んでいるため、同義の表現は驚くほど分散します。
ふたつ目は、否定文と肯定文が同じカウントに混ざることです。「広告が多すぎ」と「広告が少なくて良い」がキーワード「広告」で同じ列に並びます。さらに「広告のレイアウトが古臭くて押し間違える」のような複合的な不満も、単に「広告」「レイアウト」のキーワードに分解されてしまい、真の意味を失います。
みっつ目は、多言語でカウントが断絶することです。英語の "too many ads" と日本語の「広告が多すぎ」が別物扱いになり、グローバルでの優先度が判定できません。私の場合、英語ユーザーの DL 比率が約55%、日本語が約30%、その他言語が約15%という分布で、英語を切り捨てると優先度が大きく狂います。
Embedding は意味で束ねるので、この3つを横断します。書き手の語彙ゆれや言語境界に左右されず、開発者が見るべき「同じ課題のグループ」として並びます。広告量の不満だけ抽出したいときも、極性付きで分離して取り出せます。
レビュー収集 ─ 3ソース統合の最初の壁
App Store Connect API はレビューを customerReviews リレーションで返しますが、ページング上限とレートリミットが厳しめです。さらに直近1〜2ヶ月分が中心で、古いレビューは時々欠落します。Google Play は逆に直近1週間に偏ります。SNS(X / Threads)の言及はもっと薄く、ハッシュタグも統一されません。
AdMob で月100万円規模を支えている主要アプリだけでも、累計レビューが日英合計で1万件を超えています。3ソースをそのまま結合してしまうと出自情報が消えるので、メタデータを残したまま正規化する形に落としました。
# 入力レビューの正規化スキーマ
from dataclasses import dataclass
from datetime import datetime
from typing import Literal
@dataclass ( frozen = True )
class Review :
source: Literal[ "app_store" , "google_play" , "x" , "threads" ]
app_id: str
locale: str # "ja-JP" / "en-US" など
rating: int # 1-5、SNS は 0 を入れる
title: str # SNS は空文字
body: str
posted_at: datetime
review_id: str # "store:id" でソース横断一意化
metadata: dict # device / version / country をそのまま保持
正規化のときに unicodedata.normalize("NFKC", body) を必ず通します。App Store からのレビューには全角英数字が混じっていることがあり、これを忘れると後段のキャッシュキーが本人が気づかないうちに割れて、同じレビューを二度 Embedding して二重課金される事故になります。1ヶ月目にこれをやってしまい、複数のアプリで余計に支払いました。
メタデータをそのまま保持しておくのが効くのは、あとからクラスタを「特定機種だけで起きている」「特定 OS バージョンだけ」など軸を切り替えて再分析するときです。設計初期に削ぎ落としたフィールドは復元できないので、ストレージコストは妥協しても保持を優先します。D1 のテーブルでは metadata を JSON 列にしてあり、必要なときだけ展開しています。
Embedding モデルの選び方 ─ gemini-embedding-001 をどう絞るか
Gemini の Embedding は gemini-embedding-001 を使うのが現状の素直な選択肢です。タスクタイプを CLUSTERING に明示すると、検索やリトリーバル用に最適化された潜在表現とは別系統が呼ばれます。クラスタリング用途では指定漏れで精度が一段落ちることが何度かありました。具体的には、検索向けのデフォルトでは「肯定的な質感の良いレビュー」と「否定的だが似た話題のレビュー」が近くなる傾向があり、後段の HDBSCAN でラベルが混ざります。
設定として固まっているのは次の3点です。task_type="CLUSTERING" を必ず指定すること、output_dimensionality は 256 まで落として保存すること、1リクエストあたり100件にバッチングして idempotency を SHA-256 で担保することです。
768 次元と 256 次元の差は、HDBSCAN にかけたあとのクラスタ数で見るとほぼ変わりません。手元の壁紙アプリのデータ(日英 12,400 件)で比較すると、768 次元でクラスタ数 73、256 次元で 71 でした。クラスタ内のドリフトもごく微小で、保存容量と検索速度のメリットが大きいので 256 を採用しています。256 にすると D1 への永続保存サイズが約3分の1になり、月の Cloudflare コストにも素直に効きました。
import os
from google import genai
from google.genai.types import EmbedContentConfig
client = genai.Client( api_key = os.environ[ "GEMINI_API_KEY" ])
def embed_reviews (reviews: list[ str ], dim: int = 256 ) -> list[list[ float ]]:
"""クラスタリング用の embedding を 256 次元で取得"""
result = client.models.embed_content(
model = "gemini-embedding-001" ,
contents = reviews,
config = EmbedContentConfig(
task_type = "CLUSTERING" ,
output_dimensionality = dim,
),
)
return [e.values for e in result.embeddings]
100 件ずつのバッチで投げ、レビュー側に SHA-256 ハッシュを付けて idempotency-key 相当の重複チェックをかけます。これで「同じレビューに対して別バッチで2回課金される」事故が消えました。バッチ単位で例外が出た場合は、5件ずつに割って再投入する縮退ルートを別に持っています。長文のレビューが1件混ざるとバッチ全体が落ちることが、半年で2回ありました。
第1層クラスタリング ─ HDBSCAN で粗く束ねる
次元削減せずに HDBSCAN を直で当てます。先に UMAP を挟む実装もありますが、5,000〜15,000 件のレビュー規模では UMAP の確率的なゆらぎがクラスタ境界に乗ってしまい、毎週同じレビュー集合で再走しても結果が揺れます。本番運用では決定的に動いてほしいので、HDBSCAN 単体で十分でした。
import hdbscan
import numpy as np
def cluster (embeddings: np.ndarray, min_cluster_size: int = 8 ):
clusterer = hdbscan.HDBSCAN(
min_cluster_size = min_cluster_size,
min_samples = 3 ,
metric = "euclidean" , # 単位正規化済みなのでこれで OK
cluster_selection_method = "leaf" ,
)
labels = clusterer.fit_predict(embeddings)
return labels, clusterer.probabilities_
min_cluster_size=8 は「8人以上が言っていれば本気で見る」という事業判断から来ています。星1の単発投稿に振り回されたくないので、ここを下げすぎないのがコツです。leaf 選択は細かい派生課題を取りこぼさず分離します。eom 選択だと「広告関連」のような大きな塊に飲み込まれ、優先度の解像度が落ちます。
min_samples=3 も意図的に小さめにしてあります。これを 8 や 10 にすると、母集団が少ない言語のクラスタが消えます。マイナーロケールでも要望は出ているので、保守的に低めです。代わりに後段の優先度スコアでクラスタサイズに重みを置くことで、最終的な順位が小さなクラスタに乗っ取られないようにバランスを取りました。
第2層 ─ Gemini にクラスタの主訴を要約させる
HDBSCAN が返すのはラベル番号と確率だけなので、人間が読める要約が要ります。Gemini 2.5 Flash にクラスタごとの代表レビュー(クラスタ中心に近い5件)を渡し、JSON で「主訴」「典型例」「想定改善」を返してもらいます。
PROMPT = """ \
あなたはアプリ開発者の改善優先度を整理する分析アシスタントです。
以下はあるユーザーレビューの意味クラスタに属する代表5件です。
JSON で次のフィールドを返してください:
- topic: クラスタの主訴を 25 文字以内の日本語名詞句で
- examples: 代表的な言い回しを最大3つ、原文の言語で
- proposed_change: 改善案を 1 文で
- severity: 影響度を 1-5 で(5 = アンインストール直結)
- polarity: -1 (不満) / 0 (中立) / +1 (称賛)
"""
def summarize_cluster (reviews: list[Review]) -> dict :
response = client.models.generate_content(
model = "gemini-2.5-flash" ,
contents = [ PROMPT , * [ f "- ( { r.locale } ★ { r.rating } ) { r.body } " for r in reviews]],
config = { "response_mime_type" : "application/json" },
)
return json.loads(response.text)
ここで Flash を選ぶ理由は単純で、クラスタ数 70 を毎週1回まわしてもおおむね4円から8円で収まるからです。Pro に切り替えても 25 文字の topic 推定の精度は体感差がほぼなく、月のコストは30倍以上に跳ね上がります。AdMob 収益で支えている個人事業として、ここは Flash で十分です。
代表レビューの選び方は、クラスタ中心からのコサイン距離が小さい5件を素直に選ぶだけです。最初は確率(HDBSCAN の probabilities_)が高い順に選んでいましたが、確率が高いものは「クラスタ中心」とは別の概念で、コサイン距離のほうが LLM の要約品質に直結することが、20ケースくらい目視で比較してわかりました。
第3層 ─ 改善着手の優先度スコアを決める
クラスタ要約だけでは優先度は決まりません。手元では次の重み付けで priority_score を算出しています。クラスタサイズに 0.35、平均レーティング差分に 0.30、極性符号反転に 0.15、直近30日比率に 0.15、severity の正規化値に 0.05、という配分です。
def priority_score (cluster_summary: dict ) -> float :
size = cluster_summary[ "size" ]
rating_gap = cluster_summary[ "avg_rating" ] - GLOBAL_AVG_RATING
polarity_adj = - 1 if cluster_summary[ "polarity" ] == - 1 else 0.2
recency = cluster_summary[ "recent30_ratio" ]
severity = cluster_summary[ "severity" ] / 5
return (
0.35 * normalize(size, max_size = 300 )
+ 0.30 * polarity_adj * abs (rating_gap)
+ 0.15 * ( 1 if cluster_summary[ "polarity" ] == - 1 else 0 )
+ 0.15 * recency
+ 0.05 * severity
)
LLM 推定の severity を重み 0.05 まで抑えているのは、ハルシネーション混入を限定的にするためです。クラスタサイズと否定極性が実際の業務影響をいちばん正確に表すので、ヒトの判断に近い順序になります。実装直後は severity 重み 0.30 で試しましたが、Flash が「広告が良いと言っているレビュー」を 5 と推定してしまう揺らぎが続き、優先度の上位を歪めました。重みを動かして本番に近い順位を再現できるのは、結局のところ事業を一番見ている本人なので、判断軸を LLM の出力に渡しきらないのが安全だと感じます。
直近30日比率は、いま流行り始めている課題を拾うために入れてあります。同じサイズのクラスタでも、過去2年分散しているものより直近1ヶ月に集中しているものは、新しいバージョンで生まれた問題の可能性が高く、優先順位を上げる必要があります。OS アップデート直後にこのスコアが跳ね上がるクラスタは、ほぼ確実に互換性問題の予兆でした。
キャッシュとコスト設計 ─ 月数百円に収める
レビューは差分のほうが圧倒的に多いので、Embedding をフルに再計算する運用はあり得ません。手元の運用では以下のキャッシュ階層を組んでいます。
L1 は Cloudflare KV に review:hash のキーで60日 TTL を持たせます。L2 は D1 のテーブルに永続保存し、hash・embedding・posted_at・app_id・locale を並べます。L3 は月次の再 Embed を「過去2年で hash 未登録のレビューだけ」に絞ります。
過去2ヶ月の運用ログでは、約 12,400 件のレビューに対して毎週新規に Embed されるのは 50〜140 件程度でした。1リクエスト 100 件で2バッチに収まるので、Embedding 単独の月額は数十円。Flash の要約と合わせても、月の上限は600円程度で安定しています。AdMob 1日あたり数千〜数万円の収益と比べれば、コスト面で意思決定を遅らせる要素はほぼなくなりました。
D1 に永続保存しているのは、モデルバージョンアップ時の再 Embed をやりやすくするためでもあります。gemini-embedding-001 から将来別のモデルに移行するときに、過去レビューだけ別系統で再計算したい場面が必ず来ます。生のレビュー本文 + ハッシュを正規化形式で保存しておくと、差分 Embed が走るたびにモデルバージョンも添えて記録できます。バージョン混在のクラスタリングは結果が荒れるので、再計算が完了するまで前モデルのベクトルでクラスタリングを続けるフラグも入れてあります。
実装でハマったところ ─ 3つの落とし穴
実運用で1ヶ月以上格闘したのは次の3点でした。
ひとつ目は、レビュー本文の Unicode 正規化漏れです。App Store Connect から返ってくるレビュー本文には NFKC で正規化されていない全角英数字が混じります。Embedding 自体は耐えますが、L1 キャッシュキーがズレて重複 Embed されました。正規化を最初に必ず通すことで本番運用に乗りました。
ふたつ目は、多言語混在クラスタの誤統合と分離です。日英ともに「広告が多すぎ」の意味のクラスタは統合されるのですが、「クラッシュする」の意味のクラスタは日英で別れたがります。Gemini の Embedding は多言語対応ですが、文化的文脈の差が反映されるためです。task_type="CLUSTERING" のまま 256 次元では分離されやすかったので、本当に統合したい場合は、要約後の topic を再度 Embedding して統合するメタクラスタを後段に置きました。
みっつ目は、HDBSCAN の noise ラベルへの誤った扱いです。HDBSCAN は「どのクラスタにも属さない」レビューに -1 を付けます。当初はこれを「重要度の低いレビュー」と読み替えていましたが、-1 の中に唯一のリリースブロッカー(特定機種でクラッシュ)が紛れている事故がありました。-1 のレビューも別途 Crashlytics と突合して優先度を見るようにしています。本番運用では「クラスタ未所属のレビューだけ集めた弱クラスタリング」をもう一度走らせる縮退モードも作りました。
評価方法 ─ ヒト判断との一致度を毎月測る
機械的に算出した優先度が、人間の判断とどれだけ一致するかは毎月測っています。前月に着手したクラスタ上位10件のうち、私自身が「これは正しく上位だった」と評価できる割合を一致度として記録します。1年運用してきて、一致度は約78%で安定しています。残り22%の不一致は、ほとんどがトレンドの過小評価(直近30日比率の重みをもう少し上げるべきか)と、競合アプリのリリースに引きずられた一過性ノイズ(ある程度仕方ない)に分かれます。
評価ログは Google スプレッドシートに月次で残し、四半期ごとに重みの微調整を入れています。アート活動で展示の搬入を準備しているような週でも、このスコアだけ眺めれば改善の打順が決まるので、思考の余白を確保できるようになりました。複数事業を並行している個人にとって、判断軸を機械化することは時間の余裕より、ずっと根本的な楽さになります。
比較検討した別パスとその却下理由
設計の最初に検討した別パスがいくつかあります。記録のために残しておきます。
ひとつは、Gemini 2.5 Pro に直接「このレビュー一覧から優先度の高い改善要望を JSON で抽出して」と全件投げる素朴なアプローチです。1万件のレビューを 32k トークンずつのバッチで割ると30回前後の API 呼び出しで済み、コードは10行で書けます。半月試してみたのですが、バッチ境界をまたいだクラスタが分散される(同じ主訴が複数バッチに割れる)問題が解消できず、撤退しました。Embedding + クラスタリングのほうが、トータルのコストでも安く、結果も安定します。
もうひとつは、OpenAI の text-embedding-3-large と Gemini Embedding を併用するハイブリッド構成です。多言語性能を比較しましたが、日本語のクラスタリングでは Gemini のほうが体感で1段精度が高く、英語でも差が小さかったので、運用負荷を考えて Gemini に一本化しました。ただし、text-embedding-3-large を完全に切ったわけではなく、月1のゴールドセット評価で比較用ベンチに残しています。複数モデルの観測点を持つと、Gemini 側に何かあったときの平常状態を知る基準になります。
最後に、商用 ASO ツール(AppFollow / Sensor Tower のレビューモジュール)を導入する案も検討しました。月額が3万円から始まることと、優先度算出のロジックがブラックボックスになることから見送りました。私の事業規模だと、月600円のセルフホスト運用のほうが収益への寄与が明らかに勝ちます。事業が大きくなったときに改めて比較したい選択肢ではあります。
ゴールドセットによる Embedding 品質の検証
クラスタリングの品質を継続的に保つために、ヒト判断による「ゴールドセット」を作っています。代表的な100件のレビューに、5種類の主訴ラベル(広告系・操作性系・クラッシュ系・コンテンツ要望・サブスク不満)を私自身が手で付けたものです。新しい Embedding モデルが出るたびに、このセットを Embedding して KMeans で5クラスタに割り直し、ヒトラベルとの一致率(純度)を測ります。
100件と少ないですが、固定セットで毎月推移を取ると、モデル交代時の品質劣化やリリース時のドリフトに気づけます。手元の運用では gemini-embedding-001 で純度 87% から始まり、半年の運用で 85% 前後を保っています。3%以上の落ち込みが2週連続で出たら、レビュー本文の構文変化(App Store のフォーマット変更や、突発的なテンプレレビューの増加)を疑うサインにしています。
def evaluate_purity (gold: list[tuple[ str , str ]]) -> float :
"""ゴールドセットの主訴ラベル一致率を測る"""
texts = [t for t, _ in gold]
labels_true = [lab for _, lab in gold]
vecs = np.array(embed_reviews(texts, dim = 256 ))
km = KMeans( n_clusters = 5 , n_init = 10 , random_state = 42 ).fit(vecs)
return purity_score(labels_true, km.labels_)
このスクリプトは毎月1回 GitHub Actions で走らせ、結果を Slack に流すだけの簡単な仕組みです。複雑な可観測性基盤がなくても、判断軸となる数字を継続的に眺められる体制は作れます。
改善着手フローへの接続 ─ Linear と FCM へ
優先度上位のクラスタは、ある一定スコア以上で Linear に Issue を自動起票します。重複起票を避けるため、クラスタの ID は HDBSCAN ラベル + 主要5件のハッシュで決定的に決めています。これにより、毎週走らせても同じクラスタは既存 Issue に追記される動きになります。
def to_linear_issue (cluster: dict ) -> dict :
return {
"title" : f "[Review優先度 { cluster[ 'priority' ] :.2f } ] { cluster[ 'topic' ] } " ,
"description" : format_cluster_description(cluster),
"labels" : [ "user-feedback" , cluster[ "app_id" ], polarity_label(cluster)],
"stateId" : LINEAR_TRIAGE_STATE_ID ,
}
ユーザーへの改善通知は、Firebase Cloud Messaging で「あなたが書いたレビューと近い課題が修正されました」を任意配信できる仕組みにしてあります。再起動してくれるユーザーの割合が、内部測定で約 8.4% 上振れました。eCPM の改善だけでなく、復帰ユーザーの DAU 寄与も事業インパクトとして無視できません。個人開発でこの種の感謝循環を作れるようになったのは、Embedding API が安定した時期と重なります。
1年運用して感じていること
1997年に独学でインターネットに触れて以来、自分の手で動くものを作ることだけが寄りどころでした。レビュー欄は時にきついものですが、丁寧に読み解けば必ず筋の通った要望に変換できます。Gemini の Embedding はその翻訳工程を、私一人では到底さばけない量にしてくれました。
宮大工だった両家の祖父が、見えない柱の継ぎ手まで気にして仕事をしていたと父から何度も聞きました。コードもレビューも、見えにくいところを丁寧に組み続けるのが結局いちばん効きます。次に手元のデータで試してみたいのは、クラスタごとの離脱寄与度を Firestore のセッションデータと突き合わせる仕組みです。動くようになったら、また個別の検証記事として残します。
同じように個人開発でレビュー処理に詰まっている方の参考になれば嬉しいです。お読みいただきありがとうございました。