一家大店拆成六个部门页:Store 的 department 属性与子部门实体架构

2026-10-03 01:22:24 0 次浏览
GEOdepartmentStoreLocalBusinessSchema.org

适用读者:手里管着连锁门店官网、但整站只有一个笼统 LocalBusiness 页面的后端工程师;正准备给大型卖场类站点做生成式引擎优化(Generative Engine Optimization, GEO)改造、想让 AI 搜索把「哪个部门能干什么」分清楚的技术负责人。

城东一家家居广场,六个业务部门,四个电话号码,三套营业时间,全挤在一个 LocalBusiness 页面上。用户问 AI「附近哪家商场能订整体橱柜」,引擎把餐饮部的电话推了过去——用户打过去订了一桌饭。这事儿是他们运维负责人老郑四月初跟我吐槽的,原话是「我们六个人的分工,AI 全记成了一个人的」。这篇文章就把这个翻车现场拆开讲:怎么用 Store 的 department 属性,把一家大店在实体层面拆成六个能被单独引用的子部门。

问题不在文案,在实体结构

先看这家家居广场当时的家底。六个部门的服务内容、营业时间、联系电话全不一样,但官网只有 /about 一个页面,页面底部嵌了一段整站共用的 JSON-LD,@type 写的是 Store,openingHours 只有一行「09:00-21:00」,电话是总机。

Store 的 department 子部门实体架构

部门 核心服务 营业时间 电话
建材部 瓷砖、地板、整体橱柜订制 周一至周日 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 实体图谱的骨架立好。有正在做类似改造的,欢迎评论区交流踩坑细节。

参考与延伸

GEO、Store、department、LocalBusiness、JSON-LD、实体对齐、本地服务 SEO、AI 搜索引用

🤖
本内容由 AI 辅助生成,经人工校对审核;部分素材、资料来源于公开网络,仅作个人观点分享与交流使用,无任何商业侵权意图。若内容、图片、文字涉及您的合法著作权、版权权益,请联系本人,核实后将第一时间删除、修改相关内容。