GEO 审计分三层:结构层查格式(脚本,沿用旧 Skill)、覆盖与一致性层查登记未落地/落地未登记/主答块缺失/复述块与主答块文本分叉/fact_refs 悬空(脚本,新增,需要意图注册表当期望值)、引用层查实际有没有被 AI 引用(人测,20-40 条季度一次)。其中一致性对账是纯确定性检查,不一致直接判 error。
GEO 审计升级:从「结构合规」到「有没有被引用」
先承认前作的局限
AI Skill:GEO 覆盖审计那篇定义了六项检查:
1. robots.txt 是否放行 AI 爬虫
2. 内容是否服务端渲染
3. FAQ 是否用标题标签且自包含
4. 参数表是否原生 table + caption
5. hreflang 是否双向自引用
6. Schema 完整性
把这六项排在一起看,会发现一件事:它们全都在问“格式对不对”,没有一项在问“该答的问题答了没有”。
这不是那篇写错了。是当时没有期望值可比。
SEO 侧的每一个审计都拿注册表当期望值:覆盖度审计比的是“注册表里的词 vs 实际存在的页面”,注册表审计比的是“注册表内部有没有冲突”。有了期望值,才能算差集。
GEO 侧一直没有那张表,所以只能查通用规则——一个通用规则能告诉你 FAQ 用没用 H3,永远告诉不了你少答了哪个问题。
补上意图注册表之后,这一层才补得上。
三层审计
| 层 | 查什么 | 谁做 | 期望值来源 |
|---|---|---|---|
| 一|结构 | FAQ 标签、table caption、hreflang、robots、SSR | 脚本 | 通用规则(沿用旧 Skill) |
| 二|覆盖与一致性 | 登记未落地 / 落地未登记 / 主答块 anchor 缺失 / 复述块与主答块分叉 / fact_refs 悬空 | 脚本 | 意图注册表 |
| 三|引用 | 实际有没有被引、引得对不对 | 人测 + 模型辅助记录 | 实测 |
执行顺序不能换,和旧 Skill 的致命项逻辑一样:第一层不通过,后两层做得再好都没用。robots.txt 把 GPTBot 挡了,你的答案写得再标准也没人看得到。
新增的是第二层。它是这次升级的主体,而且大部分检查是纯确定性的——脚本能百分之百判定,不需要模型介入。
第二层:五类检查
1. 登记未落地
注册表里有 Q-017,answer_unit 写着 /products/cp-100/#max-operating-temperature,但构建产物里这个页面没有这个 id。
判定:error。这条意图等于不存在。
最常见的成因:写页面时忘了加 id 属性。上一篇强调过“落地时必须同时写入 anchor”,就是为了这一项。
2. 落地未登记
页面上有一个自包含问答块(h2/h3 疑问句 + 答案段落),但注册表里没有任何一条意图指向它。
判定:warn。
为什么只是 warn:它可能是历史内容,也可能是有人绕过流程直接写的。不一定是错,但需要人看一眼——决定是补登记,还是它其实和某条已有意图重复了(那就是自相矛盾的来源)。
3. 主答块 anchor 不唯一
同一个 anchor id 在一个页面里出现两次,或者两条不同的意图指向同一个 answer_unit。
判定:error。基准版本必须唯一,这是一致复述三条规矩的第一条。
4. 复述块与主答块分叉 ★
这是第二层里最有价值的一项。
restate_on 里列出的每一块,抓出它的首句,和主答块的 canonical_answer 比对。
判定要分级,全部报 error 会让人直接关掉这个检查:
| 差异类型 | 判定 | 理由 |
|---|---|---|
| 数字不同 | error | 最严重,会导致被引错 |
确定性措辞不同(rated at 写成 delivers) |
error | 是 EEAT 问题,也是法律问题 |
| 单位缺失(少了英制) | warn | 影响北美市场匹配 |
| 标点、大小写、空白 | 忽略 | 归一化之后再比 |
| 上下文衔接词(开头加了 “For the CP-100,”) | 忽略 | 允许的差异 |
前两类必须是 error,因为它们直接对应第 74 篇里那个失败场景:LLM 拿到三个不一致的 chunk,然后引用了错的那个。
5. fact_refs 悬空
canonical_answer 里出现了数字,但 fact_refs 为空;或者 fact_refs 指向的事实层字段不存在。
判定:error。
这一项是三张表之间的接口检查,它保证了事实层的两种死法里“放过期”的检测链条是完整的——事实层的字段改了,能反查出哪些答案要跟着改。
数值对账:把不一致当 bug,不当瑕疵
第 4、5 两项合起来能做一件旧审计做不到的事:扫全站答案里的数字,和事实层比对。
页面上写: operates from -20°C to 120°C
事实层: temp_max: 118 ← 上个月供应商换版改过
→ error:/products/cp-100/#max-operating-temperature
答案中 temp_max=120,事实层 products.cp-100.temp_max=118
事实层的两种死法里有一条原则:把“页面和事实层不一致”当 bug 而不是当瑕疵。定性成 bug,才会有人去修。
这里把同一条原则往前推了一步——现在不只是“页面和事实层”要对账,是“答案、复述、事实层”三方对账。多出来的那一方(复述块)恰恰是最容易分叉的一方,因为它散在几个页面里,没人盯着。
第三层:引用实测
脚本能查到这里为止。“你的内容实际上有没有被 AI 引用”,脚本查不了。
这一层的做法在上一篇已经定了规矩,这里只补两件事。
挑选实测集的脚本判据
priority >= 4
AND self_answerable == true
AND (fact_refs 非空 OR evidence 非空)
AND status == 'active'
ORDER BY intent_class = '条件选型型' DESC, priority DESC
LIMIT 30
排序把 ② 条件选型型放前面,因为第 75 篇论证过它离成交最近。
记录表
| qid | 问题 | Perplexity | ChatGPT | AI Overview | 引用URL | 准确 |
|-------|------|-----------|---------|-------------|---------|------|
| Q-034 | 60%硫酸材质 | ✅ | ✅ | ❌ | /applications/... | ✅ |
| Q-017 | CP-100最高温度 | ✅ | ❌ | ✅ | /products/cp-100/ | ⚠️ 118 vs 120 |
| Q-004 | 磁力泵vs机封 | ❌ | ❌ | ❌ | — | — |
第二行那个 ⚠️ 就是 miscited——最高优先级。它说明第二层的对账漏了,或者对账通过了但线上还是旧构建。
Skill 说明书
# Skill:GEO 引用审计
## 何时使用
- 季度 GEO 健康检查
- 意图注册表新增一批意图并发布之后
- 实测发现某条意图 miscited 时(定向排查)
## 前置条件
- 意图注册表存在(`data/geo-intents.yaml` 或等价)
- 事实层存在(`data/products.yaml`)
- 已执行构建,`dist/` 是最新产物
## 输入
- 构建产物目录
- 意图注册表路径
- 事实层路径
- 上季度实测记录(用于趋势对比,可选)
## 执行步骤
### Step 1:第一层结构检查(不通过则停止)
执行旧 Skill 的 `scripts/geo-audit.mjs --critical`
robots.txt 屏蔽或客户端渲染 → 输出 error 并停止,
不要继续跑第二层。先解决致命问题,否则后面都是白做。
### Step 2:第二层覆盖与一致性(脚本)
执行 `scripts/geo-registry-audit.mjs`
脚本输出五类问题的原始清单,全部是确定性判定,你不需要复核。
### Step 3:判断"落地未登记"的处置(你来做)
脚本会列出页面上存在但注册表里没有的问答块。
对每一条判断:
- 和某条已有意图语义重复 → **这是自相矛盾的来源**,标为 error,
建议合并成复述块
- 是有价值的新意图 → 建议补登记
- 是历史内容且无价值 → 建议删除该块
判断依据只看语义是否重复,不要判断技术内容对不对。
### Step 4:排序行动项(你来做)
按以下顺序,不要按数量排序:
1. miscited 的意图(上季度实测发现被引错)
2. 数字分叉(error)
3. fact_refs 悬空(error)
4. 登记未落地(error)
5. 确定性措辞分叉(error)
6. 落地未登记且语义重复(error)
7. 其余 warn
### Step 5:挑选本季度实测集
按判据脚本已经挑好,你只需要确认是否有需要临时加入的
(比如新上线的重点意图)。上限 40 条,超了就砍 priority 低的。
### Step 6:输出报告
## 输出格式
# GEO 引用审计报告
生成时间:[YYYY-MM-DD]
注册表意图数:[N](active [N] / draft [N] / 不可自答 [N])
落地率:[N]% 一致率:[N]% 上季度引用率:[N]%
## 第一层:结构([通过/不通过])
[沿用旧 Skill 输出]
## ERROR([N] 项)
### E1. 复述块数字与主答块分叉
- 意图:Q-017 CP-100 最高工作温度
- 主答块:/products/cp-100/#max-operating-temperature → 120°C
- 复述块:/blog/high-temp-pumps/#cp100 → 125°C
- 事实层:products.cp-100.temp_max = 118
- 判定:三方均不一致。以事实层为准,主答块和复述块都要改
- 影响:LLM 检索到多个矛盾 chunk,大概率不引用或引用错误值
### E2. fact_refs 悬空
- 意图:Q-031 CP-200S ATEX 认证
- canonical_answer 含具体认证编号,fact_refs 为空
- 影响:证书过期时无法反查到这条答案,属于合规风险
## WARN([N] 项)
### W1. 落地未登记
- 位置:/applications/mining-dewatering/#flow-selection
- 内容:"What flow rate is needed for mine dewatering?"
- 建议:补登记为 ② 条件选型型,主答块归本页
## 本季度实测集([N] 条)
[表格]
## 判定为不需处理([N] 项)
[说明哪些 warn 是可接受的,以及理由]
## 禁止行为
- 第一层不通过时,不要输出第二层细节
- 不要判断某条技术答案的内容对不对
(错了看不出,回到「不可发现」那一档,见第 68 篇)
- 不要因为 self_answerable: false 的意图"没有落地"就报 error
(它们本来就不该落地)
- 不要建议为了提高落地率而批量生成答案块
(没有真实提问支撑的答案块是灌水)
确定性脚本
// scripts/geo-registry-audit.mjs
import fs from 'fs/promises';
import path from 'path';
import { parse } from 'node-html-parser';
import YAML from 'yaml';
const DIST = 'dist';
const INTENTS = 'data/geo-intents.yaml';
const FACTS = 'data/products.yaml';
// ── 首句归一化:去掉允许的差异,保留必须一致的部分 ──
function normalize(s) {
return (s ?? '')
.replace(/\s+/g, ' ')
.replace(/[""'']/g, '"')
.replace(/^(for the [\w-]+,?\s*)/i, '') // 允许的上下文衔接前缀
.trim()
.toLowerCase();
}
const NUM_RE = /-?\d+(?:[.,]\d+)?/g;
// 表示确定性的动词,写错一档就是 EEAT / 法律问题
const CERTAINTY_RE = /\b(delivers?|provides?|achieves?|rated at|nominal|per manufacturer|reported|typically)\b/gi;
function numbersOf(s) { return (s.match(NUM_RE) ?? []).map(n => n.replace(',', '.')); }
function certaintyOf(s) { return [...new Set((s.match(CERTAINTY_RE) ?? []).map(w => w.toLowerCase()))].sort(); }
// ── 从构建产物里抽出所有带 id 的问答块 ──
async function collectBlocks(dir, out = new Map()) {
for (const e of await fs.readdir(dir, { withFileTypes: true })) {
const full = path.join(dir, e.name);
if (e.isDirectory()) { await collectBlocks(full, out); continue; }
if (!e.name.endsWith('.html')) continue;
const root = parse(await fs.readFile(full, 'utf8'));
const urlPath = '/' + path.relative(DIST, full)
.replace(/index\.html$/, '').replace(/\\/g, '/');
root.querySelectorAll('h2[id], h3[id]').forEach(h => {
const q = h.text.trim();
if (!q.endsWith('?')) return; // 只收问句块
let node = h.nextElementSibling;
const parts = [];
while (node && !['H2', 'H3'].includes(node.tagName)) {
if (node.tagName === 'P') parts.push(node.text.trim());
node = node.nextElementSibling;
}
const answer = parts.join(' ');
const firstSentence = answer.split(/(?<=[.!?])\s/)[0] ?? '';
const key = `${urlPath}#${h.getAttribute('id')}`;
out.set(key, { key, urlPath, question: q, answer, firstSentence });
});
}
return out;
}
// ── 按路径取事实层字段:products.cp-100.temp_max ──
function getFact(facts, refPath) {
return refPath.split('.').reduce((o, k) => (o == null ? o : o[k]), facts);
}
// ── 主流程 ──
const intents = YAML.parse(await fs.readFile(INTENTS, 'utf8')).intents ?? [];
const facts = YAML.parse(await fs.readFile(FACTS, 'utf8'));
const blocks = await collectBlocks(DIST);
const errors = [], warns = [];
const seenUnits = new Map();
const claimed = new Set();
for (const it of intents) {
if (it.status !== 'active') continue;
// 不可自答的意图本来就不该落地,跳过全部检查
if (it.self_answerable === false) continue;
// 检查 1:登记未落地
const main = blocks.get(it.answer_unit);
if (!main) {
errors.push({ type: 'not_landed', qid: it.qid, expected: it.answer_unit });
continue;
}
claimed.add(it.answer_unit);
// 检查 3:主答块唯一性
if (seenUnits.has(it.answer_unit)) {
errors.push({
type: 'duplicate_answer_unit', unit: it.answer_unit,
qids: [seenUnits.get(it.answer_unit), it.qid],
});
}
seenUnits.set(it.answer_unit, it.qid);
// 检查 5:fact_refs 悬空
const canonNums = numbersOf(it.canonical_answer ?? '');
const refs = it.fact_refs ?? [];
if (canonNums.length > 0 && refs.length === 0) {
errors.push({ type: 'fact_refs_missing', qid: it.qid, numbers: canonNums });
}
for (const ref of refs) {
if (getFact(facts, ref) === undefined) {
errors.push({ type: 'fact_refs_dangling', qid: it.qid, ref });
}
}
// 数值对账:答案里的数字 vs 事实层的值
for (const ref of refs) {
const v = getFact(facts, ref);
if (v == null || typeof v === 'object') continue;
if (!canonNums.includes(String(v))) {
errors.push({
type: 'fact_value_mismatch', qid: it.qid, ref,
factValue: v, answerNumbers: canonNums,
});
}
}
// 检查 4:复述块与主答块分叉
const canon = it.canonical_answer ?? main.firstSentence;
for (const unit of it.restate_on ?? []) {
claimed.add(unit);
const r = blocks.get(unit);
if (!r) {
errors.push({ type: 'restate_not_landed', qid: it.qid, expected: unit });
continue;
}
if (normalize(r.firstSentence) === normalize(canon)) continue;
const a = numbersOf(canon), b = numbersOf(r.firstSentence);
const numDiff = a.join('|') !== b.join('|');
const certDiff = certaintyOf(canon).join('|') !== certaintyOf(r.firstSentence).join('|');
const rec = { qid: it.qid, unit, canonical: canon, actual: r.firstSentence };
if (numDiff) errors.push({ type: 'restate_number_diff', ...rec, canonNums: a, actualNums: b });
else if (certDiff) errors.push({ type: 'restate_certainty_diff', ...rec });
else warns.push({ type: 'restate_wording_diff', ...rec });
}
}
// 检查 2:落地未登记
for (const [key, b] of blocks) {
if (!claimed.has(key)) warns.push({ type: 'unregistered_block', unit: key, question: b.question });
}
const active = intents.filter(i => i.status === 'active' && i.self_answerable !== false);
const landed = active.filter(i => blocks.has(i.answer_unit)).length;
console.log(JSON.stringify({
summary: {
intentsTotal: intents.length,
intentsActive: active.length,
notSelfAnswerable: intents.filter(i => i.self_answerable === false).length,
blocksFound: blocks.size,
landedRate: active.length ? Math.round(landed / active.length * 100) : 0,
errors: errors.length,
warns: warns.length,
},
errors,
warns,
}, null, 2));
脚本的边界:它只做确定性判定。“这条答案的技术内容对不对”、“这两个问题是不是同一个意思”——这两类交给模型,因为它们需要判断;而模型判断错了,你看得出来(这正是AI 的边界划在哪那条标准)。
一个副产品:从注册表生成 llms.txt
llms.txt 与 AI 爬虫优化那篇里,llms.txt 是手写的站点结构清单——按页面组织,列出产品、应用、资源几个分区。
有了意图注册表,它可以自动生成。而且按问题组织比按页面组织有用得多:
# AquaFlow Industrial
## Frequently answered technical questions
- What pump material for 60% sulfuric acid at 80°C?
→ /applications/chemical-transfer-pump/#material-selection
- What is the maximum operating temperature of the CP-100?
→ /products/cp-100/#max-operating-temperature
- Is a magnetic drive pump worth the cost over a double seal?
→ /blog/mag-drive-vs-seal/#cost-comparison
一个爬虫(或一个 agent)拿到这份清单,能直接知道“这个站能回答哪些具体问题、答案在哪一块”——这比“这个站有一个产品分区”的信息量大得多。
生成脚本很简单:过滤 status: active 且 self_answerable: true,按 priority 排序,输出 question + answer_unit。收在工具箱篇。
提醒:llms.txt 的实际效果依然如旧篇所说,确定性回报低于结构和内容本身。这是一个顺手做的副产品,不是优先项。
与前作和健康度报告的关系
| Skill | 职责 | 关系 |
|---|---|---|
| 第 49 篇 GEO 覆盖审计 | 结构合规 | 本篇的第一层直接调用它,不重复实现 |
| 本篇 | 覆盖 + 一致性 + 引用 | 第二、三层 |
| 第 50 篇 健康度报告 | 全站快照 | 汇总时,GEO 就绪度指标口径要改 |
下一篇健康度报告汇总时,“GEO 就绪度”不再只算结构合规率,而采用这张表提供的新口径:
GEO 就绪度 = 落地率 × 一致率 × 引用率
落地率 = 已落地的 active 意图 / 全部 active 意图
一致率 = 无 error 的意图 / 已落地意图
引用率 = 实测 cited 的意图 / 本季度实测集
三个数相乘的原因:它们是串联关系。没落地就谈不上一致,不一致就大概率不会被引。任何一环塌了,整体就塌了——这比三个数分开列更能反映真实状态。
自动化编排接SEO/GEO 运营自动化:第二层的脚本适合放进每周定时审计,第三层的实测放进季度报告的“待人工完成”部分。
小结
- 旧 GEO 审计只能查结构,因为它没有期望值——这不是设计缺陷,是缺一张表
- 补上意图注册表后,第二层的五类检查全部是确定性的,脚本能直接判
- 复述块与主答块的分叉检查是最大收益,因为它检测的是唯一会导致“被引错”的那类问题
- 不一致要分级:数字不同和确定性措辞不同是 error,排版差异忽略
- 第三层实测必须克制规模(20–40 条 / 季度)并固定全部变量
- 健康度报告的 GEO 指标应改成
落地率 × 一致率 × 引用率
最后一篇是工具箱:模板、校验规则、提示词、脚本、起步问题集,全部收在一处。
→ 配套资源:GEO 意图注册表工具箱
RELATED / 相关推荐
接着读这些
按同一分类、系列与标签为你挑选。
AI Skill:GEO 覆盖审计——多语言与 AI 可见性缺口分析
GEO 优化做了没做、做到什么程度,肉眼看不出来。这个 Skill 把第 40–44 篇的所有 GEO 规范转成可自动检查的规则:FAQ 是否自包含、参数表是否语义化、hreflang 是否双向、AI 爬虫是否被放行,并输出优先级排序的缺口清单。
AI Skill:Cannibalization 诊断——同站竞争页面识别
注册表审计查的是规划层面的冲突,Cannibalization 诊断查的是已发布内容的实际竞争。这个 Skill 用 GSC 数据找出同一查询下多个页面轮流排名的证据,再用页面正文比对确认原因,最后给出合并、差异化或降权的处置决策。
AI Skill:页面覆盖度审计——找出注册表中无对应页面的词
注册表里躺着一批词,但对应的页面还没建——这是最容易被遗忘的增长机会。这个 Skill 用脚本做注册表与实际路由的差集比对,模型负责按业务价值排序并输出可执行的建页清单。