商品分类字段对齐实录:Product.category 映射 Google 商品分类体系的三条规则
适用读者:在零售电商站负责商品结构化数据的前端、后端与内容工程同学;已经在商品页埋了 Schema.org Product 标记,但商品在 AI 搜索回答里几乎不被提及、想从类目信号下手做改造的团队。
8 月中旬接了一个母婴零售站的生成式引擎优化(Generative Engine Optimization, GEO)诊断,翻遍全站 1,200 个商品页的 JSON-LD,category 字段清一色写着「商品」两个字。AI 搜索引擎拿到的类目信号等于零,商品自然进不了候选集。这篇把当时的排查过程和改造方案拆成三条规则讲清楚:映射表怎么建、三个字段各管什么、AI 引擎拿类目词去干什么。
现场还原:1,200 个商品页,category 全是「商品」
用脚本顺着 sitemap 扫了一遍商品页的 application/ld+json,结果比预想的还整齐:

- 1,183 个商品页的
Product.category值为「商品」,占比 97.4%; - 剩下 17 个页面写了「母婴用品」「家居日用」这类一级大类,同样细不到叶子层;
- 站内真实的类目树其实有四层:大类 > 品类 > 子类 > 款式,共 214 个末级类目,但这套树只活在导航栏和筛选器里,从未进入结构化数据。
先做了一轮基线测试:挑 60 条品类问句喂给主流 AI 搜索(比如「推荐一款适合新生儿的分腿睡袋」「有无纯棉 A 类标准的婴儿床品」),记录回答里是否出现本站域名、是否出现与本站商品对应的细分类目词。结果 60 条里只有 4 条回答提到本站,出现「婴儿睡袋」这类类目锚点词并同时带出本站的,只有 1 条。
类目栏全站一个泛分类,等于告诉 AI 引擎:这个站的商品没有品类属性,没法归档,也就没法在回答里被按品类召回。
AI 引擎读类目的机制:类目是召回的分诊台
这里把机制说透。AI 搜索引擎抓到商品页后,并不会像传统爬虫那样只存正文,而是把结构化数据解析成一堆实体槽位:品牌是什么、价格区间、属于哪个品类、有什么属性。其中类目槽位承担「分诊」职能——回答引擎生成内容时,先根据问句语义锁定一个候选品类集(比如「婴儿睡袋」),再从已索引的商品实体里筛出类目匹配的那些进入候选池。
如果 category 是「商品」,这个实体在分诊环节就挂了:候选池建立阶段根本轮不到它,后面的价格对比、属性补充、链接引用全部无从谈起。这就是为什么那家站 60 条问句只命中 4 条——不是内容不行,是入口就被关了。
类目词在最终回答里的「落点」也有讲究。回答引擎倾向把品类词作为叙述锚点,比如「挑选婴儿睡袋时可以关注……」,锚点词一旦出现,引擎会顺手挂上同品类的商品链接。类目词命中回答文本,是被推荐的前置条件。
映射表怎么建:站内类目树对齐 Google 商品分类体系
Google 维护了一套公开的商品分类体系(Google Product Taxonomy),每条分类是一条全路径,形如「服装与配饰 > 婴幼儿服装 > 婴儿睡袋」,官方提供包括简体中文在内的多语言版本,并有明确的版本号和更新节奏。做 GEO 改造时,映射表是地基,我们的表结构长这样:
| 字段 | 说明 | 示例 |
|---|---|---|
| 站内类目 ID | 站内类目树的主键 | cat_0182 |
| 站内全路径 | 大类到末级的完整路径 | 母婴用品 > 婴儿寝居 > 睡袋 |
| Taxonomy 全路径 | 对齐后的官方分类全路径 | 服装与配饰 > 婴幼儿服装 > 婴儿睡袋 |
| 匹配方式 | 精确匹配 / 关键词匹配 / 人工复核 | 末级精确匹配 |
| 在售 SKU 数 | 用于排定人工复核优先级 | 342 |
| 复核状态 | 待复核 / 已通过 / 有争议 | 已通过 |
建表流程没有秘密,就是把「按末级类目名查官方分类」这件事自动化,剩下的疑难杂症走人工队列。当时的脚本逻辑如下:
# -*- coding: utf-8 -*-
"""
build_category_map.py
站内类目 CSV 与 Google 商品分类体系文本文件自动比对,生成映射表。
依赖:Python 3.10+,无第三方库
环境:内网跳板机运行,taxonomy 文件用官方发布的简体中文版文本文件
用法:python build_category_map.py shop_categories.csv taxonomy_zh-CN.txt
输入:
1. shop_categories.csv —— 站内类目导出,字段:
id, full_path, name, sku_count
示例:cat_0182,母婴用品 > 婴儿寝居 > 睡袋,睡袋,342
2. taxonomy_zh-CN.txt —— Google 官方分类简体中文版,每行一条全路径:
服装与配饰 > 婴幼儿服装 > 婴儿睡袋
输出(stdout,每行一条):
id, full_path, taxonomy, method
method 取值:末级精确匹配 / 关键词匹配 / 人工复核
人工复核的行 taxonomy 为空,需人工补全后回填映射表。
"""
import csv
import sys
import unicodedata
def load_taxonomy(path):
"""
读官方分类文件,建立「末级分类名 -> 全路径列表」倒排索引。
输入:taxonomy 文本文件路径
输出:dict,key 为 NFKC 归一化后的末级分类名,value 为该末级名对应的
所有全路径列表(同名分类可能有多条,如不同父级下的「套装」)。
边界情况:
- 空行直接跳过;
- 全路径用「 > 」分隔,取最后一段作为末级分类名;
- 对末级名做 NFKC 归一化,吸收全角括号、不可见空格等导出噪音。
"""
leaf_index = {}
with open(path, encoding="utf-8") as fh:
for line in fh:
full = line.strip()
if not full:
continue
# 全路径形如:服装与配饰 > 婴幼儿服装 > 婴儿睡袋
leaf = full.split(" > ")[-1]
key = unicodedata.normalize("NFKC", leaf)
leaf_index.setdefault(key, []).append(full)
return leaf_index
def match(row, leaf_index):
"""
单条站内类目与官方分类倒排索引比对,返回 (官方全路径, 匹配方式)。
输入:row 为站内类目导出的 dict(含 name 字段),leaf_index 为倒排索引
输出:官方分类全路径 + 匹配方式;全路径为空则必须人工复核。
匹配策略(按优先级):
1. 末级类目名精确命中且唯一 -> 自动通过,标记「末级精确匹配」;
2. 精确命中多条(同名分类) -> 无法自动消歧,标记「人工复核」;
3. 精确未命中,退化为包含匹配(双向包含) -> 标记「关键词匹配」,
结果仍需人工复核确认;
4. 兜底也未命中(站内自造类目词) -> 标记「人工复核」。
边界情况:
- 同名分类多条:必须人工按上级路径消歧,不能自动取第一条;
- 包含匹配只做兜底,命中结果同样要过人工复核再落表;
- 站内类目名与官方完全无关(如「宝宝辅食盒」跨界品)时,
返回空路径,由人工按最接近父级落位并标注争议。
"""
leaf = unicodedata.normalize("NFKC", row["名称"])
hits = leaf_index.get(leaf, [])
if len(hits) == 1:
# 恰好一条命中才算自动通过,两条以上同名分类必须消歧
return hits[0], "末级精确匹配"
if len(hits) > 1:
# 命中多条说明有同名分类,交给人工按上级路径消歧
return "", "人工复核"
# 精确未命中,退化成包含匹配,命中同样进人工队列
# 包含匹配只做兜底,结果同样要过人工复核再落表
for leaf_name, paths in leaf_index.items():
if leaf_name in leaf or leaf in leaf_name:
return paths[0], "关键词匹配"
# 兜底也没命中:站内自造类目词,落人工复核
return "", "人工复核"
def main():
"""主流程:加载索引 -> 按 SKU 数倒序处理 -> 输出映射结果。"""
if len(sys.argv) != 3:
print("用法:python build_category_map.py shop_categories.csv taxonomy_zh-CN.txt")
sys.exit(1)
leaf_index = load_taxonomy(sys.argv[2])
with open(sys.argv[1], encoding="utf-8") as fh:
# 站内类目按在售 SKU 数倒序排,优先处理流量大的类目
rows = sorted(csv.DictReader(fh), key=lambda r: -int(r["sku_count"]))
for row in rows:
taxonomy, method = match(row, leaf_index)
print(f"{row['id']},{row['full_path']},{taxonomy},{method}")
if __name__ == "__main__":
main()
跑完这版脚本,214 个末级类目里 137 个自动通过,61 个走关键词匹配进人工队列,16 个确实在官方体系里找不到对应(比如「宝宝辅食盒」这类跨界品),按最接近的父级分类落位并在映射表里标注了争议。映射表是资产不是一次性脚本,官方分类每年更新版本,映射表要跟着比对 diff。
category、AdditionalProperty 与 BreadcrumbList:三个字段各管一段
规则二解决字段分工。很多团队的困惑是:类目语境到底写在哪、要不要重复写。我们定下的口径是三条线,各管一段:
Product.category是单值字符串,只写映射后 Taxonomy 全路径的最细一级,整条全路径用「 > 」连接。不要塞数组、不要堆同义词、不要把品牌词混进去。AdditionalProperty承担属性语境,类目之外的尺码、材质、适用月龄、执行标准都放这里,用PropertyValue的 name/value 成对表达,帮 AI 引擎补全商品属性槽位。BreadcrumbList表达站内导航语境,面包屑链上的类目词必须与 category 的分类语境一致——category 写了「婴幼儿服装 > 婴儿睡袋」,面包屑就不能是「首页 > 母婴专区 > 精选推荐」这种运营位链路。
语境一致性是最容易被忽略的一条。AI 引擎会把面包屑和 category 做交叉校验,两边对不上,实体可信度就打折。三个字段如何被引擎组合使用,见下面的时序:
落到商品页 JSON-LD 里就是下面这个样子(注释仅作讲解用,实际部署时去掉注释行):
{
"@context": "https://schema.org",
"@type": "Product",
// name 与品牌语境保持常规写法,本例重点看 category 与属性块
"name": "纯棉分腿婴儿睡袋 春秋款",
// category 只写一个值:官方分类体系里最细一级的全路径
// 全路径用「 > 」连接,层级从粗到细,不塞数组不堆同义词
"category": "服装与配饰 > 婴幼儿服装 > 婴儿睡袋",
"brand": { "@type": "Brand", "name": "Example Baby" },
"offers": {
"@type": "Offer",
"price": "129.00",
"priceCurrency": "CNY",
// 价格与库存语境放 offers,与类目字段互不干扰
"availability": "https://schema.org/InStock"
},
// 类目之外的属性语境全部收进 additionalProperty
// 每条属性 name/value 成对出现,帮引擎补全商品属性槽位
"additionalProperty": [
{ "@type": "PropertyValue", "name": "适用月龄", "value": "0-18个月" },
{ "@type": "PropertyValue", "name": "面料", "value": "100%棉" },
// 执行标准这类权威属性对可信度加成明显,建议单独成条
{ "@type": "PropertyValue", "name": "执行标准", "value": "GB/T 23158" }
],
// 面包屑的末级词必须与 category 的类目语境对齐
// 不能写「首页 > 活动专区 > 爆款推荐」这类运营位链路
"breadcrumb": {
"@type": "BreadcrumbList",
"itemListElement": [
// position 从 1 开始,逐级对应类目树的上层与末级
{ "@type": "ListItem", "position": 1, "name": "婴幼儿服装" },
{ "@type": "ListItem", "position": 2, "name": "婴儿睡袋" }
]
}
}
模板批量下发到商品详情页渲染层,1,200 个页面两天内全部替换完成,category 字段覆盖率从 2.6% 拉到 100%。
改造前后:AI 回答里的类目词命中对照
结构化数据改完不等于立刻见效,抓取和索引有滞后。8 月 20 日全量下发,9 月 5 日用同一组 60 条品类问句复测,对照数据如下:
| 指标 | 改造前(8 月 12 日) | 改造后(9 月 5 日) |
|---|---|---|
| 回答提及本站的问句数 | 4 / 60 | 17 / 60 |
| 回答中出现细分类目词 | 7 / 60 | 41 / 60 |
| 类目词与本站 category 一致的回答 | 1 / 60 | 23 / 60 |
| 回答同时带商品链接与类目锚点词 | 1 / 60 | 12 / 60 |
几个具体的对比例子更直观:
| 抽样问句 | 改造前回答 | 改造后回答 |
|---|---|---|
| 推荐适合新生儿的分腿睡袋 | 泛泛列选购要点,无站点引用 | 出现「婴儿睡袋」锚点词,引用本站商品页 |
| 纯棉 A 类婴儿床品有哪些 | 只提跨境平台 | 出现「婴儿床品」类目词并带出本站列表页 |
| 婴儿温奶器怎么选 | 无本站内容 | 类目锚点词命中,引用本站测评长文 |
复测结论和当时预判一致:类目信号修好之后,AI 搜索回答里最先回来的是品类锚点词,其次才是商品引用。 类目词命中率翻到六成多,站点被引用的量翻了三倍,中间的差距是属性语境(AdditionalProperty)和站内内容厚度补的,那是另一个话题。
三个容易踩的坑
- category 填了但只填到大类。 「母婴用品」这种一级大类对分诊环节几乎没有增益,务必写到映射表里 Taxonomy 的叶子层。宁可部分类目暂时空缺走人工复核,也别全站先上一个粗粒度值凑数。
- 面包屑用运营位链路。 首页 > 活动专区 > 爆款推荐这种链路和 category 完全对不上,引擎交叉校验直接扣可信度。面包屑必须走类目树的实时数据源。
- 官方分类更新后映射表不动。 Google 商品分类体系有版本号,每年都会增删调整。我们把这步挂进了季度任务:下载新版文件,diff 出变化项,回放映射表,改动超过 20 条就重新过一遍人工队列。
排查清单
- 抽查 10 个商品页 JSON-LD,确认 category 值是否已到叶子层。 用脚本抓取页面
application/ld+json,检查 category 是否为映射表里 Taxonomy 的最细一级;只要有一个页面仍是大类或「商品」,即判定未达标,触发整改。 - 对比面包屑末级词与 category 末级词是否一致。 取 BreadcrumbList 最后一级 name 与 category 全路径的末级词比对;不一致则触发告警,说明导航链路与类目语境脱节。
- 检查官方分类版本号与映射表记录版本是否匹配。 比对 Google 商品分类体系当前版本号与映射表里记录的版本;不一致则触发告警,需下载新版文件并回放映射表。
- 确认 category 字段是单值字符串而非数组。 校验 JSON-LD 中 category 的类型与长度,必须是单个字符串且不含数组、同义词堆叠;出现数组或多值即判定违规,触发修正。
- 用 AI 搜索实测 3 条品类问句,看回答是否出现类目锚点词。 挑 3 条与站内商品对应的品类问句喂给主流 AI 搜索;回答里未出现对应类目锚点词或未引用本站,则判定信号未生效,需复查抓取与索引。
批量校验脚本:上线后如何自动盯住 category 质量
改造上线不等于一劳永逸。商品上新、模板改版、运营误操作都可能让 category 悄悄退回泛分类。与其靠人工抽查,不如写一个批量校验脚本,把「单值、叶子层、面包屑一致」这三条硬指标变成可自动执行的检查,挂进 CI 或定时任务,违规页面当天就能暴露。
脚本逻辑如下:
# -*- coding: utf-8 -*-
"""
validate_category.py
输入商品页 URL 列表,自动抓取 JSON-LD,校验 category 质量。
依赖:Python 3.10+,requests(需 pip install requests)
环境:CI 或定时任务运行,需能访问商品页公网地址
用法:python validate_category.py urls.txt
输入:
urls.txt —— 每行一个商品页 URL
输出(stdout,每行一条违规记录):
url, 违规类型
违规类型取值:category 非单值 / category 未到叶子层 / 面包屑末级不一致
退出码:
0 —— 全部通过
1 —— 存在违规(供 CI 判定失败)
"""
import json
import re
import sys
import unicodedata
import requests
# 官方分类叶子层末级词白名单,来自映射表 Taxonomy 全路径的末级
# 实际使用时应从映射表导出,避免硬编码
LEAF_WHITELIST = {"婴儿睡袋", "婴儿床品", "温奶器"}
def fetch_json_ld(url):
"""
抓取商品页并解析 application/ld+json 块。
输入:商品页 URL
输出:解析后的 JSON 对象;页面无 JSON-LD 或解析失败返回 None。
边界情况:
- 页面可能内联多个 JSON-LD 块,取第一个 @type 为 Product 的;
- 网络异常或非 200 状态码直接返回 None,由调用方标记为抓取失败。
"""
resp = requests.get(url, timeout=10, headers={"User-Agent": "Mozilla/5.0"})
resp.raise_for_status()
# 匹配 <script type="application/ld+json">...</script>
for match in re.finditer(
r'<script[^>]*type=["\']application/ld\+json["\'][^>]*>(.*?)</script>',
resp.text,
re.DOTALL,
):
try:
data = json.loads(match.group(1))
except json.JSONDecodeError:
continue
# 兼容单对象与数组两种写法
candidates = data if isinstance(data, list) else [data]
for item in candidates:
if item.get("@type") == "Product":
return item
return None
def check_category(data):
"""
校验 category 是否为单值字符串且已到叶子层。
输入:Product 的 JSON-LD 对象
输出:违规类型列表;空列表表示通过。
边界情况:
- category 缺失、为数组、为对象都算「非单值」;
- 字符串但末级词不在白名单,算「未到叶子层」;
- 白名单未覆盖的类目会误报,需随映射表同步维护。
"""
violations = []
category = data.get("category")
if not isinstance(category, str):
violations.append("category 非单值")
return violations
# 取全路径末级词,如「服装与配饰 > 婴幼儿服装 > 婴儿睡袋」->「婴儿睡袋」
leaf = unicodedata.normalize("NFKC", category).split(" > ")[-1]
if leaf not in LEAF_WHITELIST:
violations.append("category 未到叶子层")
return violations
def check_breadcrumb(data):
"""
校验面包屑末级词与 category 末级词是否一致。
输入:Product 的 JSON-LD 对象
输出:违规类型列表;空列表表示通过。
边界情况:
- 无 breadcrumb 或 itemListElement 为空,直接判违规;
- 取 position 最大的 ListItem 的 name 作为末级词;
- 与 category 末级词做 NFKC 归一化后比对。
"""
violations = []
category = data.get("category")
if not isinstance(category, str):
return violations # category 本身已违规,不再重复报
breadcrumb = data.get("breadcrumb", {})
items = breadcrumb.get("itemListElement", [])
if not items:
violations.append("面包屑末级不一致")
return violations
# 取 position 最大的作为末级
last_item = max(items, key=lambda i: i.get("position", 0))
crumb_leaf = unicodedata.normalize("NFKC", last_item.get("name", ""))
category_leaf = unicodedata.normalize("NFKC", category.split(" > ")[-1])
if crumb_leaf != category_leaf:
violations.append("面包屑末级不一致")
return violations
def main():
"""主流程:逐 URL 抓取校验,输出违规清单,按违规数决定退出码。"""
if len(sys.argv) != 2:
print("用法:python validate_category.py urls.txt")
sys.exit(2)
with open(sys.argv[1], encoding="utf-8") as fh:
urls = [line.strip() for line in fh if line.strip()]
has_violation = False
for url in urls:
try:
data = fetch_json_ld(url)
except requests.RequestException:
print(f"{url},抓取失败")
has_violation = True
continue
if data is None:
print(f"{url},无 Product JSON-LD")
has_violation = True
continue
violations = check_category(data) + check_breadcrumb(data)
if violations:
has_violation = True
for v in violations:
print(f"{url},{v}")
else:
print(f"{url},通过")
sys.exit(1 if has_violation else 0)
if __name__ == "__main__":
main()
接入方式有两种,按团队习惯二选一:
- CI 门禁:在商品发布流水线里加一步
python validate_category.py urls.txt,退出码非 0 即阻断合并。适合上新频繁、希望问题在发布前拦截的团队。 - 定时任务:用 cron 或 GitHub Actions 每天跑一次全量商品 URL 列表,输出违规清单到告警群。适合已经上线、主要防回退的场景。
脚本里的 LEAF_WHITELIST 是硬编码的末级词白名单,实际使用时应从映射表导出,并随官方分类版本更新同步维护,否则新类目会被误报成「未到叶子层」。
误区澄清与趋势判断
常见误区是把「填了 category」当成终点。类目字段的价值不在字段本身,而在它把商品实体接进了 AI 引擎的品类召回体系——分诊台进不去,后面所有优化都是空转。另一个反向误区是过度填写:往 category 里堆关键词、塞多个值,引擎解析到的是脏数据,效果反而差,单值、叶子层、语境一致这三点守住了就够了。
趋势上看,主流 AI 搜索正在把购物类问答往「品类意图识别 + 商品实体推荐」的方向收紧,类目层级的语义利用会越来越深。零售站与其等着引擎猜自己的类目,不如主动把站内类目树翻译成官方分类语言送上去。改造细节或映射表字段设计有疑问,欢迎评论区交流。
参考与延伸
- Schema.org Product 定义与属性说明:https://schema.org/Product
- Schema.org BreadcrumbList 定义:https://schema.org/BreadcrumbList
- Schema.org PropertyValue 与 AdditionalProperty:https://schema.org/PropertyValue
- Google 商品分类体系官方文档与多语言文件下载:https://developers.google.com/shopping-content/guides/product-taxonomy
GEO · AI 搜索 · Product.category · Google 商品分类 · 类目映射 · 结构化数据 · 零售电商

33

被折叠的 条评论
为什么被折叠?



