服务范围写进结构化数据之后:areaServed 与 GeoCoordinates 的 60 天引用对照

2026-09-18 08:34:29 13 次浏览
结构化数据Schema.orgLocalBusiness本地SEO生成式引擎

适用读者:负责本地生活类站点技术 SEO 的工程师、多门店连锁企业的数字营销负责人、给客户做 LocalBusiness 改造的外包开发者。

我们服务的一家设备维保公司总部在临江市江北区,实际接单范围覆盖南湖区、梅湾区、青枫区三个邻区。在主流 AI 助手里连问三次「南湖区设备上门维修」「梅湾区设备上门维修」「青枫区设备上门维修」,三个提问里只有江北区自己的推荐结果里出现了这家公司,另外两个区的答案完全没提它。痛点很直白:AI 引擎认为它的服务范围只有总部所在的那一个区。

一、人工测试暴露的问题

发现问题的方式很土:市场同事每周做一次人工抽测,把 12 条真实客户常问的 prompt 丢进三个 AI 应用里,记录自家品牌被提及的次数。那次抽测里,涉及江北区的问题命中 5 次,涉及另外三个区的 9 次提问命中 0 次——不是排名靠后,是压根不在候选里。

我们拉了站点的首页源码看,页面上确实有一段 LocalBusiness 的 JSON-LD,但字段只有 nameaddresstelephone 三项,address 里的 addressLocality 写的是「临江市江北区」。对生成式引擎来说,这段数据传递的地理信号就一条:这家商家在江北区。至于它接不接南湖区的单,结构化数据里没有任何证据,AI 只能按「无证据即不覆盖」处理。

搜索引擎时代的本地 SEO 有兜底手段——GBP(Google Business Profile)里可以画服务范围、第三方平台上有门店列表页可以蹭排名。生成式引擎不爬这些动态内容,它主要吃页面里的结构化数据和历史语料。语料里没有「南湖区 + 这家公司」的共现,结构化数据里没有 areaServed,两边都空,引用就归零。

二、改造方案:从门店表批量生成 Schema

方案不是手工改首页那一段 JSON-LD,而是把结构化数据的生成整个搬到后端。这家公司在CRM 里维护着一张门店表,4 个服务网点共 23 条记录,每条有门店名、详细地址、服务行政区列表。改造流程如下图。

flowchart LR
    A[(CRM 门店表<br/>23 条记录)] --> B[ nightly ETL 任务<br/>凌晨 2 点拉取增量 ]
    B --> C[地理编码服务<br/>地址转经纬度]
    C --> D{坐标一致性校验}
    D -- 通过 --> E[拼装 LocalBusiness<br/>JSON-LD]
    D -- 偏差超阈值 --> F[告警 + 挂起该门店<br/>人工复核]
    E --> G[站点 sitemap 同步<br/>输出到各区落地页]
    G --> H[AI 引擎抓取解析<br/>写入商家知识条目]

为什么要走批量生成?因为服务范围会变。青枫区是 2025 年新开的片区,梅湾区今年 3 月才把响应时长从 4 小时缩到 2 小时。如果 areaServed 是手写在首页里的一段静态代码,业务侧每次调整范围都得提工单找开发,改不动的事实会持续几个月。改成从门店表 nightly 生成之后,业务同事在 CRM 里勾上新行政区,次日早上站点上的 Schema 就跟着变了。

areaServed 本身有两种常见写法,解析行为不一样,值得单独列出来对比。

写法 示例片段 引擎解析行为 维护成本
City 对象数组 "areaServed": [{"@type": "City", "name": "南湖区"}, ...] 可与行政区划实体显式对齐,同名区县可挂 containedInPlace 消歧 中,需维护对象结构
名称字符串数组 "areaServed": ["南湖区", "梅湾区", "青枫区"] 退化做字符串匹配,遇到跨市同名区易错配 低,改起来快
混合写法 字符串和对象混在同一数组 部分引擎只识别对象项,字符串项被丢弃 高,不建议

字符串写法的问题在于同名消歧。全国叫「城东区」的区不止一个,AI 引擎拿到一个光秃秃的字符串,要么放弃对齐,要么猜一个。对象写法可以再套一层 containedInPlace: {"@type": "City", "name": "临江市"},把「南湖区」钉死在临江市下面,实体对齐的歧义基本消失。我们最终全部采用 City 对象写法。

三、JSON-LD 完整示例

