← 文章 / 编程开发
GIS传道者 7小时前 · 2026-09-06 12:38:19 · 5 阅读

GeoAI实战:别让大模型算距离——用 TypeScript 做一个可复核的空间查询助手


业务同事提出一个需求:

找出到最近公园入口的直线距离超过 800 米的小区,按距离从远到近排序。

一个有用的系统,不应该只回答“建议关注公园覆盖不足的区域”。它应该交出具体小区、对应的最近入口、计算距离,以及可以重新打开核对的数据文件。

这篇文章就完成这件事:用 TypeScript 实现一个受控的 GeoAI 空间查询助手。大模型负责理解问题,程序负责计算,最终输出可复核的空间数据。

800 米只是演示阈值,不是通用规划标准。示例采用合成数据,不代表任何真实城市的调查结果。

配套文件:需要可以私信发我邮箱。


一、先确定 AI 做什么、不做什么

这里的 AI 不负责猜测地图上的距离,也不直接生成并执行 Python、SQL 或 Shell。它只把一句话转换成一个有限的查询计划:

{
  "status": "ready",
  "operation": "nearest_park_entrance",
  "metric": "great_circle_m",
  "threshold_m": 800,
  "comparison": "gt",
  "sort": "desc",
  "reason": ""
}

这份计划的意思是:针对现有的小区点和公园入口点,计算最近球面距离,筛选严格大于 800 米的结果,再按距离降序排列。

整个流程是:

自然语言
   ↓
结构化查询计划
   ↓
程序校验 + 人工核对
   ↓
确定性空间计算
   ↓
GeoJSON 结果 + 核对记录

第一版只有一个空间工具,不需要先搭建复杂的多智能体系统。这样,理解错误和计算错误可以分别定位,而不是都藏在一段自然语言回答里。

一个需要说清楚的问题:固定执行“超过 800 米”这条规则,完全不需要 AI。

AI 的价值在于提供自然语言入口,让用户通过不同表达调用同一套可靠工具。若业务只需要一个固定筛选按钮,直接做表单会更简单。

二、先跑通不需要模型的部分

配套工程包含完整 TypeScript 源码、两份合成 GeoJSON、测试和预期结果。工程没有第三方 npm 依赖,不需要先运行 npm install

示例要求 Node.js 22.16 或更高版本,使用 Node.js 的 type stripping 执行 TypeScript。这一功能会移除类型标注,但不会代替静态类型检查。

解压工程后运行:

cd geoai-practical-demo

npm test
npm run demo

demo 使用固定查询计划,目的是先确认数据读取、空间计算和导出正常。它没有调用大模型,也不是在模拟大模型已经理解了问题。

在配套合成数据上,结果如下。距离为实际计算值,表格仅在展示时保留两位小数。

小区

最近公园入口

球面距离,单位:米

是否满足 > 800 米

小区 D

P2

1348.17

小区 B

P1

1155.57

小区 A

P1

288.89

小区 C

P2

288.89

每次成功运行会生成一个独立结果目录,里面有三个文件:

out/runs/<本次运行编号>/
├── all_results.geojson   # 全部小区及计算字段
├── selected.geojson      # 仅入选的小区
└── audit.json            # 输入摘要、查询计划和计算口径

all_results.geojson 保留全部小区,是为了能够检查“哪些没有入选、为什么没有入选”。audit.json 则记录输入文件的 SHA-256、查询计划、结果数量和距离模型,便于追溯。

因此,一份合适的结论是:

在本次输入的 4 个小区代表点中,有 2 个到最近已提供公园入口的球面距离严格大于 800 米。

不要把它改写成“这座城市有两个小区缺乏公园服务”。代码没有验证全市数据是否齐全,也没有计算实际步行路线。

三、数据准备比提示词更重要

工具只需要两份文件:

data/residences.geojson       # 小区代表点
data/park_entrances.geojson   # 公园入口点

一条小区记录可以是:

{
  "type": "Feature",
  "properties": {
    "id": "A",
    "name": "小区 A"
  },
  "geometry": {
    "type": "Point",
    "coordinates": [120.003, 30]
  }
}

完整文件应把这些 Feature 放在 FeatureCollection 的 features 数组中。按照 GeoJSON RFC 7946,坐标采用 WGS84,经度在前、纬度在后,单位是十进制度。

这里有三个容易改变结论的细节。

入口不等于公园中心

如果问题涉及到达公园,应明确候选点是不是实际入口。

把公园中心点当入口,会改变计算对象;提示词再好,也无法自动修正这个定义。最直接的做法,是在数据准备阶段就明确:每个候选点代表一个什么位置,以及这个位置是否经过核验。

代表点不等于整个小区

小区大门、楼栋位置和几何中心回答的是不同问题。

