# 贵州省 POI 空间网格采集与查询联合设计 > 范围:高德 POI 全省采集 + KG nearby 查询共用一套 H3 网格的完整方案 > 关联: > - 现有空间 KG 总体设计 `docs/kg-redesign/spatial_kg_design.md` > - 召回参数实测 `docs/reports/spatial_h3_resolution_sweep_result.md` > - 高德采集器代码 `app/agents/gaode_connector.py` --- ## 1. 目标与原则 ### 1.1 目标 1. 全省覆盖:贵州省 176,167 km² 内所有公开 POI 都能被有计划地采集。 2. 不丢数据:高德接口的截断行为可被识别,触发自适应下钻补采。 3. 查询高效:nearby 检索(500m / 1km / 3km)保持 sub-ms 级召回。 4. 采集与查询零耦合:同一份 POI 表 / 节点既服务采集任务调度,也服务实时 nearby。 ### 1.2 设计原则 - **同源 H3**:采集网格和查询网格使用同一套 H3 cell,仅分辨率不同。H3 各分辨率严格嵌套(1 个 r6 = 7 r7 = 49 r8 = 343 r9),天然不冲突。 - **多档预算**:POI 落盘时一次性算齐 `h3_r6 ~ h3_r10` 五档索引,查询期零计算。 - **自适应密度**:默认 r6 起手,单 cell × typecode 触发硬上限后下钻到 r7 / r8 / r9。 - **形状分工**:采集用 hex 多边形顶点串调高德;查询用 hex k-ring 召回再做圆形精确过滤。 - **单点先做对**:先把"单 cell × 单 typecode"的采集做到位(识别截断、正确去重),再讨论全省并发和调度优化。 --- ## 2. 高德接口关键约束(官方) 以下数据来自高德开放平台 Web 服务 API 官方文档实查,是本方案所有阈值和上限的依据。 | 约束项 | 取值 | 来源 | |---|---|---| | 单次 polygon 查询总条数上限 | **200 条**(`page_size` 25 × max `page` 8)| Web 服务 API 搜索 POI 文档 | | `offset` / `page_size` 最大值 | 25 | 同上 | | `count` 字段含义 | 本次返回条数,**不是** polygon 内全量估计 | 同上 | | polygon 顶点格式 | `lng,lat` 用逗号,顶点对用 `\|` 分隔,小数 ≤ 6 位 | 同上 | | polygon 非矩形 | **首尾顶点必须相同**(闭合)| 同上 | | `types` 多 typecode | 用 `\|` 分隔,但**共享** 200 条配额 | 同上 | | 个人开发者 key 配额 | 500 次 / 日 | 控制台说明 | | 企业开发者 key 配额 | 控制台查 + 工单调整 | 同上 | | v3 vs v5 | v3 稳定通用;v5 (`/v5/place/polygon`) 在企业试用阶段,字段更全(用 `show_fields` 控制)| 文档与发布说明 | **两个最容易踩的坑**: 1. 200 条是接口硬天花板,与 typecode 或 `extensions` 无关。多 typecode 合并查询只会让密度高的类型挤掉低密度类型,**不要合并**。 2. `count` 字段不能用于"是否被截断"的事前预判,必须用"翻完页后的实际累计数 + 最后一页是否仍为满页"作为事后兜底判据。 --- ## 3. 整体架构 ``` 贵州省 GeoJSON | | h3.polygon_to_cells(geojson, res=6) v ~4,900 个 r6 cell | v collect_grid 任务表 (cell_id × typecode 行级粒度) | +-----------------+-----------------+ | | v | [采集侧] | worker: | hex_to_amap_polygon(cell) -> 高德 API | 拉满 8 页或 < 25 条提前结束 | fetched >= 180 且末页满 -> split_cell | | v | | 统一 places 表 / 节点 | { gaode_poi_id, lng, lat, | h3_r6..h3_r10, typecode } | | | v | +-----------------+-----------------+ | v [查询侧] nearby_pois(lng, lat, radius): 500m / 1km -> h3_r9 + k-ring 3km -> h3_r8 + k-ring > 3km -> h3_r7 + 动态 k WHERE h3_rN IN (cells) 再做 haversine 圆形精确过滤 ``` 三层清晰分工: - **采集侧**:以 cell × typecode 为任务粒度,自适应下钻,向 places 表写入。 - **存储层**:唯一一张 places 表 / 一组 Place 节点,承担去重、索引、多分辨率属性。 - **查询侧**:按用户半径选择分辨率列,索引召回 + 精确过滤。 采集和查询不直接互相调用,**所有交互都经过 places 表**。 --- ## 4. H3 分辨率方案 ### 4.1 分辨率对照(H3 官方统计) | res | 平均边长 | 平均面积 | 贵州省总 cell 数 | |----:|---------:|---------:|-----------------:| | 5 | 8.54 km | 252 km² | ~700 | | 6 | 3.23 km | 36.13 km² | **~4,900** | | 7 | 1.22 km | 5.16 km² | ~34,000 | | 8 | 461 m | 0.737 km² | ~240,000 | | 9 | 174 m | 0.105 km² | ~1,670,000 | | 10 | 65.9 m | 0.0151 km² | ~11,700,000 | > 注:sweep 报告里的"边长"是 H3 库 `average_hexagon_edge_length` 返回的"平均到顶点的距离",与本表"平均边长"略有差异,量级一致。 ### 4.2 起手分辨率论证 按贵阳老城实测密度反推每 km² 单 typecode POI 数 ≈ 120: | 分辨率 | 老城单格预估 | 是否爆 200 上限 | 山区单格预估 | 是否浪费 | |---:|---:|:---:|---:|:---:| | r6 | ~4,320 | 必爆 | ~36 | 否 | | r7 | ~620 | 必爆 | ~5 | 略浪费 | | r8 | ~89 | 安全 | ~1 | 严重浪费 | | r9 | ~13 | 安全 | <1 | 大量空格 | 结论: - 城区合理分辨率是 **r8**,老城热点商圈可能要 **r9**。 - 山区/农村合理分辨率是 **r6 或 r7**。 - 单一分辨率全省扫不可行,**必须从粗到细自适应**。 - 选 **r6 作为起手**:山区一次拉完,城区一两次下钻就能解决,初始任务数仅 4,900 × 4 = 19,600,调度可控。 ### 4.3 下钻终点 下钻终止于 **r9**: - r9 单格 ~0.1 km² × 老城密度 120 ≈ 13 条/typecode,绝无溢出风险。 - r10 太碎(贵州全省 1100 万格),下钻管理成本远大于收益。 - r9 与查询侧主索引一致,下钻产生的 cell 直接是 nearby 召回的天然单位。 ### 4.4 多档索引存哪些 POI 落盘时一次性算齐 **5 档**:`h3_r6`, `h3_r7`, `h3_r8`, `h3_r9`, `h3_r10`。 - `r6 / r7`:服务大半径(> 3km)查询和采集任务反查。 - `r8`:服务 3km 查询(sweep 实测最优)。 - `r9`:服务 500m / 1km 查询(sweep 实测最优)。 - `r10`:预留给"高密度商圈临时升精度"场景。 - **不存 r11+**:sweep 已验证 r11 在 3km 时 k=31 仅覆盖 36.5%,无生产价值。 --- ## 5. 采集侧设计 ### 5.1 任务表 `collect_grid` ```sql CREATE TABLE collect_grid ( cell_id TEXT, -- H3 index(混合分辨率,字符串形式) resolution SMALLINT, -- 6 / 7 / 8 / 9 typecode TEXT, -- 110000 景点 / 050000 美食 / 100000 酒店 / 060000 商场 parent_cell_id TEXT, -- 下钻血缘,NULL 表示根 r6 status TEXT, -- pending | running | done | saturated | error fetched_count INT, pages_consumed INT, attempt_count INT DEFAULT 0, last_attempt_at TIMESTAMPTZ, last_error TEXT, PRIMARY KEY (cell_id, typecode) ); CREATE INDEX ON collect_grid (status, resolution); CREATE INDEX ON collect_grid (parent_cell_id); ``` 字段说明: - `cell_id` 和 `typecode` 组成联合主键,**同一个 cell 内每个 typecode 独立调度**。 - `parent_cell_id` 用于下钻血缘追溯(debug 和重采)。 - `status` 状态机见 5.5。 ### 5.2 初始化 ```python import h3 import json def init_province_grid(geojson_path: str) -> int: geo = json.load(open(geojson_path)) # h3 v4 接受 geojson 风格的 polygon cells = h3.polygon_to_cells(geo, res=6) typecodes = ["110000", "050000", "100000", "060000"] rows = [ (cell, 6, tc, None, "pending", 0, 0, 0, None, None) for cell in cells for tc in typecodes ] bulk_insert("collect_grid", rows) return len(rows) # ~19,600 ``` 省界严格裁剪:`h3.polygon_to_cells` 默认只返回中心点落在 polygon 内的 cell。跨省界的 POI 不属于贵州,主动丢弃。 ### 5.3 Polygon 参数生成 高德 polygon 参数要求 `lng,lat` 顺序、`|` 分隔、6 位小数、非矩形闭合。 ```python def hex_to_amap_polygon(cell_id: str) -> str: boundary = h3.cell_to_boundary(cell_id) # [(lat, lng), ...] × 6 pts = [(lng, lat) for lat, lng in boundary] pts.append(pts[0]) # 闭合:首尾相同 return "|".join(f"{lng:.6f},{lat:.6f}" for lng, lat in pts) ``` ### 5.4 单 cell × 单 typecode 采集循环 ```python HARD_CAP = 200 # 高德 polygon 接口硬上限 SATURATE = 180 # 触发下钻的阈值(留 10% 缓冲) PAGE_SIZE = 25 MAX_PAGE = HARD_CAP // PAGE_SIZE # = 8 def collect_cell(cell_id: str, typecode: str) -> str: """返回: done | saturated;POI 已落盘""" all_pois: list[dict] = [] last_raw = 0 polygon = hex_to_amap_polygon(cell_id) for page in range(1, MAX_PAGE + 1): pois, raw = search_polygon_v3( polygon=polygon, typecode=typecode, offset=PAGE_SIZE, page=page, ) all_pois.extend(pois) last_raw = raw if raw < PAGE_SIZE: # 高德判定已拉尽 break save_pois(all_pois) # 见 7.1 # 唯一可靠的"截断"信号:累计接近硬天花板 + 最后一页仍是满页 if len(all_pois) >= SATURATE and last_raw >= PAGE_SIZE: split_cell(cell_id, typecode) return "saturated" return "done" ``` ### 5.5 状态机 ``` +-----------+ | pending | +-----+-----+ | (worker pick) v +-----------+ | running | +-----+-----+ +-----------+-----------+----------+ | | | fetched < SATURATE fetched >= SATURATE 异常 | 且末页满 | v v v +----------+ +-------------+ +---------+ | done | | saturated | | error | +----------+ +------+------+ +----+----+ | | split_cell attempt < 3 -> pending | | v v 7 个子格 pending 终止 ``` ### 5.6 下钻 ```python def split_cell(parent_cell: str, typecode: str) -> None: res = h3.get_resolution(parent_cell) if res >= 9: return # 已是下钻终点,不再细分 children = h3.cell_to_children(parent_cell, res + 1) for child in children: # 1 -> 7 upsert("collect_grid", cell_id=child, resolution=res + 1, typecode=typecode, parent_cell_id=parent_cell, status="pending", fetched_count=0, pages_consumed=0, attempt_count=0) ``` **关键纪律**: - 每个 typecode 独立判断、独立下钻。同一个 cell 的美食爆了不影响酒店。 - 子格状态从 pending 重新开始,不复用父格状态。 - 已触发过下钻的父行保留 `saturated` 状态作为审计记录,不删。 ### 5.7 限流与重试 - 启动 worker 时按 key 类型设置 QPS 上限(个人 key 建议 ≤ 2 QPS,企业按工单配额)。 - 任意 HTTP 异常或高德返回 `status != "1"` 时 `attempt_count += 1`,回 `pending`;3 次后终止为 `error`。 - 退避:每次重试间隔 `2 ** attempt` 秒。 --- ## 6. 查询侧设计 完全沿用 `docs/reports/spatial_h3_resolution_sweep_result.md` 的实测推荐,**无需任何参数修改**。 ### 6.1 半径 → 分辨率 + k-ring 映射 | 半径 | 索引列 | k-ring | sweep 实测耗时 | 候选膨胀 | |---|---|---:|---:|---:| | ≤ 500m | `h3_r9` | 2 | 0.30 ms | 2.11× | | ≤ 1000m | `h3_r9` | 4 | 0.89 ms | 1.71× | | ≤ 3000m | `h3_r8` | 4 | 3.58 ms | 1.21× | | > 3000m | `h3_r7` | 按需 | — | — | ### 6.2 查询函数 ```python import math import h3 def nearby_pois(lng: float, lat: float, radius_m: int, typecode: str | None = None) -> list[dict]: if radius_m <= 500: res, col, k = 9, "h3_r9", 2 elif radius_m <= 1000: res, col, k = 9, "h3_r9", 4 elif radius_m <= 3000: res, col, k = 8, "h3_r8", 4 else: res, col = 7, "h3_r7" edge = h3.average_hexagon_edge_length(7, unit="m") k = max(2, math.ceil(radius_m / edge)) center = h3.latlng_to_cell(lat, lng, res) cells = list(h3.grid_disk(center, k)) candidates = query_db(f""" SELECT gaode_poi_id, name, typecode, lng, lat FROM places WHERE {col} = ANY(:cells) {"AND typecode = :typecode" if typecode else ""} """, cells=cells, typecode=typecode) # H3 是六边形不是圆,必须再做精确距离过滤 return [ {**p, "distance_m": d} for p in candidates if (d := haversine_m(lng, lat, p["lng"], p["lat"])) <= radius_m ] ``` **关键纪律**: - 查询时**不再算 h3**(落盘已算齐),只做 `WHERE h3_rN IN (cells)`,B-tree 索引秒命中。 - 六边形 k-ring 召回 ≠ 圆形,最后一定要 haversine 圆形过滤。 --- ## 7. 存储设计 ### 7.1 POI 表 ```sql CREATE TABLE places ( gaode_poi_id TEXT PRIMARY KEY, -- ★ 去重主键 name TEXT NOT NULL, typecode TEXT NOT NULL, type TEXT, -- 中文类型名 lng DOUBLE PRECISION NOT NULL, lat DOUBLE PRECISION NOT NULL, h3_r6 TEXT NOT NULL, h3_r7 TEXT NOT NULL, h3_r8 TEXT NOT NULL, h3_r9 TEXT NOT NULL, h3_r10 TEXT NOT NULL, province TEXT, city TEXT, district TEXT, business_area TEXT, address TEXT, tel TEXT, rating REAL, source_cell_id TEXT, -- 最近一次采集来源 cell (debug) source_res SMALLINT, fetched_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL ); CREATE INDEX ON places (h3_r9); -- 500m / 1km 主查询索引 CREATE INDEX ON places (h3_r8); -- 3km 查询索引 CREATE INDEX ON places (h3_r7); -- > 3km 预留 CREATE INDEX ON places (typecode); CREATE INDEX ON places (city, district); ``` 图谱版本(FalkorDB / Neo4j)把以上列作为节点属性,索引等价: ```cypher CREATE INDEX FOR (p:Place) ON (p.h3_r9); CREATE INDEX FOR (p:Place) ON (p.h3_r8); CREATE INDEX FOR (p:Place) ON (p.h3_r7); CREATE INDEX FOR (p:Place) ON (p.typecode); ``` ### 7.2 写入函数 ```python def save_pois(pois: list[dict]) -> int: rows = [] now = datetime.utcnow() for p in pois: lat = float(p["lat"]); lng = float(p["lng"]) if not (103.0 <= lng <= 110.5 and 24.0 <= lat <= 29.8): continue # 落在贵州合理范围外 rows.append({ "gaode_poi_id": p["gaode_poi_id"], "name": p["name"], "typecode": p["typecode"], "type": p.get("type"), "lng": lng, "lat": lat, "h3_r6": h3.latlng_to_cell(lat, lng, 6), "h3_r7": h3.latlng_to_cell(lat, lng, 7), "h3_r8": h3.latlng_to_cell(lat, lng, 8), "h3_r9": h3.latlng_to_cell(lat, lng, 9), "h3_r10": h3.latlng_to_cell(lat, lng, 10), **{k: p.get(k) for k in [ "province","city","district","business_area", "address","tel","rating"]}, "fetched_at": now, "updated_at": now, }) return upsert_on_conflict("places", rows, key="gaode_poi_id") ``` `UPSERT` 在 PG 用 `ON CONFLICT (gaode_poi_id) DO UPDATE`,保证多次采集重叠时只保留最新版本。 --- ## 8. 联合不冲突的原理 H3 严格嵌套: ``` 1 个 r6 cell == 7 个 r7 子 cell == 49 个 r8 == 343 个 r9 == 2,401 个 r10 ``` 因此一个 POI 在 r6 是 cell-A,必然在 cell-A 的某个 r7 / r8 / r9 / r10 子格里。落盘时一次算齐所有分辨率后: - **采集侧**:调度任务以 cell × typecode 为单位,下钻不会丢失 POI(子格集合恰好等于父格)。 - **查询侧**:按任意分辨率召回,POI 的 `h3_rN` 列直接命中索引。 - **去重**:始终以 `gaode_poi_id` 为唯一主键,跨 cell 重叠(如果用矩形 bbox 而非 hex)不会产生重复行。 形状差异由各侧自己处理,互不影响: | 侧 | 形状 | 处理方式 | |---|---|---| | 采集 | hex 顶点串 | 直接调高德 polygon API,hex tiling 无重叠 | | 查询召回 | hex k-ring | H3 库返回 | | 查询精排 | 圆形 | haversine 距离过滤 | --- ## 9. API 用量预估 按贵州 POI 分布估算(基于贵阳老城实测密度推断): | 区域类型 | 估计 r6 数 | 平均最终 cell × 4 typecode | 平均页数 | API 调用 | |---|---:|---:|---:|---:| | 老城(贵阳/遵义/凯里) | ~50 | 50 × 49 r8 × 4 ≈ 9,800 | ~4 | ~39,200 | | 各市县城区 | ~400 | 400 × 7 r7 × 4 ≈ 11,200 | ~3 | ~33,600 | | 镇区 | ~1,500 | 1,500 × 4 = 6,000 | ~2 | ~12,000 | | 乡村山区 | ~2,950 | 2,950 × 4 = 11,800 | 1–2 | ~17,700 | | **合计** | **~4,900** | **~38,800 cell-task** | | **~102,500 次** | 按 key 类型折算: | Key 类型 | 估计日配额 | 首轮全省采集耗时 | |---|---:|---:| | 个人开发者 | 500 | **~205 天**(不可行) | | 企业(小) | ~50,000 | ~2 天 | | 企业(标准) | ~300,000 | ~8 小时 | **结论**:业务正式上线必须使用企业 key。个人 key 仅适合 5.4 节验证脚本。 --- ## 10. 风险与对策 | 风险 | 影响 | 对策 | |---|---|---| | 高德 polygon `count` 字段不可信用于事前预测 | 浪费 quota 或误判 | 仅用"累计 ≥ 180 且末页满"作为唯一截断信号 | | 单一 typecode 在小区域内极端密集(如商场区美食)| 即使 r9 仍可能爆 200 | 下钻终点 r9 通常足够;极端情况手工标记 r10 | | 高德接口 typecode 编码升级 / 子类型新增 | 漏类别 | 启动时调用 `available_types`,定期与高德官方对照表 diff | | POI 跨 cell 漂移(坐标更新)| 旧 cell 索引失效 | 增量采集时按 `gaode_poi_id` UPSERT,重算 h3 | | 高德 QPS / 日配额超限 | 限流或封禁 | worker 启动配 token bucket;监控错误码 10003 / 10014 | | 个人 key 多账号轮转 | 封号 / 数据合规问题 | 不推荐;正式采集走企业 key | | 省界裁剪时跨省 POI | 数据归属混乱 | `h3.polygon_to_cells` 默认按中心点裁;POI 落盘时再按 bbox 二次校验 | | v3 接口未来下线 | 重写迁移成本 | 接口层封装 `search_h3_cell(cell, typecode)`,底层切换 v3/v5 透明 | --- ## 11. 落地清单 按顺序,每步独立可验证。 1. **`scripts/build_province_grid.py`**:拉贵州 geojson,`h3.polygon_to_cells(res=6)`,写入 `collect_grid` 共 ~19,600 行 pending。 - 验证点:cell 数量在 4,800–5,000 之间;可在地图上可视化省界覆盖。 - 零 API 调用。 2. **校准小样本(30 min,< 100 次 API)**:选 1 个贵阳老城 r6 cell + 1 个山区 r6 cell × 4 typecode,验证: - 200 条上限是否真的触发 - hex polygon 闭合格式是否被高德正确解析 - 各 typecode 的实际密度,校准 `SATURATE` 阈值 3. **改造 [gaode_connector.py](app/agents/gaode_connector.py)**:新增 `search_h3_cell(cell_id, typecode, page)`,内部用 `hex_to_amap_polygon`,保留现有 `search_polygon(bbox)` 兼容。 4. **`scripts/collect_worker.py`**:实现 5.4 / 5.5 / 5.6 的 worker,配 QPS 限流和重试退避。 5. **POI 写入适配**:`save_pois` 一次算齐 5 档 h3,UPSERT 入库 / 图谱。 6. **单市试点(贵阳)**:~200 个 r6 cell × 4 typecode,端到端跑通采集 → 落盘 → 查询闭环。 7. **全省扩展**:按试点实测的 API 用量等比放大,确认企业 key 配额够。 8. **nearby 查询替换**:把现有 `h3_neighbor_cells` 调用换成 6.2 的 `nearby_pois`,sweep 验证过的参数直接用。 --- ## 12. 待确认项 正式开工前必须回答: 1. **当前高德 key 是个人还是企业认证?** 实际日配额 / QPS 是多少?(去控制台 → 流量分析 → 配额管理) 2. **存储后端**:POI 主表落在 Postgres 还是 FalkorDB?两者 schema 设计兼容,但索引语法和并发模型不同。 3. **采集触发方式**:一次性全省扫 + 增量更新,还是按需扫(用户区域热力驱动)?影响 `collect_grid` 的优先级字段设计。 4. **v3 还是 v5**:v5 字段更全但企业试用,是否申请? --- ## 13. 参考资料 - 搜索 POI - 高德 Web 服务 API:https://lbs.amap.com/api/webservice/guide/api/search - 搜索 POI 2.0(v5):https://lbs.amap.com/api/webservice/guide/api-advanced/newpoisearch - 流量限制说明:https://lbs.amap.com/api/webservice/guide/tools/flowlevel - H3 cell statistics:https://h3geo.org/docs/core-library/restable/ - H3 traversal / grid disk:https://h3geo.org/docs/api/traversal/ - 实测 sweep 报告:`docs/reports/spatial_h3_resolution_sweep_result.md` - 现有空间 KG 设计:`docs/kg-redesign/spatial_kg_design.md` - 当前采集器代码:`app/agents/gaode_connector.py`