一家大店拆成六个部门页:Store 的 department 属性与子部门实体架构
适用读者:手里管着连锁门店官网、但整站只有一个笼统 LocalBusiness 页面的后端工程师;正准备给大型卖场类站点做生成式引擎优化(Generative Engine Optimization, GEO)改造、想让 AI 搜索把「哪个部门能干什么」分清楚的技术负责人。
城东一家家居广场,六个业务部门,四个电话号码,三套营业时间,全挤在一个 LocalBusiness 页面上。用户问 AI「附近哪家商场能订整体橱柜」,引擎把餐饮部的电话推了过去——用户打过去订了一桌饭。这事儿是他们运维负责人老郑四月初跟我吐槽的,原话是「我们六个人的分工,AI 全记成了一个人的」。这篇文章就把这个翻车现场拆开讲:怎么用 Store 的 department 属性,把一家大店在实体层面拆成六个能被单独引用的子部门。
问题不在文案,在实体结构
先看这家家居广场当时的家底。六个部门的服务内容、营业时间、联系电话全不一样,但官网只有 /about 一个页面,页面底部嵌了一段整站共用的 JSON-LD,@type 写的是 Store,openingHours 只有一行「09:00-21:00」,电话是总机。

| 部门 | 核心服务 | 营业时间 | 电话 |
|---|---|---|---|
| 建材部 | 瓷砖、地板、整体橱柜订制 | 周一至周日 09:00-20:00 | 8011 分机 |
| 家装部 | 设计师对接、装修施工队 | 周一至周日 09:00-19:00 | 8012 分机 |
| 家具部 | 成品家具、软装搭配 | 周一至周日 10:00-21:00 | 8013 分机 |
| 餐饮部 | 美食广场、商务包间 | 10:30-21:30 | 8015 分机 |
| 儿童乐园 | 托管、游乐设施 | 周末及节假日 10:00-20:00 | 8016 分机 |
| 售后服务部 | 安装、维修、退换货 | 周一至周六 09:00-18:00 | 8018 分机 |
这张表里每一条差异,AI 引擎在回答时都可能被问到。儿童乐园周末才开门,餐饮部晚上九点半才打烊,售后服务部周六下午五点就下班。全挤在同一个 openingHours 里,引擎只能随机挑一条,挑错了就白费劲——用户被引到关着门的部门。
老郑团队五月中旬改过一版文案,把六个部门的服务写成了六个卡片,文字上分得很清楚。没用。AI 引擎抽实体的时候看的是结构化数据和 URL 信号,页面上的文字卡片在它眼里还是同一个 Store 实体的属性。改文案这条路走不通,得动结构。这也是做 GEO 时最常交的学费:引擎认实体,不认排版的用心程度。
结论先放这儿:部门之间的差异必须体现在 URL、结构化数据、实体关系三个层面,只改文案等于没改。
三层拆法:URL、JSON-LD、@id 关联
我们给老郑定的方案分三层,一层都不能少。
第一层是 URL。每个部门一个独立页面,挂在 /departments/ 路径下:/departments/jiancai、/departments/canyin 这样。独立 URL 的意义在于 AI 引擎可以单独抓取、单独建索引、单独被引用。回答里给出的是一个能直接落地的部门页,用户点进去看到的就是橱柜订制流程,不用再自己在官网里找。
第二层是结构化数据。主页面保留 Store 实体,用 department 属性挂一个子 LocalBusiness 数组;每个子实体用 departmentOf 反向指回 Store。schema.org 对这两个属性的定义是成对的:父写 department,子写 departmentOf,两边 @id 互相咬合,引擎就能把父子关系钉死。
第三层是 @id 命名规范。所有实体 ID 都基于同一个 baseURL 拼,主店 #store,部门 #department-jiancai 这种。ID 一旦定下来就不要换,AI 引擎的知识库是按 ID 合并实体信息的,换 ID 等于让引擎重新认识你一遍。
// 环境说明:以下 JSON-LD 由服务端模板输出,schema.org 版本遵循 2024 年后的
// LocalBusiness / Store 定义;放在首页 <head> 内,与部门页数据同源生成
{
// 根节点声明:@context 固定指向 schema.org,引擎按此解析全部属性
"@context": "https://schema.org",
// 主实体类型用 Store:带卖场属性的本地商户,比泛化的 LocalBusiness 更精确
"@type": "Store",
// 主店实体的固定标识,全站引用保持一致
"@id": "https://www.example-mall.com/#store",
"name": "城东家居广场",
"url": "https://www.example-mall.com/",
// 总机,部门电话写在子实体里
"telephone": "+86-591-8888-0000",
// 关键属性:数组,一个元素一个部门实体
"department": [
{
// 子实体也用 LocalBusiness 类型:部门同样有地址电话营业时间,语义成立
"@type": "LocalBusiness",
// @id 规则:部门页 URL + #department 锚点,与部门页内嵌脚本一字不差
"@id": "https://www.example-mall.com/departments/jiancai#department",
"name": "建材部",
// url 与页面地址严格一致,引擎靠它把实体和页面绑定
"url": "https://www.example-mall.com/departments/jiancai",
"telephone": "+86-591-8888-8011",
// 反向指回父实体,department 与 departmentOf 成对出现才算关系成立
"departmentOf": { "@id": "https://www.example-mall.com/#store" },
// 营业时间按部门拆,不共用总店时段
"openingHoursSpecification": [
{
"@type": "OpeningHoursSpecification",
// dayOfWeek 是数组:写全周一到周日,少写一天就等于告诉引擎那天不开门
"dayOfWeek": ["Monday","Tuesday","Wednesday","Thursday","Friday","Saturday","Sunday"],
// 时间用 24 小时制 HH:mm,注意补零,"9:00" 这种写法部分引擎解析不了
"opens": "09:00",
"closes": "20:00"
}
]
}
// 其余五个部门实体结构相同,此处省略
]
}
这段 JSON 有几个容易踩的坑。@id 必须是带域名的完整可解析地址,不能写半截,老郑第一版写成了 #department-1 这种裸锚点,抓取日志里引擎解析出来的实体和页面对不上号。departmentOf 写成完整对象还是只有 @id 引用都行,但引用的 ID 必须在父实体里真实存在,悬空引用会被引擎直接丢弃。
引擎侧为什么认这个结构:对齐机制剖析
这一节讲原理。AI 引擎(不管是问答类还是搜索类)处理本地商户信息时,走的是「抓取、抽取、对齐、合成」四步。对齐这步最要命。
抓取阶段,引擎拿到的是 HTML 和 JSON-LD 的混合体。抽取阶段,解析器把 JSON-LD 展平成一堆实体节点,每个节点带 @type 和 @id。对齐阶段,引擎判断这些节点里哪些说的是同一个东西、哪些是父子关系。它判断的依据有三类信号:@id 是否互相引用、department/departmentOf 属性是否成对出现、URL 是否存在层级路径。
三个信号同时成立时,引擎才有把握把「建材部」作为一个独立实体存进知识库,并且记住它属于这家家居广场。GEO 改造里大部分工作,其实就是在给这三类信号补料。只有一个笼统页面时,引擎侧根本没有第二个实体可存,「附近能订橱柜的商场」这类查询在召回阶段就只能命中主店实体,而主店实体上挂着的电话是总机、营业时间是全店概况,合成出来的回答自然张冠李戴。
flowchart TD
A["AI 引擎抓取 /departments/jiancai"] --> B["解析 JSON-LD 抽出 LocalBusiness 实体"]
B --> C{"departmentOf 是否指回 Store?"}
C -- "是" --> D["实体入库,挂到主店 department 列表下"]
C -- "否" --> E["按孤立实体处理,可信度降级"]
D --> F["查询『订橱柜』时按部门实体召回"]
F --> G["回答引用建材部电话与营业时间"]
E --> H["回答退化为引用主店总机"]
还有一层原因和训练数据的时效性有关。引擎的知识库不是实时更新的,实体被合并进知识库后,后续修正靠的是再次抓取时的 @id 匹配。@id 稳定的站点,改错只需要等下一次抓取覆盖;@id 变来变去的站点,旧实体和新实体对不上,库里会同时留着两套互相矛盾的数据,这种站点引擎干脆降低引用权重。这也是为什么 ID 规范要在改造一开始就定死。
ASP.NET Core 批量输出:一个 Builder 覆盖主店和六个部门
老郑的系统是 ASP.NET Core,门店和部门数据存在 SQL Server 两张表里。我们没有引入第三方 JSON-LD 库,直接用 System.Text.Json 手拼,好处是注释可以写进生成逻辑,后面接手的人看得懂每个字段为什么这么填。
环境:.NET 8、ASP.NET Core MVC、SQL Server 2019,数据访问用 Dapper,无其他第三方依赖。
// 环境:.NET 8 / ASP.NET Core MVC,仅依赖框架自带的 System.Text.Json 与 Dapper 7.x
// 设计说明:手拼字典而不是建强类型 DTO,是因为 JSON-LD 的属性名带 @ 前缀,
// 用 [JsonPropertyName("@id")] 也可以,但字段一多维护成本反而高
public class DepartmentLdBuilder
{
private readonly string _baseUrl; // 如 https://www.example-mall.com,末尾不带斜杠
// 构造函数统一收 baseURL:全站所有页面的 @id 都从这里拼,改域名只改一处
public DepartmentLdBuilder(string baseUrl) => _baseUrl = baseUrl.TrimEnd('/');
// 主店实体:department 数组里塞全部部门的引用对象
public object BuildStore(StoreDto store, IReadOnlyList<DepartmentDto> departments)
{
return new Dictionary<string, object?>
{
["@context"] = "https://schema.org",
["@type"] = "Store",
// @id 用 baseUrl + 锚点拼出来,保证全站任何页面输出都一字不差
["@id"] = $"{_baseUrl}/#store",
["name"] = store.Name,
["url"] = $"{_baseUrl}/",
["telephone"] = store.Phone,
// department 属性是数组:每个元素都是完整的子 LocalBusiness,
// 引擎既可以在本页抽全量,也可以顺着 url 去部门页单独抓
["department"] = departments.Select(BuildDepartmentObject).ToList()
};
}
// 部门实体:departmentOf 只放 @id 引用,减小 JSON 体积
private object BuildDepartmentObject(DepartmentDto d)
{
return new Dictionary<string, object?>
{
["@type"] = "LocalBusiness",
// @id 规则:部门页 URL + #department,与页面上内嵌的脚本保持一致
["@id"] = $"{_baseUrl}/departments/{d.Slug}#department",
["name"] = d.Name,
["url"] = $"{_baseUrl}/departments/{d.Slug}",
["telephone"] = d.Phone,
// 反向语义:子实体必须指回父实体,父子关系才成对成立
["departmentOf"] = new Dictionary<string, object?>
{
["@id"] = $"{_baseUrl}/#store"
},
// 营业时间按部门各自的表拆开输出,绝不共用总店时段
["openingHoursSpecification"] = d.Hours.Select(h => new Dictionary<string, object?>
{
["@type"] = "OpeningHoursSpecification",
["dayOfWeek"] = h.Days, // 如 ["Monday","Tuesday"],来自数据库日历位
["opens"] = h.Opens, // "09:00" 格式,注意补零
["closes"] = h.Closes
}).ToList()
};
}
}
Razor 页面里的输出只有一行:
// 在 _Layout.cshtml 的 <head> 中:主店页传全部部门,部门页只传自己
// JsonSerializer 选项要关掉转义:默认会把中文转成 \uXXXX,实体名可读性全无
@Html.Raw(JsonSerializer.Serialize(Model.JsonLd, _jsonOpts))
两个细节值得写下来。数据库里营业时间的日历位存的是 bit 列(周一到周日七个 0/1),转 dayOfWeek 数组的映射逻辑我放在了 DTO 层,Mapper 里写了六个 if,很丑但直观,谁改都不会改错。另一个是电话号码格式,统一转成 +86-区号-号码 的 E.164 风格,引擎对电话的解析比想象中严格,带括号和横线混排的号码在部分引擎侧会被拆成两条数据。
实体关系整体长这样:
flowchart LR
P["/departments/jiancai 部门页"] -->|内嵌| J1["建材部 LocalBusiness"]
H["首页 /"] -->|内嵌| J2["Store 主实体"]
J2 -->|"department[]"| J1
J1 -->|"departmentOf"| J2
J1 --> O1["openingHoursSpecification 09:00-20:00"]
J1 --> T1["telephone 8011"]
改造前后对比与验证
方案六月底上线,前后差异整理成一张表。
| 对比项 | 改造前 | 改造后 |
|---|---|---|
| 部门页面 | 无独立页,全挤在 /about | 六个 /departments/xxx 独立页 |
| JSON-LD 实体 | 1 个 Store 实体 | 1 个 Store + 6 个子 LocalBusiness |
| 营业时间 | 全店共用一行 | 按部门用 openingHoursSpecification 拆分 |
| 电话 | 只有总机 | 每部门独立电话 + 总机兜底 |
| 父子关系 | 无 | department 与 departmentOf 成对、@id 互指 |
| 内部观测 | 问「订橱柜」回答常引总机 | 同类问题多数引用建材部页与分机 |
验证方法说两句。第一是用 schema.org 官方的 validator 和 Google 的 Rich Results Test 各跑一遍,确认 JSON 语法和属性名没写错。第二是盯抓取日志:改造后第三周,老郑发来消息,说部门页的抓取频次从几乎为零涨到了每天十几次,这是引擎开始单独消费部门实体的直接信号。第三是拿真实问题去问各家 AI 搜索,观察回答里引用的是哪个 URL、哪个电话——内部观测数据显示,八月上旬「订橱柜」「周末带孩子去哪玩」这两类问题的回答,已经能对到建材部和儿童乐园各自的分机上。
三个常见误区
误区一,department 数组里塞满空壳实体。有的站点为了「看起来丰富」,给每个部门只填 name 和 url,电话和营业时间留空。引擎对这种半成品实体的处理方式是不入库或者降权,还不如不写。子实体的字段完整度要和主店页面一个标准。
误区二,父子关系只写单向。只在父实体写 department、子实体不写 departmentOf,结构上也能被解析,但两个信号缺一个,对齐阶段的置信度就打折。成对写是成本为零的保险。
误区三,把 department 和子域名、多门店混淆。department 解决的是「一家店内部多个业务条线」,连锁多门店要用 subOrganization 或者干脆每家门店独立 Store 实体,别混着用。这事儿分不清,后面做连锁化改造时实体关系会缠成一团麻。
趋势上看,本地商户被 AI 引用的粒度正在从「店」细化到「店里的服务单元」,餐厅的包间预订、商场的某个专柜,未来都可能成为独立召回的实体。现在把 department 架构打牢,等于提前把 GEO 实体图谱的骨架立好。有正在做类似改造的,欢迎评论区交流踩坑细节。
参考与延伸
- schema.org LocalBusiness 定义(含 department、departmentOf 属性)
- schema.org Store 类型说明
- Google 搜索中心:本地商家结构化数据
- Google Rich Results Test 验证工具
GEO、Store、department、LocalBusiness、JSON-LD、实体对齐、本地服务 SEO、AI 搜索引用