← 回文章列表

2023年5月30日

用 Elasticsearch 做地圖找房:geo_point、geo_bounding_box 與 geo_distance

ElasticsearchGEO地圖搜尋搜尋引擎

為什麼地圖搜尋不適合用關聯式資料庫做

如果物件表上有 Lat、Lon 兩個欄位,「方框內的物件」可以寫成:

SQL
SELECT * FROM Houses
WHERE Lat BETWEEN 25.030 AND 25.048
  AND Lon BETWEEN 121.550 AND 121.580
  AND Price <= 3000 AND Rooms >= 3;

資料量小的時候沒什麼問題,但地圖搜尋的特性是:使用者每拖曳一次、縮放一次,就要重新查詢一次,還要疊上總價、房數、屋齡等篩選條件。兩個欄位的範圍條件很難同時有效利用一般的 B-Tree 索引;「某個點方圓一公里內」更需要計算距離,每一筆都算一次就是全表掃描。

Elasticsearch 的 geo_point 欄位專門為這種查詢設計,地理條件可以和其他篩選條件一起在索引層完成。基本的索引、Mapping 與 Query DSL 概念可以參考 Elasticsearch 入門 這篇。

geo_point:先把座標存對

Mapping 中把座標欄位宣告為 geo_point:

JSON
PUT /houses
{
  "mappings": {
    "properties": {
      "title":    { "type": "text" },
      "price":    { "type": "integer" },
      "rooms":    { "type": "integer" },
      "location": { "type": "geo_point" }
    }
  }
}

寫入時,geo_point 支援好幾種格式,最容易出錯的是順序:

JSON
{ "location": { "lat": 25.0330, "lon": 121.5654 } }   // 物件格式:清楚,推薦
{ "location": "25.0330,121.5654" }                    // 字串格式:lat,lon
{ "location": [121.5654, 25.0330] }                   // 陣列格式:lon, lat(順序相反!)

陣列格式遵循 GeoJSON 的慣例,是 [經度, 緯度],和字串格式剛好相反。如果以為陣列也是「緯度在前」而寫成 [25.0330, 121.5654],緯度就變成 121.5654——緯度只能介於 −90 到 90,這筆資料會直接寫入失敗;更麻煩的是兩個值剛好都在合法範圍內的情況,資料會寫入成功,但物件默默被放到地球的另一個地方。統一使用物件格式,可以完全避開這個問題。

Mapping 一旦建立,欄位型別就不能修改。如果座標已經用 float 存進去了,必須建新索引、重新寫入資料,所以第一次建立索引時就要決定好。

geo_bounding_box:地圖可視範圍內的物件

兩種地理查詢示意圖:左邊 geo_bounding_box 以地圖可視範圍的左上角與右下角座標畫出方框,只回傳框內的物件,框外的物件不回傳;右邊 geo_distance 以目前物件為圓心、半徑 1 公里畫圓,圓內的物件依距離由近到遠排序,圓外的物件排除

地圖元件(Google Maps、Leaflet 等)都可以在拖曳、縮放結束後取得目前畫面的邊界。把左上角與右下角的座標送到後端,組成 geo_bounding_box 查詢:

JSON
GET /houses/_search
{
  "size": 200,
  "_source": ["id", "price", "location"],
  "query": {
    "bool": {
      "filter": [
        {
          "geo_bounding_box": {
            "location": {
              "top_left":     { "lat": 25.0480, "lon": 121.5500 },
              "bottom_right": { "lat": 25.0300, "lon": 121.5800 }
            }
          }
        },
        { "range": { "price": { "lte": 3000 } } },
        { "range": { "rooms": { "gte": 3 } } }
      ]
    }
  }
}

幾個重點:

  • 所有條件都放在 filter:地理範圍與篩選條件都是「符合/不符合」,不需要計算相關性分數。放在 filter 中,Elasticsearch 會跳過評分,並且可以快取篩選結果
  • 只回傳地圖需要的欄位:地圖上的價格標籤只需要 ID、價格和座標,用 _source 過濾掉標題、描述、照片等大欄位,回應大小可以小好幾倍。使用者點了某個標籤,再查那一筆的詳細資料
  • 限制回傳數量:使用者把地圖縮小到整個台北市時,框內可能有上萬筆物件。一次全部丟給前端,傳輸慢、地圖也畫不動(下面會談怎麼處理)

