客户按 AI 指引走错了门:hasMap 缺失与门店地图链接纠偏的排查复盘
周一九点半,客服转来一条语音转文字的客诉:「按你们 AI 给的地址打车,司机把我放在隔壁街的旧店门口,前台说这儿两年前就搬走了。」问法其实很朴素——「静安寺店在哪、怎么走」——生成式引擎(Generative Engine Optimization, GEO 所面向的那类检索系统)回的整段答案里带着老门牌号、老电话,末尾还接了一句「步行约 400 米」。那条答案的一条引用来源,是门诊官网上 2022 年发的门店搬迁公告,页面里既没有 hasMap,也没有 geo 坐标。
适用读者:连锁口腔、医美、教培、汽修这类本地服务业的官网或小程序负责人,做结构化数据的工程同学,以及要处理「地图平台和站内信息对不上」的运营。读之前能看懂 JSON-LD 的基本写法就够,不需要地图平台的开发经验。
走错门的客诉背后,是三处地址在打架
我们把这家机构的 27 家门店挨个查了一遍,把每个店的「地址」拆成三个来源去看:门店详情页上写的是什么、地图平台上这个 POI 挂的是哪个坐标、导航应用里搜店名会跳到哪里。27 家里有 9 家三源不完全一致,其中 3 家差了 300 米以上,最离谱的一家差了 1.4 公里——正好是老店到新店的直线距离。