本例对每个小区只使用一个代表点,不能进一步推断所有住户的到达距离。需要更细的分析时,应先调整数据粒度,而不是让模型把一个点的结果扩展成整片区域的结论。

研究区边界不应该随手变成设施筛选边界

假设一个小区位于研究区边缘,最近的公园入口刚好在边界外。如果预处理时把边界外的入口全部删除,这个小区的最近距离就可能被高估。

还要区分两件事:

判断“800 米内有没有入口”,与输出“所有候选入口中最近的是哪一个”,所需要的数据范围并不完全相同。

配套校验器会检查必要字段、重复 ID、点几何和经纬度范围。但坐标数值合法,不等于实际坐标参考系正确。仅把文件标注为 WGS84,不会自动完成坐标转换。

四、距离计算必须独立于模型

本例用 Haversine 公式计算球面大圆距离,并固定球半径为 6,371,008.8 米。Turf.js 的 distance 文档也采用 Haversine 方法;这里提供一个小型独立实现,便于在没有 npm 依赖时运行和测试。

下面的函数就是工程中的计算核心。调用前,由输入校验器检查坐标:

type XY = [number, number];

const EARTH_RADIUS_M = 6_371_008.8;

function greatCircleMeters(a: XY, b: XY): number {
  const rad = Math.PI / 180;
  const lat1 = a[1] * rad;
  const lat2 = b[1] * rad;

  const sinLat = Math.sin((lat2 - lat1) / 2);
  const sinLon = Math.sin((b[0] - a[0]) * rad / 2);

  const h =
    sinLat * sinLat +
    Math.cos(lat1) * Math.cos(lat2) * sinLon * sinLon;

  // 避免浮点误差使数值略微超出合法范围。
  const clamped = Math.max(0, Math.min(1, h));

  return (
    2 *
    EARTH_RADIUS_M *
    Math.atan2(
      Math.sqrt(clamped),
      Math.sqrt(1 - clamped)
    )
  );
}

随后,程序为每个小区遍历候选入口,保存距离最小的一个,再根据已确认的阈值和比较符号筛选。

最近入口 ID、原始距离和入选状态都会进入结果文件,而不是只留下最终数量。

这里还有一个容易被忽略的实现细节:

先比较,再四舍五入。

799.999 米和 800.001 米可能显示成同一个数字,但它们对“严格大于 800 米”的判断不同。展示精度不能改变筛选条件。

本文所说的“直线距离”是球面近似,不是沿道路行走的距离,也不是测绘级椭球距离。需要更高精度时,应明确量算模型再替换工具,而不是让模型自由选择。PostGIS 的 geography 距离默认在椭球上计算,不能不加说明地与本例视为完全相同的数值口径。

五、再接入自然语言,但不要自动执行

示例使用 Ollama 的本地模型接口。Ollama 支持把 JSON Schema 放入请求的 format 字段,约束模型输出结构;返回内容仍需经过程序校验。

安装并启动 Ollama 后,下载示例模型:

ollama pull qwen3:4b

qwen3:4b 是官方模型库中的一个可用标签,这里仅作为接入示例,不表示它是最新或最佳选择。桌面应用已启动本地服务时,不必重复启动;没有服务时,可在另一个终端运行 ollama serve

接着生成计划:

npm run plan -- "找出到最近公园入口的直线距离超过800米的小区,按距离从远到近排序。"

这条命令只写入 out/plan.json,不会立即计算。

先检查 status 是否为 ready,再核对下面几个字段:

threshold_m = 800
comparison  = gt
metric      = great_circle_m
sort        = desc

确认与原问题一致后执行:

npm run execute -- --confirm

为什么保留这次确认?

因为 JSON 格式正确,不代表语义正确。

模型可能把“不超过”理解成“超过”,也可能错误处理公里与米的换算。这类错误可以通过结构校验,却会改变分析结果。

程序还保留两个非执行状态:needs_clarification 表示问题缺少明确条件;unsupported 表示超出了现有工具能力。

用户问题

应有行为

直线距离不超过 0.8 公里

转换为 800 米,比较符号为 lte

直线距离至少 800 米

比较符号为 gte

附近哪些小区离公园比较远

要求补充距离定义和阈值

步行超过 800 米的小区

拒绝用本工具回答,需要路网分析

超过 800 米且人口大于 1000

拒绝部分执行,因为没有人口数据或对应工具

最后一条尤其重要。

一个不可靠的助手可能忽略“人口大于 1000”,只执行自己能完成的距离筛选,再给出看似完整的答案。宁可明确说明不支持,也不要悄悄把用户的问题换成另一个问题。

在配套实现中,模型请求只接收用户问题、固定数据目录说明和 Schema,不接收 GeoJSON 坐标文件。模型也不能通过返回一个文件路径,决定程序读取什么数据。