下面是单家门店最终输出的 JSON-LD 结构。注释行是说明性标记,实际输出时会去掉,这里为方便阅读保留。运行环境:站点为 Python 3.11 + Django 4.2,Schema 由后端模板渲染,页面缓存 24 小时。

{
  // @context 固定指向 schema.org 词表,声明下面所有字段按该词表解析
  "@context": "https://schema.org",
  // 业务大类用 LocalBusiness,设备维保也可用其子类 HomeAndConstructionBusiness
  "@type": "LocalBusiness",
  // 全站不重复的门店标识,AI 引擎靠它做去重与跨页面归并
  "@id": "https://example-hvac.com/#store-jbz",
  "name": "临江安捷设备维保(江北总店)",
  "telephone": "+86-510-8888-0000",
  // 落地页 URL,AI 回答引用商家时的跳转出口
  "url": "https://example-hvac.com/store/jiangbei",
  "address": {
    "@type": "PostalAddress",
    // 详细到门牌号,粒度越细地理编码误差越小
    "streetAddress": "江北区望江路 128 号 3 号厂房西侧",
    // addressLocality 写市级、区级信息交给 areaServed 表达
    "addressLocality": "临江市",
    "addressRegion": "JS",
    // 邮编与国别码补全,国际化的解析器都认这两个字段
    "postalCode": "214000",
    "addressCountry": "CN"
  },
  // 总部坐标,来自 CRM 门店表 geohash 字段反解
  "geo": {
    "@type": "GeoCoordinates",
    // 先纬度后经度,写反了坐标会飘到另一个半球
    "latitude": 31.5712,
    "longitude": 120.3021
  },
  // hasMap 指向可公开访问的地图页,为坐标提供第二证据
  "hasMap": "https://maps.example-map.com/?q=31.5712,120.3021",
  // 服务范围用 City 对象数组,每项挂 containedInPlace 消同名区歧义
  "areaServed": [
    // 江北区是总部所在区,同样显式声明,不要省略
    { "@type": "City", "name": "江北区", "containedInPlace": { "@type": "City", "name": "临江市" } },
    // 以下三区为跨区服务范围,来自门店表的 districts 勾选项
    { "@type": "City", "name": "南湖区", "containedInPlace": { "@type": "City", "name": "临江市" } },
    { "@type": "City", "name": "梅湾区", "containedInPlace": { "@type": "City", "name": "临江市" } },
    { "@type": "City", "name": "青枫区", "containedInPlace": { "@type": "City", "name": "临江市" } }
  ],
  // 营业时间即维修受理时段,AI 在生成答案时会引用它描述响应时效
  "openingHoursSpecification": [
    // 工作日一条
    { "@type": "OpeningHoursSpecification", "dayOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"], "opens": "08:00", "closes": "18:00" },
    // 周末一条,时段与 CRM 排班表保持同步
    { "@type": "OpeningHoursSpecification", "dayOfWeek": ["Saturday", "Sunday"], "opens": "09:00", "closes": "17:00" }
  ]
}

四个字段里,areaServed 解决「覆盖哪里」,geohasMap 解决「总部在哪、可信不可信」,openingHoursSpecification 解决「什么时候能上门」。AI 引擎回答「XX 区设备上门维修」时通常会带一句响应时效,营业时间缺失的商家在生成答案里容易被描述成「营业时间未知」,间接降低被引用的概率。

四、批量生成脚本与坐标一致性校验

批量生成环节最容易出的错是坐标:门店表里早期录入的数据,有 6 条填的是总部坐标而不是门店自己的坐标。如果直接生成,AI 引擎做距离计算时会认为「南湖区营业点」离南湖区中心 40 公里,服务声明与位置证据自相矛盾,反而拉低信任分。所以我们加了一道一致性校验。

flowchart TD
    A[拉取 CRM 门店表] --> B[逐条地理编码<br/>详细地址转 lat/lng]
    B --> C{表内 geohash<br/>与编码结果比对}
    C -- 距离 &lt; 300m --> D[写入 Schema 缓存表]
    C -- 距离 ≥ 300m --> E[比对已知区中心点<br/>判断偏到哪个区]
    E --> F[告警到运维群<br/>该门店暂停输出]
    F --> G[人工改表后<br/>次日重跑]

阈值定 300 米的依据:地理编码服务对详细到门牌号的地址,误差通常在百米内;超过 300 米大概率是录入错误而不是编码误差。实际跑下来第一周抓出 6 条脏数据,全部是把总部坐标复制粘贴进了分店记录。

