elasticsearch之实例篇
本文上接《elasticsearch之搭建篇》,看看如何实现类似糯米的检索功能。
在商铺和商品的存储方面,有嵌套(Nested)和父子文档(Parent-Child)两种方式,下面将依次探索。
嵌套(Nested)
创建type
商品将嵌套在商铺的文档中,其创建方法如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 |
<?php require_once __DIR__ . "/vendor/autoload.php"; // 客户端 $client = Elasticsearch\ClientBuilder::fromConfig([ 'hosts' => ['localhost:9200', 'localhost:9201', 'localhost:9203'], // 最好在为ES集群搭建Haproxy反向代理 'retries' => 2 ]); // 创建商铺type $indices = $client->indices(); // 先删除旧的basic索引 $indices->delete(['index' => 'basic']); // 创建basic索引的同时指定商铺的type mapping $indices->create([ 'index' => 'basic', 'body' => [ // index配置 'settings' => [ "number_of_shards" => 3, // 3个分区 "number_of_replicas" => 2, // 每个分区有1个主分片和2个从分片 ], // type映射 'mappings' => [ // 商铺 'merchant' => [ // 属性 'properties' => [ // 商铺名称 'merchant_name' => [ 'type' => 'string', // 字符串 'index' => 'analyzed', // 全文索引 'analyzer' => 'ik_max_word', // 中文分词 ], // 商铺图片 'merchant_img' => [ 'type' => 'string', // 字符串 'index' => 'no', // 不索引 ], // 商铺类型 'merchant_type' => [ 'type' => 'string', // 字符串 'index' => 'not_analyzed', // 不分词,直接索引 ], // 用户评分 'merchant_score' => [ 'type' => 'integer', // 整形 'index' => 'not_analyzed', // 直接索引,用于过滤/排序 ], // 人均价格 'merchant_avg_price' => [ 'type' => 'integer', // 整形 'index' => 'not_analyzed', // 直接索引,用于过滤/排序 ], // 地理坐标 'merchant_location' => [ 'type' => 'geo_point', // 地址坐标 ], // 嵌套商品列表 'merchant_product' => [ 'type' => 'nested', // 嵌套文档 'properties' => [ // 商品ID 'product_id' => [ 'type' => 'long', // 长整形 'index' => 'not_analyzed', // 不分词,直接索引 ], // 商品名称 'product_name' => [ 'type' => 'string', // 字符串 'index' => 'analyzed', // 全文索引 'analyzer' => 'ik_max_word', // 中文分词 ], // 商品图片 'product_img' => [ 'type' => 'string', // 字符串 'index' => 'no', // 不索引 ], // 商品类型 'product_type' => [ 'type' => 'string', // 字符串 'index' => 'not_analyzed', // 不分词,直接索引 ], // 商品价格 'product_price' => [ 'type' => 'integer', // 整形 'index' => 'not_analyzed', // 直接索引,用于过滤/排序 ], // 商品销量 'product_sold' => [ 'type' => 'integer', // 整形 'index' => 'not_analyzed', // 直接索引,用于排序/过滤 ] ] ] ] ], ] ], ]); |
可见,商品列表作为一个属性存储在商铺中(type=nested,嵌套的),一个商铺有多个商品。(嵌套文档可以在这里学习)
通过curl查看刚刚建立的商铺type:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 |
[work@df6c675da97e nuomi-search]$ curl localhost:9200/basic?pretty { "basic" : { "aliases" : { }, "mappings" : { "merchant" : { "properties" : { "merchant_avg_price" : { "type" : "integer" }, "merchant_img" : { "type" : "keyword", "index" : false }, "merchant_location" : { "type" : "geo_point" }, "merchant_name" : { "type" : "text", "analyzer" : "ik_max_word" }, "merchant_product" : { "type" : "nested", "properties" : { "product_id" : { "type" : "long" }, "product_img" : { "type" : "keyword", "index" : false }, "product_name" : { "type" : "text", "analyzer" : "ik_max_word" }, "product_price" : { "type" : "integer" }, "product_sold" : { "type" : "integer" }, "product_type" : { "type" : "keyword" } } }, "merchant_score" : { "type" : "integer" }, "merchant_type" : { "type" : "keyword" } } } }, "settings" : { "index" : { "creation_date" : "1489890529746", "number_of_shards" : "3", "number_of_replicas" : "2", "uuid" : "sY5hH9kqQLq2mmZyW9HQmA", "version" : { "created" : "5020299" }, "provided_name" : "basic" } } } } |
上述信息与我创建时传入的一致,表明:商铺type已经成功建立,它利用嵌套文档技术,在其内部直接保存商品列表。
录入数据
我们通过bulk API批量的插入测试数据:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 |
<?php require_once __DIR__ . "/vendor/autoload.php"; // 客户端 $client = Elasticsearch\ClientBuilder::fromConfig([ 'hosts' => ['localhost:9200', 'localhost:9201', 'localhost:9203'], // 最好在为ES集群搭建Haproxy反向代理 'retries' => 2 ]); // 批量插入测试数据 $client->bulk([ 'index' => 'basic', 'type' => 'merchant', 'body' => [ // index索引请求,元信息是['_id':1] ['index' => ['_id' => 1]], // _id就是店铺的ID(一般来自于Mysql) // 请求体 [ // 主文档 'merchant_name' => '鑫明明拉面', 'merchant_score' => 4, 'merchant_type' => '美食', 'merchant_img' => 'http://merchant.com/1.jpg', 'merchant_avg_price' => 2100, 'merchant_location' => [120.3945890000, 36.0705170000], 'merchant_product' => [ // 嵌套文档列表 [ 'product_id' => 1, 'product_name' => '羊肉烩面', 'product_type' => '面食', 'product_img' => 'http://product.com/2.jpg', 'product_sold' => 11, 'product_price' => 2200, ], [ 'product_id' => 2, 'product_name' => '烤羊肉串', 'product_type' => '烤串', 'product_img' => 'http://product.com/3.jpg', 'product_sold' => 12, 'product_price' => 2300, ], ] ], ['index' => ['_id' => 2]], [ 'merchant_name' => '东方宫兰州拉面', 'merchant_score' => 3, 'merchant_type' => '美食', 'merchant_img' => 'http://merchant.com/2.jpg', 'merchant_avg_price' => 1800, 'merchant_location' => [36.0693500000, 120.3928290000], 'merchant_product' => [ [ 'product_id' => 3, 'product_name' => '牛肉炒面', 'product_type' => '面食', 'product_img' => 'http://product.com/4.jpg', 'product_sold' => 10, 'product_price' => 2400, ], [ 'product_id' => 4, 'product_name' => '蛋炒饭', 'product_type' => '主食', 'product_img' => 'http://product.com/5.jpg', 'product_sold' => 14, 'product_price' => 2300, ], [ 'product_id' => 5, 'product_name' => '羊肉汤', 'product_type' => '汤粉', 'product_img' => 'http://product.com/6.jpg', 'product_sold' => 10, 'product_price' => 2200, ], ] ], ['index' => ['_id' => 3]], [ 'merchant_name' => '开海饭店', 'merchant_score' => 3, 'merchant_type' => '美食', 'merchant_img' => 'http://merchant.com/3.jpg', 'merchant_avg_price' => 3500, 'merchant_location' => [120.4051170000, 36.0683000000], 'merchant_product' => [ [ 'product_id' => 6, 'product_name' => '海鲜炒饭', 'product_type' => '主食', 'product_img' => 'http://product.com/7.jpg', 'product_sold' => 10, 'product_price' => 2400, ], [ 'product_id' => 7, 'product_name' => '西红柿鸡蛋面', 'product_type' => '面食', 'product_img' => 'http://product.com/8.jpg', 'product_sold' => 10, 'product_price' => 2300, ], [ 'product_id' => 8, 'product_name' => '鸭血粉丝汤', 'product_type' => '汤粉', 'product_img' => 'http://product.com/9.jpg', 'product_sold' => 10, 'product_price' => 2200, ], [ 'product_id' => 9, 'product_name' => '兰州炒饭', 'product_type' => '主食', 'product_img' => 'http://product.com/10.jpg', 'product_sold' => 15, 'product_price' => 2500, ], ] ], ] ]); |
- 商铺ID作为ES文档的唯一_id。
- 商品ID作为普通字段保存在嵌套文档的product_id字段。
嵌套查询
因为商铺和商品是嵌套关系,所以在查询时需要使用”嵌套查询”语法。
我的查找需求表达如下:
若某商铺的名称或者其售卖的”商品”的名称,能够匹配”搜索关键字”,那么返回该商铺的信息。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
<?php require_once __DIR__ . "/vendor/autoload.php"; // 客户端 $client = Elasticsearch\ClientBuilder::fromConfig([ 'hosts' => ['localhost:9200', 'localhost:9201', 'localhost:9203'], // 最好在为ES集群搭建Haproxy反向代理 'retries' => 2 ]); // 搜索关键字 $keyword = '东方宫拉面'; // 嵌套查询 $result = $client->search([ 'index' => 'basic', // 数据库 'type' => 'merchant', // 表 'body' => [ // 查询体 'query' => [ // 查询请求,影响相关性打分 'bool' => [ // 布尔组合 'should' => [ // 各个子句相当于或的关系 // 第1项 [ // 全文匹配 'match' => ['merchant_name' => $keyword], // 商铺名 ], // 第2项 [ // 嵌套查询 'nested' => [ 'path' => 'merchant_product', // 子文档的路径 'score_mode' => 'max', // 子文档的评分方式(max表示取最多个子文档中最匹配的那个的相关性) 'query' => [ // 子文档查询请求,影响相关性打分 'match' => [ // 全文匹配 'merchant_product.product_name' => $keyword, // 商品名(必须全路径) ] ] ] ] ] ] ], ] ]); print_r($result); |
分析一下这个查询的组成部分(注意配合代码注释理解):
- query:查询语句必须放在其内部。
- bool:组合查询,可以表达多个子句之间的AND(must),OR(should),NOT(must_not)关系。
- should:OR的意思,里面多个子句满足任意一个即匹配,这个例子有2个子句。
- should的总相关性是这样计算的:所有子句的相关性和/子句的数量。
- match:全文匹配,会对$keyword分词,然后分别进行倒排查找。
- 该查询是should的第一个子句,会匹配得到一个相关性。
- nested:嵌套查询,是should的第二个子句。
- path:嵌套查询指向的子文档路径。
- score_mode:嵌套文档有多个,该参数指定nested子句的总相关性是如何计算的。
- 这里指定max,表示取多个商品中的最大相关性。
- query:对于嵌套查询来说,查询语句必须放在其内部。
- match:全文匹配,会对$keyword分词,然后分别进行倒排查找。
- 该查询是nested query的唯一子句,产生的相关性是nested的总相关性,也是should第二个子句的相关性。
其结果如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 |
[work@df6c675da97e nuomi-search]$ php main.php Array ( [took] => 21 [timed_out] => [_shards] => Array ( [total] => 3 [successful] => 3 [failed] => 0 ) [hits] => Array ( [total] => 3 [max_score] => 2.5505729 [hits] => Array ( [0] => Array ( [_index] => basic [_type] => merchant [_id] => 1 [_score] => 2.5505729 [_source] => Array ( [merchant_name] => 鑫明明拉面 [merchant_score] => 4 [merchant_type] => 美食 [merchant_img] => http://merchant.com/1.jpg [merchant_avg_price] => 2100 [merchant_location] => Array ( [0] => 127 [1] => 128 ) [merchant_product] => Array ( [0] => Array ( [product_id] => 1 [product_name] => 羊肉烩面 [product_type] => 面食 [product_img] => http://product.com/2.jpg [product_sold] => 11 [product_price] => 2200 ) [1] => Array ( [product_id] => 2 [product_name] => 烤羊肉串 [product_type] => 烤串 [product_img] => http://product.com/3.jpg [product_sold] => 12 [product_price] => 2300 ) ) ) ) [1] => Array ( [_index] => basic [_type] => merchant [_id] => 2 [_score] => 2.0315127 [_source] => Array ( [merchant_name] => 东方宫兰州拉面 [merchant_score] => 3 [merchant_type] => 美食 [merchant_img] => http://merchant.com/2.jpg [merchant_avg_price] => 1800 [merchant_location] => Array ( [0] => 120 [1] => 120 ) [merchant_product] => Array ( [0] => Array ( [product_id] => 3 [product_name] => 牛肉炒面 [product_type] => 面食 [product_img] => http://product.com/4.jpg [product_sold] => 10 [product_price] => 2400 ) [1] => Array ( [product_id] => 4 [product_name] => 蛋炒饭 [product_type] => 主食 [product_img] => http://product.com/5.jpg [product_sold] => 14 [product_price] => 2300 ) [2] => Array ( [product_id] => 5 [product_name] => 羊肉汤 [product_type] => 汤粉 [product_img] => http://product.com/6.jpg [product_sold] => 10 [product_price] => 2200 ) ) ) ) [2] => Array ( [_index] => basic [_type] => merchant [_id] => 3 [_score] => 1.0982643 [_source] => Array ( [merchant_name] => 开海饭店 [merchant_score] => 3 [merchant_type] => 美食 [merchant_img] => http://merchant.com/3.jpg [merchant_avg_price] => 3500 [merchant_location] => Array ( [0] => 50 [1] => 50 ) [merchant_product] => Array ( [0] => Array ( [product_id] => 6 [product_name] => 海鲜炒饭 [product_type] => 主食 [product_img] => http://product.com/7.jpg [product_sold] => 10 [product_price] => 2400 ) [1] => Array ( [product_id] => 7 [product_name] => 西红柿鸡蛋面 [product_type] => 面食 [product_img] => http://product.com/8.jpg [product_sold] => 10 [product_price] => 2300 ) [2] => Array ( [product_id] => 8 [product_name] => 鸭血粉丝汤 [product_type] => 汤粉 [product_img] => http://product.com/9.jpg [product_sold] => 10 [product_price] => 2200 ) [3] => Array ( [product_id] => 9 [product_name] => 兰州炒饭 [product_type] => 主食 [product_img] => http://product.com/10.jpg [product_sold] => 15 [product_price] => 2500 ) ) ) ) ) ) ) |
查询结果出乎意料!
直观来看,”东方宫兰州拉面”应该更符合我的预期,为什么”鑫明明拉面”的相关性却高于”东方宫兰州拉面”呢?
首先,我们要了解相关性,它表示搜索词与文档的匹配程度。
搜索引擎一般采用『词频/逆向文档频率 (TF/IDF)』来计算相关性:
- TF:单词(TERM)在一个文档内的出现比例,出现越多说明TERM对这篇文章更重要,比如本文的”ES”就多次出现。
- IDF:单词(TERM)在所有文档中出现的比例,出现越多说明TERM越大众越不起眼,比如”的,了,吧”这些助词。
- TF/IDF:TERM在文档内的TF越高,在所有文档中的IDF越小,说明TERM与该文档越相关。
通过肉眼分析,”东方宫兰州拉面”这家店无论从名字还是商品的名字都更贴近于我的搜索词”东方宫拉面”,是ES有BUG吗?
答案:并不是,出现这个现象的原因是因为不准确的IDF!
我的商铺表有3个分片Shard,通过查看获知”东方宫兰州拉面”独自在分片1中,而”鑫明明拉面”和”开海饭店”在分片2中。
ES在计算IDF的时候是基于分片内的数据统计的,分片1内的”拉面”只出现在”东方宫兰州拉面”内,相当于100%的IDF(在所有文档内出现);分片2内的”拉面”只出现在”鑫明明拉面”内,而”开海饭店”里并没有出现,相当于50%的IDF(在1/2的文档内出现),讲到这里我们就明白了:”东方宫兰州拉面”和”鑫明明拉面”中”拉面”都出现了1次,但是前者的IDF是1,而后者是1/2,经过TF/IDF计算显然是后者的值更大,也就是更相关了!
这个问题在数据规模较大的情况下可以忽略,在我们开发阶段可以通过指定一个参数解决:search_type=dfs_query_then_fetch,它将获取集群所有分片的IDF和之后再计算TF/IDF,因此更加准确。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 |
<?php require_once __DIR__ . "/vendor/autoload.php"; // 客户端 $client = Elasticsearch\ClientBuilder::fromConfig([ 'hosts' => ['localhost:9200', 'localhost:9201', 'localhost:9203'], // 最好在为ES集群搭建Haproxy反向代理 'retries' => 2 ]); // 搜索关键字 $keyword = '东方宫拉面'; // 嵌套查询 $result = $client->search([ 'index' => 'basic', // 数据库 'type' => 'merchant', // 表 'search_type' => 'dfs_query_then_fetch', // 汇总IDF计算相关 'body' => [ // 查询体 'query' => [ // 查询请求,影响相关性打分 'bool' => [ // 布尔组合 'should' => [ // 各个子句相当于或的关系 // 第1项 [ // 全文匹配 'match' => ['merchant_name' => $keyword], // 商铺名 ], // 第2项 [ // 嵌套查询 'nested' => [ 'path' => 'merchant_product', // 子文档的路径 'score_mode' => 'max', // 子文档的评分方式(max表示取最多个子文档中最匹配的那个的相关性) 'query' => [ // 子文档查询请求,影响相关性打分 'match' => [ // 全文匹配 'merchant_product.product_name' => $keyword, // 商品名(必须全路径) ] ] ] ] ] ] ], ] ]); print_r($result); |
这次结果正确:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 |
[work@df6c675da97e nuomi-search]$ php main.php Array ( [took] => 30 [timed_out] => [_shards] => Array ( [total] => 3 [successful] => 3 [failed] => 0 ) [hits] => Array ( [total] => 3 [max_score] => 3.5293567 [hits] => Array ( [0] => Array ( [_index] => basic [_type] => merchant [_id] => 2 [_score] => 3.5293567 [_source] => Array ( [merchant_name] => 东方宫兰州拉面 [merchant_score] => 3 [merchant_type] => 美食 [merchant_img] => http://merchant.com/2.jpg [merchant_avg_price] => 1800 [merchant_location] => Array ( [0] => 120 [1] => 120 ) [merchant_product] => Array ( [0] => Array ( [product_id] => 3 [product_name] => 牛肉炒面 [product_type] => 面食 [product_img] => http://product.com/4.jpg [product_sold] => 10 [product_price] => 2400 ) [1] => Array ( [product_id] => 4 [product_name] => 蛋炒饭 [product_type] => 主食 [product_img] => http://product.com/5.jpg [product_sold] => 14 [product_price] => 2300 ) [2] => Array ( [product_id] => 5 [product_name] => 羊肉汤 [product_type] => 汤粉 [product_img] => http://product.com/6.jpg [product_sold] => 10 [product_price] => 2200 ) ) ) ) [1] => Array ( [_index] => basic [_type] => merchant [_id] => 1 [_score] => 2.155528 [_source] => Array ( [merchant_name] => 鑫明明拉面 [merchant_score] => 4 [merchant_type] => 美食 [merchant_img] => http://merchant.com/1.jpg [merchant_avg_price] => 2100 [merchant_location] => Array ( [0] => 127 [1] => 128 ) [merchant_product] => Array ( [0] => Array ( [product_id] => 1 [product_name] => 羊肉烩面 [product_type] => 面食 [product_img] => http://product.com/2.jpg [product_sold] => 11 [product_price] => 2200 ) [1] => Array ( [product_id] => 2 [product_name] => 烤羊肉串 [product_type] => 烤串 [product_img] => http://product.com/3.jpg [product_sold] => 12 [product_price] => 2300 ) ) ) ) [2] => Array ( [_index] => basic [_type] => merchant [_id] => 3 [_score] => 1.1084312 [_source] => Array ( [merchant_name] => 开海饭店 [merchant_score] => 3 [merchant_type] => 美食 [merchant_img] => http://merchant.com/3.jpg [merchant_avg_price] => 3500 [merchant_location] => Array ( [0] => 50 [1] => 50 ) [merchant_product] => Array ( [0] => Array ( [product_id] => 6 [product_name] => 海鲜炒饭 [product_type] => 主食 [product_img] => http://product.com/7.jpg [product_sold] => 10 [product_price] => 2400 ) [1] => Array ( [product_id] => 7 [product_name] => 西红柿鸡蛋面 [product_type] => 面食 [product_img] => http://product.com/8.jpg [product_sold] => 10 [product_price] => 2300 ) [2] => Array ( [product_id] => 8 [product_name] => 鸭血粉丝汤 [product_type] => 汤粉 [product_img] => http://product.com/9.jpg [product_sold] => 10 [product_price] => 2200 ) [3] => Array ( [product_id] => 9 [product_name] => 兰州炒饭 [product_type] => 主食 [product_img] => http://product.com/10.jpg [product_sold] => 15 [product_price] => 2500 ) ) ) ) ) ) ) |
最佳子句
接下来我替换搜索关键字为:”兰炒饭”,其实我本意是”兰州炒饭”,只不过我输错了(如果搜索仍旧可以给我理想的结果,我会爱上它)。
我们直接看查询结果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 |
[work@df6c675da97e nuomi-search]$ php main.php Array ( [took] => 17 [timed_out] => [_shards] => Array ( [total] => 3 [successful] => 3 [failed] => 0 ) [hits] => Array ( [total] => 3 [max_score] => 2.666227 [hits] => Array ( [0] => Array ( [_index] => basic [_type] => merchant [_id] => 2 [_score] => 2.666227 [_source] => Array ( [merchant_name] => 东方宫兰州拉面 [merchant_score] => 3 [merchant_type] => 美食 [merchant_img] => http://merchant.com/2.jpg [merchant_avg_price] => 1800 [merchant_location] => Array ( [0] => 120 [1] => 120 ) [merchant_product] => Array ( [0] => Array ( [product_id] => 3 [product_name] => 牛肉炒面 [product_type] => 面食 [product_img] => http://product.com/4.jpg [product_sold] => 10 [product_price] => 2400 ) [1] => Array ( [product_id] => 4 [product_name] => 蛋炒饭 [product_type] => 主食 [product_img] => http://product.com/5.jpg [product_sold] => 14 [product_price] => 2300 ) [2] => Array ( [product_id] => 5 [product_name] => 羊肉汤 [product_type] => 汤粉 [product_img] => http://product.com/6.jpg [product_sold] => 10 [product_price] => 2200 ) ) ) ) [1] => Array ( [_index] => basic [_type] => merchant [_id] => 1 [_score] => 2.155528 [_source] => Array ( [merchant_name] => 鑫明明拉面 [merchant_score] => 4 [merchant_type] => 美食 [merchant_img] => http://merchant.com/1.jpg [merchant_avg_price] => 2100 [merchant_location] => Array ( [0] => 127 [1] => 128 ) [merchant_product] => Array ( [0] => Array ( [product_id] => 1 [product_name] => 羊肉烩面 [product_type] => 面食 [product_img] => http://product.com/2.jpg [product_sold] => 11 [product_price] => 2200 ) [1] => Array ( [product_id] => 2 [product_name] => 烤羊肉串 [product_type] => 烤串 [product_img] => http://product.com/3.jpg [product_sold] => 12 [product_price] => 2300 ) ) ) ) [2] => Array ( [_index] => basic [_type] => merchant [_id] => 3 [_score] => 1.76352 [_source] => Array ( [merchant_name] => 开海饭店 [merchant_score] => 3 [merchant_type] => 美食 [merchant_img] => http://merchant.com/3.jpg [merchant_avg_price] => 3500 [merchant_location] => Array ( [0] => 50 [1] => 50 ) [merchant_product] => Array ( [0] => Array ( [product_id] => 6 [product_name] => 海鲜炒饭 [product_type] => 主食 [product_img] => http://product.com/7.jpg [product_sold] => 10 [product_price] => 2400 ) [1] => Array ( [product_id] => 7 [product_name] => 西红柿鸡蛋面 [product_type] => 面食 [product_img] => http://product.com/8.jpg [product_sold] => 10 [product_price] => 2300 ) [2] => Array ( [product_id] => 8 [product_name] => 鸭血粉丝汤 [product_type] => 汤粉 [product_img] => http://product.com/9.jpg [product_sold] => 10 [product_price] => 2200 ) [3] => Array ( [product_id] => 9 [product_name] => 兰州炒饭 [product_type] => 主食 [product_img] => http://product.com/10.jpg [product_sold] => 15 [product_price] => 2500 ) ) ) ) ) ) ) |
从直接上来看,”开海饭店”应排在第一位,因为它正在售卖我的最爱:”兰州炒饭”,可为什么”东方宫兰州拉面”在第一位呢?
之前说过,bool的should会对其内部的2个子句(一个匹配商铺名称,一个匹配商品名称)的相关性加和并除以子句个数(这里有2个子句),其结果作为商铺文档的总相关性。
- “开海饭店”是商铺标题,和”兰炒饭”没有一丁点相关性,因此第一个子句的相关性=0。
- “兰州炒饭”完美匹配我的查询,因此第二个子句有很高的相关性。
- 总相关性 = (0 + 一个很高的相关性)/ 2,变成了很高相关性的一半。
- “东方宫兰州拉面”是商铺标题,出现了”兰”,因此第一个子句的相关性还不错。
- “蛋炒饭”出现了”炒饭”,因此第二个子句的相关性还不错。
- 总相关性 = (一个不错的相关性 + 一个不错的相关性)/2,还是一个不错的相关性。
上面的查询结果就是这样的情况下产生的,完全不符合预期!
我的初衷是找到最符合搜索关键字的字段,无论它是”商铺名称”还是”商品名称”。
“最佳字段“就是解决这个问题的:它保留多个检索字段中最大的相关性作为总相关性,查询变化如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 |
<?php require_once __DIR__ . "/vendor/autoload.php"; // 客户端 $client = Elasticsearch\ClientBuilder::fromConfig([ 'hosts' => ['localhost:9200', 'localhost:9201', 'localhost:9203'], // 最好在为ES集群搭建Haproxy反向代理 'retries' => 2 ]); // 搜索关键字 $keyword = '兰拉面'; // 嵌套查询 $result = $client->search([ 'index' => 'basic', // 数据库 'type' => 'merchant', // 表 'search_type' => 'dfs_query_then_fetch', // 汇总IDF计算相关 'body' => [ // 查询体 'query' => [ // 查询请求,影响相关性打分 'dis_max' => [ // 最佳字段 'queries' => [ // 取最大的相关性 // 第1项 [ // 全文匹配 'match' => ['merchant_name' => $keyword], // 商铺名 ], // 第2项 [ // 嵌套查询 'nested' => [ 'path' => 'merchant_product', // 子文档的路径 'score_mode' => 'max', // 子文档的评分方式(max表示取最多个子文档中最匹配的那个的相关性) 'query' => [ // 子文档查询请求,影响相关性打分 'match' => [ // 全文匹配 'merchant_product.product_name' => $keyword, // 商品名(必须全路径) ] ] ] ] ] ] ], ] ]); print_r($result); |
主要做了如下调整:
- bool组合查询替换成了dis_max最佳字段查询。
- should替换成了queries,下面同样包含多个查询子句。
现在结果正确!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 |
[work@df6c675da97e nuomi-search]$ php main.php Array ( [took] => 41 [timed_out] => [_shards] => Array ( [total] => 3 [successful] => 3 [failed] => 0 ) [hits] => Array ( [total] => 3 [max_score] => 1.76352 [hits] => Array ( [0] => Array ( [_index] => basic [_type] => merchant [_id] => 3 [_score] => 1.76352 [_source] => Array ( [merchant_name] => 开海饭店 [merchant_score] => 3 [merchant_type] => 美食 [merchant_img] => http://merchant.com/3.jpg [merchant_avg_price] => 3500 [merchant_location] => Array ( [0] => 50 [1] => 50 ) [merchant_product] => Array ( [0] => Array ( [product_id] => 6 [product_name] => 海鲜炒饭 [product_type] => 主食 [product_img] => http://product.com/7.jpg [product_sold] => 10 [product_price] => 2400 ) [1] => Array ( [product_id] => 7 [product_name] => 西红柿鸡蛋面 [product_type] => 面食 [product_img] => http://product.com/8.jpg [product_sold] => 10 [product_price] => 2300 ) [2] => Array ( [product_id] => 8 [product_name] => 鸭血粉丝汤 [product_type] => 汤粉 [product_img] => http://product.com/9.jpg [product_sold] => 10 [product_price] => 2200 ) [3] => Array ( [product_id] => 9 [product_name] => 兰州炒饭 [product_type] => 主食 [product_img] => http://product.com/10.jpg [product_sold] => 15 [product_price] => 2500 ) ) ) ) [1] => Array ( [_index] => basic [_type] => merchant [_id] => 2 [_score] => 1.6903362 [_source] => Array ( [merchant_name] => 东方宫兰州拉面 [merchant_score] => 3 [merchant_type] => 美食 [merchant_img] => http://merchant.com/2.jpg [merchant_avg_price] => 1800 [merchant_location] => Array ( [0] => 120 [1] => 120 ) [merchant_product] => Array ( [0] => Array ( [product_id] => 3 [product_name] => 牛肉炒面 [product_type] => 面食 [product_img] => http://product.com/4.jpg [product_sold] => 10 [product_price] => 2400 ) [1] => Array ( [product_id] => 4 [product_name] => 蛋炒饭 [product_type] => 主食 [product_img] => http://product.com/5.jpg [product_sold] => 14 [product_price] => 2300 ) [2] => Array ( [product_id] => 5 [product_name] => 羊肉汤 [product_type] => 汤粉 [product_img] => http://product.com/6.jpg [product_sold] => 10 [product_price] => 2200 ) ) ) ) [2] => Array ( [_index] => basic [_type] => merchant [_id] => 1 [_score] => 1.1084312 [_source] => Array ( [merchant_name] => 鑫明明拉面 [merchant_score] => 4 [merchant_type] => 美食 [merchant_img] => http://merchant.com/1.jpg [merchant_avg_price] => 2100 [merchant_location] => Array ( [0] => 127 [1] => 128 ) [merchant_product] => Array ( [0] => Array ( [product_id] => 1 [product_name] => 羊肉烩面 [product_type] => 面食 [product_img] => http://product.com/2.jpg [product_sold] => 11 [product_price] => 2200 ) [1] => Array ( [product_id] => 2 [product_name] => 烤羊肉串 [product_type] => 烤串 [product_img] => http://product.com/3.jpg [product_sold] => 12 [product_price] => 2300 ) ) ) ) ) ) ) |
过滤距离
通常,我们希望找到附近N公里内的商铺,因此必须利用坐标进行筛选,ES提供了索引地理位置的能力。
假设我的坐标是(X,Y),搜索范围是以它为圆心,半径为1公里的圆形,那么ES会怎么做呢?
ES首先为每个商铺的merchant_location建立了索引,(X,Y)坐标将被建立2个索引:
- 按经度索引。
- 按纬度索引。
ES在执行查询时,首先以(X,Y)为中心画一个矩形,它恰好能够包裹圆形,这样的目的是可以利用2个索引快速缩小范围:
- 矩形的x轴区间范围,可以使用经度索引筛选出一批X轴在1公里范围内的文档。
- 矩形的y轴区间范围,可以使用纬度索引筛选出一批Y轴在1公里范围内的文档。
- 两个文档集合求交集,可以得到矩形范围内的所有文档。
- 矩形比圆形要多一些区域,因此遍历所有文档计算它们和(X,Y)之间的距离,将圆形外的点删除。
这种工作方式叫做”地理坐标盒模型“,它是一种精度高,计算耗费资源比较多的一种手段(另外一种精度低,资源耗费少的方式是geohash)。
下面的请求,首先利用”距离”过滤出1KM内的商铺,之后再基于过滤的结果进行全文检索并计算相关性:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 |
<?php require_once __DIR__ . "/vendor/autoload.php"; // 客户端 $client = Elasticsearch\ClientBuilder::fromConfig([ 'hosts' => ['localhost:9200', 'localhost:9201', 'localhost:9203'], // 最好在为ES集群搭建Haproxy反向代理 'retries' => 2 ]); // 搜索关键字 $keyword = '拉面'; // 嵌套查询 $result = $client->search([ 'index' => 'basic', // 数据库 'type' => 'merchant', // 表 'search_type' => 'dfs_query_then_fetch', // 汇总IDF计算相关 'body' => [ // 查询体 'query' => [ // 组合 'bool' => [ 'must' => [ // 查询请求,影响相关性打分 'dis_max' => [ // 布尔组合 'queries' => [ // 各个子句相当于或的关系 // 第1项 [ // 全文匹配 'match' => ['merchant_name' => $keyword], // 商铺名 ], // 第2项 [ // 嵌套查询 'nested' => [ 'path' => 'merchant_product', // 子文档的路径 'score_mode' => 'max', // 子文档的评分方式(max表示取最多个子文档中最匹配的那个的相关性) 'query' => [ // 子文档查询请求,影响相关性打分 'match' => [ // 全文匹配 'merchant_product.product_name' => $keyword, // 商品名(必须全路径) ] ] ] ] ] ] ], // 过滤 'filter' => [ // 地理距离过滤器 'geo_distance' => [ 'distance' => '1km', 'merchant_location' => [ 120.3887320000, 36.0683290000 ] ] ] ] ], ] ]); print_r($result); |
这里使用了过滤,它在全文检索之前对数据进行按条件筛选,过滤的结果可以被ES缓存。
在ES5.x版本中,过滤语法filter必须和全文检索放在一个bool组合中,而全文检索放在must中即可。
下面是结果,它们按相关性排序:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 |
[work@df6c675da97e nuomi-search]$ php main.php Array ( [took] => 89 [timed_out] => [_shards] => Array ( [total] => 3 [successful] => 3 [failed] => 0 ) [hits] => Array ( [total] => 2 [max_score] => 1.1084312 [hits] => Array ( [0] => Array ( [_index] => basic [_type] => merchant [_id] => 1 [_score] => 1.1084312 [_source] => Array ( [merchant_name] => 鑫明明拉面 [merchant_score] => 4 [merchant_type] => 美食 [merchant_img] => http://merchant.com/1.jpg [merchant_avg_price] => 2100 [merchant_location] => Array ( [0] => 120.394589 [1] => 36.070517 ) [merchant_product] => Array ( [0] => Array ( [product_id] => 1 [product_name] => 羊肉烩面 [product_type] => 面食 [product_img] => http://product.com/2.jpg [product_sold] => 11 [product_price] => 2200 ) [1] => Array ( [product_id] => 2 [product_name] => 烤羊肉串 [product_type] => 烤串 [product_img] => http://product.com/3.jpg [product_sold] => 12 [product_price] => 2300 ) ) ) ) [1] => Array ( [_index] => basic [_type] => merchant [_id] => 2 [_score] => 0.97589093 [_source] => Array ( [merchant_name] => 东方宫兰州拉面 [merchant_score] => 3 [merchant_type] => 美食 [merchant_img] => http://merchant.com/2.jpg [merchant_avg_price] => 1800 [merchant_location] => Array ( [0] => 120.383579 [1] => 36.071833 ) [merchant_product] => Array ( [0] => Array ( [product_id] => 3 [product_name] => 牛肉炒面 [product_type] => 面食 [product_img] => http://product.com/4.jpg [product_sold] => 10 [product_price] => 2400 ) [1] => Array ( [product_id] => 4 [product_name] => 蛋炒饭 [product_type] => 主食 [product_img] => http://product.com/5.jpg [product_sold] => 14 [product_price] => 2300 ) [2] => Array ( [product_id] => 5 [product_name] => 羊肉汤 [product_type] => 汤粉 [product_img] => http://product.com/6.jpg [product_sold] => 10 [product_price] => 2200 ) ) ) ) ) ) ) |
现在扩大距离范围distance为2km,可以看到三个”商铺”全部返回(我就不贴结果了,亲自动手试试吧)。
排序
在糯米搜索中,”综合排序”其实就是指相关性排序,是ES的默认排序方法。
但是仔细观察糯米检索会发现,它支持若干其他排序方式,比如:按距离排序。
新的查询需求如下:搜索2KM之内,与”拉面”相关的”店铺”,并且按照距离远近排序。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 |
<?php require_once __DIR__ . "/vendor/autoload.php"; // 客户端 $client = Elasticsearch\ClientBuilder::fromConfig([ 'hosts' => ['localhost:9200', 'localhost:9201', 'localhost:9203'], // 最好在为ES集群搭建Haproxy反向代理 'retries' => 2 ]); // 搜索关键字 $keyword = '拉面'; // 嵌套查询 $result = $client->search([ 'index' => 'basic', // 数据库 'type' => 'merchant', // 表 'search_type' => 'dfs_query_then_fetch', // 汇总IDF计算相关 'body' => [ // 查询体 'query' => [ // 组合 'bool' => [ 'must' => [ // 查询请求,影响相关性打分 'dis_max' => [ // 布尔组合 'queries' => [ // 各个子句相当于或的关系 // 第1项 [ // 全文匹配 'match' => ['merchant_name' => $keyword], // 商铺名 ], // 第2项 [ // 嵌套查询 'nested' => [ 'path' => 'merchant_product', // 子文档的路径 'score_mode' => 'max', // 子文档的评分方式(max表示取最多个子文档中最匹配的那个的相关性) 'query' => [ // 子文档查询请求,影响相关性打分 'match' => [ // 全文匹配 'merchant_product.product_name' => $keyword, // 商品名(必须全路径) ] ] ] ] ] ] ], // 过滤 'filter' => [ // 地理距离过滤器 'geo_distance' => [ 'distance' => '2km', 'merchant_location' => [ 120.3887320000, 36.0683290000 ] ] ] ] ], // 排序 'sort' => [ [ '_geo_distance' => [ // 计算与这个点之间的距离 'merchant_location' => [ 120.3887320000, 36.0683290000 ], // 距离近的排列在前面 'order' => 'asc', // 返回单位是km 'unit' => 'km', ] ] ] ] ]); print_r($result); |
- sort是必须要写的。
- sort内部可以并列多个排序条件。
- _geo_distance是坐标排序的系统关键字。
结果如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 |
[work@df6c675da97e nuomi-search]$ php main.php Array ( [took] => 68 [timed_out] => [_shards] => Array ( [total] => 3 [successful] => 3 [failed] => 0 ) [hits] => Array ( [total] => 3 [max_score] => [hits] => Array ( [0] => Array ( [_index] => basic [_type] => merchant [_id] => 1 [_score] => [_source] => Array ( [merchant_name] => 鑫明明拉面 [merchant_score] => 4 [merchant_type] => 美食 [merchant_img] => http://merchant.com/1.jpg [merchant_avg_price] => 2100 [merchant_location] => Array ( [0] => 120.394589 [1] => 36.070517 ) [merchant_product] => Array ( [0] => Array ( [product_id] => 1 [product_name] => 羊肉烩面 [product_type] => 面食 [product_img] => http://product.com/2.jpg [product_sold] => 11 [product_price] => 2200 ) [1] => Array ( [product_id] => 2 [product_name] => 烤羊肉串 [product_type] => 烤串 [product_img] => http://product.com/3.jpg [product_sold] => 12 [product_price] => 2300 ) ) ) [sort] => Array ( [0] => 0.57992238133363 ) ) [1] => Array ( [_index] => basic [_type] => merchant [_id] => 2 [_score] => [_source] => Array ( [merchant_name] => 东方宫兰州拉面 [merchant_score] => 3 [merchant_type] => 美食 [merchant_img] => http://merchant.com/2.jpg [merchant_avg_price] => 1800 [merchant_location] => Array ( [0] => 120.383579 [1] => 36.071833 ) [merchant_product] => Array ( [0] => Array ( [product_id] => 3 [product_name] => 牛肉炒面 [product_type] => 面食 [product_img] => http://product.com/4.jpg [product_sold] => 10 [product_price] => 2400 ) [1] => Array ( [product_id] => 4 [product_name] => 蛋炒饭 [product_type] => 主食 [product_img] => http://product.com/5.jpg [product_sold] => 14 [product_price] => 2300 ) [2] => Array ( [product_id] => 5 [product_name] => 羊肉汤 [product_type] => 汤粉 [product_img] => http://product.com/6.jpg [product_sold] => 10 [product_price] => 2200 ) ) ) [sort] => Array ( [0] => 0.60523716061392 ) ) [2] => Array ( [_index] => basic [_type] => merchant [_id] => 3 [_score] => [_source] => Array ( [merchant_name] => 开海饭店 [merchant_score] => 3 [merchant_type] => 美食 [merchant_img] => http://merchant.com/3.jpg [merchant_avg_price] => 3500 [merchant_location] => Array ( [0] => 120.405117 [1] => 36.0683 ) [merchant_product] => Array ( [0] => Array ( [product_id] => 6 [product_name] => 海鲜炒饭 [product_type] => 主食 [product_img] => http://product.com/7.jpg [product_sold] => 10 [product_price] => 2400 ) [1] => Array ( [product_id] => 7 [product_name] => 西红柿鸡蛋面 [product_type] => 面食 [product_img] => http://product.com/8.jpg [product_sold] => 10 [product_price] => 2300 ) [2] => Array ( [product_id] => 8 [product_name] => 鸭血粉丝汤 [product_type] => 汤粉 [product_img] => http://product.com/9.jpg [product_sold] => 10 [product_price] => 2200 ) [3] => Array ( [product_id] => 9 [product_name] => 兰州炒饭 [product_type] => 主食 [product_img] => http://product.com/10.jpg [product_sold] => 15 [product_price] => 2500 ) ) ) [sort] => Array ( [0] => 1.4726960298128 ) ) ) ) ) |
更多的过滤和排序
上面的几个例子里包含了这些知识点:相关性排序,距离排序,距离过滤…
为了体现ES的强大之处,我们继续丰富搜索请求:
2KM以内,与”拉面”相关,商铺评分>=4分,商铺均价<=20元,按商铺评分、商品总销量排序,并且返回结果中包含距离。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 |
<?php require_once __DIR__ . "/vendor/autoload.php"; // 客户端 $client = Elasticsearch\ClientBuilder::fromConfig([ 'hosts' => ['localhost:9200', 'localhost:9201', 'localhost:9203'], // 最好在为ES集群搭建Haproxy反向代理 'retries' => 2 ]); // 搜索关键字 $keyword = '拉面'; // 嵌套查询 $result = $client->search([ 'index' => 'basic', // 数据库 'type' => 'merchant', // 表 'search_type' => 'dfs_query_then_fetch', // 汇总IDF计算相关 'body' => [ // 查询体 'query' => [ // 组合 'bool' => [ 'must' => [ // 查询请求,影响相关性打分 'dis_max' => [ // 最佳子句 'queries' => [ // 各个子句相当于或的关系 // 第1项 [ // 全文匹配 'match' => ['merchant_name' => $keyword], // 商铺名 ], // 第2项 [ // 嵌套查询 'nested' => [ 'path' => 'merchant_product', // 子文档的路径 'score_mode' => 'max', // 子文档的评分方式(max表示取最多个子文档中最匹配的那个的相关性) 'query' => [ // 子文档查询请求,影响相关性打分 'match' => [ // 全文匹配 'merchant_product.product_name' => $keyword, // 商品名(必须全路径) ] ] ] ] ] ] ], // 过滤(不参与相关性计算) 'filter' => [ 'bool' => [ // 组合过滤 'must' => [ // AND关系 // 2KM内 [ // 地理距离过滤器 'geo_distance' => [ 'distance' => '2km', 'merchant_location' => [ 120.3887320000, 36.0683290000 ] ] ], // 商铺评分>=4 [ 'range' => [ 'merchant_score' => [ 'gte' => 4, ] ] ], // 商品均价<=20元 [ 'range' => [ 'merchant_avg_price' => [ 'lte' => 2100, ] ] ], ] ] ] ] ], // 排序 'sort' => [ [ // 先按店铺评分从高到低排序 'merchant_score' => [ 'order' => 'desc', ], // 再按嵌套的商品总销量从高到低排序 'merchant_product.product_sold' => [ 'mode' => 'sum', // 求商品的总销量 'order' => 'desc', 'nested_path' => 'merchant_product', // 嵌套文档的路径 ] ] ], // 仍旧返回_source完整文档内容 '_source' => [], // 脚本计算字段 'script_fields' => [ // 自定义的字段名 'distance' => [ 'script' => [ "lang" => "painless", // 自定义的脚本输入参数 'params' => [ 'lon' => 120.3887320000, 'lat' => 36.0683290000, ], //脚本内容 'inline' => "doc['merchant_location'].arcDistance(params['lat'],params['lon'])" ] ] ] ] ]); print_r($result); |
分析一下这个查询:
- query:通过bool组合实现”带过滤filter的相关性查询”:
- 即先通过filter过滤出一批满足条件的数据。
- 再对这批数据进行must(AND关系)相关性计算。
- sort:有2项排序规则:
- 按商铺分数倒序。
- 如果商铺分数一样,则按商品总销量倒序。
- _source:控制返回的字段,为空表示返回所有字段。
- script_fields:脚本计算生成额外的字段,这里定义了一个distance字段:
- lang是标明脚本使用的语言(ES支持多种脚本语言)。
- params是脚本输入参数。
- inline是内联脚本,它计算出每个文档和我所在坐标之间的距离。
得到的结果如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 |
[work@df6c675da97e nuomi-search]$ php main.php Array ( [took] => 74 [timed_out] => [_shards] => Array ( [total] => 3 [successful] => 3 [failed] => 0 ) [hits] => Array ( [total] => 1 [max_score] => [hits] => Array ( [0] => Array ( [_index] => basic [_type] => merchant [_id] => 1 [_score] => [_source] => Array ( [merchant_name] => 鑫明明拉面 ) [fields] => Array ( [distance] => Array ( [0] => 579.92238133363 ) ) [sort] => Array ( [0] => 4 [1] => 23 ) ) ) ) ) |
- 使用script_fields后_source不会返回,因此需要在请求里显式的指定_source。
- fields里是脚本计算的独立字段。
- sort里的2个数据是排序时所使用的字段值。
ES的脚本功能很丰富,可以自己学习扩展:painless脚本语言与ES5.x废弃的脚本语法。
查询还是过滤
在上面这个例子中,我们使用了”带过滤的全文检索”,最终却没有使用”相关性”作为排序标准。
我们知道”全文检索”是会计算相关性的,这岂不是白白浪费计算资源吗?
简单的说:没有使用”相关性”作为排序的时候,全文检索的用途仅仅是确认 『匹配还是不匹配』,至于匹配的程度就无关紧要了。
怎么跳过全文检索的相关性计算呢?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 |
<?php require_once __DIR__ . "/vendor/autoload.php"; // 客户端 $client = Elasticsearch\ClientBuilder::fromConfig([ 'hosts' => ['localhost:9200', 'localhost:9201', 'localhost:9203'], // 最好在为ES集群搭建Haproxy反向代理 'retries' => 2 ]); // 搜索关键字 $keyword = '拉面'; // 嵌套查询 $result = $client->search([ 'index' => 'basic', // 数据库 'type' => 'merchant', // 表 'search_type' => 'dfs_query_then_fetch', // 汇总IDF计算相关 'body' => [ // 查询体 'query' => [ // 组合 'bool' => [ 'must' => [ 'constant_score' => [ 'query' => [ // 查询请求,影响相关性打分 'dis_max' => [ // 最佳子句 'queries' => [ // 各个子句相当于或的关系 // 第1项 [ // 全文匹配 'match' => ['merchant_name' => $keyword], // 商铺名 ], // 第2项 [ // 嵌套查询 'nested' => [ 'path' => 'merchant_product', // 子文档的路径 'score_mode' => 'max', // 子文档的评分方式(max表示取最多个子文档中最匹配的那个的相关性) 'query' => [ // 子文档查询请求,影响相关性打分 'match' => [ // 全文匹配 'merchant_product.product_name' => $keyword, // 商品名(必须全路径) ] ] ] ] ] ] ] ] ], // 过滤(不参与相关性计算) 'filter' => [ 'bool' => [ // 组合过滤 'must' => [ // AND关系 // 2KM内 [ // 地理距离过滤器 'geo_distance' => [ 'distance' => '2km', 'merchant_location' => [ 120.3887320000, 36.0683290000 ] ] ], // 商铺评分>=4 [ 'range' => [ 'merchant_score' => [ 'gte' => 4, ] ] ], // 商品均价<=20元 [ 'range' => [ 'merchant_avg_price' => [ 'lte' => 2100, ] ] ], ] ] ] ] ], // 排序 'sort' => [ [ // 先按店铺评分从高到低排序 'merchant_score' => [ 'order' => 'desc', ], // 再按嵌套的商品总销量从高到低排序 'merchant_product.product_sold' => [ 'mode' => 'sum', // 求商品的总销量 'order' => 'desc', 'nested_path' => 'merchant_product', // 嵌套文档的路径 ] ] ], // 仍旧返回_source完整文档内容 '_source' => [], // 脚本计算字段 'script_fields' => [ // 自定义的字段名 'distance' => [ 'script' => [ "lang" => "painless", // 自定义的脚本输入参数 'params' => [ 'lon' => 120.3887320000, 'lat' => 36.0683290000, ], //脚本内容 'inline' => "doc['merchant_location'].arcDistance(params['lat'],params['lon'])" ] ] ] ] ]); print_r($result); |
与之前的代码相比,must下面的全文检索子句挪到了constant_score下面,又被一层query包裹起来。
constant_score会让其下的query相关性打分将始终为常量1,因此ES将不再为内部的全文检索计算相关性。
聚合统计分析
ES支持对查询出来的结果集合进行进一步的聚合分析,支持类似Mysql中的max,min,count,sum等聚合操作,也支持类似group by的分桶,以及分桶后的聚合操作。
下面的例子单纯的为了演示聚合,并没有多少实际价值:
首先保持之前的查询请求不变,额外增加一个aggs聚合语句,它统计每种product_type的平均销量:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 |
<?php require_once __DIR__ . "/vendor/autoload.php"; // 客户端 $client = Elasticsearch\ClientBuilder::fromConfig([ 'hosts' => ['localhost:9200', 'localhost:9201', 'localhost:9203'], // 最好在为ES集群搭建Haproxy反向代理 'retries' => 2 ]); // 搜索关键字 $keyword = '拉面'; // 嵌套查询 $result = $client->search([ 'index' => 'basic', // 数据库 'type' => 'merchant', // 表 'search_type' => 'dfs_query_then_fetch', // 汇总IDF计算相关 'body' => [ // 查询体 'query' => [ // 组合 'bool' => [ 'must' => [ 'constant_score' => [ 'query' => [ // 查询请求,影响相关性打分 'dis_max' => [ // 最佳子句 'queries' => [ // 各个子句相当于或的关系 // 第1项 [ // 全文匹配 'match' => ['merchant_name' => $keyword], // 商铺名 ], // 第2项 [ // 嵌套查询 'nested' => [ 'path' => 'merchant_product', // 子文档的路径 'score_mode' => 'max', // 子文档的评分方式(max表示取最多个子文档中最匹配的那个的相关性) 'query' => [ // 子文档查询请求,影响相关性打分 'match' => [ // 全文匹配 'merchant_product.product_name' => $keyword, // 商品名(必须全路径) ] ] ] ] ] ] ] ] ], // 过滤(不参与相关性计算) 'filter' => [ 'bool' => [ // 组合过滤 'must' => [ // AND关系 // 2KM内 [ // 地理距离过滤器 'geo_distance' => [ 'distance' => '2km', 'merchant_location' => [ 120.3887320000, 36.0683290000 ] ] ], // 商铺评分>=4 [ 'range' => [ 'merchant_score' => [ 'gte' => 4, ] ] ], // 商品均价<=20元 [ 'range' => [ 'merchant_avg_price' => [ 'lte' => 2100, ] ] ], ] ] ] ] ], // 排序 'sort' => [ [ // 先按店铺评分从高到低排序 'merchant_score' => [ 'order' => 'desc', ], // 再按嵌套的商品总销量从高到低排序 'merchant_product.product_sold' => [ 'mode' => 'sum', // 求商品的总销量 'order' => 'desc', 'nested_path' => 'merchant_product', // 嵌套文档的路径 ] ] ], // 仍旧返回_source完整文档内容 '_source' => [], // 脚本计算字段 'script_fields' => [ // 自定义的字段名 'distance' => [ 'script' => [ "lang" => "painless", // 自定义的脚本输入参数 'params' => [ 'lon' => 120.3887320000, 'lat' => 36.0683290000, ], //脚本内容 'inline' => "doc['merchant_location'].arcDistance(params['lat'],params['lon'])" ] ] ], // 聚合(aggs和query一样必须写) 'aggs' => [ // 1个aggs下面可以写多个key,每个key是一个聚合项 'merchant_product' => [ 'nested' => [ // 深入到merchant_product嵌套文档 'path' => 'merchant_product' ], // merchant_product没有分桶,直接运用如下的聚合运算 'aggs' => [ // 一个聚合项 'product_type' => [ // 数据先按product_type分桶 'terms' => [ 'field' => 'merchant_product.product_type', ], // 对每个桶,进一步聚合 'aggs' => [ // 一个聚合项 'product_avg_sold' => [ // 不分桶 // 直接计算product_sold的平均值 'avg' => [ 'field' => 'merchant_product.product_sold' ] ] ] ], ] ] ] ] ]); print_r($result); |
- aggs用来容纳若干聚合项,它们将被分别计算并在查询结果中返回。
- 每个聚合项可以应用2种子句:
- 调用sum,avg,min,max等对字段进行聚合,它们被称为”指标”。
- 调用terms将数据分成桶,然后通过嵌套的aggs对每个桶进一步聚合。
聚合(aggs)是在query完成后执行的,它的输入是query的输出,如果没有query语句,那么aggs将在全部文档上执行。
聚合只是用来分析数据用的,并不能把聚合的结果拿来作为过滤文档的条件,这是一定要注意的。
如果你感觉理解困难,对括号嵌套一塌糊涂,建议多次揣摩上面的例子和解释并动手实践,别忘了阅读官方文档。
经过一系列的实例,应该对ES的常见用法有了一定的掌握。
我认为2个知识点是学以致用关键:
- 掌握全文检索的基本原理(TF/IDF,相关性)。
- 亲自动手实践ES的查询语法,揣摩编写的逻辑。
父子文档(Parent-Child)
“parent-child父子文档”功能:它关联同一个index下的2个type形成父子关系,两个type可以各自独立更新,在查询的时候可以选择其中之一作为检索主表,另外一张作为辅表,从而实现用子文档筛选父文档或者用父文档筛选子文档的能力。
一定要注意,”父子文档”并不是数据库那样的JOIN操作,因此不会将匹配的父子记录一起返回,这也是为什么”糯米美食”同时检索”店铺”和”商品”信息,但最终只能返回”店铺”数据的原因。
“父子文档”要求父亲与孩子符合一定的存储要求:
- 如果父亲与孩子不在同一个index中存储,那么不同index各自进行分布式存储,两者数据无法本地化。
- 父亲与属于其的孩子不在同一个shard中存储,那么不同shard各自进行分布式存储,两者数据也无法本地化。
因为”父子文档”在技术上的这些限制,因此:
- 父亲与孩子必须存储在同一个index下的不同type中。
- 对于父亲A和属于A的孩子们,它们应该”路由”到同一个shard下。
无论如何,本节我们先创建一个index再说,它有3个分片shard,每个shard有2个备份,这样规划的原因是因为我有3个ES节点:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
<?php require_once __DIR__ . "/vendor/autoload.php"; // 客户端 $client = Elasticsearch\ClientBuilder::fromConfig([ 'hosts' => ['localhost:9200', 'localhost:9201', 'localhost:9203'], // 最好在为ES集群搭建Haproxy反向代理 'retries' => 2 ]); // 创建index $indices = $client->indices(); $indices->create([ 'index' => 'basic', // 基础数据 'body' => [ 'settings' => [ "number_of_shards" => 3, // 3个分区 "number_of_replicas" => 2, // 每个分区有1个主分片和2个从分片 ] ] ]); |
首先创建”商铺”表:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 |
<?php require_once __DIR__ . "/vendor/autoload.php"; // 客户端 $client = Elasticsearch\ClientBuilder::fromConfig([ 'hosts' => ['localhost:9200', 'localhost:9201', 'localhost:9203'], // 最好在为ES集群搭建Haproxy反向代理 'retries' => 2 ]); // 创建商铺type $indices = $client->indices(); $indices->putMapping([ 'index' => 'basic', 'type' => 'merchant', // 商铺表 'body' => [ // 属性 'properties' => [ // 商铺名称 'merchant_name' => [ 'type' => 'string', // 字符串 'index' => 'analyzed', // 全文索引 'analyzer' => 'ik_max_word', // 中文分词 ], // 商铺图片 'merchant_img' => [ 'type' => 'string', // 字符串 'index' => 'no', // 不索引 ], // 商铺类型 'merchant_type' => [ 'type' => 'string', // 字符串 'index' => 'not_analyzed', // 不分词,直接索引 ], // 用户评分 'merchant_score' => [ 'type' => 'integer', // 整形 'index' => 'not_analyzed', // 直接索引,用于过滤/排序 ], // 人均价格 'merchant_avg_price' => [ 'type' => 'integer', // 整形 'index' => 'not_analyzed', // 直接索引,用于过滤/排序 ], // 地理坐标 'merchant_location' => [ 'type' => 'geo_point', // 地址坐标 ] ] ], ]); |
需要过滤,排序,检索的字段,需要根据其用途配置index项:
- not_analyzed:不需要分词的将被作为整体索引,那么使用。
- analyzed:需要分词的,先经过analyzer分词成很多单词再逐个被索引。
- no:不需要索引,仅作为数据字段一起被保存。
同样的道理,我们现在创建”商品type”:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 |
<?php require_once __DIR__ . "/vendor/autoload.php"; // 客户端 $client = Elasticsearch\ClientBuilder::fromConfig([ 'hosts' => ['localhost:9200', 'localhost:9201', 'localhost:9203'], // 最好在为ES集群搭建Haproxy反向代理 'retries' => 2 ]); // 创建商品type $indices = $client->indices(); $indices->putMapping([ 'index' => 'basic', 'type' => 'product', // 商品表 'body' => [ '_parent' => [ 'type' => 'merchant', ], // 属性 'properties' => [ // 商品名称 'product_name' => [ 'type' => 'string', // 字符串 'index' => 'analyzed', // 全文索引 'analyzer' => 'ik_max_word', // 中文分词 ], // 商品图片 'product_img' => [ 'type' => 'string', // 字符串 'index' => 'no', // 不索引 ], // 商品类型 'product_type' => [ 'type' => 'string', // 字符串 'index' => 'not_analyzed', // 不分词,直接索引 ], // 商品价格 'product_price' => [ 'type' => 'integer', // 整形 'index' => 'not_analyzed', // 直接索引,用于过滤/排序 ], // 商品销量 'product_sold' => [ 'type' => 'integer', // 整形 'index' => 'not_analyzed', // 直接索引,用于排序/过滤 ] ] ], ]); |
我通过给_parent指定了它的父亲为merchant,但是这条命令会报错:can’t add a _parent field that points to an already existing type, that isn’t already a parent。
ES的强制规定,表达父子关系的2个type必须在创建index的时候一起提交mapping。也就是说:假如父type已存在,希望让一个新的子type关联到父type是不可能的(详细可见说明)!
无论如何,我确定要使用”父子文档”,因此我重新执行这段代码:它删除现在的index,并在重建index的同时传入2个type的mapping定义:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 |
<?php require_once __DIR__ . "/vendor/autoload.php"; // 客户端 $client = Elasticsearch\ClientBuilder::fromConfig([ 'hosts' => ['localhost:9200', 'localhost:9201', 'localhost:9203'], // 最好在为ES集群搭建Haproxy反向代理 'retries' => 2 ]); // 创建商铺type $indices = $client->indices(); $indices->delete(['index' => 'basic']); $indices->create([ 'index' => 'basic', 'body' => [ // index配置 'settings' => [ "number_of_shards" => 3, // 3个分区 "number_of_replicas" => 2, // 每个分区有1个主分片和2个从分片 ], // type映射 'mappings' => [ // 商铺 'merchant' => [ // 属性 'properties' => [ // 商铺名称 'merchant_name' => [ 'type' => 'string', // 字符串 'index' => 'analyzed', // 全文索引 'analyzer' => 'ik_max_word', // 中文分词 ], // 商铺图片 'merchant_img' => [ 'type' => 'string', // 字符串 'index' => 'no', // 不索引 ], // 商铺类型 'merchant_type' => [ 'type' => 'string', // 字符串 'index' => 'not_analyzed', // 不分词,直接索引 ], // 用户评分 'merchant_score' => [ 'type' => 'integer', // 整形 'index' => 'not_analyzed', // 直接索引,用于过滤/排序 ], // 人均价格 'merchant_avg_price' => [ 'type' => 'integer', // 整形 'index' => 'not_analyzed', // 直接索引,用于过滤/排序 ], // 地理坐标 'merchant_location' => [ 'type' => 'geo_point', // 地址坐标 ] ] ], // 商品 'product' => [ '_parent' => [ 'type' => 'merchant', ], // 属性 'properties' => [ // 商品名称 'product_name' => [ 'type' => 'string', // 字符串 'index' => 'analyzed', // 全文索引 'analyzer' => 'ik_max_word', // 中文分词 ], // 商品图片 'product_img' => [ 'type' => 'string', // 字符串 'index' => 'no', // 不索引 ], // 商品类型 'product_type' => [ 'type' => 'string', // 字符串 'index' => 'not_analyzed', // 不分词,直接索引 ], // 商品价格 'product_price' => [ 'type' => 'integer', // 整形 'index' => 'not_analyzed', // 直接索引,用于过滤/排序 ], // 商品销量 'product_sold' => [ 'type' => 'integer', // 整形 'index' => 'not_analyzed', // 直接索引,用于排序/过滤 ] ] ], ] ], ]); |
通过curl看一下basic数据库的定义:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 |
[work@df6c675da97e nuomi-search]$ curl localhost:9200/basic?pretty { "basic" : { "aliases" : { }, "mappings" : { "product" : { "_parent" : { "type" : "merchant" }, "_routing" : { "required" : true }, "properties" : { "product_img" : { "type" : "keyword", "index" : false }, "product_name" : { "type" : "text", "analyzer" : "ik_max_word" }, "product_price" : { "type" : "integer" }, "product_sold" : { "type" : "integer" }, "product_type" : { "type" : "keyword" } } }, "merchant" : { "properties" : { "merchant_avg_price" : { "type" : "integer" }, "merchant_img" : { "type" : "keyword", "index" : false }, "merchant_location" : { "type" : "geo_point" }, "merchant_name" : { "type" : "text", "analyzer" : "ik_max_word" }, "merchant_score" : { "type" : "integer" }, "merchant_type" : { "type" : "keyword" } } } }, "settings" : { "index" : { "creation_date" : "1489749586006", "number_of_shards" : "3", "number_of_replicas" : "2", "uuid" : "iOCEqZHiQ1inEK9SvePetw", "version" : { "created" : "5020299" }, "provided_name" : "basic" } } } } |
可以看到product表的_parent已经生效,_routing强制为true是因为之前说的原因:子文档必须和父文档路由到同一个shard内才能实现”父子文档”的联合查询,那么什么是_routing呢?
一个文档进入哪个shard是由hash(_routing)%分片个数 来决定的,而_routing默认等于文档_id(可以点这里了解)。
插入数据
下面通过bulk批量API,先添加3个店铺:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 |
<?php require_once __DIR__ . "/vendor/autoload.php"; // 客户端 $client = Elasticsearch\ClientBuilder::fromConfig([ 'hosts' => ['localhost:9200', 'localhost:9201', 'localhost:9203'], // 最好在为ES集群搭建Haproxy反向代理 'retries' => 2 ]); $client->bulk([ 'index' => 'basic', 'type' => 'merchant', 'body' => [ /* 第1行记录 */ // index方法+元数据 ['index' => ['_id' => 1]], // _id:店铺ID,默认_routing=_id // 请求体 [ 'merchant_name' => '鑫明明拉面', 'merchant_score' => 4, 'merchant_type' => '美食', 'merchant_img' => 'http://merchant.com/1.jpg', 'merchant_avg_price' => 2100, 'merchant_location' => ['127', '128'] ], /* 第2行记录 */ // index方法+元数据 ['index' => ['_id' => 2]], // 请求体 [ 'merchant_name' => '东方宫兰州拉面', 'merchant_score' => 3, 'merchant_type' => '美食', 'merchant_img' => 'http://merchant.com/2.jpg', 'merchant_avg_price' => 1800, 'merchant_location' => ['120', '120'] ], /* 第3行记录 */ // index方法+元数据 ['index' => ['_id' => 3]], // 请求体 [ 'merchant_name' => '开海饭店', 'merchant_score' => 3, 'merchant_type' => '美食', 'merchant_img' => 'http://merchant.com/3.jpg', 'merchant_avg_price' => 3500, 'merchant_location' => ['50', '50'] ], ] ]); |
之后为每个店铺添加一些商品:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 |
$client->bulk([ 'index' => 'basic', 'type' => 'product', 'body' => [ // 第1个商铺的商品,设置其_parent=1从而与对应的商铺进入同一个shard // index方法+元数据 ['index' => ['_id' => 1, '_parent' => 1]], // _id:商品ID // 请求体 [ 'product_name' => '牛肉拉面', 'product_type' => '面食', 'product_img' => 'http://product.com/1.jpg', 'product_sold' => 10, 'product_price' => 2100, ], // index方法+元数据 ['index' => ['_id' => 2, '_parent' => 1]], // _id:商品ID // 请求体 [ 'product_name' => '羊肉烩面', 'product_type' => '面食', 'product_img' => 'http://product.com/2.jpg', 'product_sold' => 11, 'product_price' => 2200, ], // index方法+元数据 ['index' => ['_id' => 3, '_parent' => 1]], // _id:商品ID // 请求体 [ 'product_name' => '烤羊肉串', 'product_type' => '烤串', 'product_img' => 'http://product.com/3.jpg', 'product_sold' => 12, 'product_price' => 2300, ], ///////// 第1个商铺的商品插入结束 // 第2个商铺的商品,设置其_parent=2从而与对应的商铺进入同一个shard // index方法+元数据 ['index' => ['_id' => 4, '_parent' => 2]], // _id:商品ID // 请求体 [ 'product_name' => '牛肉炒面', 'product_type' => '面食', 'product_img' => 'http://product.com/4.jpg', 'product_sold' => 10, 'product_price' => 2400, ], // index方法+元数据 ['index' => ['_id' => 5, '_parent' => 2]], // _id:商品ID // 请求体 [ 'product_name' => '蛋炒饭', 'product_type' => '主食', 'product_img' => 'http://product.com/5.jpg', 'product_sold' => 10, 'product_price' => 2300, ], // index方法+元数据 ['index' => ['_id' => 6, '_parent' => 2]], // _id:商品ID // 请求体 [ 'product_name' => '羊肉汤', 'product_type' => '汤粉', 'product_img' => 'http://product.com/6.jpg', 'product_sold' => 10, 'product_price' => 2200, ], ///////// 第2个商铺的商品插入结束 // 第3个商铺的商品,设置其_parent=3从而与对应的商铺进入同一个shard // index方法+元数据 ['index' => ['_id' => 7, '_parent' => 3]], // _id:商品ID // 请求体 [ 'product_name' => '海鲜炒饭', 'product_type' => '主食', 'product_img' => 'http://product.com/7.jpg', 'product_sold' => 10, 'product_price' => 2400, ], // index方法+元数据 ['index' => ['_id' => 8, '_parent' => 3]], // _id:商品ID // 请求体 [ 'product_name' => '西红柿鸡蛋面', 'product_type' => '面食', 'product_img' => 'http://product.com/8.jpg', 'product_sold' => 10, 'product_price' => 2300, ], // index方法+元数据 ['index' => ['_id' => 9, '_parent' => 3]], // _id:商品ID // 请求体 [ 'product_name' => '鸭血粉丝汤', 'product_type' => '汤粉', 'product_img' => 'http://product.com/9.jpg', 'product_sold' => 10, 'product_price' => 2200, ], ///////// 第3个商铺的商品插入结束 ] ]); |
需要注意:
- 通常来说,每个店铺的id可能来自于mysql中的自增ID,商品id也是同样的,上面可以看出它们独立自增。
- 为了满足”父子文档”,商铺文档按商铺ID路由即可,而对应的商品在添加时不能用商品id路由而是应该使用_routing=其所属的商铺id,这样hash(product._routing)==hash(merchant._id)。另外,我们不必自己传递_routing值而是直接指定_parent即可,相关父子数据将自动进入同一个shard存储。
- bulk API批量提交一堆请求,每个请求由”元数据”+”请求体”共2行组成,其中”元数据”应该指定操作的类型和表名等,而”请求体”则承载具体的请求参数。
父子查询筛选店铺
下面是一个简单的筛选,即搜索售卖”鸭血粉丝汤”的”店铺”有哪些:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
<?php require_once __DIR__ . "/vendor/autoload.php"; // 客户端 $client = Elasticsearch\ClientBuilder::fromConfig([ 'hosts' => ['localhost:9200', 'localhost:9201', 'localhost:9203'], // 最好在为ES集群搭建Haproxy反向代理 'retries' => 2 ]); // 搜索框的输入内容 $keyword = '鸭血粉丝汤'; $ret = $client->search([ 'index' => 'basic', 'type' => 'merchant', 'body' => [ 'query' => [ 'has_child' => [ 'type' => 'product', 'score_mode' => 'max', 'query' => [ 'match' => [ 'product_name' => $keyword, ] ] ] ] ] ]); print_r($ret); |
这里score_mode表示:同一个”店铺”下有多个”商品”匹配关键字,那么取匹配程度最高的那个”商品”的打分作为”店铺”的打分依据。
结果很准确,”开海饭店”售卖”鸭血粉丝汤”,其匹配度打分_score高达10分+,而”东方宫兰州拉面”因为售卖”羊肉汤”而命中”汤”字,所以也出现在了结果集之中,不过匹配度打分_score才区区0.9分。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 |
[work@df6c675da97e nuomi-search]$ php main.php Array ( [took] => 18 [timed_out] => [_shards] => Array ( [total] => 3 [successful] => 3 [failed] => 0 ) [hits] => Array ( [total] => 2 [max_score] => 10.727891 [hits] => Array ( [0] => Array ( [_index] => basic [_type] => merchant [_id] => 3 [_score] => 10.727891 [_source] => Array ( [merchant_name] => 开海饭店 [merchant_score] => 3 [merchant_type] => 美食 [merchant_img] => http://merchant.com/3.jpg [merchant_avg_price] => 3500 [merchant_location] => Array ( [0] => 50 [1] => 50 ) ) ) [1] => Array ( [_index] => basic [_type] => merchant [_id] => 2 [_score] => 0.93239146 [_source] => Array ( [merchant_name] => 东方宫兰州拉面 [merchant_score] => 3 [merchant_type] => 美食 [merchant_img] => http://merchant.com/2.jpg [merchant_avg_price] => 1800 [merchant_location] => Array ( [0] => 120 [1] => 120 ) ) ) ) ) ) |
父子文档也支持同时筛选父亲和孩子,只需要把父亲和孩子的子句放在一个bool中即可:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
<?php require_once __DIR__ . "/vendor/autoload.php"; // 客户端 $client = Elasticsearch\ClientBuilder::fromConfig([ 'hosts' => ['localhost:9200', 'localhost:9201', 'localhost:9203'], // 最好在为ES集群搭建Haproxy反向代理 'retries' => 2 ]); // 搜索框的输入内容 $keyword = '拉面'; $ret = $client->search([ 'index' => 'basic', 'type' => 'merchant', 'body' => [ 'query' => [ 'bool' => [ 'should' => [ [ 'has_child' => [ 'type' => 'product', 'score_mode' => 'max', 'query' => [ 'match' => [ 'product_name' => $keyword, ] ] ] ], [ 'match' => [ 'merchant_name' => $keyword, ] ] ] ] ] ] ]); print_r($ret); |
上面代码在店铺名或商品名中寻找”拉面”,bool中使用should表示或的关系,而should中有2项子句,一个针对父文档merchant_name进行检索,一个针对子文档product_name进行检索,最后按照bool should的方式计算总体相关性(可以回顾一下上面的dis_max方式取代should)。
特别感谢Jasonsoso留言纠正了本文关于父子文档的错误描述。
父子文档当前最大的实战问题是无法根据孩子/父亲排序,使用时需谨慎,可以参考:https://github.com/elastic/elasticsearch/issues/2917。
如果文章帮助您解决了工作难题,您可以帮我点击屏幕上的任意广告,或者赞助少量费用来支持我的持续创作,谢谢~
父子文档同时过滤父文档和子文档都可以呀!只不过同时排序父文档和子文档不行(据我所知是不行的)
QQ交流一下,我没能找到父子文档同时筛选父与子的方法。
写的非常好。收益匪浅
谢谢赞赏,有问题随时交流~
這些天都在找 elasticsearch 的文章,這篇寫得最清楚
谢谢认可
你的文章写的太好了,我能转载吗?
可以的。
我有一个疑问,两家商店售卖同一件商品呢 要怎么插入数据呢?
商品ID和商家ID都不一样,为什么不能插入。。
我的意思是
比如方便面 店铺A,店铺B都有这件商品
子文档从 属于 两个不同的父文档
噢,按我的理解应该给每个店铺插入一个方便面的子文档记录。
两个方便面的parent不同。
1