配件兼容性页被 AI 张冠李戴之后:compatibleWith 与 isRelatedTo 的 45 天引用对照

2026-09-21 01:35:38 0 次浏览
GEOAI搜索Schema.orgJSON-LDcompatibleWith制造业B2B

适用读者:维护制造业 B2B 官网的前端或 SEO(Search Engine Optimization,搜索引擎优化)工程师,尤其是配件页早就打上了 Product 结构化数据(structured data),AI 回答里却总把配件安到错误机型上的那批人。 文内脚本在 Python 3.12 标准库下跑通,结构化数据按 Schema.org 现行写法组织。

某做包装机械的客户,售后组长三个月里连着退了三单封口加热条:客户按 AI 答案里"适用"的说法下单,装到自家 X2 机型上,长度差 40 毫米。三单货值不到两万,来回运费加停机损失远超这个数。我们把这三单拆开回查,问题不在配件质量,在配件页那句"适用于部分同系列机型"。

三单退货,把问题钉死在配件页上

第 1 周没动代码,先翻单据。售后把近半年的配件退货单拉了个清单,23 单,逐单回查客户下单前读过哪一页。翻到第十单规律就出来了:11 单的客户读的都是配件详情页,页面上只有一句"适用于部分同系列机型",下面挂着型号表,但表里写的是系列名"X 系列",不是具体机型 X2-300、X3-500。 设备配件兼容关系网络图

这句话对人有效,对机器没用。人读完会翻手册或打客服电话;引擎读完,只能从"X 系列"这一个字符串里猜边界。

退货归因 单数 对应配件页的原文特征
型号边界含糊,写"同系列适用" 11 只给系列名,未列具体机型与排除项
页面未提任何机型,标"通用配件" 5 整页无型号字段
客户自己看错 4 页面明确列了机型清单
物流与质量问题 3 与页面表述无关

11 单加 5 单是 16 单,占 23 单的约七成。范围收住了:退货跟兼容关系的表达方式有关,跟配件做工无关。

"适用于部分同系列机型"这句话,机器读不出边界

一句话里塞了四个悬空变量

把这句话拆开:适用(原装替换还是可改装)、部分(哪些,有没有清单)、同系列(边界由谁定义)、机型(型号字符串的写法)。四个变量在人类语境里靠常识就能补齐,在引擎侧全部悬空。悬空不等于留白,引擎会填——用统计上最相邻的实体去填,填错的代价就是我们收到的那三单。

同一台设备在三处写了三种名字

名称不统一是第二个问题。产品页写"X2-300 型全自动封口机",参数页 H1 是"X2300 技术参数",配件页表格里写"X2 系列",再算上连字符、空格混写,一共 6 种变体。没有 @id 的产品节点在语义网里叫匿名节点(blank node),引擎每读一次就当成一个新对象,读六次就多出六个互不认识的对象。

散落的型号提及,聚不成一个可信实体;聚不成实体,兼容性判断就退化成字符串相似度比较。

原理与机制剖析:实体解析在比什么,引用单元怎么切

这一段是改造方案能不能成立的关键。AI 搜索不是"抓了就会引用",中间隔着解析、对齐、候选三步,我们的动作全作用在第二步。

实体解析不是字符串相等判定

实体解析(Entity Resolution)回答的是"页面上的这个 X2-300,和我知识库里那条记录是不是同一台设备"。它比的是一组信号:@id 是否在多个页面稳定复现、名称变体是否收敛、有没有 sameAs 指向外部权威档案、@type 是否一致、以及被多少页面共同指向。信号越密,置信度越高;信号稀疏时引擎不会拒绝回答,它会用最相近的邻居顶上——这就是张冠李戴的来历。

散落的型号提及为什么聚不成可信实体

配件页里那句自然语言,对实体解析几乎是零贡献:给出一个系列名,没给 @id,没给类型,也没给反向佐证。更麻烦的是"部分"这个词,引擎判断不了它是"部分成立"还是"部分不成立",生成答案时按更宽松的口径处理,因为它倾向于给一个能用的型号,而不是承认不知道。生成式引擎优化(Generative Engine Optimization, GEO)在这个语境下很直白:不是让页面更好抓,而是让页面在实体解析这一步被认出来、认得准。对齐不准的页面抓得再勤,只会把错误放大。

显式声明之后,引用单元怎么切分

