设备参数页在 AI 眼里没有归属:about 与 mentions 实体提及标注的改造实录
适用读者:维护制造业 B2B 官网的前端或 SEO(Search Engine Optimization,搜索引擎优化)工程师,尤其是已经给设备参数页、案例页打上 Product 结构化数据(structured data),却始终等不到 AI 答案引用的那批人。文内脚本在 Python 3.12 标准库下跑通,结构化数据按 Schema.org 现行写法组织。
客户站点有 380 个设备参数页,四周里被 AI 爬虫抓走了 217 个,链接一次都没出现在答案里。 页面上的参数表是完整的,Product 标记也早就打上了,缺的是归属:引擎读完这一页,说不清它在讲哪一台设备。 后来我们花两周把实体库立起来,第 3 周把 about 与 mentions 标到 342 个页面上,第 5 周参数页链接第一次出现在 AI 答案里。
页面被抓走了,引用一次都没给
我们服务的这家设备厂商做数控光纤激光切割机,官网结构是典型的制造业 B2B:首页下面挂着机型列表,每款机型拆出参数页、应用案例页、配件页三类,加起来 380 个页面。对方市场负责人老周的原话是:"展会来的线索越来越贵,官网参数页是我们最全的资料,AI 那边问'3000×1500 幅面切割机功率多少',答的却是经销商的二手页面。"

