同一台设备在 AI 眼里有三个身份:GTIN、MPN 与 SKU 标识符写法解读
适用读者:负责工业品独立站与 B2B 店铺的产品数据工程师、结构化数据(Structured Data)维护者,以及被「AI 助手报错型号」这件事反复叫去救火的运营技术负责人。
上个月客户把 AI 助手给出的报价单打印出来,指着上面三个型号问我们哪个才是他要的那台机器:HX-820-3P、SKU88213047、MAT-1029384,报价还分了三档。三行字,同一台设备,连外壳的喷漆颜色都一样,客户纠结四十分钟,最后打电话到车间核对铭牌才下单。
这不是模型胡说。同一个型号串在官网、平台店铺、经销页面各出现一次,谁也没声明它们指向同一样东西。
三套编号,各有各的出处
三套编号不是谁抄错了,是三套系统各自长出来的。ERP 里这台设备是内部物料号 MAT-1029384,2019 年上线时按物料大类排的流水,跟产品本身没有语义关系。铭牌与图纸上写的是厂家型号 HX-820-3P,820 是系列,3P 表示三相电,改一次电控方案就换一次后缀。平台店铺里的 SKU88213047 是上架时自动生成的,换一个平台又会拿到另一串数字。
单看每个来源都合规,官网填了 sku、平台填了条码、经销页面标题里写了系列名,麻烦出在这三份声明之间没有互相指认的字段。当成标识符的作用域问题会清楚很多:GTIN 是全球作用域,谁读到都指向同一个贸易项目;MPN 的作用域是「品牌 + 型号」,缺了 brand 就没有分辨力;SKU 的作用域只有商家自己,出了这家店的域名就不成立。
| 标识符 | 谁在用、什么场景该填 | 格式约束 | 我们见过的误用 |
|---|---|---|---|
gtin13 |
有零售条码的整机、配件包、耗材盒;平台商品与线下条码同源 | 13 位纯数字,末位为 mod 10 校验位;0 开头不能省,也别补成 14 位 |
把内部物料号凑够 13 位填进来;手抄时校验位算错;官网填 gtin13、平台填 gtin12 |
gtin8 |
小体积耗材、单只滤芯这类只印得下 8 位码的包装 | 8 位纯数字,同样带 mod 10 校验位 | 与 gtin13 混填;同一条码在两个字段各写一遍,反而让引擎怀疑哪个可信 |
mpn |
厂家型号,铭牌、图纸、出厂检验单上的那一串;B2B 买家搜型号的主要入口 | 自由文本;连字符、大小写、空格必须与铭牌逐字一致 | 一个页面里写了两个系列号;带上中文描述(HX-820-3P 三相电款);把平台编号写进 mpn |
sku |
商家内部编号,同商家域名内不重复;ERP 与店铺后台对得上的那串 | 自由文本,建议不超过 50 字符;换系统时值会变 | 用 sku 字段承载条码;平台换码后官网没同步;把内部号当成公开编号展示 |
identifier |
兜底字段,承载平台商品号、内部物料号、海关编码这类非标准编号 | 写成 PropertyValue,propertyID 与 value 成对出现 |
只写 value 不写 propertyID,引擎判断不出这串数字是什么 |
填之前先回答一个问题:这串编号换了商家还算数吗。算数就往 gtin13 或 mpn 上靠,不算数就放进 identifier,用 propertyID 说清来历。
三个来源怎么被拆成三个实体
改造前那台设备的抓取链路,画出来只有十来步,断点在哪一眼可见。
flowchart TD
A[同一台设备 HX-820-3P] --> B[官网产品页]
A --> C[B2B 平台店铺页]
A --> D[经销商产品页]
B --> B1["Product.sku = MAT-1029384"]
B --> B2["name = 全自动高压清洗机 820 系列"]
C --> C1["gtin13 = 6901234567892 校验位算错"]
C --> C2["title = SKU88213047 工业清洗机"]
D --> D1["标题写 820 型清洗机 无结构化数据"]
D --> D2["参数表用图片发布 正文无数字"]
B1 --> E[实体一 高压清洗机 820 系列]
B2 --> E
C1 --> F[实体二 SKU88213047]
C2 --> F
D1 --> G[实体三 820 型清洗机]
D2 --> G
E --> H[压力 180 BAR]
F --> I[压力 15 MPa 单位不同]
G --> J[无参数 页面只有图]
H --> K[三条记录分别进索引]
I --> K
J --> K
K --> L{用户提问 820 清洗机压力多少}
L --> M[回答只引用其中一条]
L --> N[评价与案例被引到另一个实体的页面]
实体一那条路径里,官网参数写的是 180 BAR,平台侧写的是 15 MPa,两个数都对,差的 30 BAR 来自版本区别,没人标注适用批次。引擎两条都读到了,合成一句话时取哪个都得赌。
改造前我们量了什么
观测口径在动手之前就写死:抽样 60 台机型,三个产品线各 20 台,选样时保证各类的来源条目数量接近。每周固定 20 个参数类提问,覆盖额定压力、整机功率、进水口径、外形尺寸、标配附件五类,每类四个问法变体,记录回答里的型号串、引用的页面地址与参数值。
HX-820-3P 在引擎侧被拆成 3 个实体,20 次提问里 11 次报错型号。配件机 HX-F08 更糟,平台店铺把它的条码填成了主机的条码,引擎把配件参数挂到主机名下,客户照这个参数选型会选错规格。
基线数字:60 台机型在引擎侧共生成了 168 个实体记录,平均一台被拆成 2.8 个;参数来自正确机型页面的比例是 41%;全站 641 条来源条目按标题与正文相似度去重后,仍有 388 条被判内容重复;gtin13 三来源逐位一致的只有 34%。
原理剖析:标识符如何驱动实体对齐
实体对齐(Entity Alignment)就是多来源产品数据的合并判据。引擎侧拿到的是三元组,同一台设备出现在三个页面上会产生三组主语不同的三元组,合并依据只有两个——一条可验证的全局键,或者一组相似度够高的弱特征。
flowchart TD
S1[官网产品页 JSON-LD] --> P[抽取标识符集合]
S2[B2B 平台商品页] --> P
S3[经销商页面与正文] --> P
P --> N1{是否存在 gtin 且校验位通过}
N1 -- 有 --> K1[以 gtin 为主对齐键 全局作用域]
N1 -- 无或校验位不通过 --> N2{brand 是否一致 且 mpn 是否相同}
N2 -- 是 --> K2[以 brand 加 mpn 组合键对齐 厂商作用域]
N2 -- 否 --> N3{sku 与页面域名是否同源}
N3 -- 是 --> K3[以 域名加 sku 对齐 仅商家作用域内有效]
N3 -- 否 --> F[退回弱特征匹配 标题 品牌 图片指纹 规格数字]
F --> R{相似度是否越过阈值}
R -- 否 --> X[判为不同实体 双方都留在候选池]
R -- 是 --> Y[合并 但标记为低置信 引用时降权]
K1 --> M[落入同一实体节点]
K2 --> M
K3 --> M
Y --> M
M --> C[校验三来源声明值是否逐位一致]
C -- 不一致 --> W[挂起该机型 人工确认前不参与引用]
C -- 一致 --> Z[进入索引与召回]
强键在前的道理很直白:gtin13 带校验位,错一位就被拒,这种可自校验的键在合并阶段接近硬证据;mpn 配合 brand 属于厂商作用域,两家厂商撞型号的概率不低,缺了品牌这一半会误把竞品合并;sku 出了自己的域名就失去意义,跨站拿它做键等于拿别的店也会重用的流水号去撞库。
弱特征匹配是分叉的源头。标题相似度、品牌字符串、图片感知哈希、规格数字重合度的权重都在窄区间里浮动,三个页面落在阈值上下两侧时判断就会分叉——官网页与平台页并成一个实体,经销页因为标题多了「厂家直销」四个字另起一个。分叉之后每个实体各带一份不完整的属性集,评价挂在经销实体上、参数取的是平台实体下的,两者的关联只存在于现实世界。
低置信合并的记录在召回阶段还会被降权。
改造前后对照
八个星期,节奏是这样安排的:第 1 到 2 周盘点编号来源,把 641 条来源条目对应的内部物料号抄成对齐表;第 3 到 4 周在官网与平台两侧把 gtin13、mpn、brand、identifier 全量落上;第 5 到 6 周写脚本做跨来源一致性校验;第 7 到 8 周复核引擎侧合并结果,处理过度合并的案例。
| 观测指标 | 改造前(第 0 周基线) | 改造后(第 9 周复核) |
|---|---|---|
| 抽样 60 台机型在引擎侧形成的实体数 | 168 | 63 |
| 参数类回答引用到正确机型页面的比例 | 41% | 89% |
| 20 次提问里出现错误型号的次数(周均) | 11 | 2 |
| 全站被判为内容重复的来源条目数 | 388 | 47 |
三来源 gtin13 逐位一致率 |
34% | 99% |
| 平台侧条码校验位错误的记录数 | 87 | 0 |
| 低置信合并记录的占比 | 引擎未区分 | 6% |
第 9 周的 63 个实体比 60 台多出 3 个,这不是没修干净,是三组过度合并(Over-merging):三台设备共用一个系列条码,mpn 后缀被引擎忽略,HX-820-3P 与 HX-820-1P 合到了同一实体里。办法是把后缀写进 mpn,再在 identifier 里补一条 propertyID 为「电控版本」的 PropertyValue。
重复条目从 388 降到 47,是来源被正确归并的结果,不是页面被删。
引用准确率从 41% 提到 89%,提升最大的一段在第 5 周和第 6 周之间,那两周只做了一件事:把校验位错的 87 条修完,把位数不一致的 41 条统一到 13 位。字段填得对不对,比填了多少个字段更要紧。 有 12 台机器一开始既填 gtin13 又填 gtin8,引擎在 offer 层级取错了值,后来只保留整机码。
落地:Product Schema 里的标识符写法
关键是把四个字段的作用域分清楚,再让 identifier 兜住非标准编号。环境与依赖:无需额外运行时,抓取器直接读取;@context 必须写 https://schema.org。下面为便于阅读保留了 // 注释行,部署前删掉即可得到合法 JSON。
// 放在产品页 head 内的 application/ld+json 脚本里
// 建议与 Organization、BreadcrumbList 同处一个 @graph,用 @id 互相指向
// 这台设备在官网的正式身份:品牌 HXKJ,厂家型号 HX-820-3P
{
// @context 必须写 https,写成 http 或 //schema.org 会被校验工具判为无效
"@context": "https://schema.org",
// 有零售条码的整机用 Product 就够了,配件包另建一条
"@type": "Product",
// @id 用带域名的完整 URL,三套编号都挂在这个锚点上
"@id": "https://example.com/products/hx-820-3p#product",
// name 写消费者看得懂的名称,编号不要往这里塞
"name": "全自动高压清洗机 820 系列 三相电版本",
// mpn 与铭牌、图纸、出厂检验单逐字一致,连字符和大小写照抄
"mpn": "HX-820-3P",
// sku 是商家内部编号,同商家域名内不重复,换 ERP 时这个值要跟着改
"sku": "MAT-1029384",
// gtin13 必须 13 位且校验位正确,平台已备案的条码直接照抄不要手打
"gtin13": "6901234567892",
// brand 用 Brand 节点而不是裸字符串,品牌名才进得了实体图谱
"brand": {
// @id 与 Organization 节点复用同一个值,品牌实体就只留一个
"@id": "https://example.com/#brand",
"@type": "Brand",
// name 用商标上的写法,别写渠道简称或中文译名
"name": "HXKJ"
},
// identifier 承载非标准编号,不写 propertyID 等于让引擎猜这串数字的来历
"identifier": [
{
"@type": "PropertyValue",
// propertyID 与内部系统字段名一致,脚本做映射时不用二次翻译
"propertyID": "内部物料号",
"value": "MAT-1029384"
},
{
"@type": "PropertyValue",
// 平台商品编号单独声明,跨来源核对时多一个可对齐的锚点
"propertyID": "平台商品编号",
"value": "SKU88213047"
},
{
"@type": "PropertyValue",
// 电控版本这类差异写成显式属性,别指望引擎从型号后缀里读出来
"propertyID": "电控版本",
"value": "3P"
}
],
// offers 里的 sku 与 gtin13 要和上面一致,两处不一致时引擎取哪个都不好说
"offers": {
"@type": "Offer",
// url 指向可直接询价的页面,别指向首页或跳转链接
"url": "https://example.com/products/hx-820-3p",
// availability 用 schema.org 的枚举 URL,写中文或自造值会失效
"availability": "https://schema.org/InStock",
// 工业品多数是询价而非明码,币种仍要声明,价格另走报价单
"priceCurrency": "CNY",
// 这里的两串编号与 Product 层级保持逐字一致
"sku": "MAT-1029384",
"gtin13": "6901234567892"
},
// sameAs 指向同一实体在平台、经销侧的落地页,是一条显式的跨站对齐路径
"sameAs": [
// 第一条是 B2B 平台的商品页
"https://b2b.example.net/item/SKU88213047",
// 第二条是经销商承接页,三边互指之后合并才稳定
"https://dealer.example.org/machine/820-3p"
],
// 参数用 additionalProperty 逐条声明,值与单位分开写
"additionalProperty": [
{
"@type": "PropertyValue",
// 参数名与页面表格的表头逐字一致,引擎对齐数值时少一步猜测
"name": "额定工作压力",
// value 只写数字,别把单位拼进去,否则数值比较会失败
"value": 180,
// unitCode 用 UN/CEFACT 三位代码,BAR、MMT、KGM 这类
"unitCode": "BAR"
}
]
}
sameAs 这一条经常被漏掉。它不参与索引打分,但给了引擎一条不需要做相似度判断的路径——两个页面自己声明了指向同一实体,合并阶段就少一次猜。
GTIN 校验位批量生成与校验脚本
641 条记录靠人工核对不现实,我们写了个脚本每天凌晨跑一次,把新增机型覆盖进来。
环境与依赖:Python 3.10 及以上,pymysql 1.1.x(pip install "pymysql>=1.1,<2.0"),数据库为 MySQL 5.7 / 8.0 或兼容协议的服务。
# -*- coding: utf-8 -*-
# 从跨来源对齐表批量生成并校验 GTIN 校验位,把问题记录挑出来
# 环境:Python 3.10+,pymysql 1.1.x,MySQL 5.7/8.0 或兼容协议
# 依赖安装:pip install "pymysql>=1.1,<2.0"
import sys
# dataclass 只是让字段名可读,没有引入 ORM
from dataclasses import dataclass
# 驱动只负责连接与游标,业务判断全在脚本里,换库时改动面最小
import pymysql
# 连接参数从环境变量注入,生产密码不要进仓库
DB_CONF = {
# 数据库地址,跨机房部署时换成内网域名
"host": "127.0.0.1",
# 默认端口,走代理时按运维给的端口改
"port": 3306,
# 只给读写对齐表的账号权限,不要用高权限账号
"user": "geo_rw",
# 密码留占位符,实际值由环境变量覆盖
"password": "******",
# 库名与线上保持一致,测试库另开一份配置
"database": "product_hub",
# utf8mb4 必须显式声明,型号里的希腊字母与全角符号会踩字符集
"charset": "utf8mb4",
}
# 每批处理的行数,几千条机型一次拉完内存顶得住,但事务太长会锁表
BATCH = 500
@dataclass
class IdentityRow:
"""一条跨来源对齐记录,对应 product_identity 表的一行。"""
# 主键,用来回写校验结果
id: int
# 内部物料号,改造前被误填进 gtin13 的就是它
material_no: str
# 厂家型号,gtin 缺失时靠它加品牌做对齐
mpn: str
# 平台侧条码,可能为空、可能位数不对、可能校验位错
gtin: str
def gtin_check_digit(body: str) -> int:
"""按 GS1 的 mod 10 规则算校验位,body 是去掉校验位后的数字串。"""
# 从右往左数,第 1、3、5 位权重 3,第 2、4、6 位权重 1
total = 0
# 8 位与 13 位用的是同一套权重规则,不用按长度分支
for idx, ch in enumerate(reversed(body)):
# idx 从 0 开始,偶数下标对应从右数的奇数位
total += int(ch) * (3 if idx % 2 == 0 else 1)
# 补到最近的 10 的整数倍,差额就是校验位;余数为 0 时校验位是 0
return (10 - total % 10) % 10
def normalize_gtin(raw: str) -> tuple[str, str]:
"""归一化条码,返回 (规范化结果, 问题描述),问题为空表示可用。"""
# 去掉空格与连字符,ERP 导出的值经常带分隔符
digits = "".join(ch for ch in (raw or "") if ch.isdigit())
# 空值或全是字母时 digits 是空串,下一步的长度判断会把它拦下来
# 长度不是 8 或 13 的直接打回,12 位老数据单独走升级流程,不要就地补位
if len(digits) not in (8, 13):
return "", f"长度 {len(digits)} 不是 8 或 13"
# 校验位对不上说明这条码在源头被手改过,或者从纸质单上抄错了
if int(digits[-1]) != gtin_check_digit(digits[:-1]):
return "", "校验位不匹配"
# 以 0 开头的 13 位码按原样保留,补成 14 位会破坏与平台的对应关系
return digits, ""
def load_rows(cur) -> list[IdentityRow]:
"""只拉未确认的行,已确认过的用 status 跳过,避免重复计算。"""
# 三个来源合并到一张对齐表,source 字段区分官网、平台、经销
cur.execute(
"SELECT id, material_no, mpn, gtin FROM product_identity "
"WHERE status <> 'verified' ORDER BY id LIMIT %s",
(BATCH,),
)
# 驱动默认返回元组,包一层 dataclass,后面加字段不会错位
return [IdentityRow(*row) for row in cur.fetchall()]
def main() -> int:
# 关掉自动提交,校验与回写放在同一个事务里,中途失败不留半批数据
conn = pymysql.connect(**DB_CONF, autocommit=False)
# 问题行计数,最后拿它决定退出码
bad = 0
# 先声明再赋值,异常分支里也要能读到它
rows: list[IdentityRow] = []
try:
# 用上下文管理器拿游标,提前 return 也不会漏关
with conn.cursor() as cur:
# 取数放在事务里,和后面的回写共用同一个游标
rows = load_rows(cur)
# 逐条处理,单条出错只记账,不让整批中断
for row in rows:
gtin, problem = normalize_gtin(row.gtin)
# 校验位错的先写回问题字段,人工在后台能直接看到原因
if problem:
bad += 1
cur.execute("UPDATE product_identity SET gtin_issue = %s WHERE id = %s", (problem, row.id))
continue
# 同一条码被两个机型占用,通常是平台侧复制粘贴串行了
cur.execute("SELECT COUNT(*) FROM product_identity WHERE gtin = %s AND id <> %s", (gtin, row.id))
if cur.fetchone()[0]:
bad += 1
cur.execute("UPDATE product_identity SET gtin_issue = %s WHERE id = %s", ("条码被其他机型占用", row.id))
continue
# 通过的行标成 verified,下一轮扫描不再重复拉取
cur.execute(
"UPDATE product_identity SET gtin = %s, gtin_issue = NULL, status = 'verified' WHERE id = %s",
(gtin, row.id),
)
# 整批处理完一次性提交,中途任何一条抛错都不会留下半批状态
conn.commit()
except Exception:
# 任何异常都回滚,脏状态比跑得慢难查得多
conn.rollback()
raise
finally:
# 连接一定关掉,否则调度跑久了会攒出一堆空闲连接
conn.close()
# 汇总一行日志,方便在调度平台上看趋势
print(f"checked={len(rows)} bad={bad}")
# 有问题行时退出码为 1,CI 里可以直接卡住发布流程
return 1 if bad else 0
if __name__ == "__main__":
# 挂到每日调度即可,配合 status 字段只处理增量
sys.exit(main())
第一次跑完,checked=641 bad=162。三类问题:校验位算错的 87 条,多数是运营在平台上手改过后三位;同一条码被两个机型占用的 34 条,集中在两个旧款共用一个包装箱条码;剩下 41 条长度不对。修完再跑 bad=0,verified 状态的行占比 99.5%,剩 3 条是同一电控版本的两个壳体色号,产品部还在确认要不要拆成两条码。
误区澄清与接下来一年的判断
第一个误区是把内部物料号塞进 gtin13 凑长度。有人在前面补 690,有人在后面补流水位,凑出来 13 位看着像条码,校验位一算就废。引擎对带校验位字段的处理有个特点:格式错了不是被忽略,而是整条记录的可信度一起下降,连带填对的 mpn、brand 也被打上低置信标记。我们接手的一个站有 200 多条这种码,改完之后引用准确率的提升比补齐字段那次还大。
第二个误区是认为型号大小写无所谓。HX-820-3P 与 hx8203p 在字符串比对层面是两个值,引擎没有义务替你归一化。平台运营为了好看把连字符去掉、字母改小写的做法很常见,改回来花了两周逐条对铭牌。同理还有全角半角括号、× 与 x、空格与下划线,这些差异在人眼里是同一串,在算法眼里是两条记录。
第三个误区是只改官网不改平台。实体对齐是双向的,官网声明了 sameAs 指向平台页面,平台对面没有回指,引擎只能按单边声明做弱合并。后来我们在平台侧商品描述末尾加了一行型号对应关系,再让经销商页面同步,三边互指之后合并才稳定。先在哪个来源放主键,取决于哪个来源你能直接改。
接下来一年可以预判两条。一条是引擎侧的实体图谱会往规格参数层面下沉,现在合并的是产品实体,以后会合并到「同一实体在不同批次的参数差异」这一层,additionalProperty 里带批次与适用型号的写法会更值钱。另一条是 identifier 会往规范上收,propertyID 现在是自由文本,多个来源各自发明名称迟早对不上。
工程上的落地次序我们跑通了:先盘出所有编号来源和实际取值,做一张对齐表;再把 gtin13、mpn、brand、identifier 四个字段在三边补齐,propertyID 写清楚;然后跑校验脚本把位数、校验位、重复占用清掉;最后用 sameAs 把三个来源显式互指,间隔两周复核引擎侧的合并结果。最容易跳过的是第一步,但它决定了后面能不能对账——没有那张对齐表,脚本报出来的问题你都不知道该找谁改。跑完 bad 不为零的同学可以在评论区贴一下问题分布,我们对一下各来源哪一类脏数据最多。
参考与延伸
- schema.org gtin13:
gtin13的官方定义、适用类型与取值说明 - schema.org Product:
mpn、sku、brand、identifier的权威定义 - GS1 校验位计算规则:GTIN-8 与 GTIN-13 的 mod 10 算法与官方计算器
- Google 商品结构化数据指南:
gtin、mpn、brand的组合要求与常见错误
关键词:GTIN, MPN, SKU, 实体对齐, Product Schema, 生成式引擎优化, AI优化AIO