検索エンジンの順位は昔から測れました。では、ChatGPTやPerplexityが回答の中で自社サイトを引用しているかどうかは、どう測ればいいのでしょうか。
順位のように1本の数字が返ってくるわけではありません。同じ質問を投げても回答は毎回変わりますし、そもそも「引用された」の定義自体が曖昧です。回答文中にURLが出典として並ぶこともあれば、社名だけが本文に出てURLはないこともあります。
この記事は、その曖昧なものを0〜100のスコアに落とすツールを実装したときの設計と、動かして初めて気づいた失敗を書いたものです。
何を「引用」と数えるか
まず引用を3種類に分けました。
| 種別 | 何を指すか |
|---|---|
source_url |
回答の出典リストに自社URLが載っている |
answer_mention |
回答本文に自社名やドメインが出てくる(出典リストにはない) |
search_result |
AIが内部で叩いた検索結果に入っている |
同じ「引用された」でも価値は全く違います。出典リストに載るのは、AIがその情報を根拠として扱ったということです。本文に名前が出るだけなら、AIは知ってはいるが根拠にはしていない。検索結果に入っているだけなら、まだ候補にすぎません。
そこで強度を3段階に分け、重みを付けました。
const strengthPoints = citations.reduce((sum, c) => {
if (c.strength === "strong") return sum + 1;
if (c.strength === "medium") return sum + 0.65;
return sum + 0.35;
}, 0);
0.65 と 0.35 に厳密な根拠はありません。「弱い引用3件が強い引用1件に勝ってしまわない」ことだけを条件に決めた値です。ここを等価にすると、検索結果に引っかかっているだけのサイトが、実際に根拠として使われているサイトを追い抜いてしまいます。
スコアの中身
エンジン1つあたりのスコアは5つの成分の和です。
function scoreEngine(engine: EngineResult): number {
if (!engine.found || engine.mentionCount === 0) return 0;
const presence = engine.metrics.presenceRate * 0.45;
const strength = engine.metrics.strengthScore * 0.25;
const frequency = Math.min(engine.mentionCount / Math.max(engine.metrics.queryCount, 1), 1) * 15;
const position = engine.metrics.averagePosition
? Math.max(0, (11 - Math.min(engine.metrics.averagePosition, 10)) / 10) * 10
: 0;
const sourceClarity = engine.citations.some((c) => c.sourceUrl) ? 5 : 0;
return Math.round(presence + strength + frequency + position + sourceClarity);
}
配点は「出現率45 / 強度25 / 頻度15 / 掲載位置10 / 出典の明示5」です。最も重いのは presenceRate ——「投げた質問のうち何%で引用されたか」です。
これを一番重くしたのには理由があります。1つの質問で3回引用されるより、3つの違う質問でそれぞれ1回引用されるほうが、実際の集客にはるかに効くからです。前者はたまたま相性の良い質問が1つあるだけで、後者はそのトピック領域でAIに認識されているということです。だから frequency には Math.min(..., 1) で上限を設けて、同じ質問での引用回数を稼いでもスコアが伸びないようにしています。
実装して初めて分かった落とし穴
ここからが本題です。
1. 「引用ゼロ」と「測れなかった」を同じ扱いにしていた
最初の実装では、各エンジンのAPIエラーを握り潰して部分結果として返していました。6エンジンのうち1つが落ちても、残り5つの結果でスキャンは完了します。堅牢に見えます。
問題は、落ちたエンジンの結果が「引用0件」として表示されていたことです。
ユーザーから見れば、「Geminiには引用されていない」と「Geminiが測れなかった」は全く違う情報です。前者なら対策を打つべきで、後者なら測り直すべきです。それが画面上で区別できませんでした。しかも0件として平均に入るので、スコア自体も不当に低く出ます。
修正は、状態を結果に明示的に持たせることです。
export type EngineErrorKind = "timeout" | "auth" | "other";
export type EngineRunStatus = "ok" | "error";
export type EngineRunError = { kind: EngineErrorKind; message: string };
1つのエンジン内で1クエリでも成功していれば ok とし、部分結果で継続する挙動そのものは変えていません。変えたのは、成功も失敗もしなかったという第3の状態を消したことです。
集計側でも、APIキーが未設定のエンジンを平均から除外しています。
function calcBaseScore(engines: EngineResult[]): number {
const live = engines.filter((e) => e.context !== API_KEY_MISSING);
if (live.length === 0) return 0;
if (!live.some((e) => e.found && e.mentionCount > 0)) return 0;
const total = live.reduce((sum, engine) => sum + scoreEngine(engine), 0);
return Math.min(100, Math.round(total / live.length));
}
設定していないエンジンの0点で平均を薄める理由はどこにもありません。
2. 1つのAPIが黙ると全体が死ぬ
1回のスキャンで外部APIを最大38回呼びます。6つのエンジンにそれぞれ3〜6件のクエリを投げて24回、さらに競合の自動検出と結果の分析が加わるので、すぐこの数になります。
このとき、応答を返さないAPIが1つあるだけでスキャン全体がハングします。サーバーレス関数の実行時間上限まで待ち続けて、最終的に何の結果も返らずに死ぬ。1つのAPIの不調が、他の4つの正常な結果まで巻き添えにします。
対処は単純で、全ての外部fetchにタイムアウトを入れました。
const EXTERNAL_FETCH_TIMEOUT_MS = 30_000;
タイムアウト時は AbortError を投げ、各エンジンが既に持っている「部分結果で継続する」エラーハンドリングにそのまま乗せます。新しい経路を足すのではなく、既存の失敗経路に合流させるのが要点です。分岐が増えるほど、片方だけ壊れます。
3. スコアが動かないと何もわからない
これは設計というより運用の話ですが、スコアは単発では意味がありません。「62点」と言われても高いのか低いのか分かりません。
意味が出るのは、施策を打つ前と後で比べたときです。だから引用の中身(どの質問で、どのエンジンが、どの強度で引用したか)を全部残しています。スコアが5点上がったとき、それが出現率の改善なのか強度の改善なのかが分からなければ、次に何をすればいいか決められません。
まとめ
- 引用は種別と強度で分けて重み付けする。全部同じ1件として数えると実態と合わない
- 出現率(何%の質問で引用されたか)を最も重くする。同じ質問での回数稼ぎは上限で止める
- 「引用ゼロ」と「計測失敗」を絶対に同じ値にしない。 これが一番痛い落とし穴だった
- 外部APIを多数叩くなら全部にタイムアウトを付け、失敗は既存のエラー経路に合流させる
この考え方で作ったのが LLMO Scanner です。URLを入れると、6つのAIエンジン(Perplexity / Google AI Overview / Gemini / Bing / ChatGPT / DeepSeek)での引用状況をスキャンして可視性スコアを出します。