引用单元(citation unit)是引擎决定往答案里挂哪条链接时的最小切分粒度。改造前配件页是一整块正文,兼容性那一句混在参数表里,引擎只能整页引用,机型限定条件自然丢失。改造后兼容性被独立声明成一组关系,引擎可以按机型重新切:X2-300 一段、X3-500 一段各自成块,引用哪块取决于用户问的是哪台设备。

flowchart TD
    A["配件页正文:适用于部分同系列机型"] --> B["解析:抽出系列名与型号字符串"]
    B --> C["型号变体:X2 / X2300 / X2-300"]
    C --> D{"是否带稳定 @id"}
    D -- "无 @id" --> E["落成多个匿名节点,互不认识"]
    E --> F["实体解析阶段无法合并"]
    F --> G["兼容性判断退化为字符串相似"]
    G --> H["答案把配件安到错误机型"]
    D -- "有 @id" --> I["同一 @id 合并为同一设备实体"]
    I --> J["读取 compatibleWith 声明的机型集合"]
    J --> K["按机型切分引用单元"]
    K --> L["答案给出带机型限定的兼容结论"]

图里 D 这个分支就是病灶:抓得勤、解析也没问题,卡在"没有稳定锚点,只能猜"上。

兼容关系建模:两个属性各管一段

动手前先把语义分清。兼容性不是一个布尔值,它至少有"有关系""是它的备件""能装上"三个层次,对应不同属性。

属性 回答的问题 我们的取值方式 踩过的坑
isRelatedTo 这台配件与哪些产品有关系 机型 @id 列表,3-6 个 一次塞进 20 个机型,关系被稀释
isAccessoryOrSparePartFor 它主要是谁的备件 单个主机型 @id 空着不写,主机型关系丢失
compatibleWith 装上能不能用 逐机型给结论 直接写在 Product 上被校验器标红
additionalProperty 核心词表没有的字段怎么带 PropertyValue,name 写 compatibleWith 值写成纯文本,机型无法对齐

第三行是第 2 周真实踩到的坑:把 compatibleWith 当作 Product 属性直接写进 JSON-LD,校验工具报"该属性不是 schema.org/Product 的已知属性"。翻词表确认后改用 additionalProperty 承载,值里带上机型代码,既保留可读性又让引擎能对齐。

双向对称:配件页指过去,机型页指回来

单向声明只能说明"配件页自己这么说的",双向一致才是可信证据。配件页用 isRelatedTo 指向机型,机型页用 mentions 回指配件,两边 @id 必须是同一个锚点。第 2 周做对称性校验时,164 个配件页里有 71 页指了机型、机型页却没回指,全部补上。

sequenceDiagram
    participant U as 采购方提问
    participant E as AI 引擎
    participant P as 配件页
    participant M as 机型页
    U->>E: X2-300 的封口加热条是哪个型号
    E->>P: 抓取配件页的 JSON-LD
    P-->>E: isRelatedTo 指向机型 @id 列表
    E->>M: 按 @id 回访机型页
    M-->>E: mentions 回指该配件 @id
    E->>E: 双向提及一致,进入候选池
    E->>E: 读取 compatibleWith 的机型结论
    E-->>U: 给出配件型号并附配件页链接

双向这一步对实体解析的增益最明显:一个实体被越多页面共同指向,置信度越高,配件与机型互相指等于彼此加权。

先立实体库,再让页面挂上去

执行顺序不能反。先有实体库,页面上的 @id 才有东西可指;反过来做,标注出来的是一堆无处安放的锚点。第 2 周我们两个人和市场部一位同事,花四天把 96 个机型、164 个配件的名称与 @id 对齐,实体清单放在 CMS 里,导出成 JSON-LD 挂在机型列表页,其他页面只引用 @id。

配件页的一段 JSON-LD

下面这段是封口加热条页面最终的样子。行首 // 是讲解用注释,正式上线前必须删掉,JSON-LD 不接受注释。