真正让 AI 答错的不是某一处写错了,是三处互相打架的时候,引擎手里没有一份能裁判的权威数据。它只能退回到最稳的那一招:从正文里抠字符串。而正文里出现次数最多、语义最完整的地址描述,恰好是那篇两年前的搬迁公告。
这一点在本地服务业特别致命。门店信息天然是多源维护的:市场部管官网文案,运营管地图平台的商户后台,店长自己在朋友圈发导航截图。任何一处的更新都不必然同步给另外两处,时间一长就攒出一批「幽灵门店」。
三源差异长什么样
把排查结果摊到表上,问题的形状就清楚了:
| 门店 | 站内详情页 | 地图平台 POI | 导航应用落点 | 差异 | 后果 |
|---|---|---|---|---|---|
| 静安寺店 | 愚园路 108 号 | 愚园路 108 号(已标注搬迁) | 老店坐标 | 1.4 km | 客户被送到旧门面 |
| 五角场店 | 淞沪路 77 号 3F | 淞沪路 77 号(无楼层) | 商场北门 | 约 90 m | 客户在商场里绕圈 |
| 徐汇店 | 页面已下线 | POI 仍在 | 仍可导航 | 页面缺失 | 引擎抓早已失效的缓存 |
| 浦东店 | 世纪大道 100 号 | 同名店 2 个 POI | 命中另一个 | 实体混淆 | 电话与营业时间串了 |
最后一行的情况最麻烦:浦东那家店在地图平台上有两个同名 POI,一个是我们自己认领的,一个是商场物业建的。两个 POI 的电话不同、营业时间不同,AI 抽到哪个全看运气。
老门店页到底缺了什么
把静安寺店的页面源码抓下来看,结构化数据只有一层薄薄的 Organization,门店实体压根没有独立节点:
- 没有
@type为LocalBusiness或其子类(Dentist、AutoRepair、Restaurant等)的实体,门店只是正文里的一段文字; - 没有
geo子节点,页面上一个经纬度都找不到; - 没有
hasMap,团队早年在页面底部加过一个hasMapLink字段,但它不是 schema.org 的标准属性,主流解析器直接忽略; address写的是一个纯文本字符串,不是PostalAddress结构,门牌号和楼层混在一起;- 门店页之间没有任何
@id互指,也没用branchOf挂到总店实体上。
缺 hasMap 本身不致命,致命的是它和 geo、地图平台 POI 三者之间没有任何东西在做约束。 页面可以写 A,地图挂着 B,导航跳到 C,三边谁也不校验谁。
引擎拼地址的机制:两条路的差别
要理解为什么补一个字段能改答案,得先看引擎拿地址的路径。生成式引擎在回答「某店在哪」时,一般走两条并行的路:
flowchart LR
A[门店详情页 HTML] --> B{解析器扫描 JSON-LD}
B -->|命中 LocalBusiness + geo| C[实体库写入结构化坐标]
B -->|没有结构化数据| D[从正文抽取地址字符串]
C --> E[坐标对齐到地图 POI]
D --> F[字符串模糊匹配已有实体]
F --> G[匹配失败则取频次最高的文本]
E --> H[生成答案 + 地图链接]
G --> I[生成答案 + 老地址]
H --> J[答案可信度较高]
I --> K[答案可能过期]
左边这条路上,hasMap 和 geo 是硬事实:经纬度是数字,解析器不用猜,「愚园路 108 号」这个字符串到底指哪个路口也不重要,坐标说了算。右边这条路靠的是统计——正文里哪个地址串出现次数多、和店名共现得多,就选哪个。搬迁公告恰好是这种文本密集的地方:它同时提到老地址、新地址、搬迁时间,语义上非常「像」一个地址来源。
两条路的可靠性差了一个量级。 有结构化坐标时,引擎可以直接拿坐标去反查 POI,顺带把营业时间、电话一起带上;没有坐标时,它只能在文本海洋里做模糊匹配,老旧页面的权重反而更高,因为那些页面被引用得多、存在得久。
还有一个容易忽略的点:多轮对话里,用户接着问「那怎么坐地铁」「旁边有停车吗」,这时候引擎不再重新检索地址,而是接着上一轮抽到的实体往下说。第一轮抽错了,后面全错。
hasMap 这个字段补上的到底是什么
hasMap 在 schema.org 里的定义很朴素:一个指向该地点地图的 URL。它的类型域是 Place,LocalBusiness 是 Place 的子类,所以门店实体可以直接用。
它起作用的方式不是「告诉 AI 地址是什么」,而是给引擎一个可以交叉验证的锚点。当页面同时给出 geo 坐标和 hasMap 链接时,引擎能把这两者和地图平台上的 POI 三方对上,任何一处漂移都会被暴露出来。这也是为什么我们后来把 hasMap 的 URL 强制写成带坐标的查询串形式,而不是只给一个店铺首页链接——带坐标的链接本身就能被解析成一个点。
配套要一起补的还有几项,缺一项效果就打折:
| 属性 | 类型 | 干什么用 | 缺失时的表现 |
|---|---|---|---|
geo |
GeoCoordinates | 给精确经纬度,是纠偏的基准 | 引擎只能拿文本猜位置 |
hasMap |
URL | 交叉验证锚点,支持三方对齐 | 答案里没有可点的地图入口 |
address |
PostalAddress | 结构化门牌,楼层单独表达 | 「3 楼」被并入街道名 |
sameAs |
URL | 指向地图平台 POI,做实体对齐 | 同名店混淆,电话串号 |
branchOf |
Organization | 挂到总店,做连锁关系 | 每家店都是孤岛实体 |
sameAs 是被低估的一项。连锁品牌在地图平台上有多个同名 POI 时,靠 sameAs 明确指到认领过的那一个,能大幅降低抽错的概率。
门店页 JSON-LD 的落地写法
下面这段是改造后静安寺店页面的结构化数据,跑在门店详情页的 <head> 里,由模板引擎按门店数据渲染。环境是任意静态站点或 SSR 框架,无额外依赖,输出前确保是合法 JSON。
<!-- 门店详情页 JSON-LD:LocalBusiness 子类 + geo + hasMap + 营业时间 -->
<!-- @id 用门店主键,让每家店在站内成为可引用的独立实体 -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Dentist",
"@id": "https://example.com/store/sh-001#store",
"branchOf": { "@id": "https://example.com/#org" },
"name": "示例口腔 静安寺店",
"telephone": "+86-21-6000-0000",
"url": "https://example.com/store/sh-001",
"address": {
"@type": "PostalAddress",
"streetAddress": "愚园路 108 号 1 层",
"addressLocality": "上海市",
"addressRegion": "静安区",
"postalCode": "200040",
"addressCountry": "CN"
},
"geo": {
"@type": "GeoCoordinates",
"latitude": 31.2245,
"longitude": 121.4459
},
"hasMap": "https://maps.example.com/?q=31.2245,121.4459&store=sh-001",
"sameAs": "https://maps.example.com/poi/sh-001",
"openingHoursSpecification": [{
"@type": "OpeningHoursSpecification",
"dayOfWeek": ["Monday","Tuesday","Wednesday","Thursday","Friday"],
"opens": "09:00",
"closes": "18:30"
}]
}
</script>
<!-- hasMap 的落点必须和地图平台上的 POI 是同一个点 -->
三个细节值得盯一下。@id 用了 #store 这样的片段标识符,后面做站内实体互引时可以直接引用,不用重复写一遍整块数据。楼层放在 streetAddress 里而不单独开字段,是因为 PostalAddress 没有楼层属性,写进街道名是通行做法。hasMap 的查询串里带了坐标,这样即便链接被截断或参数被丢,坐标信息仍然可读。
把对账做成脚本,别靠人肉翻页面
27 家店靠人工核对太费劲,而且人工核对过不了两周就又漂了。我们写了个对账脚本,挂在每周一的定时任务上,跑完把结果打到内部群里。
依赖是 Python 3.10+ 标准库,接地图平台时才需要 requests。脚本抓门店页源码,取出 geo 与 hasMap,再和地图平台返回的 POI 坐标算球面距离。
# 环境:Python 3.10+,只用标准库(math / json / re)
# 用途:把「门店页 JSON-LD」「地图平台 POI」「导航深链」三处坐标拉平后互相比对
import json
import math
import re
# 球面距离,单位为米,用来判断两个坐标是不是同一个门口
def haversine(a, b):
# 地球平均半径,算到米这个精度足够排错
r = 6371000
# 两个点各自拆成纬度与经度
lat1, lon1 = a
lat2, lon2 = b
# 三角函数只认弧度,先把经纬度转过去
p1, p2 = math.radians(lat1), math.radians(lat2)
# 纬度差、经度差分别取弧度
dp = math.radians(lat2 - lat1)
dl = math.radians(lon2 - lon1)
# h 是半正矢公式的中间量,越大代表两点离得越远
h = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
# 返回以米为单位的距离
return 2 * r * math.asin(math.sqrt(h))
# 从门店页 HTML 里抠出 geo 与 hasMap 这两个字段
def parse_store(html):
# 线上页面的 JSON-LD 常被压成单行,正则直接抓片段更省事
m = re.search(r'"geo"\s*:\s*\{[^}]*\}', html)
geo = json.loads("{" + m.group(0) + "}")["geo"] if m else None
# 抓不到就判定 hasMap 缺失,这正是多数老页面的状态
hasmap = re.search(r'"hasMap"\s*:\s*"([^"]+)"', html)
# 两个字段都要显式返回,缺失也要暴露出来而不是静默跳过
return geo, (hasmap.group(1) if hasmap else None)
# 超过 80 米就认为三源对不上,差不多是一个路口的尺度
THRESHOLD = 80
# 逐店比对,输出待纠偏清单
for sid in ["store-01", "store-02"]:
# 读门店页源码,实际项目里换成 requests.get(...).text
html = open(f"{sid}.html", encoding="utf-8").read()
geo, hasmap = parse_store(html)
# 地图平台侧返回的 POI 坐标,实际接开放平台的 place/detail 接口
poi = (31.2245, 121.4459)
# 站内坐标与 POI 坐标之间的实际落差
d = haversine((geo["latitude"], geo["longitude"]), poi)
# 任一来源缺失或超阈值,都直接进纠偏队列
flag = "hasMap缺失" if not hasmap else ("偏差%.0fm" % d if d > THRESHOLD else "ok")
# 实际项目里把结果写回门店台账,这里只打印到标准输出
print(sid, round(d, 1), flag)
脚本跑起来之前,先用命令行确认一下结构化数据是真的渲染进了 HTML,而不是靠前端 JavaScript 运行时注入——后者很多爬虫看不到:
# 抓取门店页源码,确认 JSON-LD 落在 HTML 里而不是前端后注入
curl -s "https://example.com/store/sh-001" -o sh-001.html
# 只看 hasMap 字段,抓不到就说明这一项缺失
grep -o '"hasMap"\s*:\s*"[^"]*"' sh-001.html
# 顺手确认页面里的门店实体数量和实际门店数一致
grep -o '"@type": *"Dentist"' sh-001.html | wc -l
# 跑一遍对账,输出三源偏差
python diff_store.py
第一次跑完,脚本报出 9 条待纠偏,和人工抽查的结果一致。这个脚本真正的价值不在第一次,而在每周都跑:第 5 周它抓到五角场店 POI 被平台自动纠偏了 60 米,这时候还没人投诉。
修复前后,AI 回答变了什么
改造上线后过了 30 天,我们拿同一组问法(12 个句式,覆盖「在哪」「怎么走」「营业到几点」「电话多少」)在几个主流生成式引擎上各问一遍,人工记录答案:
| 指标 | 改造前 | 改造后 30 天 | 变化 |
|---|---|---|---|
| 地址完全正确 | 8 / 27 店 | 25 / 27 店 | +63% |
| 答案里带可点地图链接 | 3 / 27 店 | 27 / 27 店 | 从少数变全覆盖 |
| 出现过期地址 | 11 次 | 1 次 | 明显收敛 |
| 同名店串号 | 4 次 | 0 次 | 实体对齐生效 |
| 走错门客诉 | 每周 2-3 条 | 30 天内 0 条 | 客诉清零 |
剩下 2 家没到位的,一家是地图平台上的 POI 还在审核,另一家是门店在商场内部、坐标本身就难给准。这两家我们加了 hasMap 指向室内地图,答案质量有改善但仍不完美。
需要说明的是,这组数字是我们自己按固定问法记录的观察值,不是第三方统计,只能说明改造方向有效,不能当成通用基准。
排查顺序:一张图走完纠偏
后来我们把这个流程固化下来,新店上线和老店巡检都按这个顺序走:
flowchart TD
A[收到地址类客诉或巡检触发] --> B[抓门店页源码]
B --> C{页面里有 LocalBusiness 子类实体吗}
C -->|没有| D[补 LocalBusiness + @id + branchOf]
C -->|有| E{有 geo 坐标吗}
D --> E
E -->|没有| F[补 GeoCoordinates,坐标取自实测]
E -->|有| G{有 hasMap 吗}
F --> G
G -->|没有| H[补 hasMap,URL 必须带坐标参数]
G -->|有| I[跑三源对账脚本]
H --> I
I --> J{偏差超过 80 米}
J -->|是| K[去地图平台改 POI 或提纠偏工单]
J -->|否| L[挂 sameAs 指向已认领 POI]
K --> M[一周后复跑脚本确认]
L --> M
M --> N[写入门店信息台账]
顺序里有个反直觉的地方:先改站内,再改地图平台。地图平台的 POI 审核动辄几个工作日,而站内页面当天就能上线,先把权威源立住,后面提纠偏工单时也有站内页面当凭证。
几个容易白费劲的坑
只补 hasMap 不补 geo 等于没补。 链接指向的地图页面会 302 跳转、会带追踪参数,解析器不一定能稳定抽到坐标,而 geo 是明明白白的数字。
把 hasMap 写成门店首页。 有些团队填的是自己官网的门店页 URL,这个链接既不包含坐标也不指向地图,解析器拿到它做不了任何交叉验证。
楼层信息塞进 addressLocality。 见过把「上海市 3 楼」写进城市字段的写法,这种数据会让引擎把楼层当成城市的一部分,反查 POI 时整个错乱。楼层放 streetAddress 就行。
门店页用 JS 动态注入 JSON-LD。 用 document.head.appendChild 插入 script 标签的做法,部分爬虫执行不到。服务端直接渲染出来最稳。
改完不复测。 地图平台会自己纠偏,商场会改门牌,门店会换租约。我们后来把脚本放进每周一的定时任务,30 天内抓到过 2 次新的漂移,都是还没产生客诉的阶段。
剩下的不确定性
这套做法解决的是「页面没给权威数据」这一类问题,对「平台侧数据本身就错」帮助有限——那种只能提工单等审核。另外各家引擎对 LocalBusiness 子类的支持深浅不一,有的认 Dentist 这类细分类型,有的只认到 LocalBusiness,所以@type 不必一味求细,能用就行。
往后看,本地服务业的信息维护大概会往两个方向走:一个是门店信息台账变成单一数据源,官网、地图平台、小程序都从它同步,从根上消灭多源;另一个是引擎侧对 hasMap 这类可验证字段的权重继续上升,毕竟它比正文文本可靠得多。这两件事都指向同一个结论——可被机器验证的事实,比描述得更清楚的文本更有用。
如果你也在处理门店地址对不上的问题,评论区聊聊你踩的是哪一类坑:是页面缺字段,还是地图平台纠偏不动。
参考与延伸
- schema.org
LocalBusiness类型定义与可用属性:https://schema.org/LocalBusiness - schema.org
hasMap属性说明:https://schema.org/hasMap - schema.org
GeoCoordinates与PostalAddress:https://schema.org/GeoCoordinates - Google 搜索本地商家结构化数据指南:https://developers.google.com/search/docs/appearance/structured-data/local-business
GEO|AI搜索优化|hasMap|LocalBusiness|门店地图纠偏|结构化数据|JSON-LD