跳到主要内容
IM智引科技

研究 / 外贸与 B2B 增长

GEO 审计升级:从「结构合规」到「有没有被引用」

之前那个 GEO 覆盖审计 Skill 只能查结构,查不了覆盖——因为它没有期望值可比。补上意图注册表之后,审计从一层变三层:结构、覆盖与一致性、引用实测。其中复述块与主答块的一致性检查是纯确定性的,脚本能百分之百判定,这是升级带来的最大收益。

BLKTECH 编辑部2026年8月9日15 分钟难度 实战免费Node.jsAI Agent

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-017answer_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: activeself_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 意图注册表工具箱

NEXT ACTION / 下一步

继续系列:AI 外贸站建设全系列

把读到的方法变成一个小行动,完成后再回来迭代。

继续

RELATED / 相关推荐

接着读这些

按同一分类、系列与标签为你挑选。