配件兼容性页被 AI 张冠李戴之后:compatibleWith 与 isRelatedTo 的 45 天引用对照
适用读者:维护制造业 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 带一句。效果一般,答案仍然偶尔忽略排除项。
参考与延伸
- Schema.org Product 类型与属性清单:https://schema.org/Product
- isRelatedTo 属性说明:https://schema.org/isRelatedTo
- isAccessoryOrSparePartFor 属性说明:https://schema.org/isAccessoryOrSparePartFor
- 结构化数据入门与 @id 用法:https://schema.org/docs/gs.html
关键词:GEO, AI 搜索, Schema.org, JSON-LD, compatibleWith, isRelatedTo, 实体解析, 制造业 B2B