散装商品按克还是按斤:UnitPriceSpecification 单价结构化的架构设计与落地
也适合负责 GEO 的同学,需要知道单价字段该落在哪张表、哪个 JSON 节点,以及写了之后怎么验证真的被读到。 文里有 MySQL 8.0 建表、Python 3.11 批量生成脚本和两张数据流图,可以直接照着改。
用户问「这个多少钱」,AI 助手回了一句「29.9 元」。可我们那袋每日坚果的价签是「¥29.9/500g」,用户实际下单称重 748g,付了 44.75 元。差了将近一半。问题不在模型算错,在于我们把 29.9 原样塞进 Offer.price,却没告诉它这 29.9 到底是几个克的钱。
这类错在标品上不会出,一包 200g 固定规格的饼干,price 就是成交价,怎么读都对。散装商品不一样,价格的分子是钱、分母是重量,分母丢了,答案必然跑偏。
价签写的是 500g,AI 把它当成整份价
我们商品中心大约 4.6 万个 SKU,其中散装称重类 1.1 万个,集中在生鲜、坚果、冻品三条线。第 3 周灰度完结构化数据改造之后,客服那边转过来一批工单,话术都差不多:用户拿着 AI 给的报价来下单,发现对不上。

复现方式很朴素。抓一个详情页 HTML,把 <script type="application/ld+json"> 里的文本抠出来看:
{
"@context": "https://schema.org",
"@type": "Product",
"name": "每日坚果 混合装",
"offers": {
"@type": "Offer",
"price": "29.90",
"priceCurrency": "CNY",
"availability": "https://schema.org/InStock"
}
}
站在解析器角度,这段数据能推出的结论只有一条:这个商品卖 29.90 CNY。它没有任何地方写着「29.90 是 500 克的价格」。模型要么照抄 price,要么凭商品名里的「500g」去猜,猜错了也不奇怪。
而生成式引擎优化(Generative Engine Optimization, GEO)在这件事上的处境有点尴尬:GEO 想让 AI 引擎引用你的数据,前提是这份数据自解释。价格字段自己说不清楚单位,再怎么优化文案也是白费劲。
先搞清楚 UnitPriceSpecification 规定了什么
UnitPriceSpecification 是 PriceSpecification 的子类型,专门用来描述「每单位数量的价格」。它挂在 Offer.priceSpecification 下面,本身不替代 Offer.price。
| 字段 | 类型 | 作用 | 我们踩过的填法错误 |
|---|---|---|---|
price |
Number / Text | 与 referenceQuantity 配套的那笔钱 |
填了整份成交价,和基准量对不上 |
priceCurrency |
Text | ISO 4217 货币码,如 CNY | 写成「元」「¥」这类符号 |
referenceQuantity |
QuantitativeValue | 价格的计数基准 | 直接写 500,单位信息全丢 |
referenceQuantity.value |
Number | 基准数值 | 千克和克混用,0.5 与 500 写反 |
referenceQuantity.unitCode |
Text | UN/CEFACT 代码 | 写 g、克、G,正确是 GRM |
referenceQuantity.unitText |
Text | 给人看的单位文本 | 留空,页面价签和结构化数据各说各话 |
billingIncrement |
Number | 最小计费步进 | 称重商品按 1 克计价却没标 |
unitCode 这块值得单独说。Schema.org 要求用 UN/CEFACT Recommendation 20 的三字母代码:重量用 GRM(克)、KGM(千克),体积用 MLT(毫升)、LTR(升),按件用 EA。写「g」「斤」「500克」都不合规,解析器遇到不认识的代码,最保守的做法就是整段忽略,等于白写。
unitText 不是冗余字段。它是给人和给检索片段看的,unitCode 是给机器做换算用的。两个都写,才不会出现「机器按克算、页面按斤显示」的错位。
解析机制:引擎怎么把 price 和 referenceQuantity 拼起来
底层其实是三步。第一步,解析器在 JSON-LD 里定位 Offer 节点,读 price 和 priceCurrency,得到一个「数值 + 货币」的对子。第二步,如果 priceSpecification 里存在 UnitPriceSpecification,它会继续读 referenceQuantity,把数量抽成一个带单位的量。第三步,把这个量和价格绑定成比率,存进实体的价格属性。
关键在于第二步失败会怎样。多数解析器不会因为 referenceQuantity 缺 unitCode 就整页判废,它们会退化成第一步的结果——也就是把 price 当成整份成交价。这就是我们线上那个错误答案的来源:不是没抓,是抓到了一半。
还有一层,Offer 上同时有 price 和 priceSpecification 时,不同引擎的取值优先级不一样。有的以 price 为准,有的优先信 priceSpecification。所以两者必须互相自洽:如果 price 放的是可成交价,priceSpecification 里的单价乘基准量就得回到同一个数量级,别让两份数据打架。
商品中心里单价字段怎么落
单价不能是页面模板里的一个字符串,得进库。不进库就没法批量校验,也没法在改价时自动重算。
我们的做法是新建一张独立的单价表,跟主 SKU 表按 sku_id 一对一,避免在 product 主表上继续堆列。
-- 环境:MySQL 8.0.35,InnoDB,utf8mb4
-- 用途:散装商品计价基准量表,页面的 JSON-LD 片段由它批量生成
CREATE TABLE product_unit_price (
-- 商品中心 SKU 主键,与主表一对一
sku_id BIGINT NOT NULL COMMENT '商品中心 SKU 主键',
-- 价签金额,即 referenceQuantity 对应的那笔钱
sale_price DECIMAL(12,2) NOT NULL COMMENT '价签金额',
-- ISO 4217 货币代码,国内站点固定 CNY
price_currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217 货币代码',
-- 计价基准数量,填 500 表示 500 个基准单位
ref_qty_value DECIMAL(12,3) NOT NULL COMMENT '计价基准数量',
-- UN/CEFACT 代码:GRM 克 / KGM 千克 / LTR 升
ref_qty_unit_code VARCHAR(8) NOT NULL COMMENT 'UN/CEFACT 单位代码',
-- 人读单位文本,如 500g、0.5kg、1L
ref_qty_unit_text VARCHAR(16) NOT NULL COMMENT '人读单位文本',
-- 折算到基准单位后的量,重量折克、体积折毫升,用于跨 SKU 比价
base_qty_value DECIMAL(16,6) NOT NULL COMMENT '折算到基准单位后的数量',
-- 基准单位代码,重量统一 GRM,体积统一 MLT
base_unit_code CHAR(3) NOT NULL COMMENT '基准单位代码',
-- 最小计费步进,按 1 克计价就写 1,可以为空
billing_increment DECIMAL(12,3) NULL COMMENT '最小计费步进',
-- 1 生效 0 下线,下线状态不生成结构化数据
status TINYINT NOT NULL DEFAULT 1 COMMENT '生效状态',
-- 更新时间,改价后靠它判断是否需要重算片段
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
-- 一个 SKU 只留一条生效单价,重复写入直接报冲突,方便早发现
PRIMARY KEY (sku_id),
-- 单位代码必须落在白名单内,写「斤」或「g」会被拒绝入库
CONSTRAINT chk_ref_unit CHECK (ref_qty_unit_code IN ('GRM','KGM','MLT','LTR')),
-- 基准单位只认克和毫升,保证比价口径统一
CONSTRAINT chk_base_unit CHECK (base_unit_code IN ('GRM','MLT')),
-- 数量与价格都得是正数,负价或零量属于数据污染
CONSTRAINT chk_positive CHECK (ref_qty_value > 0 AND sale_price > 0 AND base_qty_value > 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='散装商品单价基准量表';
base_qty_value 这一列是后来补的。第一版没有它,做跨 SKU 比价的时候,一个 SKU 按 500g 标、另一个按 1kg 标,程序得先把单位统一才能比,每次都临时换算又慢又容易错。加一列把折算结果存死,比价直接查就行。
ref_qty_value 和 base_qty_value 的关系靠写入侧保证:运营后台保存时,服务层拿到 ref_qty_unit_code 查乘数表,算出 base_qty_value 一起写。数据库只做范围约束,不做换算,逻辑放在一处好排查。
从库表到 JSON-LD 的批量生成架构
生成是离线的,不在请求链路上做。详情页 QPS 高峰期上过 3 万,实时拼 JSON-LD 不划算,而且结构化数据本来也不需要秒级更新。
flowchart LR
A["商品中心 MySQL<br/>product_unit_price"] --> B["单价归一服务<br/>换算到基准单位"]
B --> C["JSON-LD 组装器<br/>Offer.priceSpecification"]
C --> D["片段缓存 Redis<br/>key = sku_id"]
D --> E["详情页渲染<br/>注入 ld+json 脚本块"]
E --> F["AI 引擎与爬虫抓取"]
F --> G["抽样校验与工单回流"]
G --> B
那条回流的线是第 4 周才加的。一开始生成完就不管了,后来发现运营在后台把「500g」改成「0.5kg」,页面上 unitText 跟着变了,但有人在另一个系统里手工改了价,单价和基准量就对不上了。加了一道抽样校验,每天抽 800 个页面跑断言,报错直接进值班群。
生成脚本本身不长,麻烦的是单位换算和边界处理。
# -*- coding: utf-8 -*-
# 环境:Python 3.11.6 + PyMySQL 1.1.1,decimal 用标准库,无外部数值依赖
# 用途:从 product_unit_price 读单价,批量生成 Product 的 JSON-LD 片段
from decimal import Decimal, ROUND_HALF_UP
import json
import pymysql
# UN/CEFACT 代码折算到基准单位所需乘数
# 重量基准取克 GRM,体积基准取毫升 MLT,两条线各一个基准,互不混用
TO_BASE = {
# 1 千克 = 1000 克
"KGM": Decimal("1000"),
# 1 克 = 1 克,无需换算
"GRM": Decimal("1"),
# 1 升 = 1000 毫升
"LTR": Decimal("1000"),
# 1 毫升 = 1 毫升,无需换算
"MLT": Decimal("1"),
}
# 允许作为计价基准的单位,与数据库 CHECK 约束保持一致
ALLOWED_REF_UNIT = {"GRM", "KGM", "MLT", "LTR"}
# 把一行库记录折算到基准单位,返回基准数量
def normalize(row):
# 取出单位代码,白名单外的单位一律不处理
code = row["ref_qty_unit_code"]
if code not in ALLOWED_REF_UNIT:
# 宁可这条不生成,也不要产出脏数据
raise ValueError(f"非法的计价单位: {code}, sku_id={row['sku_id']}")
# 基准数量 = 标称数量 × 折算乘数
base_value = Decimal(str(row["ref_qty_value"])) * TO_BASE[code]
# 这里不做四舍五入,保留六位小数避免中间过程累积误差
return base_value.quantize(Decimal("0.000001"))
# 构造一个 UnitPriceSpecification 节点
def build_unit_price(row):
# 价格定到两位小数,银行家舍入改成四舍五入,跟财务口径一致
price = Decimal(str(row["sale_price"])).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
# normalize() 去掉小数末尾多余的 0,500.000 会变成 500
qty = Decimal(str(row["ref_qty_value"])).normalize()
return {
# 类型必须显式写 UnitPriceSpecification,写 PriceSpecification 不会被当单价
"@type": "UnitPriceSpecification",
# 只写数字不写货币符号,货币单独放 priceCurrency
"price": str(price),
"priceCurrency": row["price_currency"],
"referenceQuantity": {
# 基准量是个 QuantitativeValue,value / unitCode / unitText 三样齐全
"@type": "QuantitativeValue",
"value": str(qty),
"unitCode": row["ref_qty_unit_code"],
"unitText": row["ref_qty_unit_text"],
},
}
# 拼出完整 Product 节点
def build_product(row):
# Offer.price 表示可成交价,priceSpecification 表示单价
# 散装商品这两处写同一个数字,避免引擎取到互相矛盾的答案
offer_price = Decimal(str(row["sale_price"])).quantize(Decimal("0.01"))
return {
# @context 固定写 https,写 http 也能解析但不规范
"@context": "https://schema.org",
"@type": "Product",
# 商品名与页面 H1 保持一致,实体对齐靠它
"name": row["sku_name"],
# sku 用字符串,数字类型在部分解析器里会被当成 ID 丢精度
"sku": str(row["sku_id"]),
"offers": {
"@type": "Offer",
"price": str(offer_price),
"priceCurrency": row["price_currency"],
# 库存状态用 schema.org 的枚举 URL,不要写「有货」这种中文
"availability": "https://schema.org/InStock",
# 单价挂在这里,一个 Offer 可以有多个 priceSpecification
"priceSpecification": build_unit_price(row),
},
}
def main():
# 只读生效中的单价,下线的 SKU 不生成
sql = """
SELECT p.sku_id, p.sku_name, u.sale_price, u.price_currency,
u.ref_qty_value, u.ref_qty_unit_code, u.ref_qty_unit_text
FROM product_unit_price u
JOIN product p ON p.sku_id = u.sku_id
WHERE u.status = 1
"""
# 只读账号连接商品中心从库,避免影响主库写入
conn = pymysql.connect(host="127.0.0.1", user="geo_ro",
password="***", database="product_center",
charset="utf8mb4")
# 成功与失败分开计数,最后一起打印
ok = failed = 0
with conn.cursor(pymysql.cursors.DictCursor) as cur:
cur.execute(sql)
for row in cur:
try:
# 先归一,非法单位在这一步就拦下,不进缓存
row["base_qty_value"] = normalize(row)
payload = build_product(row)
# 写进 Redis 片段缓存,详情页渲染时直接取字符串
save_fragment(row["sku_id"], json.dumps(payload, ensure_ascii=False))
ok += 1
except ValueError as e:
# 单条失败不影响整批,记日志后继续跑
log_error(e)
failed += 1
# 值班群里靠这个数字判断当天生成是否正常
print(f"generated={ok} failed={failed}")
生成的片段长这样,关键是 referenceQuantity 里三样齐全:
{
"@context": "https://schema.org",
"@type": "Product",
"name": "每日坚果 混合装",
"sku": "10023871",
"offers": {
"@type": "Offer",
"price": "29.90",
"priceCurrency": "CNY",
"availability": "https://schema.org/InStock",
"priceSpecification": {
"@type": "UnitPriceSpecification",
"price": "29.90",
"priceCurrency": "CNY",
"referenceQuantity": {
"@type": "QuantitativeValue",
"value": "500",
"unitCode": "GRM",
"unitText": "500g"
}
}
}
}
灰度三周里踩到的坑
第一周只放了 200 个 SKU 做验证,第二周铺到 6000 个,第三周全量。三周里出的问题集中在单位和数据同步两处。
单位这块最常见的错是「写了一半」。运营在后台填了 ref_qty_value = 500,单位下拉框选的还是上一版的「千克」,生成出来就是「29.9 元/500 千克」。库里有 CHECK 约束挡不住这种语义错,只能靠断言:如果折算后每 100 克的单价低于 0.01 元或高于 500 元,判为异常,进人工复核队列。这个区间是我们自己按品类标定的,冻品和坚果差别很大,别照抄。
第二个坑是四舍五入。sale_price 存两位小数,比价时算「每 100g 多少钱」会出现 5.9800000001 这种尾巴。我们统一在生成环节用 Decimal 加 ROUND_HALF_UP 定到两位,不在 SQL 里做 ROUND,因为 MySQL 的 ROUND 在不同版本对 .5 的处理细节有差异,踩过一次之后就挪到应用层了。
第三个坑跟 GEO 直接相关。改完之后我们用几个 AI 搜索入口复查,发现有些引擎会优先读 priceSpecification 里的 price,有些仍然读 Offer.price。只要两处一致就不会出现矛盾答案,所以我们定了条规矩:散装商品这两处必须写同一个数字,禁止一边写整份价一边写单价。
改造前后的可观测变化:
| 指标 | 改造前 | 改造后 | 采集方式 |
|---|---|---|---|
| 带单价结构化数据的 SKU 数 | 0 | 11240 | 库表 count |
| 每周价格答错的客服工单 | 37 条 | 4 条 | 工单系统按标签统计 |
| 单价字段人工维护工时 | 每人每周约 6 小时 | 约 0.5 小时 | 运营排期表 |
| 抽样页面校验失败率 | 未采集 | 0.6% | 每日抽 800 个页面跑断言 |
| 详情页渲染耗时 P99 | 41 ms | 42 ms | 网关埋点 |
耗时的变化可以忽略,因为片段是缓存好的,渲染只是多拼一次字符串。剩下 4 条工单查下来是用户对「500g 价」和「实付称重价」本身就有误解,属于展示问题,不是数据问题。
校验和改价后的同步
结构化数据写完就扔是最常见的掉链子方式。改价之后片段没重算,页面上的老价格能挂好几天。
sequenceDiagram
participant O as 运营后台改价
participant DB as product_unit_price
participant S as 生成服务
participant C as 片段缓存
participant A as AI 引擎抓取
O->>DB: 写入 sale_price 与 ref_qty_value
DB-->>S: binlog 变更事件
S->>S: 单位归一 + 区间断言
S->>C: 覆盖该 sku_id 的片段
A->>C: 详情页请求返回新片段
A-->>A: 更新价格实体与引用
同步链路用的是订阅 binlog,不靠定时任务扫全表。全表扫一遍 4.6 万条要十几分钟,改价高峰期根本追不上;binlog 事件基本是秒级。
校验分三层。入库前由数据库约束挡住非法单位和非正数;生成时由脚本里的区间断言挡住语义错;生成后每天抽样 800 个页面,把 HTML 里的 JSON-LD 抠出来重新解析一遍,确认 unitCode 在白名单内、price 与 priceSpecification.price 一致。三层加起来,剩下的就是运营填写本身的错了,只能靠培训。
跑断言的入口长这样,挂在定时任务里:
# 环境:Python 3.11.6,脚本位于 /opt/geo/tools/verify_ldjson.py
# 用途:抽样校验线上页面的 JSON-LD,异常退出码交给告警
# --sample 800 表示每天随机抽 800 个详情页
# --strict-unit 打开后,unitCode 不在 GRM/KGM/MLT/LTR 内直接判失败
# 退出码 0 全通过,1 存在失败样本,2 抓取本身出错(网络或超时)
python /opt/geo/tools/verify_ldjson.py --sample 800 --strict-unit
# 打印退出码,配合 cron 的邮件告警使用
echo "exit=$?"
误区澄清和往后怎么走
误区一:把 Offer.price 直接换成每克单价就完事。这会让「下单付多少钱」这件事彻底失去结构化表达,AI 答得出单价却答不出实付。正确的做法是 price 描述可成交价,priceSpecification 描述单价,两者数量级自洽。
误区二:以为写了 referenceQuantity 就一定被读到。如果 unitCode 不合规,解析器会静默忽略整段,页面上看起来一切正常,实际等于没写。这也是为什么我们把单位白名单做成了数据库约束,而不是靠文档约定。
往后看,AI 搜索入口对结构化数据的依赖只会更重。现在引擎还能靠商品名里的「500g」猜一猜,等商品库规模再往上翻,猜的成本就扛不住了,谁的数据自解释谁被引用。单价只是其中一个切口,QuantitativeValue 能挂的还有净含量、规格、包装数,建模思路是一样的:数值、单位代码、单位文本三样齐全,缺一样就可能被当成噪声丢掉。
你们那边散装商品的价格是怎么标的,是按 500g 还是按斤?评论区聊聊各自的处理方式。
参考与延伸
- UnitPriceSpecification 官方定义与字段说明:https://schema.org/UnitPriceSpecification
- QuantitativeValue 的 value / unitCode / unitText 用法:https://schema.org/QuantitativeValue
- Offer 上 price 与 priceSpecification 的关系:https://schema.org/Offer
关键词:GEO、AI优化AIO、UnitPriceSpecification、Schema.org 结构化数据、商品被 AI 推荐、Python JSON-LD 生成、MySQL 商品建模