设备参数页在 AI 眼里没有归属:about 与 mentions 实体提及标注的改造实录

2026-09-20 01:19:01 5 次浏览
GEOAI搜索Schema.orgJSON-LD结构化数据制造业B2B

适用读者:维护制造业 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搜索

🤖
本内容由 AI 辅助生成,经人工校对审核;部分素材、资料来源于公开网络,仅作个人观点分享与交流使用,无任何商业侵权意图。若内容、图片、文字涉及您的合法著作权、版权权益,请联系本人,核实后将第一时间删除、修改相关内容。