週次の検索データを開いて、表示回数の多い順に並べ替えたときのことです。上位5件のクリックが、揃って 0 でした。
最初に浮かんだのはタイトルの問題でした。順位は悪くないのに誰も押していないのだから、見出しか説明文が弱いのだろう、と。
読み直して、手が止まりました。
rork.com core business features target audience — これを検索窓に打ち込む人を、私は想像できませんでした。
表示回数の上位に並んでいた5つのクエリ
対象は、私が個人開発と並行して運営している技術サイトのうち、アプリ開発ツールを扱っている1サイトです。集計期間の全表示回数は 41,700 でした。
クエリ 表示 クリック
rork.com core business features target audience 1,371 0
rork max swiftui features and ai capabilities 1,282 0
onspace vs rork for complex app logic 1,149 0
expo audio continue playing screen locked react native 2025 1,023 0
rork max swiftui features and native app generation 624 0
合計 5,449 表示。全体の 13.1% です。クリックは全て 0。
上から4件のうち3件は、語の並びが不自然です。core business features target audience は属性名を並べただけですし、features and ai capabilities も同じ構造をしています。onspace vs rork for complex app logic に至っては、比較軸まで文中に埋め込まれています。
人間はこうは打ちません。打つとしたら「rork max 料金」のように、短く、必要な語だけを置きます。
これは AI 検索が内部で展開したサブクエリです。ひとつの質問を受け取った側が、答えを組み立てるために8〜16程度の下位クエリへ分解して検索を回す。その挙動は query fan-out として整理されています 。Google 側も AI 機能を含む検索面での表示の扱い を説明していますが、レポート上で人間の検索と分けて出てくるわけではありません。
つまり、私の CTR の分母には、構造的にクリックが発生し得ない表示が混ざっています。
クリック数を判定条件に入れてはいけない
ここで最初の落とし穴を踏みかけました。
「クリック 0 かつ高表示」を合成クエリの判定条件にすれば、機械的に仕分けられます。実際、上の5件は全てその条件を満たしています。
しかしこれをやると、除外した瞬間に CTR が必ず上がります。分子に寄与しない表示だけを狙って分母から抜くのですから、当然です。改善したのではなく、改善したように見える計算を作っただけになります。
私はこの手の「自分に都合よく効く指標」を、過去に何度か作ってしまったことがあります。数字が動いたときに嬉しくなってしまい、検算をしなくなるのが怖いところです。
ですので、判定はクリック数を一切見ずに、クエリの語の並びだけ で行うと決めました。結果としてクリックが多いクエリが合成と判定されたなら、それは判定条件のほうが間違っている、という検証の順序を残しておきたかったからです。
語の並びだけで仕分けるスコアリング
Search Console からエクスポートした Queries.csv をそのまま食わせる想定で書きました。API を通す必要はありません。
# fanout_score.py — クエリ文字列だけで「AI 検索の展開クエリらしさ」を採点する
# クリック数・表示回数は一切参照しない(循環参照を避けるため)
import csv
import re
import sys
from dataclasses import dataclass, field
# 属性を並列で繋ぐ機能語。人間の検索語では出現頻度が低い
JOINERS = ( " vs " , " versus " , " for " , " and " , " with " , " without " )
# 人間が困って打つときに現れる語。出たら強く減点する
INTENT_MARKERS = (
"error" , "fix" , "not working" , "failed" , "how to" , "why" , "cannot" , "can't" ,
"エラー" , "できない" , "直し方" , "とは" , "使い方" ,
)
# 末尾の年号は「最新情報が欲しい人間」のシグナルとして扱う
YEAR = re.compile( r " \b( 19 | 20 )\d {2} \b " )
# 「何ができるか」を列挙させる語。展開クエリ側に偏る
ATTRIBUTE_NOUNS = re.compile(
r " \b( features | capabilities | use cases | target audience | overview | benefits )\b "
)
@dataclass
class Verdict :
query: str
score: int
band: str
reasons: list = field( default_factory = list )
def fanout_score (query: str ) -> Verdict:
q = query.lower().strip()
tokens = q.split()
score, reasons = 0 , []
if len (tokens) >= 6 :
score += 1
reasons.append( f "tokens>=6 ( { len (tokens) } )" )
if len (tokens) >= 8 :
score += 1
reasons.append( f "tokens>=8 ( { len (tokens) } )" )
joiners = [j.strip() for j in JOINERS if j in f " { q } " ]
if joiners:
score += 1
reasons.append( "joiner:" + "/" .join(joiners))
hits = [m for m in INTENT_MARKERS if m in q]
if hits:
score -= 2
reasons.append( "intent:" + "/" .join(hits))
if YEAR .search(q):
score -= 1
reasons.append( "year-suffix" )
if ATTRIBUTE_NOUNS .search(q):
score += 1
reasons.append( "attribute-noun" )
# 3 点以上は展開クエリ、1 点以下は人間、2 点は保留
band = "fanout" if score >= 3 else ( "human" if score <= 1 else "ambiguous" )
return Verdict( query = query, score = score, band = band, reasons = reasons)
def main (path: str ) -> None :
with open (path, newline = "" , encoding = "utf-8-sig" ) as f:
for row in csv.DictReader(f):
v = fanout_score(row[ "Top queries" ])
print ( f " { v.band :9s } score= { v.score :2d } { v.query } " )
print ( f " { v.reasons } " )
if __name__ == "__main__" :
main(sys.argv[ 1 ] if len (sys.argv) > 1 else "Queries.csv" )
上の5件を通したときの実際の出力です。
ambiguous score= 2 rork.com core business features target audience
['tokens>=6 (6)', 'attribute-noun']
fanout score= 3 rork max swiftui features and ai capabilities
['tokens>=6 (7)', 'joiner:and', 'attribute-noun']
ambiguous score= 2 onspace vs rork for complex app logic
['tokens>=6 (7)', 'joiner:vs/for']
human score= 1 expo audio continue playing screen locked react native 2025
['tokens>=6 (9)', 'tokens>=8 (9)', 'year-suffix']
fanout score= 4 rork max swiftui features and native app generation
['tokens>=6 (8)', 'joiner:and', 'attribute-noun', 'tokens>=8 (8)']
5件のうち、確信を持って合成と言えたのは 2 件だけでした。
目視では4件が明らかに機械的だと感じたのに、機械に落とすと半分以下しか取れない。ここが予想と逆でした。人間の直感は「語彙の座り」を見ていますが、スコアリングが見ているのは語数と機能語の有無だけです。両者は同じものを測っていません。
そして、この差を埋めるために閾値を下げると、今度は4件目のような正当な検索語を巻き込みます。閾値の調整では解決しない種類のずれです。
判定が割れた2件を Gemini に回す
保留になった 2 件だけを Gemini に投げます。全件を投げないのは、コストの問題というより、判定の再現性を落としたくないから です。スコアリングで機械的に決まる部分は機械に任せ、言語の座りを見なければ判断できない部分だけをモデルに委ねる、という分担にしました。
出力は必ず構造化させます。自由文で返させると、後段の集計スクリプトが壊れます。
# adjudicate.py — ambiguous 判定のクエリだけをモデルに回す
from google import genai
from pydantic import BaseModel
client = genai.Client( api_key = "YOUR_API_KEY" )
SCHEMA_PROMPT = """あなたは検索ログの分析者です。
与えられたクエリが、人間が検索窓に入力した語である可能性を判定してください。
判定基準:
- 人間の検索語は短く、必要な語だけで構成される傾向がある
- 属性名の列挙(features / capabilities / target audience 等)や、
比較軸を文中に埋め込んだ表現は、AI が内部で生成した下位クエリに多い
- ただし、エラー文をそのまま貼り付けた長いクエリは人間側である
クリック数や表示回数は与えません。語の並びだけで判定してください。"""
class Judgement ( BaseModel ):
likely_human: bool
confidence: float # 0.0-1.0
reason: str
def adjudicate (query: str ) -> Judgement:
response = client.models.generate_content(
model = "gemini-3.7-flash" ,
contents = f " { SCHEMA_PROMPT }\n\n クエリ: { query } " ,
config = {
"response_mime_type" : "application/json" ,
"response_schema" : Judgement,
},
)
return Judgement.model_validate_json(response.text)
AMBIGUOUS = [
"rork.com core business features target audience" ,
"onspace vs rork for complex app logic" ,
]
for q in AMBIGUOUS :
j = adjudicate(q)
# confidence が低い判定は採用せず、保留のまま次週へ持ち越す
verdict = "human" if j.likely_human else "fanout"
status = verdict if j.confidence >= 0.7 else "hold"
print ( f " { status :7s } conf= { j.confidence :.2f } { q }\n { j.reason } " )
confidence を返させて 0.7 未満を採用しない、という一手間を入れています。モデルは迷っていても断定した文を返してきますので、迷いを数値として吐かせておかないと、迷いが記録に残りません。
保留のまま翌週へ持ち越すクエリが出るのは、設計として正しいと考えています。判定できないものを無理に片付けると、その判断の根拠がどこにも残らないまま集計だけが進んでしまいます。分類ラベルの揺れを後段で吸収する考え方は 閉じた語彙を正規化する層 にも通じますし、スキーマ側の設計は Structured Output のバリデーション の考え方をそのまま流用できます。
除外方針を3つ並べると、実質 CTR は一つに定まらない
ここが、この作業で一番効いた発見でした。
除外の範囲を変えて、実質 CTR を計算し直します。元の CTR は 0.70%(表示 41,700)です。
除外方針 除外表示 全体比 実質 CTR
除外しない 0 0% 0.70%
スコア3以上のみ除外 1,906 4.6% 0.73%
保留分も除外 4,426 10.6% 0.78%
上位5件すべて除外 5,449 13.1% 0.81%
0.70% から 0.81% まで、方針の違いだけで振れます。
私はこれを見て、「実質 CTR」という単一の数字を報告するのをやめました。数字だけを見せると、受け取った側は必ず「で、結局どっちなんですか」と聞きます。そして、答えは除外方針の中にしかありません。
以後は、実質 CTR を出すときに必ず除外方針を併記しています。「スコア3以上のみ除外して 0.73%」のように、方針と数字を1つの文にまとめてしまう書き方です。分けて書くと、翌週には数字だけが独り歩きします。
人間が打ったのに0クリックだった1件を消してはいけない
4件目の expo audio continue playing screen locked react native 2025 は、スコアリングでも人間側に落ちました。9語と長いのですが、これは「画面ロック中に音声を再生し続けたい」という具体的な困りごとをそのまま並べた語です。個人開発で音声再生を扱ったことがあれば、この語順には見覚えがあるはずです。
1,023 表示、クリック 0。
こちらは分母の汚染ではありません。表示されているのに選ばれていない、本物の問題 です。順位が低くて画面外にいるのか、タイトルが検索語と噛み合っていないのか、そこは別途調べる必要があります。
分母を掃除する作業の危うさは、ここにあります。掃除しているうちに、掃除の対象ではないものまで視界から消えてしまう。合成クエリの除外は「CTR を正しく読むため」の処理であって、「0 クリックのクエリを見なくてよくするため」の処理ではありません。
私は集計スクリプトの出力を2段に分け、除外したクエリの一覧を必ず別セクションに残すようにしました。消すのではなく、脇に置く。それだけで、翌週この1件を拾い直せます。
週次の手順に落とし込む
毎週の見方として固定したのは、次の5ステップです。
Search Console の Queries を表示回数の降順でエクスポートする
fanout_score.py を通し、fanout / ambiguous / human の3群に分ける
ambiguous の件数が10件を超えた週だけ、adjudicate.py を回す(毎週は回さない)
実質 CTR を「除外方針つきの1文」で記録する
human に落ちた 0 クリッククエリを別リストに積み、翌週の改善候補として残す
3番目に条件を付けたのは、モデル呼び出しを常設にすると、判定基準がいつの間にかモデル側へ移ってしまうためです。スコアリングで足りている週は、スコアリングだけで終える。呼ばずに済んだ週があること自体が、判定条件が安定している証拠になります。
数値の推移を1画面にまとめる部分は、以前 Streamlit と Gemini API で作ったダッシュボード にそのまま乗せられました。表示回数のグラフの下に、除外表示のグラフを重ねて置いています。
まとめ
まず、ご自分のサイトの表示回数を降順に並べて、上位10件を目で読んでみてください。それだけで、CTR の分母に何が入っているかが分かります。
私自身、この作業をするまでは CTR の低下をタイトルの問題として受け取っていました。実際には、測っている対象がいつの間にか変わっていただけでした。AI 検索が広がっていく間、この種のずれは静かに増えていくのだと思います。
指標が動かないとき、まず疑うべきは施策ではなく、分母のほうかもしれません。