一套产品目录喂给 AI 搜索:OfferCatalog 把整条产线讲清楚的架构方案
买家在 AI 搜索里问「这家厂做哪几个系列的工业冷水机」,回答只列了两个型号,还补了一句「主力产品是 CW-300 和 CW-500」。我们站上实际有 186 个在售 SKU、7 个系列、中英西三个语言版本。这事儿的毛病不在模型记性差,在我们从来没用机器可读的方式讲过一次整条产线:系列名躺在导航下拉里,型号散在 186 个详情页上,AI 抓到哪页就介绍哪页。
适用读者:外贸独立站、B2B 制造型站点的前端或后端同学;站点已经给产品页加了 Product / Offer 结构化数据,但「我们到底卖哪几条线」这件事仍然只存在于导航和分类页里。 也被「AI 介绍我们公司时只说了两个型号」这类截图烦过,想找一条能跟着商品主库自动更新的路子。 读完能自己设计一棵目录树,用商品主库(Product Information Management, PIM)的导出数据批量渲染 JSON-LD,并把多语言一致性做成流水线里的自动检查。
目录页是有的,机器读不出来
三月第一周,海外销售 Sam 在工作群甩了张截图:一个采购方用 AI 助手做供应商初筛,问「有哪些工业冷水机系列」,回答里只有两个型号,末尾还带了句「其他型号未公开」。我们当天把常见问题整理成 31 条问法去测,能完整说出 7 个系列的只有 4 条,剩下的要么说两个型号,要么干脆建议「直接联系销售获取完整目录」。

