服务范围写进结构化数据之后:areaServed 与 GeoCoordinates 的 60 天引用对照
适用读者:负责本地生活类站点技术 SEO 的工程师、多门店连锁企业的数字营销负责人、给客户做 LocalBusiness 改造的外包开发者。
我们服务的一家设备维保公司总部在临江市江北区,实际接单范围覆盖南湖区、梅湾区、青枫区三个邻区。在主流 AI 助手里连问三次「南湖区设备上门维修」「梅湾区设备上门维修」「青枫区设备上门维修」,三个提问里只有江北区自己的推荐结果里出现了这家公司,另外两个区的答案完全没提它。痛点很直白:AI 引擎认为它的服务范围只有总部所在的那一个区。
一、人工测试暴露的问题
发现问题的方式很土:市场同事每周做一次人工抽测,把 12 条真实客户常问的 prompt 丢进三个 AI 应用里,记录自家品牌被提及的次数。那次抽测里,涉及江北区的问题命中 5 次,涉及另外三个区的 9 次提问命中 0 次——不是排名靠后,是压根不在候选里。
我们拉了站点的首页源码看,页面上确实有一段 LocalBusiness 的 JSON-LD,但字段只有 name、address、telephone 三项,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 解决「覆盖哪里」,geo 和 hasMap 解决「总部在哪、可信不可信」,openingHoursSpecification 解决「什么时候能上门」。AI 引擎回答「XX 区设备上门维修」时通常会带一句响应时效,营业时间缺失的商家在生成答案里容易被描述成「营业时间未知」,间接降低被引用的概率。
四、批量生成脚本与坐标一致性校验
批量生成环节最容易出的错是坐标:门店表里早期录入的数据,有 6 条填的是总部坐标而不是门店自己的坐标。如果直接生成,AI 引擎做距离计算时会认为「南湖区营业点」离南湖区中心 40 公里,服务声明与位置证据自相矛盾,反而拉低信任分。所以我们加了一道一致性校验。
flowchart TD
A[拉取 CRM 门店表] --> B[逐条地理编码<br/>详细地址转 lat/lng]
B --> C{表内 geohash<br/>与编码结果比对}
C -- 距离 < 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。
落地过程中踩过坑、对引用归因口径有不同做法的,欢迎评论区交流。
参考与延伸
- schema.org/areaServed —— areaServed 属性定义,支持 Place、AdministrativeArea、Text 等多种取值类型
- schema.org/GeoCoordinates —— GeoCoordinates 类型定义,latitude 与 longitude 字段规范
- Google Search Central:本地商家结构化数据 —— LocalBusiness 标记的官方实现指引,含字段必填项与校验工具说明
关键词:areaServed, GeoCoordinates, LocalBusiness, 本地服务SEO, 结构化数据, 生成式引擎优化, AI优化AIO