我们拿着纸质地图漫步城市的日子一去不返了。得益于智能手机,我们现在总是可以知道 自己所处的准确位置,也预料到网站会使用这些信息。我想知道从当前位置步行 5 分钟内可到的那些餐馆,对伦敦更大范围内的其他餐馆并不感兴趣。
但地理位置功能仅仅是 Elasticsearch 的冰山一角,Elasticsearch 的妙处在于,它让你可以把地理位置、全文搜索、结构化搜索和分析结合到一起。
例如:告诉我提到 vitello tonnato 这种食物、步行 5 分钟内可到、且晚上 11 点还营业的餐厅,然后结合用户评价、距离、价格排序。另一个例子:给我展示一幅整个城市8月份可用假期出租物业的地图,并计算出每个区域的平均价格。
Elasticsearch 提供了 两种表示地理位置的方式:用纬度-经度表示的坐标点使用 geo_point 字段类型, 以 GeoJSON 格式定义的复杂地理形状,使用 geo_shape 字段类型。
Geo-points 允许你找到距离另一个坐标点一定范围内的坐标点、计算出两点之间的距离来排序或进行相关性打分、或者聚合到显示在地图上的一个网格。另一方面,Geo-shapes 纯粹是用来过滤的。它们可以用来判断两个地理形状是否有重合或者某个地理形状是否完全包含了其他地理形状。
地理坐标点
地理坐标点 是指地球表面可以用经纬度描述的一个点。 地理坐标点可以用来计算两个坐标间的距离,还可以判断一个坐标是否在一个区域中,或在聚合中。
地理坐标点不能被动态映射(dynamic mapping)自动检测,而是需要显式声明对应字段类型为 geo-point :
PUT /attractions
{
"mappings": {
"properties": {
"name": {
"type": "text"
},
"location": {
"type": "geo_point"
}
}
}
}
经纬度坐标格式
如上例,location 字段被声明为 geo_point 后,我们就可以索引包含了经纬度信息的文档了。经纬度信息的形式可以是字符串、数组或者对象:
PUT /attractions/_doc/1
{
"name": "Chipotle Mexican Grill",
"location": "40.715, -74.011"
}
PUT /attractions/_doc/2
{
"name": "Pala Pizza",
"location": {
"lat": 40.722,
"lon": -73.989
}
}
PUT /attractions/_doc/3
{
"name": "Mini Munchies Pizza",
"location": [ -73.983, 40.719 ]
}
- 字符串形式以半角逗号分割,如 “lat,lon” 。
- 对象形式显式命名为 lat 和 lon 。
- 数组形式表示为 [lon,lat] 。
可能所有人都至少一次踩过这个坑:地理坐标点用字符串形式表示时是纬度在前,经度在后( “latitude,longitude” ),而数组形式表示时是经度在前,纬度在后( [longitude,latitude] )—顺序刚好相反。
其实,在 Elasticesearch 内部,不管字符串形式还是数组形式,都是经度在前,纬度在后。不过早期为了适配 GeoJSON 的格式规范,调整了数组形式的表示方式。
因此,在使用地理位置的路上就出现了这么一个“捕熊器”,专坑那些不了解这个陷阱的使用者。
通过地理坐标点过滤
有四种地理坐标点相关的过滤器可以用来选中或者排除文档:
geo_bounding_box
找出落在指定矩形框中的点。
geo_distance
找出与指定位置在给定距离内的点。
geo_distance_range
找出与指定点距离在给定最小距离和最大距离之间的点。
geo_polygon
找出落在多边形中的点。 这个过滤器使用代价很大 。当你觉得自己需要使用它,最好先看看 geo-shapes 。
这些过滤器判断点是否落在指定区域时的计算方法稍有不同,但过程类似。指定的区域被转换成一系列以quad/geohash为前缀的tokens,并被用来在倒排索引中搜索拥有相同tokens的文档。
地理坐标过滤器使用代价昂贵 — 所以最好在文档集合尽可能少的场景下使用。你可以先使用那些简单快捷的过滤器,比如 term 或 range ,来过滤掉尽可能多的文档,最后才交给地理坐标过滤器处理。
布尔型过滤器 bool filter 会自动帮你做这件事。它会优先让那些基于“bitset”的简单过滤器(见 关于缓存 )来过滤掉尽可能多的文档,然后依次才是更昂贵的地理坐标过滤器或者脚本类的过滤器。
地理坐标盒模型过滤器
这是目前为止最有效的地理坐标过滤器了,因为它计算起来非常简单。 你指定一个矩形的 顶部 , 底部 , 左边界 ,和 右边界 ,然后过滤器只需判断坐标的经度是否在左右边界之间,纬度是否在上下边界之间:
GET attractions/_search
{
"query": {
"bool": {
"filter": {
"geo_bounding_box": {
"location": {
"top_left": {
"lat": 40.8,
"lon": -74
},
"bottom_right": {
"lat": 40.7,
"lon": -73
}
}
}
}
}
}
}
- 这些坐标也可以用 bottom_left 和 top_right 来表示。
地理距离过滤器
地理距离过滤器( geo_distance )以给定位置为圆心画一个圆,来找出那些地理坐标落在其中的文档:
GET /attractions/_search
{
"query": {
"bool": {
"filter": {
"geo_distance": {
"distance": "1km",
"location": {
"lat": 40.715,
"lon": -73.988
}
}
}
}
}
}
- 找出所有与指定点距离在 1km 内的 location 字段。访问 Distance Units 查看所支持的距离表示单位。
- 中心点可以表示为字符串,数组或者(如示例中的)对象。详见 经纬度坐标格式。
地理距离过滤器计算代价昂贵。为了优化性能,Elasticsearch 先画一个矩形框来围住整个圆形,这样就可以先用消耗较少的盒模型计算方式来排除掉尽可能多的文档。 然后只对落在盒模型内的这部分点用地理距离计算方式处理。
你需要判断你的用户,是否需要如此精确的使用圆模型来做距离过滤?通常使用矩形模型 bounding box 是比地理距离更高效的方式,并且往往也能满足应用需求。
更快的地理距离计算
两点间的距离计算,有多种牺牲性能换取精度的算法:
arc
最慢但最精确的是 arc 计算方式,这种方式把世界当作球体来处理。不过这种方式的精度有限,因为这个世界并不是完全的球体。
plane
plane 计算方式把地球当成是平坦的,这种方式快一些但是精度略逊。在赤道附近的位置精度最好,而靠近两极则变差。
sloppy_arc
如此命名,是因为它使用了 Lucene 的 SloppyMath 类。这是一种用精度换取速度的计算方式, 它使用 Haversine formula 来计算距离。它比 arc 计算方式快 4 到 5 倍,并且距离精度达 99.9%。这也是默认的计算方式。
你可以参考下例来指定不同的计算方式:
GET /attractions/_search
{
"query": {
"bool": {
"filter": {
"geo_distance": {
"distance": "1km",
"distance_type": "plane",
"location": {
"lat": 40.715,
"lon": -73.988
}
}
}
}
}
}
- 使用更快但精度稍差的 plane 计算方法。
你的用户真的会在意一个餐馆落在指定圆形区域数米之外吗?一些地理位置相关的应用会有较高的精度要求;但大部分实际应用场景中,使用精度较低但响应更快的计算方式可能更好。
按距离排序
检索结果可以按与指定点的距离排序:
GET /attractions/_search
{
"query": {
"bool": {
"filter": {
"geo_bounding_box": {
"location": {
"top_left": {
"lat": 40.8,
"lon": -74
},
"bottom_right": {
"lat": 40.4,
"lon": -73
}
}
}
}
}
},
"sort": [
{
"_geo_distance": {
"location": {
"lat": 40.715,
"lon": -73.998
},
"order": "asc",
"unit": "km",
"distance_type": "plane"
}
}
]
}
- 计算每个文档中 location 字段与指定的 lat/lon 点间的距离。
- 将距离以 km 为单位写入到每个返回结果的 sort 键中。
- 使用快速但精度略差的 plane 计算方式。
你可能想问:为什么要制定距离的 单位 呢?用于排序的话,我们并不关心比较距离的尺度是英里、公里还是光年。 原因是,这个用于排序的值会设置在每个返回结果的 sort 元素中。
"hits": [
{
"_index": "attractions",
"_id": "2",
"_score": null,
"_source": {
"name": "Pala Pizza",
"location": {
"lat": 40.722,
"lon": -73.989
}
},
"sort": [
1.0868108882400258
]
},
Geohashes
Geohashes 是一种将经纬度坐标( lat/lon )编码成字符串的方式。这么做的初衷只是为了让地理位置在 url 上呈现的形式更加友好,但现在 geohashes 已经变成一种在数据库中有效索引地理坐标点和地理形状的方式。
Geohashes 把整个世界分为 32 个单元的格子 —— 4 行 8 列 —— 每一个格子都用一个字母或者数字标识。比如 g 这个单元覆盖了半个格林兰,冰岛的全部和大不列颠的大部分。每一个单元还可以进一步被分解成新的 32 个单元,这些单元又可以继续被分解成 32 个更小的单元,不断重复下去。 gc 这个单元覆盖了爱尔兰和英格兰, gcp 覆盖了伦敦的大部分和部分南英格兰, gcpuuz94k 是白金汉宫的入口,精确到约 5 米。
换句话说, geohash 的长度越长,它的精度就越高。如果两个 geohashes 有一个共同的前缀— gcpuuz—就表示他们挨得很近。共同的前缀越长,距离就越近。
这也意味着,两个刚好相邻的位置,可能会有完全不同的 geohash 。比如,伦敦 Millenium Dome 的 geohash 是 u10hbp ,因为它落在了 u 这个单元里,而紧挨着它东边的最大的单元是 g 。
地理坐标点可以自动索引相关的 geohashes ,更重要的是,他们也可以索引所有的 geohashes 前缀 。如索引白金汉宫入口位置——纬度 51.501568 ,经度 -0.141257—将会索引下面表格中列出的所有 geohashes ,表格中也给出了各个 geohash 单元的近似尺寸:
Geohash Level Dimensions
g 1 ~ 5,004km x 5,004km
gc 2 ~ 1,251km x 625km
... ... ...
gcpuuz94 8 ~ 38.2m x 19.1m
gcpuuz94k 9 ~ 4.78m x 4.78m
... ... ...
gcpuuz94kkp5 12 ~ 3.7cm x 1.8cm
geohash单元 过滤器 可以使用这些 geohash 前缀来找出与指定坐标点( lat/lon )相邻的位置。
Geohashes 映射
首先,你需要决定使用什么样的精度。虽然你也可以使用 12 级的精度来索引所有的地理坐标点,但是你真的需要精确到数厘米吗?如果你把精度控制在一个实际一些的值,比如 1km ,那么你可以节省大量的索引空间:
PUT /museums
{
"mappings": {
"properties": {
"location": {
"type": "geo_point"
}
}
}
}
POST /museums/_bulk?refresh
{"index":{"_id":1}}
{"location": "POINT (4.912350 52.374081)", "name": "NEMO Science Museum"}
{"index":{"_id":2}}
{"location": "POINT (4.901618 52.369219)", "name": "Museum Het Rembrandthuis"}
{"index":{"_id":3}}
{"location": "POINT (4.914722 52.371667)", "name": "Nederlands Scheepvaartmuseum"}
{"index":{"_id":4}}
{"location": "POINT (4.405200 51.222900)", "name": "Letterenhuis"}
{"index":{"_id":5}}
{"location": "POINT (2.336389 48.861111)", "name": "Musée du Louvre"}
{"index":{"_id":6}}
{"location": "POINT (2.327000 48.860000)", "name": "Musée d'Orsay"}
POST /museums/_search?size=0
{
"aggregations": {
"large-grid": {
"geohash_grid": {
"field": "location",
"precision": 3
}
}
}
}
Response:
{
...
"aggregations": {
"large-grid": {
"buckets": [
{
"key": "u17",
"doc_count": 3
},
{
"key": "u09",
"doc_count": 2
},
{
"key": "u15",
"doc_count": 1
}
]
}
}
}
地理位置聚合
虽然按照地理位置对结果进行过滤或者打分很有用, 但是在地图上呈现信息给用户通常更加有用。一个查询可能会返回太多结果以至于不能单独地展现每一个地理坐标点,但是地理位置聚合可以用来将地理坐标聚集到更加容易管理的 buckets 中。
处理 geo_point 类型字段的三种聚合:
地理位置距离
将文档按照距离围绕一个中心点来分组。
geohash 网格
将文档按照 geohash 范围来分组,用来显示在地图上。
地理位置边界
返回一个包含所有地理位置坐标点的边界的经纬度坐标,这对显示地图时缩放比例的选择非常有用。
地理距离聚合
geo_distance 聚合 对一些搜索非常有用,例如找到所有距离我 1km 以内的披萨店。搜索结果应该也的确被限制在用户指定 1km 范围内,但是我们可以添加在 2km 范围内找到的其他结果:
GET /attractions/_search
{
"query": {
"bool": {
"must": {
"match": {
"name": "pizza"
}
},
"filter": {
"geo_bounding_box": {
"location": {
"top_left": {
"lat": 40.8,
"lon": -74.1
},
"bottom_right": {
"lat": 40.4,
"lon": -73.7
}
}
}
}
}
},
"aggs": {
"per_ring": {
"geo_distance": {
"field": "location",
"unit": "km",
"origin": {
"lat": 40.712,
"lon": -73.988
},
"ranges": [
{
"from": 0,
"to": 1
},
{
"from": 1,
"to": 2
}
]
}
}
},
"post_filter": {
"geo_distance": {
"distance": "1km",
"location": {
"lat": 40.712,
"lon": -73.988
}
}
}
}
- 主查询查找名称中含有 pizza 的饭店。
- geo_bounding_box 筛选那些只在纽约区域的结果。
- geo_distance 聚合统计距离用户 1km 以内,1km 到 2km 的结果的数量。
- 最后,post_filter 将结果缩小至那些在用户 1km 范围内的饭店。
{
"took": 3,
"timed_out": false,
"_shards": {
"total": 1,
"successful": 1,
"skipped": 0,
"failed": 0
},
"hits": {
"total": {
"value": 1,
"relation": "eq"
},
"max_score": 0.4471386,
"hits": [
{
"_index": "attractions",
"_id": "3",
"_score": 0.4471386,
"_source": {
"name": "Mini Munchies Pizza",
"location": [
-73.983,
40.719
]
}
}
]
},
"aggregations": {
"per_ring": {
"buckets": [
{
"key": "*-1.0",
"from": 0,
"to": 1,
"doc_count": 1
},
{
"key": "1.0-2.0",
"from": 1,
"to": 2,
"doc_count": 1
}
]
}
}
}
- post_filter 已经将搜索结果缩小至仅在用户 1km 范围以内的披萨店。
- 聚合包括搜索结果加上其他在用户 2km 范围以内的披萨店。
在这个例子中,我们计算了落在每个同心环内的饭店数量。当然,我们可以在 per_rings 聚合下面嵌套子聚合来计算每个环的平均价格、最受欢迎程度,等等。
Geohash 网格聚合
通过一个查询返回的结果数量对在地图上单独的显示每一个位置点而言可能太多了。 geohash_grid 按照你定义的精度计算每一个点的 geohash 值而将附近的位置聚合在一起。
结果是一个网格—一个单元格表示一个可以显示在地图上的 geohash 。通过改变 geohash 的精度,你可以按国家或者城市街区来概括全世界。
聚合是稀疏的—它 仅返回那些含有文档的单元。 如果 geohashes 太精确,将产生太多的 buckets,它将默认返回那些包含了大量文档、最密集的10000个单元。 然而,为了计算哪些是最密集的 Top10000 ,它还是需要产生 所有 的 buckets 。可以通过以下方式来控制 buckets 的产生数量:
- 使用 geo_bounding_box 来限制结果。
- 为你的边界大小选择一个适当的 precision (精度)
GET /attractions/_search
{
"size" : 0,
"query": {
"constant_score": {
"filter": {
"geo_bounding_box": {
"location": {
"top_left": {
"lat": 40.8,
"lon": -74.1
},
"bottom_right": {
"lat": 40.4,
"lon": -73.7
}
}
}
}
}
},
"aggs": {
"new_york": {
"geohash_grid": {
"field": "location",
"precision": 5
}
}
}
}
- 边界框将搜索限制在大纽约区的范围
- Geohashes 精度为 5 大约是 5km x 5km。
Geohashes 精度为 5 ,每个约25平方公里,所以10000个单元按这个精度将覆盖250000平方公里。我们指定的边界范围,约44km x 33km,或约1452平方公里,所以我们的边界在安全范围内;我们绝对不会在内存中创建了太多的 buckets。
前面的请求响应看起来是这样的:
"aggregations": {
"new_york": {
"buckets": [
{
"key": "dr5rs",
"doc_count": 2
},
{
"key": "dr5re",
"doc_count": 1
}
]
}
}
- 每个 bucket 包含作为 key 的 geohash 值
同样,我们也没有指定任何子聚合,所以我们得到是文档计数。如果需要,我们也可以了解这些 buckets 中受欢迎的餐厅类型、平均价格或其他细节。
地理形状
地理形状( Geo-shapes )使用一种与地理坐标点完全不同的方法。我们在计算机屏幕上看到的圆形并不是由完美的连续的线组成的。而是用一个个连续的着色像素点画出的一个近似圆。地理形状的工作方式就与此相似。
复杂的形状——比如点集、线、多边形、多多边形、中空多边形——都是通过 geohash 单元 “画出来” 的,这些形状会转化为一个被它所覆盖到的 geohash 的集合。
实际上,两种类型的网格可以被用于 geo-shapes:默认使用我们之前讨论过的 geohash ,另外还有一种是 四叉树 。四叉树与 geohash 类似,除了四叉树每个层级只有 4 个单元,而不是 32 。这种不同取决于编码方式的选择。
组成一个形状的 geohash 都作为一个单元被索引在一起。有了这些信息,通过查看是否有相同的 geohash 单元,就可以很轻易地检查两个形状是否有交集。
geo-shapes 有以下作用:判断查询的形状与索引的形状的关系;这些 关系 可能是以下之一:
intersects
查询的形状与索引的形状有重叠(默认)。
disjoint
查询的形状与索引的形状完全 不 重叠。
within
索引的形状完全被包含在查询的形状中。
Geo-shapes 不能用于计算距离、排序、打分以及聚合。
索引地理形状
地理形状通过 GeoJSON 来表示,这是一种开放的使用 JSON 实现的二维形状编码方式。 每个形状都包含了形状类型— point, line, polygon, envelope —和一个或多个经纬度点集合的数组。
如,我们用一个多边形来索引阿姆斯特丹达姆广场:
PUT /attractions
{
"mappings": {
"properties": {
"name": {
"type": "text"
},
"location": {
"type": "geo_shape"
}
}
}
}
PUT /attractions/_doc/dam_square
{
"name" : "Dam Square, Amsterdam",
"location" : {
"type" : "polygon",
"coordinates" : [[
[ 4.89218, 52.37356 ],
[ 4.89205, 52.37276 ],
[ 4.89301, 52.37274 ],
[ 4.89392, 52.37250 ],
[ 4.89431, 52.37287 ],
[ 4.89331, 52.37346 ],
[ 4.89305, 52.37326 ],
[ 4.89218, 52.37356 ]
]]
}
}
- type 参数指明了经纬度坐标集表示的形状类型。
- lon/lat 列表描述了多边形的形状。
上例中大量的方括号可能看起来让人困惑,不过实际上 GeoJSON 的语法非常简单:
用一个数组表示 经纬度 坐标点:
[lon,lat]
一组坐标点放到一个数组来表示一个多边形:
[[lon,lat],[lon,lat], ... ]
一个多边形( polygon )形状可以包含多个多边形;第一个表示多边形的外轮廓,后续的多边形表示第一个多边形内部的空洞:
[
[[lon,lat],[lon,lat], ... ], # main polygon
[[lon,lat],[lon,lat], ... ], # hole in main polygon
...
]
参考
查询地理形状
geo_shape 查询不寻常的地方在于,它允许我们使用形状来做查询,而不仅仅是坐标点。
举个例子,当我们的用户刚刚迈出阿姆斯特丹中央火车站时,我们可以用如下方式,查询出方圆 1km 内的所有地标:
GET /attractions/_search
{
"query": {
"geo_shape": {
"location": {
"shape": {
"type": "circle",
"radius": "1km",
"coordinates": [
4.89994,
52.37815
]
}
}
}
}
}
- 查询使用 location 字段中的地理形状。
- 查询中的形状是由 shape 键对应的内容表示。
- 形状是一个半径为 1km 的圆形。
- 安姆斯特丹中央火车站入口的坐标点。
默认的,查询(或者过滤器 —— 工作方式相同)会从已索引的形状中寻找与指定形状有交集的部分。此外,可以把 relation 字段设置为 disjoint 来查找与指定形状不相交的部分,或者设置为 within 来查找完全落在查询形状中的。
举个例子,我们可以查找阿姆斯特丹中心区域所有的地标:
GET /attractions/_search
{
"query": {
"geo_shape": {
"location": {
"relation": "within",
"shape": {
"type": "polygon",
"coordinates": [[
[4.88330,52.38617],
[4.87463,52.37254],
[4.87875,52.36369],
[4.88939,52.35850],
[4.89840,52.35755],
[4.91909,52.36217],
[4.92656,52.36594],
[4.93368,52.36615],
[4.93342,52.37275],
[4.92690,52.37632],
[4.88330,52.38617]
]]
}
}
}
}
}
- 只匹配完全落在查询形状中的已索引的形状。
- 这个多边形表示安姆斯特丹中心区域。