这几个数字摆出来之后,我们才开始认真看分类页到底长什么样。/series/water-cooled 这个页面,头部一句系列介绍,中间是筛选器(按冷量、按电压、按认证),下面卡片列表每页 12 个,186 个 SKU 摊在 7 个系列里,多的系列要翻三页。
分类页救不回来,有三个具体原因。DOM 里的有效信息密度太低,筛选器、面包屑、分页器、相关推荐加起来的文本量远大于型号本身,抽取端拿到的是一堆噪声。分页把完整列表切断了,AI 一次只看当前页,翻页是人的行为不是爬虫的行为。语言版本靠 URL 前缀区分,hreflang 虽然标了,但引擎抓哪一版基本随机。
分类页回答的是「这个导航下有什么可点」,不是「这家卖什么」。 前者是导航设施,后者是一条需要提供方自己声明的事实。
这类把业务事实交给机器读的活儿,属于生成式引擎优化(Generative Engine Optimization, GEO)的底座:把集合型事实写成结构化断言,而不是写成让人去点的页面。
先讲机制:AI 在哪一步把产线拼不全
要修就得知道断点在哪。一句「你们有哪些系列」从我们的站点到用户屏幕,中间大致是三段。
flowchart TD
A[用户提问 这家有哪些冷水机系列] --> B[召回 按 query 取候选页]
B --> C{候选页里有没有集合型断言}
C -- 只有产品详情页 --> D[抽到若干条单品事实]
D --> E[按出现频次挑几个代表型号]
E --> F[回答只列两三个型号]
C -- 有 OfferCatalog --> G[取出目录树与 numberOfItems]
G --> H[按类目层次组织回答]
H --> I[7 个系列都说全 并给出目录页链接]
第一段是召回。引擎按 query 找候选页面,这一步不判断内容质量,只看相关性和抓取库存。我们的目录页从来没进过索引,候选集里全是产品详情页,每条详情页各自只讲一个型号。
第二段是断言化。把页面内容抽成若干条可引用的事实,这一步决定了「我们有 7 个系列」能不能变成一条带主语、带取值范围的事实。产品详情页能抽出的断言是「CW-300 是一台水冷式冷水机、冷量 30kW」,主语是单品,不是集合。
第三段是生成。模型拿这些断言去组织回答,集合型断言缺位时,它只能用出现频次最高的几个型号当代表。这里有个反直觉的点:模型不会主动说自己不知道全貌,它会把「我看到的这几个」说成「这家的主要产品」。
产线是集合型事实。集合必须由提供方一次性声明,指望模型从碎片里做聚合,结果一定偏向曝光量最高的那几个单品。
目录节点被消费的三种方式
做了之后观察下来,AI 引擎拿目录节点主要干三件事。直接回答集合类问题,比如「有哪些系列」「多少个型号」,这是最直接的一类。给产品页做上位实体,模型读到 CW-300 时能顺带知道它属于水冷式冷水机这个系列,回答的层次感来自这里。第三是消歧,我们有个 AC-200 型号和同行重名,挂在目录树上之后被误认的次数明显下降。
OfferCatalog 放在哪,层级怎么摆
OfferCatalog 在 schema.org 里的定义是「由同一提供方提供的、包含若干 Offer 或嵌套 OfferCatalog 的 ItemList」。这句话信息量不小:它继承 ItemList,所以 itemListElement 和 numberOfItems 都能用;它的元素可以是 Offer,也可以是另一层 OfferCatalog,层级就是靠这种嵌套表达出来的。
挂载点是 hasOfferCatalog,这个属性属于 Organization、Person、Service 三种类型。制造业站点挂在 Organization 上就行,服务型业务挂在 Service 上更贴。
| 属性 / 节点 | 期望类型 | 这里我们放什么 | 常见写法错误 |
|---|---|---|---|
Organization.hasOfferCatalog |
OfferCatalog |
顶层目录节点,一个站点只挂一个 | 直接塞数组,不套 OfferCatalog 壳 |
OfferCatalog.itemListElement |
ListItem / Text / Thing |
子 OfferCatalog 或 Offer |
放纯字符串型号,丢了实体语义 |
OfferCatalog.numberOfItems |
Integer |
该层子项数量 | 写全站总数,与子节点数量自相矛盾 |
OfferCatalog.url |
URL |
该层目录页的真实可访问地址 | 填 # 或产品列表页筛选后的 URL |
Offer.sku / name / url |
Text / URL |
索引级字段,指向详情页 | 把二十多个参数全塞进目录节点 |
有个坑值得单独讲:schema.org 现版本里没有 OfferCategory 这个类型。不少资料里会写「用 OfferCategory 做类目」,实际写进 @type 会被校验器判成未知类型。类目层级用嵌套的 OfferCatalog 加 name 表达就够了;如果要把类目标签贴到具体报价上,用 Offer.category(期望 Text 或 Thing),别自己造类型。
层级深度我们试过两层也试过三层。两层是「顶层目录 → 系列 → 型号」,三层在系列和型号之间再加一层按冷量段分。最后选了两层,理由是第三层的划分标准(冷量段)在三个语言版本的文案里说法不一致,机器读出来反而是噪声。
站点地图式目录生成的架构
核心思路一句话:别手写目录,让它成为 PIM 的一个投影。我们管它叫站点地图式,因为目录树的结构和 URL 结构完全同构,每个目录节点都有一个真实、稳定、可被引擎索引的静态页,而不是藏在首页的 JSON-LD 里。
flowchart LR
A[PIM 商品主库] -->|每日全量导出 sku_export.csv| B[目录生成器 Python]
C[语言包 类目名与型号名] --> B
B --> D[JSON-LD 分片 catalog_zh/en/es.json]
A -->|系列与上下架状态| E[目录页渲染]
D --> E
E --> F[目录页 顶层 + 7 个系列页]
F --> G[sitemap.xml]
G --> H[AI 引擎抓取与索引]
H --> I[集合类问题命中目录节点]
生成器跑在发布流水线的 build 阶段,每天凌晨拉一次 PIM 全量导出,三个语言各渲一份 JSON-LD 分片,模板层按语言 include 进对应的目录页。整个链路没有任何人工维护的目录文件,上新、下架、改名都跟着 PIM 走。
给每个目录节点配一个真实 URL 这件事,起初组里有人觉得白费劲,觉得 JSON-LD 里写清楚就够了。做下去才发现它带来三个好处:节点能被单独索引,回答里可以直接引用目录页而不是产品页;目录页有真实的可见内容,不会出现「标记里有、页面上没有」这种会被判为不一致的情况;上新之后能单独 purge 这一层的缓存,不用整站刷。
生成脚本的关键部分
下面这段是生成器里目录树的部分,环境是 Python 3.11,只用了标准库。
# 环境:Python 3.11,仅标准库;跑在发布流水线的 build 阶段
# 输入:PIM 导出的 sku_export.csv(sku / series / name_{lang} / price)
# 输出:catalog_{lang}.json,由模板层注入目录页 head
import csv, json
from collections import defaultdict
# 站点根地址,所有 @id 都以它为前缀,改域名时只改这一处
SITE = "https://example.com"
# 各语言的类目名,取不到时回退英文,保证节点一定带 name
NAME = {"zh": {"CW": "水冷式冷水机"}, "en": {"CW": "Water-Cooled Chiller"}}
def branch(rows, lang):
# 一个系列对应一个子 OfferCatalog,型号作为 Offer 挂在里面
g = defaultdict(list)
# PIM 导出行不保证有序,这里按 series 字段重新聚合
for r in rows:
g[r["series"]].append(r)
out = []
# 逐系列装配子目录节点,顺序按照 PIM 里的系列出现次序
for series, items in g.items():
# 索引层只放 sku / name / url,参数细节留给产品页的 Product 节点
offers = [{"@type": "Offer", "sku": r["sku"],
"name": r[f"name_{lang}"],
"url": f"{SITE}/p/{r['sku']}?lang={lang}"} for r in items]
# 子目录四件套:类型、标识、可访问地址、子项数与子项列表
out.append({"@type": "OfferCatalog",
# 子目录也要独立 @id 与 url,才能被引擎单独索引
"@id": f"{SITE}/series/{series}#{lang}",
"url": f"{SITE}/series/{series}?lang={lang}",
"name": NAME.get(lang, NAME["en"]).get(series, series),
"numberOfItems": len(offers),
"itemListElement": offers})
return out
# 读 PIM 导出表,UTF-8 带 BOM 时用 utf-8-sig
rows = list(csv.DictReader(open("sku_export.csv", encoding="utf-8-sig")))
for lang in ["zh", "en", "es"]:
# 顶层目录挂在 Organization.hasOfferCatalog 上,numberOfItems 取全站在售数
root = {"@type": "Organization", "@id": f"{SITE}/#org",
"hasOfferCatalog": {"@type": "OfferCatalog",
"@id": f"{SITE}/catalog#{lang}",
"url": f"{SITE}/catalog?lang={lang}",
"numberOfItems": len(rows),
"itemListElement": branch(rows, lang)}}
# 落盘成分片文件,模板层按语言 include 进目录页
json.dump(root, open(f"catalog_{lang}.json", "w", encoding="utf-8"),
ensure_ascii=False, indent=2)
多语言目录怎么保持一致
外贸站最容易翻车的地方在这儿。三个语言版本的目录如果型号数量对不上,AI 回答里就会出现「中文问出 7 个系列、英文问出 5 个」的怪事,采购方会怀疑数据可靠性。
我们评估过三种做法。
| 方案 | 写法 | 优点 | 我们没选的原因 |
|---|---|---|---|
| 单一 @id 不分语言 | 三个页面共用 https://example.com/catalog |
实体统一,图数据库里只有一个节点 | 三个版本各写一次 name,互相覆盖,抓到哪版算哪版 |
| 按语言拆 @id | catalog#zh / catalog#en / catalog#es |
各语言互不覆盖,可逐版本校验 | 需要在流水线里加一致性检查,工作量多一点 |
| 只维护英文目录 | 中文西语页不输出目录节点 | 省事,不用对齐 | 中文问法的命中率直接掉回改造前 |
最后选了第二种。这里有个规范上的细节要说清楚:inLanguage 严格讲是 CreativeWork 的属性,写在 OfferCatalog 节点上属于宽松用法,校验器不会报错,但别指望它被消费端当成语言标识来用。真正的区分靠 @id 和 url,我们把语言码同时放进这两个字段,节点之间就不会打架。
一致性检查做成了 CI 里的一个冒烟步骤,每次构建后跑。
# 环境:CI 容器 Debian 12,curl 8.5 + jq 1.6
# 冒烟:三个语言版本的目录必须吐出同一套 SKU
for L in zh en es; do
# 抓目录页,抽 ld+json 块,取 sku 排序后落盘
# 排序是为了让后面的 diff 只比内容、不比顺序
curl -s "https://example.com/catalog?lang=$L" \
| grep -o 'ld+json">.*</script>' \
| jq -r '.hasOfferCatalog.itemListElement[].itemListElement[].sku' \
| sort > "sku_$L.txt"
done
# diff 为空且行数等于 PIM 在售数,才算多语言一致
# 行数不等于在售数时,通常是语言包缺翻译被模板过滤
wc -l sku_zh.txt sku_en.txt sku_es.txt
diff sku_zh.txt sku_en.txt && diff sku_zh.txt sku_es.txt
这条检查上线第二周就抓到一次问题:西语站有 3 个型号因为语言包缺翻译被模板层过滤掉了,目录里只有 183 个。修法是让生成器对缺翻译的字段回退到英文而不是丢整行,宁可文案是英文,也不能让目录缺项。
改造前后:收录与问答命中的变化
第 1 周上线,第 2 周补完多语言,第 3 周开始采样,窗口 28 天。问法固定 31 条,覆盖「有哪些系列」「多少个型号」「某系列有哪些规格」「XX 型号属于哪个系列」四类,三个引擎各跑一遍,每天上午一轮。
| 指标 | 改造前 | 改造后 28 天 | 变化 |
|---|---|---|---|
| 被 AI 引擎索引的目录类 URL | 0 | 11 | +11 |
| 站点被索引 URL 总数 | 214 | 268 | +54 |
| 答全 7 个系列的问法占比 | 12.9% | 71.0% | +58.1 个百分点 |
| 回答里点名不少于 5 个型号 | 8.6% | 54.8% | +46.2 个百分点 |
| 回答引用了目录页 | 0% | 46.7% | +46.7 个百分点 |
| 目录页 28 天自然点击 | — | 1320 | 新增 |
答全系列的比例从 12.9% 涨到 71.0%,剩下 29% 集中在「某系列有哪些规格」这类问法上。原因是我们的目录节点到型号层就结束了,规格变体没有进目录,模型要答规格还得回产品页抽。这属于目录粒度的问题,不是标注的问题,往后可以往下再垫一层。
引用率的变化挺有意思。目录节点本身不产生新的事实,但它给了模型一个「可以引用的全貌来源」。之前模型要说「这家有哪些系列」只能引用产品页,而产品页只讲一个型号,引用它答集合问题显得别扭,于是干脆不引用。目录页出现之后,这个空档被填上了。
踩过的几个坑
体积是第一个。186 个 SKU 全量塞进顶层节点,JSON-LD 撑到 214 KB,页面首屏解析明显变慢。改法是两层拆分加字段瘦身:顶层节点只放 7 个子目录的引用和数量,型号级字段只保留 sku / name / url,参数全部留给产品页自己的 Product 节点。改完顶层分片压到 11 KB。
第二个是 noindex。系列页是新做的静态页,模板里继承了内页的 noindex 标记,上线 5 天索引数一直是 0。后来是拿日志看爬虫行为才发现的,AI 引擎的爬虫对 noindex 一样老实。
第三个是 numberOfItems 自相矛盾。我们最初在顶层写 186(全站 SKU),但子节点数量加起来是 183(3 个型号临时下架)。这种前后不一对抽取端来说是明显的信号冲突,改法是把顶层的统计口径改成「当前在售」,和子节点严格对齐,并且用 PIM 的上下架状态而不是导出行数来算。
第四个是上新没联动。第 6 周上了 4 个新型号,PIM 里已经有,但目录是每天凌晨全量重渲的,当天下午的采样里就没它们。后来把生成器接到了 PIM 的变更通知上,上下架触发一次增量重渲加目录页 purge。
这两个误区先澄清
误区一:拿富媒体结果测试工具去验 OfferCatalog,期待出卡片。OfferCatalog 不在搜索引擎的富媒体结构化数据类型清单里,它不会给你任何可见的结果增强。语法正确性请用 validator.schema.org 验,它只报格式问题,不报富媒体支持,别因为「没有检测到富媒体结果」就把标注撤了。
误区二:目录生成一次就完事。目录是 PIM 的投影,PIM 天天在变。我们现在的做法是把生成和校验都挂在流水线上,运营在 PIM 里点保存就触发重渲,研发不介入。人工维护的目录文件撑不过两个季度,这是被我们自己的旧站点验证过的。
往后看,AI 引擎对「集合型事实」的抽取会越来越依赖提供方声明,而不是靠自己拼。这类事实除了产线,还有认证清单、可售区域、配件兼容性,写法逻辑都一样:先问自己这条事实能不能用一个带层级的结构表达清楚,能就别让它散在几十个页面里。
你们的多语言目录是怎么处理 @id 的,有没有试过更深的层级,评论区聊聊。
参考与延伸
- https://schema.org/OfferCatalog
- https://schema.org/hasOfferCatalog
- https://validator.schema.org/
- https://developers.google.com/search/docs/crawling-indexing/sitemaps/build-sitemap
GEO|AI优化AIO|产品目录结构化|OfferCatalog|JSON-LD|外贸独立站|多语言一致性