产品没被 AI 推荐?先查面包屑 BreadcrumbList 写法
客户市场部的人把聊天截图甩过来:问 AI 搜索「某型号气动阀哪家做」,答案里列了三家竞品,自家的设备页连个名字都没有。更憋屈的是,那款型号在 Google 常规搜索里排得很靠前,页面收录、外链、加载速度全都正常。产品页早就挂了 Product 的 JSON-LD 结构化数据,价格、品牌、SKU 一个字段不缺,AI 却像看不见一样。我们接手排查,绕了两个弯,最后定位的居然是页面上那个不起眼的面包屑。
问题现场:结构化数据明明都在
先说结论:Product 结构化数据只回答「这个页面是什么商品」,回答不了「这个商品挂在站点实体树的哪个位置」。 后者靠的是面包屑对应的 BreadcrumbList 结构化数据,而客户的设备详情页恰好缺了这一块。
页面顶部那个「首页 > 气动元件 > 气动阀 > XX-200 型」是 Vue 组件渲染出来的视觉元素,DOM 里全是 <li> 和 <a>,没有任何机器可读的语义标注。AI 引擎的抓取器不会去猜样式像面包屑的列表就是层级导航,它只认 schema.org 的类型声明。

更麻烦的是层级本身。客户后台把面包屑配置成了「首页 > 列表页 > 详情页」三段式,所有设备页共用一套,产品线、系列、型号这三层业务结构在 URL 和导航里被压平了。也就是说,就算当时补了 BreadcrumbList,也是一条「首页 / products / detail」的废话路径,照样说不清归属。
排查三步:两次误判之后才找对地方
复盘整个过程,真正花时间的不是改代码,是排除错误假设。
误判一:以为 Product 字段缺了关键属性
第一反应最省事:八成是结构化数据写得有问题。我们用 Rich Results Test 跑了十几个设备页,Product 全部通过校验,brand、sku、offers 都在。不死心,又对比了竞品页面,发现竞品的 Product 数据反而更简陋——价格都没有,AI 照样说得清「这是 A 公司 B 系列的产品」。这说明差异不在 Product 本身。这一步耗时两天,教训是校验工具只保证语法合法,不保证语义完整。
误判二:怀疑抓取预算不够
第二个假设是站点太大,AI 爬虫给的抓取预算有限,深层产品页没被完整索引。证据看起来挺支持:站点有八千多个 URL,日志里 AI 爬虫的访问频次确实不高。我们做了两件事来验证:挑二十个新发布的设备页主动提交索引,同时在页面日志里盯这些 URL 的被抓取情况。结果一周内二十个页面全被抓过,AI 回答里依然不提产品归属。抓取是发生了,理解没发生——问题在「读懂」这层,不在「到达」这层。
转折点:把竞品页面扒开对比
真正有价值的动作是把竞品的设备页源码拉下来逐行看。竞品的 Product 写得比客户还糙,但每个页面都有一段 BreadcrumbList 的 JSON-LD,itemListElement 四层,从首页一路指到具体型号,每层的 item 指向真实的分类 URL,name 就是「气动元件 / 气动阀 / 高频系列 / XX-100」。客户的页面里,同样的位置是空的。面包屑结构化数据的缺失,是两站之间仅有的一个稳定结构性差异。
排查各阶段的怀疑点和证据整理如下:
| 排查阶段 | 当时的怀疑 | 验证手段 | 结论 |
|---|---|---|---|
| 第一轮 | Product 字段缺失关键属性 | Rich Results Test + 竞品数据对比 | 排除,竞品更简陋却被引用 |
| 第二轮 | AI 爬虫抓取预算不足 | 主动提交 20 个页面 + 日志盯抓取 | 排除,抓到了但没读懂 |
| 第三轮 | 面包屑只有视觉组件、层级被压平 | 扒竞品源码逐行对比 | 定位,补 BreadcrumbList 后生效 |
原理剖析:AI 引擎怎么用面包屑给页面上户口
这一段是整件事里最值得讲的部分。大模型在回答「XX 型号属于谁」时,需要把页面挂到一张站点实体树上:公司是根,下面挂产品线,产品线下挂系列,系列下挂型号。AI 引擎构建这棵树,主要依据就是导航类结构化数据。
BreadcrumbList 的解析有几个关键规则。itemListElement 是个数组,每个元素的 position 必须从 1 开始严格递增,position 断了或者跳号,整条链的可信度就打折。每个元素的 item 指向该层级的真实 URL,AI 引擎会顺着这些 URL 去确认父级页面是不是真的存在、内容是不是和 name 对得上。name 则是模型引用时直接抄的「官方说法」——AI 说「XX-200 属于高频系列」,这个「高频系列」四个字就是从面包屑的 name 里来的。
层级被压平时会发生什么?模型看到「首页 / products / detail」这种路径,无法判断这个商品属于哪条产品线,于是归属判断回退到页面正文和 title 里零散的文本线索,而正文的措辞是给人看的,往往省略主语。结果就是模型要么不表态,要么从全站共同出现的品牌词里就近挑一个——挑中谁,看谁的语料在训练或检索阶段更密集,于是归属漂移到竞品就成了大概率事件。
用一张图表示这棵实体树,面包屑就是每个节点的「户口登记」:
flowchart TD
A["公司官网<br/>Organization"] --> B["产品线:气动元件"]
A --> C["产品线:液压元件"]
B --> D["系列:高频系列"]
B --> E["系列:标准系列"]
D --> F["型号:XX-200<br/>BreadcrumbList position 3"]
D --> G["型号:XX-300"]
E --> H["型号:KB-50"]
F -.->|层级被压平后<br/>归属漂移| C
图里那条虚线就是当时的现象:型号页被压平到「产品 / 详情」后,模型在实体树里找不到挂载点,归属判断开始乱飘。
改造落地:Nuxt 3 里补一条正经的面包屑链
定位清楚之后,改造方案不复杂。客户官网是 Nuxt 3 的 SSR 项目,面包屑数据从路由元信息和商品接口里都能拼出来,不需要动后端。改造前先和客户约定了站点层级口径:详情页的面包屑必须完整走到「产品线 / 系列 / 型号」,哪怕某层的中间页只是个筛选列表,也要给真实可访问的 URL。
下面是详情页用的面包屑组件,环境为 Nuxt 3.13、Vue 3.5、Node.js 20,无额外依赖,JSON-LD 通过 useHead 注入:
<script setup>
// props 传入从商品接口拿到的层级信息,避免组件内部再发请求
const props = defineProps({
// trail 是数组,每项 { name, url },按「产品线 / 系列 / 型号」顺序排列
trail: { type: Array, required: true },
})
// 统一拼首页根路径,生产域名从 runtimeConfig 读取,防止环境串了
const config = useRuntimeConfig()
// 根 URL 结尾统一去斜杠,后面拼接才不会出现双斜杠
const origin = config.public.siteUrl.replace(/\/+$/, '')
// 组装 BreadcrumbList 的 itemListElement 数组
const items = computed(() => {
// 首页固定为第一层,position 从 1 开始
const list = [{ name: '首页', url: `${origin}/` }]
// 逐层追加业务层级,过滤掉缺 name 或 url 的脏数据
for (const step of props.trail) {
// name 是 AI 引用时的措辞来源,必须写全称,不能只写型号
if (step?.name && step?.url) list.push({ name: step.name, url: `${origin}${step.url}` })
}
return list
})
// 生成符合 schema.org 的 JSON-LD 结构
const jsonLd = computed(() => ({
'@context': 'https://schema.org',
// 类型固定 BreadcrumbList,AI 引擎靠它识别层级链
'@type': 'BreadcrumbList',
// itemListElement 每项必须带 position,且严格从 1 递增
itemListElement: items.value.map((item, i) => ({
'@type': 'ListItem',
// position 断号会导致整条链可信度下降,这里用索引保证连续
position: i + 1,
name: item.name,
item: item.url,
})),
}))
// SSR 阶段就把 JSON-LD 注入 head,保证首屏 HTML 里可抓取
useHead({
script: [{ type: 'application/ld+json', innerHTML: JSON.stringify(jsonLd.value) }],
})
</script>
<template>
<!-- 视觉面包屑与 JSON-LD 共用一份数据,避免两套口径打架 -->
<nav aria-label="面包屑">
<!-- 每一层都渲染真实链接,人和爬虫看到同一条路径 -->
<ol>
<li v-for="(item, i) in items" :key="item.url">
<NuxtLink :to="item.url">{{ item.name }}</NuxtLink>
<!-- 分隔符放 CSS 里做,模板保持干净 -->
</li>
</ol>
</nav>
</template>
改造过程按四步推进,每步都有独立验收点:
flowchart LR
S1["统一层级口径<br/>产品线/系列/型号"] --> S2["组件输出 JSON-LD<br/>useHead SSR 注入"]
S2 --> S3["Node 脚本全站校验<br/>position 连续 + URL 可访问"]
S3 --> S4["观察期 3 周<br/>记录 AI 回答样本"]
一个容易忽略的细节:JSON-LD 里的 name 要和页面 H1、视觉面包屑保持一致。我们第一版把组件里的「高频系列」写成了内部编码 HF,AI 回答就跟着说 HF,市场部不认。机器会照抄你喂给它的词,喂什么说什么。
批量校验:Node 脚本扫全站的面包屑
改完模板只解决了一个页面模板,全站八千多个 URL 里还有历史遗留的静态页和另一个老项目渲染的列表页,得批量验证。写了个脚本,环境为 Node.js 20,零外部依赖(用内置 fetch),输入一份 URL 列表,逐页检查 BreadcrumbList:
#!/usr/bin/env node
// 用法:node check-breadcrumb.mjs urls.txt
// urls.txt 每行一个完整 URL,支持直接拿 sitemap 导出的列表
import { readFile } from 'node:fs/promises'
// 从 HTML 里抠出所有 JSON-LD 块,正则够用,不必引解析器
function extractJsonLd(html) {
const re = /<script type="application\/ld\+json">([\s\S]*?)<\/script>/g
const out = []
let m
// 循环取出每一块结构化数据,解析失败的单块跳过并记录
while ((m = re.exec(html)) !== null) {
try { out.push(JSON.parse(m[1])) } catch { out.push(null) }
}
return out
}
// 校验一条 BreadcrumbList 的合法性,返回问题列表
function checkBreadcrumbs(obj, url) {
const problems = []
// 类型不对直接记一条问题,后面不用再看
if (obj['@type'] !== 'BreadcrumbList') return problems
const list = obj.itemListElement || []
// 层级少于 3 说明层级又被压平了,重点报出来
if (list.length < 3) problems.push(`${url} 层级数=${list.length},疑似压平`)
list.forEach((el, i) => {
// position 必须等于下标加一,断号即失败
if (el.position !== i + 1) problems.push(`${url} 第${i + 1}层 position=${el.position}`)
// 每层都要有 name 和 item,缺一个 AI 就接不上链
if (!el.name || !el.item) problems.push(`${url} 第${i + 1}层缺 name 或 item`)
})
return problems
}
// 主流程:读列表、逐页抓取、汇总问题
const urls = (await readFile(process.argv[2], 'utf8')).split('\n').filter(Boolean)
let bad = 0
for (const url of urls) {
const res = await fetch(url)
// 非 200 的页面单独统计,这类属于可达性问题,不算面包屑问题
if (!res.ok) { console.log(`[HTTP ${res.status}] ${url}`); bad++; continue }
const html = await res.text()
for (const obj of extractJsonLd(html)) {
// 只关心 BreadcrumbList,Product 等其他类型跳过
if (obj && obj['@type'] === 'BreadcrumbList') {
for (const p of checkBreadcrumbs(obj, url)) { console.log(`[!!] ${p}`); bad++ }
}
}
}
// 退出码非零方便接 CI,有问题的构建直接拦下来
process.exit(bad > 0 ? 1 : 0)
脚本跑出来三十七个页面有问题,全是老项目渲染的列表页——它们当时也被压平成了三段式。顺手一起修了。这个脚本后来挂进了 CI,每次发布前全量跑一遍,结构化数据回退这种事故,靠人眼是盯不住的。
效果与遗留问题
改造上线后观察三周,用同一组提问句式(十组,覆盖三个产品线)每天问一遍 AI 搜索,结果如下:
| 指标 | 改造前 | 改造后第三周 |
|---|---|---|
| 十组提问中被引用自家型号的次数 | 1 | 6 |
| AI 回答能说清「型号属于某系列」的比例 | 0 | 7/10 |
| 归属到竞品的回答 | 4 | 1 |
收益不是一夜到位的,前两周几乎没有变化,第三周开始集中出现正确归属——AI 引擎的重新抓取和理解更新有自己的节奏,做 GEO 得按月评估,别按天焦虑。遗留问题是部分长尾型号的三层分类页还在重建,中间层 URL 暂时是筛选页,这类页面的面包屑指向能否稳定生效,还需要再观察一轮。
几个容易踩的坑,提前说清
有人会问:不补 BreadcrumbList,光在正文里多写几遍「XX 型号是 XX 系列产品」行不行?能起一点作用,但那是把结构信息降级成文本线索,模型要从散落的句子里重新拼层级,准确率和稳定性都差一截。结构化数据是把层级关系直接递到模型手上,两种方式的可靠性不在一个量级。另一个常见误区是只给详情页补面包屑、列表页不管——实体树是一整条链,中间任何一层缺位或对不上,链条的末端照样挂不稳。
往后看,AI 搜索对站点内部结构的依赖只会加重。Product、BreadcrumbList、Organization 这三类数据合在一起,才构成「公司—产品线—系列—型号」的完整证据链,缺哪环 AI 就在哪环含糊。如果你的设备页也遇到了类似的归属漂移,建议先别急着改文案,把页面源码里的层级链扒出来看一眼,问题往往就藏在那条被压平的面包屑里。你在 BreadcrumbList 上踩过什么别的坑,欢迎评论区聊聊。
参考与延伸
AI推荐产品、GEO、BreadcrumbList、面包屑导航、结构化数据、JSON-LD、制造业AI获客