第 1 周我们只干了一件事:把 Nginx 日志里 30 天的记录捞出来,按来源分组看 AI 爬虫到底抓了什么。抓得很勤,GPTBot 与 ClaudeBot 合计请求了 217 个参数页,平均一页抓 2.4 次,机器人每次都老老实实先读 robots.txt。抓取这关没问题,问题在后面。
第一周二组数字把范围框死
一组看抓取,一组看引用。引用这事儿没法从日志里看,我们挑了 18 个采购方常问的问题,在几个 AI 搜索产品里各问一遍,人工记录答案里有没有本站链接、链接落在哪一页。
| 指标 | 改造前(第 1-2 周) | 改造后(第 5-6 周) |
|---|---|---|
| 参数页总数 | 380 | 380 |
| 带 about 标注的页面 | 0 | 342 |
| 平均每页 mentions 部件数 | 0 | 4.2 |
| 四周内被 AI 爬虫抓取的页面 | 217 | 296 |
| 18 个问答中出现本站链接的条数 | 0 | 11 |
| 其中落在参数页的链接 | 0 | 7 |
抓取量从 217 涨到 296,是因为实体库上线后我们顺手补了 sitemap 里的 lastmod,重抓更勤。真正的变化在最后两行:参数页从零引用变成 7 条链接的来源页。
缺的不是内容,是归属
诊断阶段最花时间的是核对名称。同一款机型在三个页面里被写成三种样子:产品页叫"GH-3015 数控光纤激光切割机",参数页的 H1 是"GH3015 技术参数",案例页正文里写"GH-3015 型"。人一看就知道是同一台设备,机器看到的却是三个互不认识的字符串。
结构化数据那边更糟。每个页面各写一份 Product,字段齐全但都没带 @id。没有 @id 的节点在语义网里叫匿名节点(blank node),引擎每读一次就认为这是一个新对象,读十次就多出十个对象,谁也不知道它们指同一台设备。
结构化数据里不写 @id,等于每次出现都在宣称一个新实体。
原理与机制剖析:从抓取到引用隔着一整套链路
这段是改造方案能不能成立的关键。AI 搜索不是"抓了就会引用",中间隔着四步,about 与 mentions 主要作用在第三步。
四步链路:抓取、解析、实体对齐、候选
第一步抓取由爬虫完成,robots.txt、站点速度、渲染方式都在这关起作用。第二步解析,把 HTML 拆成正文、标题、表格,同时把 JSON-LD 里的节点读成一张图。第三步实体对齐(entity alignment),把页面上抽出来的实体和引擎内部知识库里的条目对上,判断"这页讲的这台设备,是不是我库里那条"。第四步才轮到候选与引用:回答生成时,引擎从已对齐的候选池里挑引用源。
flowchart TD
A["爬虫抓取 HTML 与 JSON-LD"] --> B["解析:正文分段 + 结构化数据建图"]
B --> C{"节点是否带 @id"}
C -- "无 @id" --> D["落成匿名节点,各自孤立"]
C -- "有 @id" --> E["与站内其他页面同一 @id 合并"]
D --> F["实体对齐阶段找不到稳定锚点"]
E --> G["实体对齐:与知识库条目比对 sameAs、name、sku"]
G --> H{"是否对齐成功"}
H -- "否" --> I["进不了候选池,不被引用"]
H -- "是" --> J["进入候选池,回答时可作为引用源"]
J --> K["答案正文附上页面链接"]
图里 C 这个分支就是我们的病灶:抓得很勤、解析也没问题,卡在"没有稳定锚点"上。
实体对齐这一步在比什么
引擎做对齐时不是字符串相等判定,它在比一组信号:@id 是否出现过多、名称变体是否收敛、有没有 sameAs 指向外部权威档案、类型是否一致(Product 还是 Article)、以及这个实体被多少页面共同指向。信号越多越密,对齐置信度越高。
生成式引擎优化(Generative Engine Optimization,GEO)在这个语境下的含义很直白:不是让页面"更容易被抓",而是让页面在第三步里被认出来。抓不到的页面谈 GEO 没有意义,对齐不上的页面抓得再勤也白费劲。
about 与 mentions 各管一段
两个属性都属于 Schema.org 的实体提及(mention annotation)机制,分工不同。about 回答"这页主要讲谁",取值应当收敛到一两个主实体;mentions 回答"这页顺带提到谁",取值可以铺开,部件、耗材、适用标准、客户行业都能放。
| 属性 | 回答的问题 | 取值建议 | 常见误用 |
|---|---|---|---|
| about | 这页主实体是谁 | 1-2 个,必须带 @id | 把所有相关机型全塞进去 |
| mentions | 顺带提到了什么 | 3-8 个,可带 @id 也可内联 | 放主实体本身,稀释 about |
| @id | 这个实体在全站叫什么 | 稳定 URL 锚点,不随改版变 | 用自增 ID 或空着不写 |
| sameAs | 外部档案里它是谁 | 权威站点主页,1-3 条 | 指向自家营销页 |
区分清楚之后,参数页的写法就定了:about 挂机型,mentions 挂激光器、数控系统、切割头这些部件。
先把实体立起来,再让页面挂上去
执行顺序不能反。先有实体库,页面上的 about 才有东西可指;反过来做,标注出来的是一堆无处安放的 @id。
实体库:一个机型一个节点
实体库我们放在 CMS 里维护,导出成一份 JSON-LD 挂在机型列表页,其他页面只引用它的 @id 而不重复定义字段。下面这段里的 // 行是讲解用注释,正式上线前必须删掉,JSON-LD 不接受注释。
{
// @context 固定指向 schema.org,写错或漏写整块都不解析
"@context": "https://schema.org",
// @graph 把多个节点装进同一张图,节点之间才能互相引用
"@graph": [
{
// 组织实体:全站只此一份,所有页面的 brand 都指向它
"@type": "Organization",
// @id 用域名锚点,页面改版换 URL 不影响指向关系
"@id": "https://www.example-machine.com/#organization",
// name 与站点品牌名保持一致,别用简称或英文缩写
"name": "某数控设备制造厂",
// url 指向官网首页,改版后只需要改这一个字段
"url": "https://www.example-machine.com/"
},
{
// 设备实体:一台机型一个节点,@id 里带机型编码
"@type": "Product",
// 后缀统一写 #product,全站别出现 #p1 这类自造写法
"@id": "https://www.example-machine.com/product/gh-3015#product",
// name 写完整机型名,正文里的别称交给引擎自己去收敛
"name": "GH-3015 数控光纤激光切割机",
// sku 与 name 分开写,改名后 sku 仍是稳定的对齐锚点
"sku": "GH-3015",
// brand 直接引用组织实体的 @id,不重复内联一份字段
"brand": {
"@id": "https://www.example-machine.com/#organization"
},
// category 用文本即可,引擎会拿它跟查询词做语义匹配
"category": "数控光纤激光切割机",
// additionalProperty 放核心参数,引擎常直接取这里的值作答
"additionalProperty": [
{
"@type": "PropertyValue",
"name": "切割幅面",
"value": "3000mm × 1500mm"
}
]
}
]
}
@graph 是关键。它把多个节点放进同一张图,节点之间才能用 @id 互相引用,而不是各写各的孤岛。
参数页只干一件事:声明归属
参数页上我们不再内联完整 Product 定义,只声明"这页讲的是哪个实体"。
{
// 与实体库共用同一份 @context,两侧版本要保持一致
"@context": "https://schema.org",
// 页面侧也用 @graph,方便和实体节点写进同一个脚本块
"@graph": [
{
// 页面节点本身也要有 @id,案例页才能反向引用它
"@type": "WebPage",
// 页面后缀统一写 #webpage,与实体的 #product 区分开
"@id": "https://www.example-machine.com/product/gh-3015/spec#webpage",
// url 必须与 canonical 完全一致,否则可能被判成两个页面
"url": "https://www.example-machine.com/product/gh-3015/spec",
// name 用页面标题,别照抄机型名,两个字段含义不同
"name": "GH-3015 技术参数",
// about 声明主实体,只放一个:这页讲的就是 GH-3015
"about": {
// 指向实体库里的机型节点,不是在这里重新定义一遍
"@id": "https://www.example-machine.com/product/gh-3015#product"
},
// mentions 声明被顺带提到的对象,部件多了就往这里加
"mentions": [
{
// 部件同样用 Product 类型,@id 指向它自己的实体节点
"@type": "Product",
"@id": "https://www.example-machine.com/part/fiber-laser-2000w#product",
// 部件名带规格,避免和同系列其他功率档混在一起
"name": "2000W 光纤激光器"
},
{
// 第二个部件,写法与第一个保持一致
"@type": "Product",
"@id": "https://www.example-machine.com/part/cnc-system#product",
// 通用件也要建实体,内联写法等于再造一个匿名节点
"name": "数控系统"
},
{
// 标准不是产品,用 Thing 占位,不硬套 Product
"@type": "Thing",
"name": "GB/T 34380 数控激光切割机精度标准"
}
],
// isPartOf 把页面挂回站点实体,给引擎一条层级线索
"isPartOf": {
"@id": "https://www.example-machine.com/#website"
}
}
]
}
案例页的写法差别只在 about:案例页讲的是一个应用故事,about 挂 Article 或 CaseStudy 节点,再把涉及的机型放进 mentions。同样一台 GH-3015,被参数页 about 一次、被 8 个案例页 mentions 八次,指向的都是同一个 @id,实体图上的边就越织越密。
graph LR
O["Organization<br/>#organization"] --> P["Product GH-3015<br/>#product"]
O --> Q["Product GH-4020<br/>#product"]
P --> S["参数页 WebPage<br/>about"]
S --> M1["2000W 光纤激光器<br/>mentions"]
S --> M2["数控系统<br/>mentions"]
P --> C1["案例页 A<br/>mentions"]
P --> C2["案例页 B<br/>mentions"]
S --> W["WebSite<br/>isPartOf"]
批量校验:@id 写歪了等于白标
342 个页面靠人肉核对不现实。我们写了个脚本,把 HTML 快照目录扫一遍,抽出所有 JSON-LD,检查每条 about / mentions 的 @id 是否能在实体库里找到。环境是 Python 3.12,只用标准库。
# -*- coding: utf-8 -*-
# 环境:Python 3.12,只用标准库,不需要 pip 装任何依赖
# 用法:python check_mentions.py ./snapshot ./entities.json
# 说明:import 全部是标准库,可以直接丢进 CI 跑
import json
import re
import sys
import pathlib
# 匹配页面里所有 JSON-LD 脚本块,非贪婪取到最近的 </script> 为止
LD_PATTERN = re.compile(
r'<script[^>]+application/ld\+json[^>]*>(.*?)</script>',
re.S | re.I,
)
def load_entities(path):
# 实体库由 CMS 导出,这里登记 @id 到 name 的映射
data = json.loads(pathlib.Path(path).read_text(encoding='utf-8'))
# 只有带 @id 的节点才可能被别的页面引用,其余跳过
return {n['@id']: n.get('name', '')
for n in data.get('@graph', []) if '@id' in n}
def iter_refs(node):
# 把 about 与 mentions 的取值摊平成一个引用列表
refs = []
for key in ('about', 'mentions'):
# 页面可能只写了其中一个属性,没写就跳过
if key not in node:
continue
val = node[key]
# 单值也包成列表,后面就不用分两种写法处理
refs += val if isinstance(val, list) else [val]
return refs
def check_page(html, index):
# 返回该页的问题列表,空列表表示这一页通过校验
problems = []
for raw in LD_PATTERN.findall(html):
try:
# 上线页面里不该有注释,解析失败基本都是注释没删干净
doc = json.loads(raw.strip())
except json.JSONDecodeError:
# 这一类问题最容易被忽略,优先单独报出来
problems.append(('parse_error', ''))
continue
# 顶层可能是 @graph 也可能是单个节点,统一成列表处理
for node in doc.get('@graph', [doc]):
# 逐条检查指向的 @id 能不能在实体库里查到
for ref in iter_refs(node):
# 非对象取值(比如直接写了字符串)直接跳过
if not isinstance(ref, dict):
continue
if '@id' not in ref:
# 内联实体拿不到 @id,等于又造了一个匿名节点
problems.append(('anonymous', ref.get('name', '')))
elif ref['@id'] not in index:
# 指向的 @id 不在库里,AI 引擎那边同样对不上
problems.append(('missing_id', ref['@id']))
return problems
def main():
# 两个命令行参数:HTML 快照目录与实体库文件
snap_dir, entity_file = sys.argv[1], sys.argv[2]
index = load_entities(entity_file)
report = {}
bad = 0
# rglob 递归遍历快照目录下的所有 html 文件
for f in pathlib.Path(snap_dir).rglob('*.html'):
# 每页独立计错,避免一页报错中断整轮扫描
problems = check_page(
f.read_text(encoding='utf-8', errors='ignore'), index)
# 有问题的页面计入 bad,供发版门禁判断
if problems:
bad += 1
for kind, _ in problems:
report[kind] = report.get(kind, 0) + 1
# 按命中次数倒序输出,先看大头再回 CMS 里改
for kind, n in sorted(report.items(), key=lambda x: -x[1]):
print(f'{kind}: {n}')
# 输出有问题页面数,非零时交给 shell 判失败
print(f'有问题的页面数 {bad}')
if __name__ == '__main__':
main()
第一遍跑出来 342 页里有 61 页报错,错误分布如下。
| 问题类型 | 命中页数 | 典型原因 |
|---|---|---|
| parse_error | 23 | 从旧模板复制来的注释没删 |
| mentions_missing | 29 | 部件 @id 拼错主机名,多了个 www |
| about_missing | 9 | 机型改名后 @id 没跟着改 |
| 合计(去重后页面数) | 61 | — |
修完再跑一轮,问题清零。这事儿花的时间比写标注本身还长,但不做等于把一堆错 @id 送上去,实体图照样断。
sequenceDiagram
participant C as CMS
participant S as 校验脚本
participant P as 线上页面
participant E as AI 引擎
C->>S: 导出实体库 entities.json
C->>P: 生成带 about/mentions 的页面
S->>P: 抓取 HTML 快照
S->>S: 抽 JSON-LD 并对 @id 查库
S-->>C: 回传问题页清单
C->>P: 修复后重新发布
P->>E: 被抓取,携带 @id 图
E->>E: 实体对齐并合并同一 @id
E-->>E: 进入候选池,回答时可引用
踩过的三个坑
第一个坑是把 about 当成"相关实体列表"。第一版我们给每个参数页的 about 塞了四五台同类机型,想着多挂几个总能中一个。结果引用数没涨,反而让引擎判断这页主题发散。about 收敛到 1 个之后,同一批页面的表现才起来。
第二个坑是 @id 跟着 URL 走。中途客户把 /product/ 目录改成 /machine/,@id 全量重写了一遍,之前积累的对齐信号全丢。@id 必须独立于 URL 结构,改版时只改 url 字段,不动 @id。
第三个坑是拿内联实体凑数。mentions 里图省事直接写 {"@type":"Product","name":"激光器"},不带 @id,等于又造了一批匿名节点。部件数量不多,我们最后给常用的 14 个部件都建了实体节点。
往后这事儿怎么走
实体图织起来之后,能做的事比"被引用"多一点。我们在试的方向是把售后服务记录也挂到同一批 @id 上,让"GH-3015 常见故障"这类问题也能落到自家页面;另外是把参数版本纳入 additionalProperty,翻新旧机型页面时保留历史参数,避免引擎读到前后矛盾的值。
GEO 这套活儿里,抓取是最容易验证的一环,对齐是最容易被忽略的一环。设备厂商的参数页天生就是结构化的,把归属标注清楚,比再写十篇泛泛的科普文更直接。要是你也在做类似的改造,评论区说说 @id 命名规则怎么定的,这块我至今没找到让自己满意的写法。
参考与延伸
- Schema.org about 属性说明:https://schema.org/about
- Schema.org mentions 属性说明:https://schema.org/mentions
- Schema.org 结构化数据入门:https://schema.org/docs/gs.html
- Google 结构化数据通用指南:https://developers.google.com/search/docs/appearance/structured-data
GEO · 生成式引擎优化 · Schema.org · JSON-LD · 实体对齐 · 实体提及 · 设备厂商 AI 获客 · AI搜索