lingbot-map 刚立项的时候,我本来以为它只是给聊天机器人加一个"地图搜索按钮":用户问一句"附近哪有修手机的",我把请求转发给某个地图服务就行。真做起来才发现完全不是这么回事——业务地点、自有网点、营业状态这些数据,外部接口根本不认识,而且聊天场景要的是稳定、可控、快,不是每次现搜一遍外部服务再碰运气。于是我把 lingbot-map 做成了一个独立的地点知识模块:它管着"哪些地点存在、坐标对不对、怎么被聊天文本命中、距离怎么算"这四件事。这个设计思路和实现过程,我在这篇里完整展开。
如果你正在做本地生活问答机器人、客服工单系统里带位置过滤,或者只是自己有一批门店/设施数据想接进对话交互,这篇文章里的库表设计、坐标坑、粗筛+精算的检索链路、文本命中逻辑,大概率能帮你少走好几轮弯路。
1. 先搞清边界:为什么聊天机器人要一份"自己的地图"
1.1 外部地图服务解决不了业务问题
最开始我确实走了弯路。搭 demo 的时候,用户问"离我最近的营业厅在哪",我直接调了某家地图产品的位置搜索接口,效果看似挺好。但一到真实业务就露馅了:公司自营的服务网点、仓库、合作门店,很多都不在公共 POI 库里,或者名称对不上、营业时间对不上。客服机器人答错一次,比不答还糟糕。更别提外部接口的配额费用和限流,跑不了多大规模。
所以 lingbot-map 的第一个设计决定就是:地点数据完全自维护。地图服务只作为辅助,比如用地理解析补全地址文本,但最终的地点列表、坐标、营业状态、分类标签,全部落在自己的数据库里。这样回答才有确定性,才能做权限、灰度、AB 测试,也才能算清楚每一次回答的成本。
1.2 地图模块的四项核心职责
在只做了一个月之后,我把 lingbot-map 的职责收敛成下面四块,后来所有功能都往这四个框里塞:
- 地点管理:地点注册入库、坐标校正、分类打标、上下架状态维护。
- 空间检索:根据用户当前位置和意图词,按距离筛选、排序。
- 文本命中:从用户的一句话里抽出地点类型、约束条件、意图模式。
- 结果话术:把命中的地点组装成适合对话返回的文本和结构化数据。
边界定清楚之后,很多讨论就变得简单了。比如有同事问"要不要支持路径规划路线展示",我说不做,那是另一个模块的活,lingbot-map 只回答"在哪、哪些离得近",不回答"怎么走"。
2. 地点表与坐标系:数据层最容易埋雷
2.1 主表结构设计思路
我把地点表简化成下面这个样子,实际项目里可以根据业务再加字段,但这个骨架扛住了大部分场景:
CREATE TABLE place ( id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL, category TEXT NOT NULL, -- 一级分类:咖啡馆/维修点/营业厅 tags TEXT[] DEFAULT '{}', -- 细粒度标签:可充电、24h、可停车 address TEXT, lat DOUBLE PRECISION, -- 纬度 lng DOUBLE PRECISION, -- 经度 region TEXT, -- 行政区/业务分区 status SMALLINT DEFAULT 1, -- 1在线 0下架 source TEXT DEFAULT 'manual', -- 手动导入/批量导入/接口同步 updated_at TIMESTAMPTZ DEFAULT now() ); CREATE INDEX idx_place_lat ON place(lat); CREATE INDEX idx_place_lng ON place(lng); CREATE INDEX idx_place_category ON place(category);几个细节我特意解释一下:category用短文本而不是中文,便于后面做同义词映射;tags用 Postgres 数组,查询时可以直接用@>操作符做包含判断,比如 "充电 AND 咖啡" 对应tags @> '{可充电}';source字段被很多人忽略,但它对排查"这条数据哪来的"非常关键。
2.2 坐标系统一:一个能让你全线翻车的细节
这是我在这个项目里踩得最深的一个坑。手机 GPS 返回的是 WGS-84 坐标;国内的地图产品大多使用 GCJ-02 加密坐标;还有使用 BD-09 的。三种坐标系在地图上的差距,最严重时能有几百米。如果入库的时候不统一,就会出现一种诡异现象:用户在 A 点,表里明明有个 300 米外的门店,算出来的距离却是 30 公里,搜索直接把它过滤掉了。
所以入库前的第一道校验必须是坐标系统一。我当时的处理策略是:数据库统一存 WGS-84。原因很简单,GPS 端采集到的原始数据就是它,业务算法和距离计算都用它,不出屏也不需要加密。展示的时候,前端如果要叠到某个地图产品上,再做一次坐标转换。
我整理了一个最小转换函数,跑测过很多次,够用:
import math def gcj02_to_wgs84(lng: float, lat: float): # 简化版:利用精度差迭代逼近,实测误差在1米以内 a = 6378245.0 ee = 0.00669342162296594323 dlat = transform_lat(lng - 105.0, lat - 35.0) dlng = transform_lng(lng - 105.0, lat - 35.0) radlat = lat / 180.0 * math.pi magic = math.sin(radlat) magic = 1 - ee * magic * magic sqrtmagic = math.sqrt(magic) dlat = (dlat * 180.0) / ((a * (1 - ee)) / (magic * sqrtmagic) * math.pi) dlng = (dlng * 180.0) / (a / sqrtmagic * math.cos(radlat) * math.pi) wgs_lng = lng - dlng wgs_lat = lat - dlat return wgs_lng, wgs_lattransform_lat和transform_lng是实现细节,网上有很多开源实现,适合抓下来按自己的风格写一遍。注意这里我略过了辅助函数,实际使用时要补全。更重要的是你要理解这段逻辑的用途:不是简单公式替代,而是把已知的 GCJ-02 加密偏移量反算回去,得到一个可靠逼近的 WGS-84 坐标。
2.3 数据导入后的抽查方法
批量导入之后我会做三个动作:第一,随机抽 5 个地点,用手机走到真实位置附近,比对表里的坐标误差;第二,用逆地理编码把库里的 WGS-84 坐标反解析成地址文本,和表里的address字段比对,如果明显对不上,八成是坐标写反了;第三,画一张简易的散点图,人眼扫一遍坐标里有没有孤立的野点——有野点基本就是数据源本身的问题。这套抽查流程每次导入数据都要走一遍,虽然原始,但能拦截掉绝大多数脏数据。
3. 检索链路:从粗筛包围盒到 Haversine 精算排序
3.1 为什么不能上来就全表算距离
最朴素的附近搜索逻辑是:遍历所有地点,逐个算distance(me, place),按距离排序取前 N 个。这个逻辑在几百条数据的时候完全没问题,但地点上万以后,每次对话查询都要扫全表做三角函数运算,响应时间会很快失控。聊天场景对延迟极其敏感,用户多等一秒就明显不耐烦。
所以检索必须分层。我采用的做法是"包围盒粗筛 + 球面距离精算",业界也很常见,直接用就好。
3.2 粗筛:先把候选范围缩到"看起来近"的一小撮
粗筛的思路是:既然要的是几公里内的地点,那就不考虑整个地球,只把用户坐标周围的矩形范围拿出来做候选。例如纬度每 0.01 度大约对应 1.1 公里,那么要查 3 公里内的点,可以先圈一个纬度和经度上下浮动 0.03 度的矩形。
-- :lat :lng 为当前用户坐标 -- 0.03 大约对应 3 公里级别的粗筛窗口 SELECT * FROM place WHERE status = 1 AND lat BETWEEN :lat - 0.03 AND :lat + 0.03 AND lng BETWEEN :lng - 0.03 AND :lng + 0.03;这里有一个容易漏掉的细节:经度在不同纬度上的实际距离不一样,在赤道附近 0.03 度经度约 3.3 公里,在高纬度地区会小很多。所以更稳妥的做法是根据目标服务半径,按当前纬度动态计算经度增量,比如delta_lng = radius_km / (111 * cos(lat))。不过大多数业务场景下,粗筛本来就要留足余量,固定值也不是不能用,但你要知道这个前提。
另一个边界问题是经度过 180 度和 -180 度的情况,粗筛条件要拆成两段 SQL,否则跨日期变更线时会把半个地球的数据都扫进来。我实际处理中一般直接用坐标值,不做跨界特判,但如果你做的是全球业务,这必须处理。
3.3 精算:球面距离才是真正能排序的距离
粗筛拿到候选集之后,再用 Haversine 公式精算两点间的球面距离,这才是真正拿来做排序和过滤的距离值:
import math def haversine_km(lat1, lng1, lat2, lng2): r = 6371.0 phi1 = math.radians(lat1) phi2 = math.radians(lat2) dphi = math.radians(lat2 - lat1) dlmb = math.radians(lng2 - lng1) a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlmb / 2) ** 2 return 2 * r * math.asin(math.sqrt(a))有人问为什么不用两点间的平面欧氏距离,因为纬度和经度的尺度不一样,直接套勾股定理会得到明显错误的排序。生活化理解就是:经度线在赤道附近是 111 公里一度,到哈尔滨地区可能只有不到 60 公里一度,你不换算直接用,距离就从源头错了。
粗筛加精算的组合,核心目的就是减少三角函数计算次数。粗筛在索引上直接做区间比较,很快;精算只对几十条候选执行,完全扛得住。
3.4 排序不等于纯距离排序
距离算出来了,但直接按距离排序往往不满足业务预期。我后来在排序里加了两个调节项:
- 权重置顶:自营网点权重高于合作门店,比如同在三公里内,用户问"营业厅在哪",优先推自营的。
- 营业状态过滤:用户如果明确说"现在能办业务",就要把打烊的降权或排后。
具体实现可以这样:先粗筛,再精算距离,然后在内存里做二次排序:
candidates = [] for p in coarse_places: d = haversine_km(user_lat, user_lng, p.lat, p.lng) if d <= radius_km: candidates.append((p, d)) candidates.sort(key=lambda item: ( -item[0]['weight'], # 权重降序 item[1], # 距离升序 ))为什么不在 SQL 里一次搞完?因为复杂排序逻辑里有业务判断,在应用层改起来更灵活,而且样本量已经被粗筛限制到很小,性能完全不是瓶颈。
4. 自然语言入口:把"附近咖啡馆"变成结构化查询
4.1 聊天文本到查询条件的转换
地点数据检索只是地基,真正让 lingbot-map 跑起来的是入口解析。用户不会说"select category from place where distance < 3km",他们说的是"刚下地铁,想找个能充电的咖啡馆坐会儿"。
我给 lingbot-map 建了一套轻量解析流程:先把句子拆成意图片段、地点类型片段、约束条件片段。意图片段决定检索动作,比如"附近""哪个最近""顺路看下"对应不同排序策略;地点类型片段决定 category;约束条件片段则对营业时间、标签、距离等字段做过滤。落地形式是一份 JSON:
{ "intent": "nearby", "target_category": "咖啡馆", "constraints": { "tags": ["可充电"], "open_now": true, "max_distance_km": 3 } }4.2 同义词映射:核心是业务定义的归类
解析要解决的第一个拦路虎是"同一个东西有无数种叫法"。用户说"修手机的""修手机""手机维修""换屏",这四个短语在 lingbot-map 里要映射到同一个 categoryphone_repair。这类映射不需要上模型,维护一份同义词表就够,而且要允许运营人员直接追加配置,不需要改代码。
我当时整理了一份基础词表,大致长这样:
| 用户说法 | 目标 category | 备注 |
|---|---|---|
| 咖啡、咖啡馆、喝咖啡的、咖啡厅 | cafe | 店名里有"咖啡"的也被映射 |
| 修手机、手机维修、换屏、刷机 | phone_repair | 同义词尽量全 |
| 营业厅、服务网点、门店 | service_store | 业务自定义词 |
| 充电宝、可充电、有插座 | tag:可充电 | 约束标签 |
这里的关键心得是:不要指望一次做全,先在线上积累真实用户说法,每隔两周把没命中的 query 捞出来,人工归类补进词表。词表永远在长,这是正常状态。
4.3 两段式解析:规则先跑,兜底再上大模型
一开始我认为要用大模型做意图识别才够聪明,实测后放弃了单一大模型方案。原因有两点:一是延迟不稳定,偶尔一次几秒钟,对对话场景是致命的;二是同一个"附近"在不同业务上下文里含义可能完全不同,模型输出不受控时维护成本很高。
所以 lingbot-map 用了两段式:第一段用规则和词表做快速解析,大部分常规句式几百毫秒内就能命中;第二段如果解析不到,或者置信度不够,才把文本交给大模型抽取结构化信息,再回来走同一套检索流程。这样既保证日常体验的稳和快,又保留了处理复杂表达的余地。
这段逻辑我用一个简单的状态流程管理,大致是:输入文本 → 先跑意图正则 + 词表匹配 → 成功就继续查询 → 失败就进入模型解析 → 再失败则回复标准兜底话术"我还没搞懂这个位置需求"。
5. 五个真实踩坑点:坐标混用、边界漏点与多轮指代
5.1 坐标系混用导致"一半地点凭空消失"
这是把整个模块搞崩过的问题。上线测试时,同一个坐标,iOS 端上报出来的数据和后台脚本导入的数据对不上,搜索某些区域每次结果都缺一大半。排查过程很痛苦:我先怀疑数据表 join 写错,又怀疑缓存策略,最后是随机抓了一条地点,拿手机走过去跟地图产品上的人工定位点位对比,才发现位置偏了几百米。再查脚本,导入工具里提前做了 GCJ-02 到 WGS-84 的转换,但接口层又转了一次,导致部分数据被"双重修正"。
这个坑的教训写在这里:任何涉及坐标的代码里,只保留一套坐标系,并且每个函数入口都标注坐标系统。我后来在所有坐标字段上都加了后缀注释,快速识别。
5.2 粗筛包围盒把边界地点滤掉了
粗筛窗口设成 0.03 度,服务半径 3 公里,听上去很匹配。但用户站在两个片区的交界处,或者本身就是从很远的地方开车赶过来,粗筛窗口可能把一个实际在服务范围内的点挡在门外。我最初发现的是静态测试没暴露、动态公测时才出现的漏点。
解决办法是"粗筛留出 1.5 倍余量":真实服务半径如果是 3 公里,粗筛窗口按 4.5 公里算,精算时再按真实半径过滤。多出来的候选只多了几十条,代价极小,但边界漏点基本消失。
5.3 多轮对话里"就这家吧""这附近"指代混乱
用户先问"徐汇区的营业厅",机器人回答了几个结果,用户接着说"就这家吧"。如果只解析"这家"两个字,没有任何信息。这种指代消解必须依赖对话上下文,所以 lingbot-map 里给每个会话维护了一个轻量上下文栈,里面至少保存上一轮命中的地点结果列表和用户最后提到的位置。解析"这家/那里/附近那个"时,先从上下文栈里取候选集,再做后续排序。
这个功能不需要很重,一个 dict 就能跑。注意清理机制,长时间不活跃的会话要把上下文丢弃,否则跨天对话会串味。
5.4 经纬度写反和浮点精度问题
经纬度写反是一个很低级但杀伤力很大的错误。lat 范围 -90 到 90,lng 范围 -180 到 180,入库时加一个断言就能避免:
def validate_coord(lat, lng): assert -90.0 <= lat <= 90.0, f"bad lat {lat}" assert -180.0 <= lng <= 180.0, f"bad lng {lng}"浮点精度方面,数据库里不要用 float 的个位数精度存储,至少用 DOUBLE PRECISION。如果只存到小数点后 4 位,对应误差已经到 10 米级别,搜索边界抖动会非常明显,距离排序结果也会不稳定。
5.5 索引失效: where 条件写法比想象中更重要
最后说一个性能排查。粗筛 SQL 最初是我自己写的,看起来用了 lat 和 lng 两个索引,跑起来却很慢。后来用查询计划一看,条件里写成了lat BETWEEN :lat - 0.03 AND :lat + 0.03,这本身没问题,但我在列上套了个函数,比如ABS(lat - :lat) < 0.03,结果索引完全失效,变成全表扫描。
这类问题典型且隐蔽。在实际项目中,要保证范围比较直接作用在裸列上,不要在外面套函数或者计算表达式。如果用了 Postgres 的 PostGIS 几何索引,也要保证查询对象和字段类型保持一致,隐式转换同样会让索引失效。
最后再分享一点体会
lingbot-map 现在跑着的版本是从"两表加一个函数"发展起来的,中途踩的坑大多不是因为功能多,而是因为基础约定没守住。
让我印象最深的一次是某次活动运营临时导入了三百个临时服务点,导入脚本少做了坐标系转换,结果活动当天用户问"我在朝阳公园附近,最近的临时服务点在哪",系统把三百个点全部算到了两三公里外的一个方向,场面一度非常尴尬。后来我把所有导入任务都强制走同一套校验函数,才彻底根治。
如果你也要做类似的东西,我给三条实用建议:第一,坐标系和精度约定在项目第一天就定死,写进 README 最顶部;第二,粗筛和精算分离这个套路直接照抄,不要尝试自己发明更复杂的索引方案;第三,文本命中先用词表跑起来,别一上来上模型,规则解决不了的问题再交给模型兜底。这套组合在大部分对话式位置查询场景下都够用了,扩展性和维护成本也比较平衡。