AI 推荐的门店为什么总在服务圈外:GeoCircle 半径落地与服务页可抓取化实战
适用读者:做连锁门店类本地服务的后端与 SEO 工程师;负责让门店信息被生成式引擎优化(Generative Engine Optimization, GEO)正确理解的开发者;正在排查「AI 推荐门店不匹配服务范围」问题的技术负责人。
上个月我们接手了一个连锁家政维修平台的案子:12 家门店,覆盖一个二线城市的六个区。客服那边连续两周在骂同一个问题——有用户在 25 公里外的区被 AI 搜索引擎推荐到了最近的门店,约了师傅上门,师傅一句「这片我们不服务」,订单黄了,用户在平台打了差评。实际呢,那家门店的服务半径只有 8 公里。
排查完发现,服务半径信息不是没写,是全埋在页面上一个 JS 地图组件里:Leaflet 画的圆,圆心和半径从前端 API 异步拉取。人看得见,爬虫和 AI 引擎读不到。生成式引擎抓页面的时候,拿到的是一段空的 <div id="map">,至于这家店服务到哪、不服务到哪,一个字都没有。
这篇就把整个改造过程写下来:怎么用 Schema.org 的 GeoCircle 把服务半径变成机器可读的结构化数据,怎么用 ASP.NET Core Razor 在服务端动态输出 JSON-LD,怎么给每家门店生成可抓取的服务圈落地页,以及四周之后数据变化了多少。
AI 引擎到底在读什么:GeoCircle 的解析机制
先说清楚问题出在哪一层。传统的 Google 搜索有 Local Pack,会结合商户的 Google Business Profile 数据判断服务范围;但 AI 搜索引擎(AI Overviews、各种对话式搜索、以及被大模型引用的答案生成链路)在推荐本地门店时,主要依赖两类输入:一是页面上结构化的标记数据,二是正文里可抽取的实体和事实。