执行阶段还会重新核对输入文件的 SHA-256。数据在生成计划后发生变化,就必须重新生成并检查计划。

这些约束减少了模型直接影响执行过程的范围,但并不证明所有提示词攻击或语义误解都已经解决。

六、如何证明结果可信,而不只是“成功运行”

本工程的空间计算和校验部分已在 Node.js 22.16.0 中实际运行,29 项测试通过,并另行通过 strict 模式静态类型检查。

测试覆盖同点距离、日期变更线、比较边界、重复 ID、空入口数据、非法字段和合成样例结果等。另有三项命令行集成检查,验证了未确认不执行、确认后正确导出,以及输入文件变化后拒绝执行。

但验证边界必须公开:

本次未运行真实 Ollama/Qwen 推理。接口测试使用模拟响应,因此不能据此宣称自然语言解析准确率或端到端成功率。

工程另外提供了 10 条人工定义的模型回归样例。启动本地模型后,可以运行:

npm run eval:llm

这个命令会真实调用模型,逐条比较状态、单位换算、比较符号和排序,并保存结果。它不执行空间分析。

即使这 10 条全部通过,也只说明通过了这组定向测试,不代表对所有中文问题都有同样效果。

再做一次不依赖模型的人工抽查

在 QGIS 中加载两份输入 GeoJSON 和本次生成的 selected.geojson,核对小区位置、入口位置和属性字段。QGIS 支持 GeoJSON,也支持从文件浏览器拖入图层。

抽查的重点不只是“距离看起来合理”,还包括:最近入口有没有选错,入口是否真实存在,候选数据是否漏掉了更近的点。

球面与椭球设置不同的量算结果可能略有差异,复核时要先对齐计算口径,而不是看到小数不同就认定程序有错。

七、换成真实项目时,先补齐这几件事

数据版本和业务口径

记录数据来源、更新时间、实际坐标参考系、代表点定义、入口开放状态和覆盖范围。

结果文件能追溯到输入文件,不等于输入文件本身准确。

例如,程序可以完全正确地算出最近入口,但那个入口已经关闭。此时问题不在算法,也不在模型,而在数据与业务现实脱节。

与需求匹配的空间工具

若问题变成“步行 15 分钟内能到达哪些公园”,就需要补充可步行路网和通行规则,改用相应的路网分析。

不要把圆形范围换个名字,就叫作步行服务区。只有工具真正支持新的距离或时间定义,才能扩展助手的能力说明。

规模和数值口径

教学实现逐个比较小区与入口,复杂度为 O(N×M)。工程把 200 万次点对计算设为保护上限,这只是人为限制,不是性能测试结论。

更大规模可引入空间索引和 PostGIS。ST_DWithin 支持利用可用索引做距离条件筛选,geography 版本以米为单位,但要注意其默认椭球口径,不能无说明地替换本文的球面计算。

权限和执行边界

本例只是本地命令行工程,没有实现多租户鉴权或生产服务防护。

接入业务平台时,应由服务端决定用户可访问的数据和工具,而不是把这些权限交给模型生成的参数。模型负责表达意图,不负责授予权限。

第一版不需要回答所有 GIS 问题。更值得交付的是:对一个定义清楚的问题,得到一个能重复计算、能解释、能发现错误的结果。

结语:AI 负责理解,证据负责结论

这个案例最重要的产物,不是聊天框,而是从原问题到查询计划、输入数据、计算结果和核对记录的完整链条。

一个真正有帮助的 GeoAI 工具,应该让使用者回答三个问题:

它理解的是不是我的需求?它究竟用了什么数据和算法?我能不能不依赖模型再算一遍?

当这三个问题都有明确答案,“AI + GIS”才不只是一个演示,而是一个可以继续接入真实业务的起点。

延伸阅读

进一步实现结构化参数解析,可以阅读 Ollama 的 Structured Outputs 文档;检查空间数据交换规范,可以阅读 GeoJSON RFC 7946;将距离筛选迁移到数据库时,可以阅读 PostGIS 的 ST_DWithin 与 ST_Distance 文档。


English Title

Practical GeoAI: Build a Verifiable Spatial Query Assistant with TypeScript

Abstract: This tutorial builds a bounded GeoAI workflow that converts a natural-language request into a validated query plan and filters residential locations by their spherical distance to the nearest supplied park entrance. It separates language interpretation from deterministic spatial computation, includes synthetic data and runnable TypeScript code, and exports GeoJSON results with an audit record. The article distinguishes straight-line proximity from walkability and clearly separates tested spatial logic from unverified live-model performance.

Keywords: GeoAI, TypeScript, spatial analysis, structured outputs, Ollama, GeoJSON, validation, reproducibility.


原始来源: GIS传道者

评论 (0)