十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

不依赖大模型的地理工具:确定性空间计算实现稳定地址解析

不依赖大模型的地理工具:确定性空间计算实现稳定地址解析 最近做位置类业务的团队很容易陷入一种误区拿到地址解析、区域匹配、兴趣点标注的需求第一反应是先接一个大模型。好像地理工具不带上模型就缺少“智能感”。但如果你真的负责一个每天要跑几百万次位置匹配的服务最该追求的品质不是“会聊天”而是“每次都能算出同一个结果”。标题里的Geo tool with no LLM run我理解成两层含义一是这个地理工具本身可以在完全不运行大模型的情况下完成核心工作二是在架构上刻意让大模型离开主链路只把模型留给更需要语义理解的可选环节。这是一个非常值得讨论的设计选择尤其当业务涉及交易风控、物流路径、行政区域打标、到店服务半径判断时“无 LLM 的主链路”往往比“什么都用模型生成”更稳、更省、更可解释。这篇文章会从架构判断出发讲清楚为什么地理信息工具的核心链路不推荐依赖 LLM然后给出一套完整的最小示例代码。你可以照着跑通一个支持坐标定位、文本城市解析、距离半径过滤的地理小工具整个过程不需要调用任何大模型接口也不需要联网推理环境。1. 为什么地理工具要刻意“不运行大模型”很多人觉得地址识别和位置理解是自然语言问题于是直接把文本扔给大模型。但从实际项目看地理工具要解决的任务绝大多数不是语义理解而是确定性计算。坐标是否落在某个多边形内、两个点之间的球面距离是多少、一个地址是否属于某个城市这些问题的正确答案是客观的只要数据源准确规则明确结果就应该唯一。把这类任务交给 LLM会遇到几个真实问题。第一是输出不稳定。同一个坐标同一段地址模型可能因为温度参数、上下文长度、模型版本变化给出不同结构。位置类接口往往要落库、要审计、要按批次重跑一旦结果不可复现后续数据比对和问题追溯都会很痛苦。第二是计算幻觉。模型并不会真正执行几何计算它只是根据训练语料预测下一个词。当被问到“这个点在哪个区域里”时模型可能会从记忆里抽出一个“看起来像”的答案但那个答案并不是经过空间计算得到的。对于风控和计费类场景这种幻觉不可接受。第三是成本不可控。如果每次请求都走模型接口批量清洗几千万条位置数据会产生很高的 token 开销而其中大量计算用哈希表、空间索引和几何库几毫秒就能完成。所以更合理的判断是**地理工具的核心链路是否运行 LLM不应该由技术潮流决定而应该由业务性质决定。**行政区划匹配、多边形包含判断、坐标距离计算、轨迹路网匹配这些本质上属于空间计算应该交给 Shapely、PostGIS、Geopandas 这类确定性工具。大模型只在“地名拼写极其混乱、需要语义归一化”这类问题上才值得进入前置处理环节。2. 不运行 LLM 的 geo tool 适合谁用不是所有地理工具都需要去掉 LLM。反过来也不是所有带 LLM 的地理功能都是画蛇添足。先看清楚适用场景再决定架构比盲目跟风重要得多。适合做成no LLM run的场景往往具备以下几个特征输入是结构化或半结构化的坐标、城市字段、区域编码。业务要求结果可复现比如批量回刷历史数据。请求量比较大单次响应的成本必须足够低。错误需要能解释出了问题需要能定位是数据问题还是规则问题。典型例子包括给用户 LBS 记录打城市标签、判断坐标是否进入某片商业围栏、按半径筛选附近的配送站、清洗历史地址数据并补全区域编码。这些任务直接用行政区划 GeoJSON 配合空间索引就能完成把结果输出成标准字段团队也好理解。不适合做成完全无 LLM 的场景也有例如用户输入一段极其口语化的描述“公司楼下那个卖煎饼的摊位”要转成地图 POI这种任务涉及强常识推理传统规则很难覆盖。但即便如此多数项目也不需要让 LLM 直接参与核心计算。更稳妥的做法是先由 LLM 做候选提取再由空间计算校验模型建议和算法结论互相印证不能只信模型。下表可以直接用于架构选型时的判断业务场景是否建议主链路跑 LLM推荐实现方式批量地址打城市标签不运行 LLM本地城市词表 区域GeoJSON坐标围栏判断不运行 LLMShapely 空间索引与多边形判断门店或配送站半径筛选不运行 LLMHaversine 距离公式或投影坐标计算口语化地名理解可让 LLM 辅助LLM 提取候选 空间库校验地图问答助手需要 LLM知识检索结果约束生成避免自由发挥这里的关键不是“绝对不用模型”而是把是否运行 LLM 变成可配置、可摘除的选项而不是主流程中绕不开的重组件。3. 核心架构先分层再决定哪一层不跑 LLM一个清晰的 no-LLM 地理工具通常可以分成四层。数据层负责持有区域边界、城市词表、POI 列表。这些数据应该是静态、可版本化的最好能随发布包一起部署。不要把行政区划边界数据放在接口里每次实时拉取既慢又不稳定。更推荐构建阶段生成一份 GeoJSON 或 Parquet随程序一起发布数据更新走 CI/CD而不是运行时临时下载。索引层负责把坐标变成可检索的空间对象。用 Shapely 构建 STRtree或者用 PostGIS 的 GiST 索引本质上都是把数十万甚至上百万个多边形组织起来让一次点查询不必遍历全部图形。没有这一层匹配性能会非常难看。计算层负责真正的地理运算包括多边形包含判断、球面距离计算、投影转换、坐标与区域的映射。这一层只依赖成熟地理算法库不依赖网络不依赖模型也不依赖外部 API。服务层在最外面负责接收参数、组装结果、处理缓存和异常。这一层可以预留两个入口一个纯规则入口所有计算都走本地算法另一个可选增强入口当规则判断置信度不足时才把文本提取这类任务交给外部模型。但即便引入模型重试、缓存、空值兜底也仍然由服务层控制。这种分层的好处是LLM 即便未来要接也只是服务层一个旁路模块不会反过来污染核心计算的正确性。新人接手代码时看到主链路上没有模型调用也能很快理解系统在做什么。4. 环境准备与目录结构本文演示代码使用 Python 3 和 Shapely。Shapely 负责几何对象构建和空间索引代码本身不依赖 GeoPandas方便你快速理解核心逻辑。建议先创建独立虚拟环境python3 -m venv .venv source .venv/bin/activate pip install shapely如果你希望直接读取大批量 GeoJSON 并做字段分析也可以额外安装 GeoPandaspip install geopandas项目目录可以这样组织geo-tool-no-llm/ ├── main.py ├── core.py ├── geo_math.py ├── text_utils.py ├── data_demo.py └── data/其中core.py放空间索引与区域匹配逻辑geo_math.py放距离计算text_utils.py放文本城市解析data_demo.py负责生成一份可运行的演示区域数据main.py组装命令入口。这样拆开主要是为了让每一层都能单独测试。实际工程中你还可以把不同模块对应到数据层、索引层、计算层和服务层后续替换数据源或增加模型插件时不需要重写整个文件。5. 完整实现一个不跑 LLM 的地理小工具下面从数据准备开始逐步实现一个最小可运行的 geo tool。这个工具会提供三个能力把一个经纬度点匹配到区域名称从非结构文本中提取城市名称按距离过滤给定中心点附近的 POI。整个过程完全不运行 LLM。5.1 准备演示区域数据为了避免读者先去找复杂的行政区划文件我用一个脚本生成两份演示区域数据。注意这里生成的坐标只是为了跑通流程不代表任何真实边界。# 文件路径data_demo.py import json from pathlib import Path DEMO_REGIONS [ { type: Feature, properties: {name: demo-alpha}, geometry: { type: Polygon, coordinates: [[[116.0, 39.5], [116.6, 39.5], [116.6, 40.1], [116.0, 40.1], [116.0, 39.5]]] }, }, { type: Feature, properties: {name: demo-beta}, geometry: { type: Polygon, coordinates: [[[117.0, 40.0], [117.8, 40.0], [117.8, 40.6], [117.0, 40.6], [117.0, 40.0]]] }, }, ] DEMO_POIS [ {name: point-a, lng: 116.35, lat: 39.9}, {name: point-b, lng: 116.31, lat: 39.81}, {name: point-c, lng: 117.5, lat: 40.2}, ] DEMO_CITIES [北京市, 上海市, 广州市] def ensure_demo_data(data_dir: Path): data_dir.mkdir(parentsTrue, exist_okTrue) region_path data_dir / regions.geojson if not region_path.exists(): region_path.write_text( json.dumps({type: FeatureCollection, features: DEMO_REGIONS}, ensure_asciiFalse), encodingutf-8, ) return region_path这段代码里的DEMO_REGIONS是两个简单的四边形区域。真实项目里你应当把这里换成有效的行政区划或业务围栏 GeoJSON通常是文件或数据库中读取发布阶段就把数据固定下来避免运行期不确定。5.2 空间索引与区域匹配接着实现核心的空间匹配逻辑。先构建一个区域几何列表再用STRtree建立空间索引。查询点时先通过索引缩小候选范围再对候选区域做精确的包含判断避免每个点都遍历全量多边形。# 文件路径core.py import json from pathlib import Path from shapely.geometry import Point, shape from shapely.strtree import STRtree class GeoIndex: def __init__(self, geojson_path: Path): with open(geojson_path, r, encodingutf-8) as f: feature_collection json.load(f) self.region_names [] geometries [] for feature in feature_collection[features]: self.region_names.append(feature[properties][name]) geometries.append(shape(feature[geometry])) self.tree STRtree(geometries) def locate(self, lng: float, lat: float): point Point(lng, lat) candidate_indexes self.tree.query(point) for idx in candidate_indexes: # 对于边界采样点用 covers 比 contains 更符合业务直觉 if self.tree.geometries[idx].covers(point): return { matched: True, region: self.region_names[idx], } return {matched: False, region: None}在 Shapely 2.x 中STRtree.query()返回与查询对象相交的几何候选索引。索引只能帮忙快速缩小范围最终是否算作命中还需要用covers或contains做精确判断。这里选择covers是因为边界上的点通常也应该归属于该区域边界归属在很多业务中会直接影响到订单计费或网格分配。5.3 从文本中提取城市名城市提取并不复杂。做法是维护一份规范城市词表匹配时先做基本文本清洗再按名称长度做最大匹配。最大匹配的意思是如果文本里同时出现“北京市”和“北京”优先取更长的“北京市”因为更长表示信息更完整。# 文件路径text_utils.py import re def clean_address_text(raw_text: str) - str: 只处理格式不改变语义。 if not raw_text: return text raw_text.strip() text text.replace(, ,) text re.sub(r\s, , text) return text def extract_city(text: str, city_list): cleaned_text clean_address_text(text) candidates [] for city in city_list: if city in cleaned_text: candidates.append(city) if not candidates: return None # 相同文本中出现多个城市名时取最长的那个 candidates.sort(keylen, reverseTrue) return candidates[0]你可能会问真实地址里经常有“朝阳区”和“北京市朝阳区”这种规则匹配能行吗答案是文本是否包含城市词只是一个快速召回动作真正判断地址有效性最终还是要依赖城市词表和业务地址库。如果发现大量地址都匹配不上优先检查发数方的数据规范而不是无脑换模型。5.4 距离计算与半径过滤第三个能力是给定中心点和半径返回半径范围内的 POI。这里用球面距离公式 Haversine 计算两点距离不调用外部接口不运行模型也不依赖地图服务。# 文件路径geo_math.py import math EARTH_RADIUS_METERS 6371008.8 def haversine_distance_meters(lng1: float, lat1: float, lng2: float, lat2: float) - float: rad_lat1 math.radians(lat1) rad_lat2 math.radians(lat2) delta_lat math.radians(lat2 - lat1) delta_lng math.radians(lng2 - lng1) a math.sin(delta_lat / 2) ** 2 math.cos(rad_lat1) * math.cos(rad_lat2) * math.sin(delta_lng / 2) ** 2 c 2 * math.asin(math.sqrt(a)) return c * EARTH_RADIUS_METERS def filter_by_radius(lng: float, lat: float, radius_meters: float, pois): result [] for poi in pois: distance haversine_distance_meters(lng, lat, poi[lng], poi[lat]) if distance radius_meters: result.append({name: poi[name], distance_m: round(distance, 1)}) result.sort(keylambda item: item[distance_m]) return resultHaversine 公式把地球近似成球体在几十公里范围内做半径筛选误差通常可以接受。更精确的工程做法是使用投影坐标系或者 Vincenty 公式但本文只为演示通用思路所以保留这个公式实现。5.5 组装命令行入口最后组装一个命令行入口同时展示区域匹配、城市解析、半径过滤三个能力。这里使用 Python 标准库argparse不额外引入 Click 等第三方库方便你直接复制运行。# 文件路径main.py import argparse import csv import json from pathlib import Path from core import GeoIndex from data_demo import DEMO_CITIES, DEMO_POIS, ensure_demo_data from geo_math import filter_by_radius, haversine_distance_meters from text_utils import extract_city def load_pois(csv_path): if not csv_path: return DEMO_POIS result [] with open(csv_path, r, encodingutf-8) as f: reader csv.DictReader(f) for row in reader: result.append({name: row[name], lng: float(row[lng]), lat: float(row[lat])}) return result def main(): parser argparse.ArgumentParser(descriptionGeo tool with no LLM run) parser.add_argument(--point, requiredTrue, help经纬度例如 116.3,39.8) parser.add_argument(--text, default, help待解析城市文本) parser.add_argument(--radius-km, typefloat, default20.0, help过滤半径单位千米) parser.add_argument(--geojson, typePath, defaultNone, help区域GeoJSON路径) parser.add_argument(--pois, typePath, defaultNone, helpPOI CSV路径) args parser.parse_args() lng_text, lat_text args.point.split(,) lng float(lng_text.strip()) lat float(lat_text.strip()) if args.geojson: region_path args.geojson else: region_path ensure_demo_data(Path(data)) geo_index GeoIndex(region_path) locate_result geo_index.locate(lng, lat) city_result None if args.text: city_result extract_city(args.text, DEMO_CITIES) pois load_pois(args.pois) nearby_pois filter_by_radius(lng, lat, args.radius_km * 1000, pois) print(input point:, lng, lat) print(region match:, locate_result) if city_result: print(city extracted:, city_result) print(nearby pois in %s km: % args.radius_km) for poi in nearby_pois: print( -, poi) if __name__ __main__: main()main.py把坐标分割后传给区域匹配索引把文本传给城市解析函数再把 POI 列表传给半径过滤函数。如果--geojson没有指定就自动生成一份演示区域数据保证首次运行也能直接看到效果。6. 运行演示并验证输出在项目根目录执行python main.py --point 116.3,39.8 --text 我住在北京市朝阳区某街道如果一切正常你会看到类似下面的输出input point: 116.3 39.8 region match: {matched: True, region: demo-alpha} city extracted: 北京市 nearby pois in 20.0 km: - {name: point-b, distance_m: 11812.3} - {name: point-a, distance_m: 20718.7}验证的重点不是“结果好看”而是可复现。连续运行两次三次输出应该完全一致。这正是 no-LLM 主链路最重要的价值之一。如果失败先检查几个地方data/regions.geojson是否生成命令是否真的在项目目录下执行Shapely 是否安装成功。也可以先单独执行python -c import shapely; print(shapely.__version__)确认 Shapely 能正常导入再继续排查数据文件路径。7. 常见问题与排查思路很多首次尝试自建 geo tool 的团队遇到的问题并不一定在算法本身而更多在数据、边界和工程习惯上。我整理了几个高频问题问题现象可能原因排查方式解决方案所有点都匹配不到区域GeoJSON 坐标顺序写反检查坐标经纬度顺序确认是“经度,纬度”统一数据写入约定并增加单元测试边界上的点归属不稳定使用了 contains对边界点不友好检查命中点在边界还是在外部需要包含边界时改用 covers城市文本提取总是为空城市词表和实际地址口径不一致打印清洗后的文本检查地名用词扩充词表或在上游做地址规范化数据量太大查询变慢没有使用空间索引查看匹配函数是否全量遍历使用 STRtree 或数据库空间索引生产环境结果和本地不一致数据文件不是随版本发布运行期被更新对比两端数据文件哈希数据文件纳入版本管理禁止运行期改动这里特别想说一下数据文件版本化的问题。地理位置工具的正确性不仅取决于算法还取决于区域数据。如果发布的 GeoJSON 在线上被运维手动覆盖过一次点匹配结果立刻就变了。数据出问题往往比代码出问题更难排查因为现象看起来像是算法坏了。所以从一开始就要让数据路径、数据版本、数据更新流程成为工程的一部分。8. 最佳实践与工程建议如果你准备把“无 LLM run 的 geo tool”落地到真实项目下面几条经验值得参考。第一把空间数据纳入版本管理。行政区划、商圈多边形、门店围栏这类数据要放到独立的仓库或制品库中每次变更都要有 commit、有 diff、有回滚能力。不要直接在生产服务器上修改 GeoJSON。一个好的中间表示是 GeoJSON 或 GeoParquet读取完后在内存中构建空间索引发布包中固定一份数据快照。第二对匹配结果加上“数据版本号”和“算法版本号”字段。同一个点在行政区划 V2025.1 和 V2025.2 里的匹配结果可能不同这很正常。返回结果里带上版本号数据团队在跑数时就能追查出哪一批数据是基于旧版本生成的避免上线前后口径不一致。第三幂等与缓存要分开设计。幂等是指同一个输入在同样的数据版本下永远产生同样的输出。缓存是性能优化手段两者不要混在一起。对于热门的坐标点可以用 Redis 做短时间缓存但过期策略要明确。对于批量回刷最好关闭动态缓存直接按批次跑确保整批数据使用同一个数据快照。第四错误不要静默吞掉。区域匹配不到时返回结果要区分“确实不在任何区域”和“数据缺失”两种状态。这两个状态在业务上的意义完全不同前者可能是用户真的在覆盖范围外后者可能是你的区域数据漏掉了某块。第五LLM 如果需要接入只能作为旁路增强。比如地名很不规范时先用规则召回候选再由模型对候选做消歧最后仍由空间计算确认坐标。要记住模型只是提供建议是否采用建议的判断权应该交给算法和规则层。生产环境里还要给 LLM 调用加超时、熔断、预算控制和审计日志避免一个外部接口超时拖垮整个地理服务。9. 什么时候才需要考虑引入 LLM尽量别把话说死。虽然本文强调主链路不要跑 LLM但在特定条件下加入 LLM 是合理的。用户输入包含大量口语化描述而且没有标准地址字段时可以引入一个轻量识别层。例如“公司北门往东走一百米那个饮料店”这种描述规则方法很难覆盖。此时可以让模型先输出候选的语义要素再用地图检索和空间计算去验证。当且仅当规则匹配置信度较低时才触发这个模型侧模块而不是每个请求都调用一次模型。这些模型侧调用要满足几个工程约束单次超时可控失败后能自动降级回规则链路结果结构化由后端的空间校验逻辑决定是否采纳调用记录完整方便后续统计“规则失败率”和“模型挽救率”。如果模型效果并不显著也可以直接下线。核心思想是保留一条随时可以完全运行、不依赖外部服务的基线路径所有增强能力都构建在这条基线之上。这类工具有一个很好的特性随着区域数据越来越规范、城市词表越来越全规则链路能解决的问题比例会越来越高。原本看起来“必须用大模型才能理解”的地址很多只是数据录入格式差。先做字段清洗和词表建设再评估模型的价值会比一上来就接模型更接近问题的本质。真正值得投入的往往是数据质量而不是模型参数。如果你接下来要研究更深入的方向可以从这几条线入手把区域数据切换到 PostGIS用 SQL 完成空间计算尝试 GeoPandas 做批量回刷和高性能空间连接研究投影坐标系与距离计算在不同纬度下的误差控制再进一步构建一个完整的地址标准化服务和配套评测集把规则链路与模型链路放在同一个评测框架里反复对比。这样的路线比单纯追逐“是否引入大模型”这个表象更有工程价值。
返回列表