电商与本地生活场景下的 GEO 搜索系统:索引结构与检索路径源码设计 一、为什么电商和本地生活需要 GEO 搜索在电商和本地生活服务场景中「用户位置」已经从一个可选字段变成全局架构必须优先考虑的核心维度电商同一商品在不同仓、不同城市的可售状态、运费、时效都可能不同本地生活门店只对附近用户有意义“周边 3 公里”“某个商圈”“某条地铁线附近”是最常见的筛选方式。如果搜索系统只按关键词、类目、价格来索引商品和门店很快会遇到几个典型痛点搜出来的店离用户很远体验极差搜出来的商品无法配送或运费极高无法表达复杂筛选比如「3 公里内评分 4.5 以上的火锅店」「距离最近且价格适中」。解决这些问题需要从索引结构和检索路径层面把 GEO地理信息当成第一等公民来设计而不是后期在 SQL 里补一个AND distance R的条件。本文从工程落地角度拆解一套适用于「电商 本地生活」的 GEO 搜索系统设计方案并给出核心源码示例。二、核心数据模型商品、门店与地理信息1. 电商场景商品 仓库 用户位置在电商场景中一件商品往往不是一个固定位置而是挂在多个仓库或站点上。简化模型可以写成from dataclasses import dataclass dataclass class WarehouseStock: sku_id: str # 商品 SKU warehouse_id: str # 仓库 ID lat: float # 仓库纬度 lon: float # 仓库经度 stock: int # 库存 ship_days: int # 配送时效(天) freight: float # 单件运费在搜索时用户输入的是商品关键词或类目系统要做的是先根据关键词找到候选 SKU再根据用户位置为每个 SKU 计算「最优仓库」或「是否可达」最后综合商品属性 运费 时效做排序。2. 本地生活场景POI门店模型本地生活场景相比电商简单一些一般一个门店对应一个位置dataclass class PoiShop: id: str name: str category: str lat: float lon: float rating: float # 用户评分 price_level: int # 人均价位等级 hot: float # 热度/曝光核心需求在指定地理范围半径/行政区/商圈内做搜索全局排序时将距离、评分、价格等一起考虑支持复杂过滤如「3 公里内评分 4.5 以上且人均 100 的火锅店」。三、索引结构设计倒排 GeoHash / GEO 索引一个高可用 GEO 搜索系统通常会同时维护三套索引文本/类目索引关键词、类目、品牌等GEO 索引经纬度、GeoHash、空间索引属性索引价格区间、评分、库存等。1. 关键词/类目索引简要可以用传统倒排方式电商sku_id挂在「标题 term、品牌 term、类目 term」本地生活shop_id挂在「店名 term、品类 term、商圈词」。这部分可以用 ES、OpenSearch 或自写倒排本文不展开把重点放在 GEO。2. GeoHash 索引设计最通用、易自建的 GEO 索引方式是 GeoHash。核心思路将经纬度编码为字符串形式的网格 ID相同前缀的 GeoHash 表示在同一大格子内前缀越长格子越小精度越高。可以为门店构建一个「GeoHash 前缀 → 门店列表」的索引结构class GeoHashIndex: def __init__(self, precision: int 7): self.precision precision self.bucket {} # prefix - set[shop_id] def geohash(self, lat: float, lon: float) - str: # 实际可用开源实现这里只保留接口结构 ... def add(self, shop_id: str, lat: float, lon: float): h self.geohash(lat, lon)[:self.precision] if h not in self.bucket: self.bucket[h] set() self.bucket[h].add(shop_id) def query_by_prefix(self, prefix: str) - set[str]: return self.bucket.get(prefix[:self.precision], set())在本地生活场景中只靠 GeoHash 前缀可能不够需要同时考虑邻居网格即中心格子的八个周边格子这可以在query时扩展。3. 电商场景中的 GEO 索引电商场景中 GEO 不直接索引商品而是索引「仓库」或「配送区域」。常见做法为仓库建立 GeoHash 索引查询用户附近的仓库再根据仓库库存和配送范围判断某 SKU 是否可售。四、距离计算与 GEO 过滤源码不管是电商还是本地生活距离计算通常用 Haversine 公式即可满足绝大部分需求球面距离。import math class GeoUtils: EARTH_RADIUS_KM 6371.0 staticmethod def _rad(x: float) - float: return x * math.pi / 180.0 classmethod def distance_km(cls, lat1: float, lon1: float, lat2: float, lon2: float) - float: dlat cls._rad(lat2 - lat1) dlon cls._rad(lon2 - lon1) rlat1 cls._rad(lat1) rlat2 cls._rad(lat2) a math.sin(dlat / 2) ** 2 \ math.cos(rlat1) * math.cos(rlat2) * math.sin(dlon / 2) ** 2 c 2 * math.asin(math.sqrt(a)) return cls.EARTH_RADIUS_KM * c对本地生活门店做半径过滤def geo_radius_filter_shops(shop_ids, user_lat, user_lon, radius_km, shop_repo): shop_ids: 候选门店 id 列表来自关键词或类目索引 shop_repo: id - PoiShop out [] for sid in shop_ids: shop shop_repo.get(sid) if not shop: continue d GeoUtils.distance_km(user_lat, user_lon, shop.lat, shop.lon) if d radius_km: out.append((shop, d)) return out电商中对仓库做类似过滤选出在指定范围内的候选仓库即可。五、本地生活场景检索路径关键词 GEO 属性过滤以一个典型需求为例「周边 3 公里评分 4.5 以上的人均 100 元以内火锅店」检索路径可以这样设计关键词/类目召回根据「火锅」类目召回所有火锅店shop_idsGEO 过滤使用用户位置(lat_q, lon_q)和半径3km对shop_ids做距离过滤属性过滤在 GEO 过滤后的集合中按rating 4.5且price_level 某值做过滤排序按综合评分排序评分、距离、热度、价格等。示例源码class ShopRepository: def __init__(self): self._store {} # id - PoiShop def add(self, shop: PoiShop): self._store[shop.id] shop def get(self, shop_id: str) - PoiShop | None: return self._store.get(shop_id)六、电商场景检索路径商品 仓库 配送能力电商场景下以「用户搜索某商品系统返回可在附近仓库发货且时效合适的 SKU」为例检索路径可以设计为商品召回根据关键词标题、品牌、类目召回候选 SKU仓库 GEO 过滤根据用户位置过滤出距离较近或时效可接受的仓库SKU-仓库匹配检查这些仓库中每个 SKU 的库存、可配送能力排序综合商品相关性、价格、运费、时效、仓库距离做排序。示例源码def search_sku_with_geo(user_lat, user_lon, keyword, sku_index, warehouse_repo: WarehouseRepo, max_ship_days3, top_k20): # 1) 关键词召回 SKU sku_ids sku_index.query(keywordkeyword) candidates [] # 2) 对每个 SKU 检查可发货仓库 for sku_id in sku_ids: stocks warehouse_repo.list_by_sku(sku_id) for s in stocks: if s.stock 0: continue if s.ship_days max_ship_days: continue dist GeoUtils.distance_km( user_lat, user_lon, s.lat, s.lon ) # 3) 构造评分 score ( - dist * 0.05 # 距离越近越好 - s.freight * 0.1 # 运费越低越好 - s.ship_days * 0.2 # 时效越短越好 ) candidates.append({ sku_id: s.sku_id, warehouse_id: s.warehouse_id, distance_km: dist, freight: s.freight, ship_days: s.ship_days, score: score, }) # 4) 排序 去重 SKU candidates.sort(keylambda x: x[score], reverseTrue) seen_sku set() results [] for c in candidates: if c[sku_id] in seen_sku: continue seen_sku.add(c[sku_id]) results.append(c) if len(results) top_k: break return results这条链路清晰地体现了电商 GEO 搜索中的关键点SKU 本身无固定位置必须通过仓库来参与 GEO 计算评分里需要把距离、运费、时效这几个维度一起考虑对外返回时每个 SKU 绑定一个最优仓库方案。七、索引结构与检索路径的工程演进上述示例是为了便于理解而做的「单机版架构」实际工程要升级的方向包括索引层文本侧用 ES/OpenSearch/自建倒排支持大规模商品/门店GEO 侧用数据库 GIS、Redis GEO 或专用空间索引属性侧用列存或 KV 库做高效过滤。检索路径层支持向量检索将语义搜索与 GEO 约束组合支持多路召回关键词/向量/热门与统一重排支持 AB 测试和在线特征调参。架构层将 GEO 搜索封装成独立服务为多个业务搜索、推荐、广告、运营位提供统一能力把评分公式和策略逻辑抽象成配置或策略引擎支持动态调优。八、最小可运行 Demo本地生活 GEO 搜索最后给一个可直接跑的最小 Demo你可以放进一个 Python 文件里跑一遍感受一下整体流程然后再替换成你自己的数据和存储。def build_demo_shops(): repo ShopRepository() shops [ PoiShop( idshop_001, name新宿火锅·聚会优选, categoryhotpot, lat35.6900, lon139.7000, rating4.7, price_level2, hot0.8, ), PoiShop( idshop_002, name新宿深夜火锅, categoryhotpot, lat35.6915, lon139.7020, rating4.5, price_level3, hot0.9, ), PoiShop( idshop_003, name涩谷火锅, categoryhotpot, lat35.6590, lon139.7000, rating4.2, price_level2, hot0.6, ), ] for s in shops: repo.add(s) return repo class SimpleKeywordIndex: def __init__(self): self.category_map {} # category - set[id] def add(self, shop: PoiShop): cat shop.category if cat not in self.category_map: self.category_map[cat] set() self.category_map[cat].add(shop.id) def query(self, category: str): return list(self.category_map.get(category, [])) if __name__ __main__: # 构建仓库和索引 shop_repo build_demo_shops() kw_index SimpleKeywordIndex() for sid, shop in shop_repo._store.items(): kw_index.add(shop) # 用户在新宿站附近 user_lat 35.6905 user_lon 139.7005 results search_hotpot_shops( user_latuser_lat, user_lonuser_lon, radius_km3.0, min_rating4.5, max_price_level3, keyword_indexkw_index, shop_reposhop_repo, top_k10, ) for r in results: print( r[id], r[name], f{r[distance_km]:.2f}km, frating{r[rating]}, fscore{r[score]:.3f}, )