geo_distance:附近物件推薦

物件詳細頁的「附近物件」,是以目前物件為圓心,找出一定半徑內的其他物件,再依距離排序:

JSON
GET /houses/_search
{
  "size": 6,
  "query": {
    "bool": {
      "filter": [
        {
          "geo_distance": {
            "distance": "1km",
            "location": { "lat": 25.0330, "lon": 121.5654 }
          }
        }
      ],
      "must_not": [
        { "term": { "id": "H-102938" } }
      ]
    }
  },
  "sort": [
    {
      "_geo_distance": {
        "location": { "lat": 25.0330, "lon": 121.5654 },
        "order": "asc",
        "unit": "m"
      }
    }
  ]
}
  • must_not 排除目前這一筆物件自己,否則距離 0 公尺的它永遠排第一
  • _geo_distance 排序時,每一筆結果的 sort 欄位會帶回實際距離,前端可以直接顯示「距離 320 公尺」,不需要自己再算一次
  • 半徑內的物件太少時(例如郊區),可以在後端判斷筆數不足就放大半徑再查一次,避免推薦區塊空空的

縮小到整個城市時:用聚合代替點

地圖縮得越小,框內的物件越多。這時候在地圖上畫幾千個標籤,不但看不清楚,瀏覽器也會很吃力。常見的做法是依縮放層級切換顯示方式:

  • 放大(街道層級):用 geo_bounding_box 回傳個別物件,顯示價格標籤
  • 縮小(行政區、城市層級):改用聚合,回傳「每一塊區域有幾筆物件」,在地圖上顯示數量泡泡

geotile_grid 聚合會依地圖圖磚(Tile)切分區域,precision 對應地圖的縮放層級:

JSON
GET /houses/_search
{
  "size": 0,
  "query": {
    "geo_bounding_box": {
      "location": {
        "top_left":     { "lat": 25.21, "lon": 121.45 },
        "bottom_right": { "lat": 24.96, "lon": 121.67 }
      }
    }
  },
  "aggs": {
    "grid": {
      "geotile_grid": { "field": "location", "precision": 13 },
      "aggs": {
        "center": { "geo_centroid": { "field": "location" } }
      }
    }
  }
}

geo_centroid 計算每一格內物件的中心點,把數量泡泡畫在物件實際集中的位置,而不是格子的正中央。另一種做法是依「行政區」這類 keyword 欄位做 terms 聚合,把泡泡畫在行政區的中心,對使用者來說更直覺。

使用情境:房仲平台的地圖找房

在房仲平台的買屋與成交行情頻道中,地圖找房的流程是:

  1. 使用者拖曳或縮放地圖,前端在動作結束後(加上 300ms 左右的 Debounce,避免拖曳過程中連續發出請求)送出目前的邊界座標與篩選條件
  2. 中台依縮放層級決定查詢方式:街道層級用 geo_bounding_box 回傳物件;較大範圍改回傳聚合結果
  3. 回應只帶 ID、價格、座標,前端畫出價格標籤;點擊標籤時再查詢物件詳細資料
  4. 物件詳細頁以 geo_distance 加上 _geo_distance 排序,推薦附近 1 公里內的物件

成交行情頻道用的是同一套做法,差別只在索引的內容是實價登錄的成交紀錄,標籤上顯示的是成交單價。

一句話總結

座標用 geo_point 存、而且統一用 { lat, lon } 物件格式避免經緯度寫反;地圖可視範圍用 geo_bounding_box、附近推薦用 geo_distance 搭配 _geo_distance 排序,條件全部放在 filter 並只回傳必要欄位;地圖縮得很小時,改用 geotile_grid 聚合回傳數量,而不是把上萬個點丟給前端。