{
  // 上下文固定指向 schema.org
  "@context": "https://schema.org",
  // 类型是配件本身,不是文章
  "@type": "Product",
  // 全站共用锚点,不随页面改版变化
  "@id": "https://example.com/parts/seal-heater-320#product",
  // 名称里带上型号,方便与正文互证
  "name": "封口加热条 SH-320",
  // sku 与站内 ERP 编码保持一致
  "sku": "SH-320",
  // isRelatedTo 声明"与哪些产品有关系",取值用 @id
  "isRelatedTo": [
    // 指向 X2-300 机型实体
    { "@id": "https://example.com/machines/x2-300#product" },
    // 指向 X3-500 机型实体
    { "@id": "https://example.com/machines/x3-500#product" }
  ],
  // isAccessoryOrSparePartFor 更严格:它是谁的备件
  "isAccessoryOrSparePartFor": {
    // 主机型只写一个,写多了语义会散
    "@id": "https://example.com/machines/x2-300#product"
  },
  // compatibleWith 不在核心词表里,用 additionalProperty 承载
  "additionalProperty": [
    {
      // PropertyValue 是扩展字段的标准容器
      "@type": "PropertyValue",
      // name 写属性名,保持与正文用词一致
      "name": "compatibleWith",
      // value 写机型代码,逐机型一条
      "value": "X2-300"
    },
    {
      // 第二条:X3-500 需要加装垫片
      "@type": "PropertyValue",
      // 同样的属性名,允许多值
      "name": "compatibleWith",
      // 条件写进值里,人读机器读都清楚
      "value": "X3-500(需加装 SH-ADP 垫片)"
    }
  ]
}

第 3 周上线当天出了个低级错误:模板里 sku 取的是数据库自增 ID,64 个页面的 sku 变成纯数字,和正文型号对不上。当晚改回 ERP 编码,重发 sitemap,第二天重抓恢复。

机型页的回指写法

机型页只加一段 mentions,把适配配件的 @id 列出来。第 3 周给 96 个机型页补上,平均一页回指 4.2 个配件。

{
  // 上下文与类型,同配件页
  "@context": "https://schema.org",
  // 机型本身也是一个 Product 实体
  "@type": "Product",
  // 与配件页引用的锚点必须完全一致
  "@id": "https://example.com/machines/x2-300#product",
  // 名称保持与实体库一致,不写别名
  "name": "X2-300 型全自动封口机",
  // mpn 用厂家型号编号
  "mpn": "X2-300",
  // mentions 回指适配配件,形成双向
  "mentions": [
    // 回指封口加热条
    { "@id": "https://example.com/parts/seal-heater-320#product" },
    // 回指温控模块
    { "@id": "https://example.com/parts/temp-module-t20#product" },
    // 回指输送带
    { "@id": "https://example.com/parts/belt-1500#product" }
  ]
}

批量生成与对称性校验

164 个配件页没法手写,我们从配件-机型映射表生成 JSON-LD,发版前跑一遍对称性校验。脚本只依赖 Python 3.12 标准库,发现不对称就让 CI 失败。

# -*- coding: utf-8 -*-
# 依赖:Python 3.12 标准库,无需第三方包
# 用途:校验配件页与机型页的兼容关系是否双向一致
import json
import pathlib
import collections

# 站点域名,用于拼接稳定的 @id 锚点
BASE = "https://example.com"

# 从映射表读出 配件 -> 机型 的适配关系
# 实际项目里这张表来自 ERP,这里用 JSON 简化
MAP_FILE = pathlib.Path("parts_machines.json")

# 存放所有页面 JSON-LD 的目录
PAGE_DIR = pathlib.Path("dist/pages")


def load_map():
    # 读取映射表,返回 {"SH-320": ["X2-300", "X3-500"]}
    with MAP_FILE.open(encoding="utf-8") as f:
        return json.load(f)


def part_id(code):
    # 配件 @id 由类型目录 + 型号代码拼成,不随改版变
    return f"{BASE}/parts/{code.lower()}#product"


def machine_id(code):
    # 机型 @id 同理,与配件页引用必须一字不差
    return f"{BASE}/machines/{code.lower()}#product"


def scan_pages():
    # 遍历产物目录,收集每页声明的关系
    # 返回 {页面路径: {"isRelatedTo": set(), "mentions": set()}}
    declared = {}
    for path in PAGE_DIR.rglob("*.json"):
        # 逐个读取页面的结构化数据
        with path.open(encoding="utf-8") as f:
            data = json.load(f)
        # 取 isRelatedTo,可能缺失,用空列表兜底
        rel = data.get("isRelatedTo", [])
        # 取 mentions,同样兜底
        men = data.get("mentions", [])
        # 只保留带 @id 的项,内联定义不计入对称校验
        declared[path] = {
            "isRelatedTo": {x["@id"] for x in rel if "@id" in x},
            "mentions": {x["@id"] for x in men if "@id" in x},
        }
    return declared


