同一台设备在 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 说清来历。
三个来源怎么被拆成三个实体
改造前那台设备的抓取链路,画出来只有十来步,断点在哪一眼可见。
#mermaid-svg-dcufGuuRToOzOtIN{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-dcufGuuRToOzOtIN .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-dcufGuuRToOzOtIN .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-dcufGuuRToOzOtIN .error-icon{fill:#552222;}#mermaid-svg-dcufGuuRToOzOtIN .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-dcufGuuRToOzOtIN .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-dcufGuuRToOzOtIN .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-dcufGuuRToOzOtIN .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-dcufGuuRToOzOtIN .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-dcufGuuRToOzOtIN .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-dcufGuuRToOzOtIN .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-dcufGuuRToOzOtIN .marker{fill:#333333;stroke:#333333;}#mermaid-svg-dcufGuuRToOzOtIN .marker.cross{stroke:#333333;}#mermaid-svg-dcufGuuRToOzOtIN svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-dcufGuuRToOzOtIN p{margin:0;}#mermaid-svg-dcufGuuRToOzOtIN .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-dcufGuuRToOzOtIN .cluster-label text{fill:#333;}#mermaid-svg-dcufGuuRToOzOtIN .cluster-label span{color:#333;}#mermaid-svg-dcufGuuRToOzOtIN .cluster-label span p{background-color:transparent;}#mermaid-svg-dcufGuuRToOzOtIN .label text,#mermaid-svg-dcufGuuRToOzOtIN span{fill:#333;color:#333;}#mermaid-svg-dcufGuuRToOzOtIN .node rect,#mermaid-svg-dcufGuuRToOzOtIN .node circle,#mermaid-svg-dcufGuuRToOzOtIN .node ellipse,#mermaid-svg-dcufGuuRToOzOtIN .node polygon,#mermaid-svg-dcufGuuRToOzOtIN .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-dcufGuuRToOzOtIN .rough-node .label text,#mermaid-svg-dcufGuuRToOzOtIN .node .label text,#mermaid-svg-dcufGuuRToOzOtIN .image-shape .label,#mermaid-svg-dcufGuuRToOzOtIN .icon-shape .label{text-anchor:middle;}#mermaid-svg-dcufGuuRToOzOtIN .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-dcufGuuRToOzOtIN .rough-node .label,#mermaid-svg-dcufGuuRToOzOtIN .node .label,#mermaid-svg-dcufGuuRToOzOtIN .image-shape .label,#mermaid-svg-dcufGuuRToOzOtIN .icon-shape .label{text-align:center;}#mermaid-svg-dcufGuuRToOzOtIN .node.clickable{cursor:pointer;}#mermaid-svg-dcufGuuRToOzOtIN .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-dcufGuuRToOzOtIN .arrowheadPath{fill:#333333;}#mermaid-svg-dcufGuuRToOzOtIN .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-dcufGuuRToOzOtIN .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-dcufGuuRToOzOtIN .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-dcufGuuRToOzOtIN .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-dcufGuuRToOzOtIN .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-dcufGuuRToOzOtIN .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-dcufGuuRToOzOtIN .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-dcufGuuRToOzOtIN .cluster text{fill:#333;}#mermaid-svg-dcufGuuRToOzOtIN .cluster span{color:#333;}#mermaid-svg-dcufGuuRToOzOtIN div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-dcufGuuRToOzOtIN .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-dcufGuuRToOzOtIN rect.text{fill:none;stroke-width:0;}#mermaid-svg-dcufGuuRToOzOtIN .icon-shape,#mermaid-svg-dcufGuuRToOzOtIN .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-dcufGuuRToOzOtIN .icon-shape p,#mermaid-svg-dcufGuuRToOzOtIN .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-dcufGuuRToOzOtIN .icon-shape .label rect,#mermaid-svg-dcufGuuRToOzOtIN .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-dcufGuuRToOzOtIN .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-dcufGuuRToOzOtIN .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-dcufGuuRToOzOtIN :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 同一台设备 HX-820-3P
官网产品页
B2B 平台店铺页
经销商产品页
Product.sku = MAT-1029384
name = 全自动高压清洗机 820 系列
gtin13 = 6901234567892 校验位算错
title = SKU88213047 工业清洗机
标题写 820 型清洗机 无结构化数据
参数表用图片发布 正文无数字
实体一 高压清洗机 820 系列
实体二 SKU88213047
实体三 820 型清洗机
压力 180 BAR
压力 15 MPa 单位不同
无参数 页面只有图
三条记录分别进索引
用户提问 820 清洗机压力多少
回答只引用其中一条
评价与案例被引到另一个实体的页面
实体一那条路径里,官网参数写的是 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)就是多来源产品数据的合并判据。引擎侧拿到的是三元组,同一台设备出现在三个页面上会产生三组主语不同的三元组,合并依据只有两个------一条可验证的全局键,或者一组相似度够高的弱特征。
#mermaid-svg-vMRgGRT5g8WmdOaA{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-vMRgGRT5g8WmdOaA .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-vMRgGRT5g8WmdOaA .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-vMRgGRT5g8WmdOaA .error-icon{fill:#552222;}#mermaid-svg-vMRgGRT5g8WmdOaA .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-vMRgGRT5g8WmdOaA .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-vMRgGRT5g8WmdOaA .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-vMRgGRT5g8WmdOaA .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-vMRgGRT5g8WmdOaA .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-vMRgGRT5g8WmdOaA .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-vMRgGRT5g8WmdOaA .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-vMRgGRT5g8WmdOaA .marker{fill:#333333;stroke:#333333;}#mermaid-svg-vMRgGRT5g8WmdOaA .marker.cross{stroke:#333333;}#mermaid-svg-vMRgGRT5g8WmdOaA svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-vMRgGRT5g8WmdOaA p{margin:0;}#mermaid-svg-vMRgGRT5g8WmdOaA .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-vMRgGRT5g8WmdOaA .cluster-label text{fill:#333;}#mermaid-svg-vMRgGRT5g8WmdOaA .cluster-label span{color:#333;}#mermaid-svg-vMRgGRT5g8WmdOaA .cluster-label span p{background-color:transparent;}#mermaid-svg-vMRgGRT5g8WmdOaA .label text,#mermaid-svg-vMRgGRT5g8WmdOaA span{fill:#333;color:#333;}#mermaid-svg-vMRgGRT5g8WmdOaA .node rect,#mermaid-svg-vMRgGRT5g8WmdOaA .node circle,#mermaid-svg-vMRgGRT5g8WmdOaA .node ellipse,#mermaid-svg-vMRgGRT5g8WmdOaA .node polygon,#mermaid-svg-vMRgGRT5g8WmdOaA .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-vMRgGRT5g8WmdOaA .rough-node .label text,#mermaid-svg-vMRgGRT5g8WmdOaA .node .label text,#mermaid-svg-vMRgGRT5g8WmdOaA .image-shape .label,#mermaid-svg-vMRgGRT5g8WmdOaA .icon-shape .label{text-anchor:middle;}#mermaid-svg-vMRgGRT5g8WmdOaA .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-vMRgGRT5g8WmdOaA .rough-node .label,#mermaid-svg-vMRgGRT5g8WmdOaA .node .label,#mermaid-svg-vMRgGRT5g8WmdOaA .image-shape .label,#mermaid-svg-vMRgGRT5g8WmdOaA .icon-shape .label{text-align:center;}#mermaid-svg-vMRgGRT5g8WmdOaA .node.clickable{cursor:pointer;}#mermaid-svg-vMRgGRT5g8WmdOaA .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-vMRgGRT5g8WmdOaA .arrowheadPath{fill:#333333;}#mermaid-svg-vMRgGRT5g8WmdOaA .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-vMRgGRT5g8WmdOaA .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-vMRgGRT5g8WmdOaA .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-vMRgGRT5g8WmdOaA .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-vMRgGRT5g8WmdOaA .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-vMRgGRT5g8WmdOaA .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-vMRgGRT5g8WmdOaA .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-vMRgGRT5g8WmdOaA .cluster text{fill:#333;}#mermaid-svg-vMRgGRT5g8WmdOaA .cluster span{color:#333;}#mermaid-svg-vMRgGRT5g8WmdOaA div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-vMRgGRT5g8WmdOaA .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-vMRgGRT5g8WmdOaA rect.text{fill:none;stroke-width:0;}#mermaid-svg-vMRgGRT5g8WmdOaA .icon-shape,#mermaid-svg-vMRgGRT5g8WmdOaA .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-vMRgGRT5g8WmdOaA .icon-shape p,#mermaid-svg-vMRgGRT5g8WmdOaA .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-vMRgGRT5g8WmdOaA .icon-shape .label rect,#mermaid-svg-vMRgGRT5g8WmdOaA .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-vMRgGRT5g8WmdOaA .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-vMRgGRT5g8WmdOaA .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-vMRgGRT5g8WmdOaA :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 有
无或校验位不通过
是
否
是
否
否
是
不一致
一致
官网产品页 JSON-LD
抽取标识符集合
B2B 平台商品页
经销商页面与正文
是否存在 gtin 且校验位通过
以 gtin 为主对齐键 全局作用域
brand 是否一致 且 mpn 是否相同
以 brand 加 mpn 组合键对齐 厂商作用域
sku 与页面域名是否同源
以 域名加 sku 对齐 仅商家作用域内有效
退回弱特征匹配 标题 品牌 图片指纹 规格数字
相似度是否越过阈值
判为不同实体 双方都留在候选池
合并 但标记为低置信 引用时降权
落入同一实体节点
校验三来源声明值是否逐位一致
挂起该机型 人工确认前不参与引用
进入索引与召回
强键在前的道理很直白: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。
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 或兼容协议的服务。
python
# -*- 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