我们那个站,结构化数据只有一份极简的 LocalBusiness,字段只有名称、地址、电话。服务范围这个关键事实完全缺失。页面正文里倒是有一句「服务周边 8 公里」,但写在一张图片的 alt 里,而且是「周边」这种模糊表述。对大模型来说,「周边」可以理解成 3 公里也可以理解成 30 公里,它只能退回到「距离用户最近的门店」这个兜底逻辑——于是就有了 25 公里外被推荐过来的那批订单。
AI 引擎不是不想给你算服务半径,是页面上根本没有一个它认的、带单位的、带圆心的服务半径。
这里的机制值得拆开讲。Schema.org 定义了两种表达服务区域的方式,大多数人对 areaServed 熟,对 GeoCircle 陌生:
| 属性 | 作用 | 典型填法 | AI 引擎解析难度 |
|---|---|---|---|
areaServed |
声明服务的行政区或区域名 | 城市名、区名、AdministrativeArea |
低,但粒度粗,区一级太宽 |
geoMidpoint + geoRadius |
用圆心坐标加半径(米)画出圆形服务区 | 经纬度 + 数字 | 精确到几何层面,可直接做距离判断 |
openingHoursSpecification |
营业时间 | 时间区间 | 与本题无关但常被顺手补全 |
GeoCircle 的妙处在于它是一个可计算的几何承诺。areaServed 写「朝阳区」是行政区划层面的粗话,AI 引擎拿到之后只能做字符串匹配;而 geoMidpoint 给了经纬度、geoRadius 给了以米为单位的数字,任何一个做地理计算的模型或检索系统都能拿用户坐标跟门店圆心算一次 haversine 距离,跟半径一比就知道在不在圈内。25 公里那个误推荐,本质上就是因为系统里只有「最近的门店」这个隐式规则,没有任何可供否决的几何边界。
还有一个容易被忽略的组合:hasOfferCatalog。它用来列出这家门店到底提供哪些服务(空调维修、水管疏通、开锁换锁……)。AI 引擎在推荐时会做意图匹配——用户搜「马桶堵塞维修」,如果门店的结构化数据里压根没有「马桶」这个服务项,推荐置信度就会掉下去。服务半径解决「推不推」,服务目录解决「推得对不对」,两个要一起上。
改造方案:把半径从 JS 里搬出来
方案定了三件事:
- 数据库里给门店表补两个字段:
ServiceLatitude、ServiceLongitude(服务圆心,一般就是门店坐标或商圈中心)、ServiceRadiusMeters(服务半径,米)。 - 每家门店生成一个独立的服务圈落地页,服务端渲染(SSR),页头输出完整的 JSON-LD,正文里同时用人类可读的方式写清楚「服务范围以门店为圆心约 X 公里、覆盖哪些街道」,图文双通道。
- 平台的门店列表页也补
areaServed,做成兜底。
动手前先画清楚改造后的数据流:
flowchart LR
A[门店管理后台] -->|录入圆心与半径| B[(门店数据库)]
B -->|EF Core 查询| C[Razor 页面 SSR]
C -->|内联 script| D[JSON-LD<br/>GeoCircle + hasOfferCatalog]
C -->|HTML 正文| E[服务圈落地页]
D --> F[AI 引擎抓取与解析]
E --> F
F -->|服务圈内才推荐| G[用户搜索]
改造前后的信息通道对比是这样的:
| 对比项 | 改造前 | 改造后 |
|---|---|---|
| 服务半径存储 | 前端 JS 组件内存 | 数据库字段,服务端渲染输出 |
| 机器可读性 | 无(div 空壳) | JSON-LD:geoMidpoint + geoRadius |
| 服务目录 | 无结构化数据 | hasOfferCatalog 列出全部服务项 |
| 落地页 | 只有门店主页 | 每店一个服务圈页,SSR 直出 |
| 人类可读描述 | 图片 alt 里的「周边」 | 正文明确写「以门店为圆心约 8 公里」 |
ASP.NET Core Razor 动态输出 JSON-LD
平台是 ASP.NET Core 8 的 Razor Pages,下面是核心实现。依赖:.NET 8、System.Text.Json(框架自带)、EF Core 8。没有引第三方 JSON-LD 库——Schema.org 的词汇表就是字符串拼接的事,引库反而多一层抽象。
先定义视图模型和序列化配置:
// Services/StoreSchemaService.cs
// 负责把门店实体转成 Schema.org 的 JSON-LD 节点
public class StoreSchemaService
{
// 这个服务类只做一件事:门店实体 → Schema.org JSON-LD
// JsonSerializerOptions 必须单例复用,避免每次请求重建元数据缓存
private static readonly JsonSerializerOptions JsonOpts = new()
{
// 属性名按 Schema.org 首字母小写的约定输出
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
// 忽略 null 字段,避免输出一堆空节点污染解析
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
// 中文不转义成 \uXXXX,AI 引擎与人工排查都更友好
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
// 圆心坐标:优先用运营录入的商圈中心,没有则回退到门店坐标
// 回退逻辑写在服务端,前端永远不用关心圆心怎么来的
public (double Lat, double Lng) ResolveMidpoint(Store store)
=> store.ServiceLatitude is null
? (store.Latitude, store.Longitude)
: (store.ServiceLatitude.Value, store.ServiceLongitude.Value);
// 组装单个门店的 JSON-LD 文档
// 返回单行 JSON 字符串,由 Razor 页面内联到 head
public string BuildStoreJsonLd(Store store)
{
// 圆心解析结果在整个方法里复用,门店 geo 与 GeoCircle 共用
var (lat, lng) = ResolveMidpoint(store);
var schema = new Dictionary<string, object>
{
// @context 与 @type 是结构化数据的固定开头
["@context"] = "https://schema.org",
["@type"] = "HomeAndConstructionBusiness",
["name"] = store.Name,
["telephone"] = store.Phone,
["address"] = new Dictionary<string, object>
{
// PostalAddress 是本地商户的标配地址节点
["@type"] = "PostalAddress",
["streetAddress"] = store.Address,
["addressLocality"] = store.City,
["addressCountry"] = "CN"
},
["geo"] = new Dictionary<string, object>
{
// geo 是门店自身坐标,供导航与距离计算
["@type"] = "GeoCoordinates",
["latitude"] = lat,
["longitude"] = lng
},
["areaServed"] = new Dictionary<string, object>
{
// 改造的核心就是这一个 GeoCircle 节点
// GeoCircle:圆心 + 半径(单位是米,不是公里)
["@type"] = "GeoCircle",
["geoMidpoint"] = new Dictionary<string, object>
{
["@type"] = "GeoCoordinates",
["latitude"] = lat,
["longitude"] = lng
},
// 8 公里 = 8000 米,这里吃过没换算的亏
["geoRadius"] = store.ServiceRadiusMeters
},
["hasOfferCatalog"] = new Dictionary<string, object>
{
// OfferCatalog 列出门店全部服务项,供 AI 做意图匹配
// 用户搜「马桶堵塞」,目录里必须有对应条目才推得对
// 服务名照用户口语写,别写行业黑话
["@type"] = "OfferCatalog",
["name"] = $"{store.Name}服务目录",
["itemListElement"] = store.Services.Select(s => new Dictionary<string, object>
{
// 每个服务项一个 Offer,name 必须是用户会搜的说法
["@type"] = "Offer",
["itemOffered"] = new Dictionary<string, object>
{
["@type"] = "Service",
["name"] = s.Name
}
}).ToList()
}
};
// 序列化成单行 JSON,供页面内联输出
return JsonSerializer.Serialize(schema, JsonOpts);
}
}
有几个坑提前说。geoRadius 的单位是米,schema.org 的定义里写得很明白,但我们第一次生成时有个同事按公里填了 8,等于宣告全世界 8 米内都服务。还有 @type 的选择:纯 LocalBusiness 太泛,Schema.org 下有更具体的类型(我们用的 HomeAndConstructionBusiness),AI 引擎对具体类型的实体识别置信度更高。
然后是 Razor 页面侧。落地页模型 StoreCircleModel 注入 StoreSchemaService,页头输出:
@page "/stores/{slug}/service-area"
@model StoreCircleModel
@{
// 视图里只做两件事:输出 JSON-LD、渲染正文
// 任何业务计算都不要写在视图里,保持 Razor 层薄
ViewData["Title"] = $"{Model.Store.Name} 服务范围";
}
@section Head {
<!-- JSON-LD 放在 head 里,服务端直出,不依赖任何前端脚本 -->
<!-- 这里绝不能用 JS 异步注入,AI 引擎抓取时不执行脚本 -->
<script type="application/ld+json">
@Html.Raw(Model.JsonLd)
</script>
}
<h1>@Model.Store.Name 的服务范围</h1>
<!-- 正文用人类语言重述几何信息,图文双通道 -->
<!-- RadiusKm 由服务端把米换算成公里,保留一位小数 -->
<p>@Model.Store.Name 位于@Model.Store.Address,
服务范围以门店为圆心、半径约 @Model.RadiusKm 公里,
覆盖 @(string.Join("、", Model.CoveredStreets)) 等区域。</p>
<!-- 覆盖街道列表来自半径与街道坐标的空间计算,预生成后入库 -->
注意 @Html.Raw 前必须保证 JSON-LD 是服务端从可信数据源拼出来的,不能把用户输入直接塞进去,否则就是标准的存储型 XSS 入口。我们门店名称允许运营录入,所以序列化走 System.Text.Json 自动转义,字符串层面天然安全,Raw 只负责去掉外层 HTML 编码。
服务圈落地页的架构与生成
落地页不是手写的,12 家门店、后面还要开新店,必须模板化。架构上做成了「预渲染 + 变更触发重建」:
flowchart TD
S[门店数据变更事件] --> Q[Channel 队列]
Q --> W[后台重建 Worker]
W -->|重算覆盖街道| G[几何计算<br/>半径 × 街道坐标表]
G --> H[(落地页缓存表)]
W -->|刷新 JSON-LD| H
R[请求进来] --> M[中间件查缓存]
M -->|命中| P[直接输出缓存 HTML]
M -->|未命中| RP[Razor 实时渲染并回写缓存]
P --> C[AI 引擎抓取]
RP --> C
几个实现细节说一下:
- 覆盖街道怎么算的。城市维度的街道/社区坐标表(大概 2000 多条)预先入库,每家门店用半正矢公式(haversine)算一遍距离,小于半径的记为覆盖项。这个计算结果持久化,不放在请求路径上,改半径才重算。
- 每店一个 URL,slug 用拼音,比如
/stores/jiangnanlu/service-area。别做那种一个页面带?store=12参数的伪落地页,AI 引擎对带查询参数的 URL 收录意愿明显偏低。 - 页面之间互链。门店主页链到服务圈页,服务圈页链回主页和相邻门店,让爬虫顺着一个站内结构把 12 家店全爬到。
- sitemap 同步更新,服务圈页全部进
sitemap.xml,lastmod用真实重建时间。
SSR 本身在 Razor Pages 里是默认行为,这里强调它是为了对比之前的方案:最初有同事提议用 Prerender 中间件给那个 JS 地图做快照,被我们否了。快照只能救地图这一小块,服务目录、覆盖街道这些新信息还是得有正经的页面载体,与其打补丁不如直接建结构正确的页。
验证过程与四周后的数据
上线当天做了三层验证。第一层是 Google 的 Rich Results Test,本地商户卡片能识别出 GeoCircle 节点;第二层用 Schema.org 官方的校验思路手写了脚本,curl 拉渲染后 HTML,正则抽 JSON-LD 反序列化,断言每家店的 geoRadius 都等于数据库值、单位是米;第三层最笨但最有用——把 12 家店的 JSON-LD 打印出来人工过了一遍,就是这遍发现了有一家店的圆心录成了仓库坐标而不是门店坐标,偏了 4 公里。
验证脚本核心片段:
# validate_schema.py — 拉取落地页并校验 GeoCircle 节点
import json, re, sys, math
import urllib.request
# 12 家门店的落地页地址清单由 sitemap 展开而来
# 清单单独维护,避免硬编码到脚本里
urls = [l.strip() for l in open("urls.txt", encoding="utf-8") if l.strip()]
def hav(lat1, lng1, lat2, lng2):
# 半正矢公式:两经纬度点间的大圆距离(公里)
# 跟 AI 引擎做距离判断用的是同一套公式
r = 6371
p1, p2 = math.radians(lat1), math.radians(lat2)
dp = math.radians(lat2 - lat1)
dl = math.radians(lng2 - lng1)
a = math.sin(dp/2)**2 + math.cos(p1)*math.cos(p2)*math.sin(dl/2)**2
return 2 * r * math.asin(math.sqrt(a))
for url in urls:
# 每家门店依次拉取,任何一家断言失败就停
# 输出 OK 行方便 CI 里数通过数
html = urllib.request.urlopen(url).read().decode("utf-8")
# 抽取 head 中的 JSON-LD 块
# re.S 让 . 能匹配换行,JSON-LD 是多行的
m = re.search(r'<script type="application/ld\+json">(.*?)</script>', html, re.S)
data = json.loads(m.group(1))
# areaServed 就是 GeoCircle 节点,直出后结构不会变
circle = data["areaServed"]
# 断言半径单位正确:合理区间 1000-50000 米
assert 1000 <= circle["geoRadius"] <= 50000, f"{url} 半径异常"
# 断言圆心到门店自身坐标距离小于 500 米,防录错点
# 这条就是那次仓库坐标事故的来源,后来固化成了断言
geo = data["geo"]
d = hav(geo["latitude"], geo["longitude"],
circle["geoMidpoint"]["latitude"], circle["geoMidpoint"]["longitude"])
assert d < 0.5, f"{url} 圆心偏离门店 {d:.1f} 公里"
print(f"OK {url}")
数据是拿真实订单回访记录统计的:用户下单时的坐标(经过同意采集)到被推荐门店圆心的距离,超过该店半径记为「圈外推荐」。结果如下:
| 时间点 | AI 渠道订单量 | 服务圈内推荐占比 | 圈外投诉工单 |
|---|---|---|---|
| 改造前一周 | 214 | 61% | 9 单 |
| 改造后第 1 周 | 198 | 70% | 6 单 |
| 改造后第 2 周 | 226 | 81% | 3 单 |
| 改造后第 4 周 | 271 | 93% | 1 单(经查是用户搬家场景,不算引擎问题) |
两周左右见效的节奏符合预期——AI 引擎对结构化数据的重新抓取和索引本身就有周期。顺带一个没预料到的收益:服务圈落地页在传统搜索里也开始承接「XX 区 空调维修」这类词的流量,第四周自然搜索进店比改造前多了三成。页面是给机器写的事实,但人搜的时候同样读得懂,这不矛盾。
误区澄清或趋势预判
三个做本地 GEO 常见的坑,一起澄清掉。
一是把 areaServed 当万能药。行政区划粒度太粗,「服务全城」和「服务 8 公里」在 AI 引擎眼里是两回事,前者还会带来大量圈外误推荐。行政区适合连锁品牌级页面,门店级页面老老实实用 GeoCircle。
二是半径写人情数字。运营总想把半径写大点显得服务能力强,写 15 公里实际送到 8 公里,短期推荐量上去,差评和投诉会把你打回原形。结构化数据是承诺,不是广告位。我们现在要求运营录入的半径必须和调度系统的实际派单边界一致,两个数字对不上算数据事故。
三是指望一次性改造一劳永逸。AI 引擎的抓取策略、解析偏好一直在动,结构化数据要当成持续运营的数据资产而不是一次性工程。下一个阶段我比较确定的方向是:服务半径这类几何事实会越来越多被 AI 引擎直接消费,甚至成为本地推荐的前置过滤条件——你不在结构化数据里声明边界,它就替你猜一个,猜错了挨骂的还是门店。
如果你也在做连锁门店的 GEO,欢迎评论区聊聊你们的 areaServed 是怎么填的。
参考与延伸
- Schema.org GeoCircle 类型定义:https://schema.org/GeoCircle
- Schema.org LocalBusiness 类型定义:https://schema.org/LocalBusiness
- Google 搜索中心本地商户结构化数据文档:https://developers.google.com/search/docs/appearance/structured-data/local-business
GEO、GeoCircle、LocalBusiness、JSON-LD、服务半径、AI 推荐门店、SSR 落地页