def main():
    # 载入映射表
    mapping = load_map()
    # 扫描已生成页面
    declared = scan_pages()
    # 记录所有不对称项
    problems = collections.defaultdict(list)
    # 先查方向一:配件页是否声明了映射表里的机型
    for part_code, machines in mapping.items():
        # 找到该配件页的声明
        page = declared.get(part_id(part_code))
        if page is None:
            # 页面压根没生成,直接记为缺失
            problems["missing_part_page"].append(part_code)
            continue
        # 逐个机型核对
        for m in machines:
            # 机型 @id 不在 isRelatedTo 里就是漏标
            if machine_id(m) not in page["isRelatedTo"]:
                problems["part_missing_relation"].append((part_code, m))
    # 再查方向二:机型页是否回指了配件
    for part_code, machines in mapping.items():
        # 同样逐个机型核对
        for m in machines:
            # 取机型页声明
            mpage = declared.get(machine_id(m))
            if mpage is None:
                # 机型页缺失
                problems["missing_machine_page"].append(m)
                continue
            # 配件 @id 不在 mentions 里就是单向
            if part_id(part_code) not in mpage["mentions"]:
                problems["machine_missing_backref"].append((m, part_code))
    # 汇总输出,非空则以退出码 1 结束,让 CI 失败
    total = sum(len(v) for v in problems.values())
    if total:
        # 打印各类问题条数
        for key, items in problems.items():
            print(f"{key}: {len(items)} 条,示例 {items[:3]}")
        # 非零退出码阻断发版
        raise SystemExit(1)
    # 全通过
    print("兼容关系双向一致,校验通过")


if __name__ == "__main__":
    # 入口
    main()

第 4 周第一次跑,machine_missing_backref 报了 71 条,正是那批没回指的机型页。补完之后连续三周零告警,成了发版流水线的一道硬门槛。

45 天对照:数字怎么变的

测量口径在第 1 周定死,后面不再改:挑 18 个采购方常问的兼容问题,在几个 AI 搜索产品里各问一遍,人工判定机型推荐对不对、有没有附本站链接,三个人各判一遍取多数。

指标 改造前(第 1 周基线) 第 45 天
配件页总数 164 164
带 isRelatedTo 的配件页 0 158
带 compatibleWith 声明的配件页 0 152
机型页双向回指 0 96
18 个问答中引用本站的条数 6 17
其中推荐错机型的条数 4 1
错型占比 66.7% 5.9%
引用落在配件页的条数 2 13

引用总量从 6 涨到 17,是补齐 lastmod 后重抓变勤带来的。要看的是错型占比:从 4/6 掉到 1/17。剩下那条错型还是 X3-500 的加装垫片条件没被读进去,答案里只说了"适用"。

分周看,变化不是线性的,前两周几乎没动静。

周次 动作 18 问中的错型条数 备注
第 1 周 基线测量,不动代码 4 引用 6 条
第 2 周 实体库立起来,@id 收敛 4 引用 6 条,无变化
第 3 周 配件页挂 isRelatedTo 3 引用 9 条
第 4 周 compatibleWith 上线 2 引用 13 条
第 5 周 机型页双向回指 2 引用 15 条
第 6-7 周 无改动,只观察 1 引用 17 条,索引刷新滞后

第 2 周做完实体库,数字一动不动,当时心里没底。第 3 周挂上关系才开始动,第 4 周 compatibleWith 把机型条件显式化,错型从 3 降到 2。第 6 周什么都没改,错型又从 2 降到 1——索引刷新滞后两到三周,这类改造得留观察窗口,别在第三周下结论。

第 6 周之后还剩下的坑

垫片那类"带条件的兼容"依然读不准。value 里写的"需加装 SH-ADP 垫片",答案里常被丢掉,只剩"适用"。后来把条件拆成单独的 PropertyValue,属性名写 compatibilityCondition,值只写条件本身。第 7 周那条错型消失,样本太小,还不能说这个写法就稳了。

别名收敛也是个持续活。市场部每月上新机型,正文里的写法总有出入,而上面的脚本只查 @id 对称,查不出别名漂移。第 5 周起每月扫一次,把正文里出现、实体库里没有的型号字符串列出来人工确认。

反向排除没解决干净。"不适用于 X1-200"这类否定表述,词表里没有属性可以承载,暂时靠 disambiguatingDescription 带一句。效果一般,答案仍然偶尔忽略排除项。

参考与延伸

关键词:GEO, AI 搜索, Schema.org, JSON-LD, compatibleWith, isRelatedTo, 实体解析, 制造业 B2B

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