生成脚本片段如下。依赖与环境版本:Python 3.10+,pymysql 1.1.0(连 CRM 的 MySQL 8.0 从库),地理编码用公司内网服务,距离计算用标准库手写 haversine,不引入额外依赖。

import math
# json 用来反序列化 CRM 里的 districts 数组字符串
import json
# dataclass 声明门店记录的内存结构,比裸字典可读
from dataclasses import dataclass

# pymysql 1.1+,负责连 CRM 只读从库
import pymysql


# haversine 公式,返回两点间大圆距离,单位米
def haversine(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
    # 地球平均半径取米制,方便和 300 米阈值直接比较
    radius = 6_371_000
    # 角度统一转弧度,math 三角函数只接受弧度
    phi1, phi2 = math.radians(lat1), math.radians(lat2)
    # 纬度差与经度差分开算
    d_phi = math.radians(lat2 - lat1)
    d_lambda = math.radians(lng2 - lng1)
    # 半正矢中间量,a 越接近 0 两点越近
    a = math.sin(d_phi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(d_lambda / 2) ** 2
    # asin 还原角距,乘半径得到米
    return 2 * radius * math.asin(math.sqrt(a))


@dataclass
class Store:
    # 与 CRM 门店表字段一一对应
    store_id: int
    # 门店展示名,进 Schema 的 name 字段
    name: str
    # 详细地址,喂给地理编码服务
    addr: str
    # 表内坐标,可能为空,空值交给校验挂起
    lat: float | None
    # 经度与纬度成对出现
    lng: float | None
    # 服务行政区列表,由 JSON 数组字符串解析而来
    districts: list[str]


# 拉取门店表,geohash 为空的记录直接跳过并记录日志
def fetch_stores() -> list[Store]:
    # 只读从库账号,避免夜间任务影响主库
    conn = pymysql.connect(host="crm-slave.internal", user="seo_ro",
                           password="***", database="crm", cursorclass=pymysql.cursors.DictCursor)
    try:
        # DictCursor 让每行按字段名取值,列顺序变了也不怕
        with conn.cursor() as cur:
            # 限定字段,只取生成 Schema 需要的列
            cur.execute("SELECT id, name, addr, geohash, districts FROM store WHERE status=1")
            rows = cur.fetchall()
    finally:
        # 连接无论成败都要关,nightly 任务跑挂不能留句柄
        conn.close()
    stores = []
    # 逐条反解坐标并收集,坐标缺失的记录后面会被校验拦下
    for r in rows:
        # geohash 反解出经纬度,脏数据交给下一步校验拦截
        lat, lng = decode_geohash(r["geohash"]) if r["geohash"] else (None, None)
        # districts 是 JSON 数组字符串,.loads 成列表
        stores.append(Store(r["id"], r["name"], r["addr"], lat, lng, json.loads(r["districts"])))
    return stores


# 坐标一致性校验:表内坐标与地理编码结果偏差超过 300 米即判失败
def validate(store: Store, coded_lat: float, coded_lng: float) -> bool:
    # 缺坐标的直接挂起,不猜、不回填总部坐标
    if store.lat is None:
        return False
    # 300 米阈值:门牌级地理编码误差通常在百米内,超出即视为录入错误
    return haversine(store.lat, store.lng, coded_lat, coded_lng) < 300


# 拼装单个门店的 areaServed 节点,全部挂 containedInPlace 消同名歧义
def build_area_served(districts: list[str], city: str = "临江市") -> list[dict]:
    # 市名做成参数,未来跨市扩张时不用改函数
    return [
        {
            # 类型固定 City,对应行政区划里的区县级
            "@type": "City",
            # 区名来自 CRM 门店表勾选项,不做自由文本
            "name": d,
            # containedInPlace 把区钉在市下面,避免跨市同名区错配
            "containedInPlace": {"@type": "City", "name": city},
        }
        # 列表推导逐区展开,顺序与 CRM 勾选顺序一致
        for d in districts
    ]

decode_geohash 是内网工具函数,此处省略。脚本挂 cron,每天 02:10 跑,输出写进 Redis 缓存,Django 模板渲染时读取。校验不通过的门店当晚不输出 Schema,而不是带着错坐标硬上。

五、机制剖析:AI 引擎怎么做地理意图匹配

这一段讲清楚 areaServed 为什么真的管用。生成式引擎处理「XX 区设备上门维修」这类查询时,内部大致走三步。

第一步是查询里的行政区实体识别(NER)。查询里的「南湖区」被识别为行政区划实体,并解析成标准地理编码——可能带行政区划代码,也可能带质心坐标。这一步决定了查询的地理意图强度:「临江市」是弱意图,「南湖区 + 上门」是强意图。

第二步是实体对齐(Entity Alignment)。引擎把候选商家的 areaServed 展开,逐项和查询里的行政区实体做匹配。City 对象写法在这里体现出价值:对象自带 containedInPlace 层级,对齐时走的是行政区划树的精确匹配;字符串写法只能走名称相似度,遇到同名区就会被丢弃或错配。对齐成功的商家才进入候选池,对齐失败的直接出局,这解释了改造前三个区引用为零的现象——不是排序输给同行,是根本没进池子。

第三步是候选筛选里的距离特征。进入候选池后,商家 geo 坐标(或分店坐标)与查询区质心的球面距离作为一个特征参与排序。这里有个容易踩的坑:引擎通常不会因为你声明了 areaServed 就忽略距离。总部坐标距离南湖区中心 22 公里,仅靠总部一个 geo 点,在「服务区覆盖声明」和「位置距离证据」之间会产生张力;我们的做法是为每个片区的实际营业点单独输出一条 LocalBusiness(各带自己的 geo 和子集 areaServed),让距离证据和覆盖声明彼此印证。分店 Schema 上线后,三个区的引用增量里有相当一部分来自分店条目而非总部条目。

六、60 天改造前后引用对照

改造在 5 月第 2 周上线,Schema 当天可被抓取,但 AI 引擎的知识条目更新有滞后,第 3 周起才开始出现变化。市场同事沿用同一套 12 条 prompt 的抽测方法,按周记录,60 天汇总如下。计入口径:AI 回答中明确列出商家名称或给出其落地页链接,计 1 次引用。

行政区 改造前 60 天引用次数 改造后 60 天引用次数 引用占比变化 首次被引用周
江北区(总部所在区) 11 14 23% → 25% 改造前已有
南湖区 0 9 0% → 16% 第 4 周
梅湾区 0 6 0% → 11% 第 6 周
青枫区 0 4 0% → 7% 第 8 周

三个数字值得展开。南湖区最快见效,因为它有独立营业点,分店 Schema 的距离证据最完整;青枫区第 8 周才首次被引用,滞后最明显,事后复盘发现是它的营业点页面上线比其他区晚了两周,页面本身没被抓到之前,Schema 覆盖声明是孤证。梅湾区居中,它的营业点是合作网点,地址在 CRM 里登记的是网点公司注册地址,和实际服务位置差了 1.2 公里,第一版校验把它拦下来了,改表之后才通过。

总的量级不大——60 天合计 33 次引用,对一个区域维保公司来说是真实可感知的线索来源,但不是爆发式增长。这符合预期:结构化数据解决的是「能不能被想起」,召回之后的排序和转化还要靠评价、内容质量这些老因子。

七、误区与趋势

三个常见误区。第一,areaServed 写得越大越好——有同行把全省地级市都塞进去,结果引擎在距离特征上把它的坐标全部判为低置信,覆盖声明反而失去权重,声明要与真实履约能力一致。第二,只改总部一条 Schema——多营业点业务必须每点一条,总部条目兜不住跨区的距离证据。第三,上线即验收——知识条目更新普遍滞后二到六周,第 1 周没数据就下结论为时过早。

趋势上,生成式引擎对本地商家的地理信号要求正在从「有没有」转向「自洽不自洽」。覆盖声明、坐标、营业时间、落地页内容四处互相印证的商家,比只做单点声明的商家有明显的引用优势。这是可验证的工程问题,不是玄学。

落地层面,这件事的完整清单是:门店表里补齐 geohash 和行政区字段、nightly ETL 生成 JSON-LD、300 米一致性校验、分店独立 Schema、每两周一次人工抽测。全部做完大约三个工程师周,其中一半时间花在校验和数据清洗上——结构化数据项目的真实成本从来不在写 Schema,而在让数据值得被写进 Schema。

落地过程中踩过坑、对引用归因口径有不同做法的,欢迎评论区交流。

参考与延伸

关键词:areaServed, GeoCoordinates, LocalBusiness, 本地服务SEO, 结构化数据, 生成式引擎优化, AI优化AIO

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