day06 Elasticsearch搜索引擎2

贵客云 2022-11-23 15:48 阅读 47

day06 Elasticsearch搜索引擎2

1、DSL查询文档

1.1、DSL查询分类

Elasticsearch提供了基于JSON的DSL(Domain Specific Language)来定义查询。常见的查询类型包括:

  • 查询所有:查询所有数据,一般在测试中用到。例如:match_all
  • 全文检索(full text)查询:利用分词器对用户输入内容进行分词,然后去倒排索引库中匹配。例如:
    • match_query
    • multi_match_query
  • 精确查询:根据精确词条值查找数据,一般是查找 keyword、数值、日期、boolean等类型字段。例如:
    • ids
    • range
    • term
  • 地理(geo)查询:根据经纬度查询。例如:
    • geo_distance
    • geo_bounding_box
  • 复合(compound)查询:复合查询可以将上述各种查询条件组合起来,合并查询条件。例如:
    • bool
    • function_score

查询的基本语法:

GET /索引库名称/_search
{

"query": {

"查询类型": {

"查询条件": "条件值"
}
}
}

以查询所有为例,其中:

  • 查询类型为 match_all
  • 没有查询条件
# 查询所有
GET /hotel/_search
{

"query": {

"match_all": {

}
}
}

Untitled

1.2、全文检索查询

使用场景

全文检索查询的基本流程如下:

  • 对用户搜索的内容进行分词,得到词条
  • 根据词条去倒排索引库中进行匹配,得到文档id
  • 根据文档id找到文档,返回给用户

比较常用的场景包括:

  • 商城的输入框搜索
  • 百度输入框搜索

Untitled

因为是拿着词条去匹配,因此参与搜索的字段也必须是可分词的 text 类型的字段。

基本语法

常见的全文检索查询包括:

  • match查询:单字段查询
  • multi_match查询:多字段查询,任意一个字段符合条件就算符合查询条件

match查询语法如下:

GET /索引库名称/_search
{

"query": {

"match": {

"FIELD": "TEXT"
}
}
}

示例:

# match查询
GET /hotel/_search
{

"query": {

"match": {

"all": "外滩如家"
}
}
}

Untitled

mulit_match 查询语法如下:

GET /索引库名称/_search
{

"query": {

"multi_match": {

"query": "TEXT",
"fields": ["FIELD1", " FIELD12"]
}
}
}

示例:

# multi_match查询
GET /hotel/_search
{

"query": {

"multi_match": {

"query": "外滩如家",
"fields": ["brand", "name", "business"]
}
}
}

Untitled

可以看到,查询结果和上面的 match 查询是一样的。

这是因为我们将 brand、name、business 值都利用 copy_to 复制到了 all 字段中,所以根据三个字段进行搜索和根据 all 字段进行搜索的效果是一样的。

但是搜索字段越多,对查询性能的影响越大,因此建议采用 copy_to 将多个字段合并成一个,然后使用单字段查询的方式。

1.3、精确查询

精确查询一般是查找 keyword、数值、日期、boolean等类型字段。所以不会对搜索条件进行分词。常见的有:

  • term:根据词条精确值查询
  • range:根据值的范围查询

term查询

因为精确查询的字段是不分词的字段,因此查询的条件也必须是不分词的词条。查询时,用户输入的内容跟文档里面的字段值完全匹配时才认为符合条件。如果用户输入的内容过多,反而搜索不到数据。

基本语法

// term查询
GET /索引库名称/_search
{

"query": {

"term": {

"FIELD": {

"value": "VALUE"
}
}
}
}

示例:

# term查询
GET /hotel/_search
{

"query": {

"term": {

"city": {

"value": "上海"
}
}
}
}

当我搜索的是精确词条时,能正确查询出结果:

Untitled

当我搜索的内容不是词条,而是多个词语形成的短语时,反而搜索不到:

Untitled

range查询

范围查询,一般应用在对数值类型做范围过滤的时候。比如做价格范围过滤。

基本语法

// range查询
GET /indexName/_search
{

"query": {

"range": {

"FIELD": {

"gte": 10, // 这里的gte代表大于等于,gt则代表大于
"lte": 20 // lte代表小于等于,lt则代表小于
}
}
}
}

示例:

# range查询
GET /hotel/_search
{

"query": {

"range": {

"price": {

"gte": 100,
"lte": 300
}
}
}
}

Untitled

小结

精确查询常见的有哪些?

  • term查询:根据词条精确匹配,一般是搜索keyword类型、数值类型、布尔类型、日期类型字段
  • range查询:根据数值范围查询,可以是数值、日期的范围

1.4、地理坐标查询

所谓的地理坐标查询,其实就是根据经纬度查询,官方文档:https://www.elastic.co/guide/en/elasticsearch/reference/current/geo-queries.html

常见的使用场景包括:

  • 携程:搜索我附近的酒店
Untitled
  • 滴滴:搜索我附近的出租车
Untitled
  • 微信:搜索我附近的人

矩形范围查询

矩形范围查询,也就是 geo_bounding_box 查询,查询 geo_point 值落在某个矩形范围内的所有文档:

Untitled

查询时,需要指定矩形的 左上右下 两个点的坐标,然后画出一个矩形,落在该矩形内的都是符合条件的点。

基本语法:

// geo_bounding_box查询
GET /索引库名称/_search
{

"query": {

"geo_bounding_box": {

"FIELD": {

"top_left": {
 // 左上点
"lat": 31.1,
"lon": 121.5
},
"bottom_right": {
 // 右下点
"lat": 30.9,
"lon": 121.7
}
}
}
}
}

附近查询

附近查询,也叫做距离查询(geo_distance):查询到指定中心点小于某个距离值的所有文档。

换句话说,在地图上找一个点作为圆心,以指定距离为半径画一个圆,落在圆内的坐标都算符合条件:

Untitled

基本语法:

// geo_distance 查询
GET /indexName/_search
{

"query": {

"geo_distance": {

"distance": "15km", // 半径
"FIELD": "31.21,121.5" // 圆心
}
}
}

示例:

# distance查询
GET /hotel/_search
{

"query": {

"geo_distance": {

"distance": "15km",
"location": "31.21, 121.5"
}
}
}

我们先搜索陆家嘴附近15km的酒店

Untitled

发现共有47家酒店,然后再把半径缩小到3公里

Untitled

可以发现,搜索到的酒店数量减少到了5家。

1.5、复合查询

复合(compound)查询:复合查询可以将其它简单查询组合起来,实现更复杂的搜索逻辑。常见的有两种:

  • fuction score:算分函数查询,可以控制文档相关性算分,控制文档排名。例如百度竞价
  • bool query:布尔查询,利用逻辑关系组合多个其它的查询,实现复杂搜索

相关性算分

当我们利用 match 查询时,文档结果会根据与搜索词条的关联度进行打分(_score),返回结果时按照分值降序排列。

例如,我们搜索 “虹桥如家”,结果如下:

[
{

"_score" : 17.850193,
"_source" : {

"name" : "虹桥如家酒店真不错",
}
},
{

"_score" : 12.259849,
"_source" : {

"name" : "外滩如家酒店真不错",
}
},
{

"_score" : 11.91091,
"_source" : {

"name" : "迪士尼如家酒店真不错",
}
}
]

在 elasticsearch 中,早期使用的打分算法是 TF-IDF 算法,公式如下:

Untitled

在后来的5.1版本升级中,elasticsearch 将算法改进为 BM25 算法,公式如下:

Untitled

TF-IDF 算法有一个缺陷,就是词条频率越高,文档得分也会越高,单个词条对文档影响较大。而 BM25 则会让单个词条的算分有一个上限,曲线更加平滑:

Untitled

算分函数查询 Function Score Query

根据相关度打分是比较合理的需求,但合理的不一定是产品经理需要的。

以百度为例,你搜索的结果中,并不是相关度越高排名越靠前,而是谁掏的钱越多排名就越靠前。

Untitled

要想人为控制相关性算分,就需要利用 elasticsearch 中的 function score 查询了。

语法说明:

Untitled

function score 查询中包含四部分内容:

  • 原始查询条件:query部分,基于这个条件搜索文档,并且基于BM25算法给文档打分,原始算分(query score)
  • 过滤条件:filter部分,符合该条件的文档才会重新算分
  • 算分函数:符合filter条件的文档要根据这个函数做运算,得到的函数算分(function score),有四种函数
    • weight:函数结果是常量
    • field_value_factor:以文档中的某个字段值作为函数结果
    • random_score:以随机数作为函数结果
    • script_score:自定义算分函数算法
  • 加权模式:算分函数的结果、原始查询的相关性算分,两者之间的运算方式,包括:
    • multiply:相乘
    • replace:用function score替换query score
    • 其它,例如:sum、avg、max、min

function score 的运行流程如下:

1、根据原始条件查询搜索文档,并计算相关性算分,称为原始算分(query score)

2、根据过滤条件,过滤文档

3、符合过滤条件的文档,基于算分函数运算,得到函数算分(function score)

4、将原始算分(query score)和函数算分(function score)基于加权模式做运算,得到最终结果,作为相关性算分。

因此,其中的关键点是:

  • 过滤条件:决定哪些文档的算分需要被修改
  • 算分函数:决定函数算分的算法
  • 加权模式:决定最终算分结果

需求:给 “如家” 这个品牌的酒店排名靠前一些

翻译一下这个需求,转换为之前说的四个要点:

  • 原始条件:不确定,可以任意变化
  • 过滤条件:brand = “如家”
  • 算分函数:可以简单粗暴,直接给固定的算分结果,weight
  • 运算模式:比如求和

因此最终的 DSL 语句如下:

GET /hotel/_search
{

"query": {

"function_score": {

"query": {
 .... }, // 原始查询,可以是任意条件
"functions": [ // 算分函数
{

"filter": {
 // 满足的条件,品牌必须是如家
"term": {

"brand": "如家"
}
},
"weight": 2 // 算分权重为2
}
],
"boost_mode": "sum" // 加权模式:求和
}
}
}

测试,在未添加算分函数时,如家得分如下:

Untitled

添加了算分函数后,如家得分就提升了:

Untitled

小结

function score query 定义的三要素是什么?

  • 过滤条件:哪些文档要加分
  • 算分函数:如何计算 function score
  • 加权方式:function score 与 query score 如何运算

布尔查询 Boolean Query

布尔查询是一个或多个查询子句的组合,每一个子句就是一个子查询。子查询的组合方式有:

  • must:必须匹配每个子查询,类似“与”
  • should:选择性匹配子查询,类似“或”
  • must_not:必须不匹配,不参与算分,类似“非”
  • filter:必须匹配,不参与算分

比如在搜索酒店时,除了关键字搜索外,我们还可能根据品牌、价格、城市等字段进行过滤:

Untitled

每一个不同的字段,其查询的条件、方式都不一样,必须是多个不同的查询,而要组合这些查询,就必须用 bool 查询了。

需要注意的是,搜索时参与打分的字段越多,查询的性能会越差。因此这种多条件查询时,建议这样做:

  • 搜索框的关键字搜索是全文检索查询,使用 must 查询,参与算分
  • 其它过滤条件,采用 filter 查询,不参与算分

语法示例:

GET /hotel/_search
{

"query": {

"bool": {

"must": [
{
"term": {
"city": "上海" }} // 城市必须是上海
],
"should": [
{
"term": {
"brand": "皇冠假日" }}, // 品牌可以是皇冠假日或者华美达
{
"term": {
"brand": "华美达" }}
],
"must_not": [
{
 "range": {
 "price": {
 "lte": 500 } }} // 价格必须大于500
],
"filter": [
{
 "range": {
"score": {
 "gte": 45 } }} // 用户评价分必须大于等于45
]
}
}
}

需求:搜索名字包含“如家”,价格不高于400,在坐标 31.21, 121.5 周围 10km 范围内的酒店。

分析:

  • 名称搜索,属于全文检索查询,应该参与算分。放到 must 中
  • 价格不高于400,用 range 查询,属于过滤条件,不参与算分。放到 must_not 中
  • 周围 10km 范围内,用 geo_distance 查询,属于过滤条件,不参与算分。放到 filter 中
# bool 查询
GET /hotel/_search
{

"query": {

"bool": {

"must": [
{

"match": {

"name": "如家"
}
}
],
"must_not": [
{

"range": {

"price": {

"gt": 400
}
}
}
],
"filter": [
{

"geo_distance": {

"distance": "10km",
"location": {

"lat": 31.21,
"lon": 121.5
}
}
}
]
}
}
}

Untitled

小结

bool查询有几种逻辑关系?

  • must:必须匹配的条件,可以理解为“与”,参与打分
  • should:选择性匹配的条件,可以理解为“或”,参与打分
  • must_not:必须不匹配的条件,不参与打分
  • filter:必须匹配的条件,不参与打分

2、搜索结果处理

搜索的结果可以按照用户指定的方式去处理或展示。

2.1、排序

elasticsearch 支持对搜索结果进行排序,默认是根据相关度算分(_score)来排序的,但是也支持自定义方式对搜索结果进行排序,可以排序的字段类型有:keyword类型、数值类型、地理坐标类型、日期类型等。

普通字段排序

keyword、数值、日期类型的排序语法基本一致。

基本语法:

GET /indexName/_search
{

"query": {

"match_all": {
}
},
"sort": [
{

"FIELD": "desc" // 排序字段、排序方式ASC、DESC
}
]
}

排序条件是一个数组,也就是可以写多个排序条件。按照声明的顺序,当第一个条件相等时,再按照第二个条件进行排序,以此类推

需求:对酒店数据按照用户评价(score)降序排序,评价相同的按照价格(price)升序排序

Untitled

# sort排序
GET /hotel/_search
{

"query": {

"match_all": {
}
},
"sort": [
{

"score": "desc"
},
{

"price": "asc"
}
]
}

Untitled

地理坐标排序

基本语法:

GET /indexName/_search
{

"query": {

"match_all": {
}
},
"sort": [
{

"_geo_distance" : {

"FIELD" : "纬度,经度", // 文档中 geo_point 类型的字段名、目标坐标点
"order" : "asc", // 排序方式
"unit" : "km" // 排序的距离单位
}
}
]
}

这个查询的含义是:

  • 指定一个坐标,作为目标点
  • 计算每一个文档中,指定字段(必须是 geo_point 类型)的坐标到目标点的距离是多少
  • 根据距离排序

需求:实现对酒店数据按照到你的位置坐标的距离升序排序

获取你的位置的经纬度的方式:https://lbs.amap.com/demo/jsapi-v2/example/map/click-to-get-lnglat/

Untitled

# 找到113.396516, 23.194149周围的酒店,按照距离升序排序
GET /hotel/_search
{

"query": {

"match_all": {
}
},
"sort": [
{

"_geo_distance": {

"location": {

"lat": 23.194149,
"lon": 113.396516
},
"order": "asc",
"unit": "km"
}
}
]
}

Untitled

2.2、分页

elasticsearch 默认情况下只返回 top10 的数据,而如果要查询更多的数据就需要修改分页参数了。elasticsearch 中通过修改 fromsize 参数来控制要返回的分页结果:

  • from:从第几个文档开始
  • size:总共查询几个文档

类似于 mysql 中的 limit ?, ?

基本语法:

GET /hotel/_search
{

"query": {

"match_all": {
}
},
"from": 0, // 分页开始的位置,默认为0
"size": 10, // 期望获取的文档总数
"sort": [
{
"price": "asc"}
]
}

示例:

# 分页查询
GET /hotel/_search
{

"query": {

"match_all": {
}
},
"sort": [
{

"price": "asc"
}
],
"from": 10,
"size": 10
}

Untitled

深度分页问题

现在我要查询 990~1000 行的数据,查询逻辑要这么写:

GET /hotel/_search
{

"query": {

"match_all": {
}
},
"from": 990, // 分页开始的位置,默认为0
"size": 10, // 期望获取的文档总数
"sort": [
{
"price": "asc"}
]
}

这里是查询 990 开始的数据,也就是 第990~第1000条 数据。

不过,elasticsearch 内部分页时,必须先查询 0~1000条,然后截取其中的990 ~ 1000的这10条:

Untitled

查询 TOP1000,如果es是单点模式,这并无太大影响。

但 elasticsearch 将来一定是集群,例如es集群有5个节点,我要查询 TOP1000 的数据,并不是每个节点查询200条就可以了。

因为节点A的TOP200,在另一个节点可能排到10000名以外了。

因此要想获取整个集群的TOP1000,必须先查询出每个节点的TOP1000,汇总结果后,重新排名,重新截取TOP1000。

例如按 price 排序后,获取 from = 990,size =10 的数据:

1、首先在每个数据分片上都排序并查询出前1000条文档

2、然后将所有节点的结果进行聚合,在内存中重新排序并选出前1000条文档

3、最后从这1000条中,选取从990开始的10条文档

Untitled

那如果我要查询 9900~10000 的数据呢?是不是要先查询 TOP10000 呢?那每个节点都要查询10000条?汇总到内存中?

当查询分页深度较大时,汇总数据过多,对内存和CPU会产生非常大的压力,因此 elasticsearch 会禁止 from + size 超过 10000 的请求。

怎么理解 节点A的TOP200,在另一个节点可能排到10000名以外了?

比如学校要从高三年级10个班级中挑出成绩排名前100名的学生,班级有尖子班和普通班之分。

如果我从每个班级挑出10名学生,那有可能在普通班前10名的学生,跑到尖子班去变成倒数了。

真正的做法是把每个班的前100名学生挑出来,然后全部合在一起做排序,找出全校的前100名学生。

针对深度分页,ES提供了两种解决方案,官方文档:

  • search after:分页时需要排序,原理是从上一次的排序值开始,查询下一页数据。官方推荐使用的方式。
  • scroll:原理是将排序后的文档 id 形成快照,保存在内存。官方已经不推荐使用。

分页查询的常见实现方案以及优缺点:

  • from + size
    • 优点:支持随机翻页
    • 缺点:存在深度分页问题,默认查询上限(from + size)是 10000
    • 场景:百度、京东、谷歌、淘宝这样的随机翻页搜索
  • search after
    • 优点:没有查询上限(单次查询的 size 不超过 10000)
    • 缺点:只能向后逐页查询,不支持随机翻页
    • 场景:没有随机翻页需求的搜索,例如手机向下滚动翻页
  • scroll
    • 优点:没有查询上限(单次查询的 size 不超过 10000)
    • 缺点:会有额外内存消耗,并且搜索结果是非实时的
    • 场景:海量数据的获取和迁移。从 ES 7.1 开始不推荐,建议用 search after 方案。

2.3、高亮

高亮原理

什么是高亮显示呢?

我们在百度,京东搜索时,关键字会变成红色,比较醒目,这就是高亮显示:

Untitled

高亮显示的实现分为两步:

1、将搜索结果中的关键字用标签标记出来,例如 <em> 标签

2、前端在页面中给 <em> 标签编写 CSS 样式

实现高亮

基本语法:

GET /hotel/_search
{

"query": {

"match": {

"FIELD": "TEXT" // 查询条件,高亮一定要使用全文检索查询
}
},
"highlight": {

"fields": {
 // 指定要高亮的字段
"FIELD": {

"pre_tags": "<em>", // 用来标记高亮字段的前置标签
"post_tags": "</em>" // 用来标记高亮字段的后置标签
}
}
}
}

注意:

  • 高亮是对关键字进行高亮,因此搜索条件必须带有关键字,而不能是范围这样的查询。
  • 默认情况下,高亮的字段必须与搜索指定的字段一致,否则无法高亮
  • 如果要对非搜索字段进行高亮,则需要添加一个属性:required_field_match=false

示例:

# 高亮查询,默认情况下,ES搜索字段必须与高亮字段一致
GET /hotel/_search
{

"query": {

"match": {

"all": "如家"
}
},
"highlight": {

"fields": {

"name": {

"require_field_match": "false"
}
}
}
}

Untitled

2.4、总结

查询的 DSL 是一个大的 JSON 对象,包含下列属性:

  • query:查询条件
  • from和size:分页条件
  • sort:排序条件
  • highlight:高亮条件
Untitled

3、RestClient查询文档

3.1、快速入门

以 match_all 查询为例。

发起查询请求

Untitled

代码解读:

  • 第一步,创建 SearchRequest 对象,指定索引库名
  • 第二步,利用 request.source() 构建DSL,DSL中可以包含查询、分页、排序、高亮等
    • query():代表查询条件,利用 QueryBuilders.matchAllQuery() 构建一个 match_all 查询的DSL
  • 第三步,利用 client.search() 发送请求,得到响应

这里关键的 API 有两个,一个是 request.source(),其中包含了查询、排序、分页、高亮等所有功能:

Untitled

另一个是 QueryBuilders,其中包含 match、term、function_score、bool 等各种查询:

Untitled

解析响应

响应结果的解析:

Untitled

elasticsearch 返回的结果是一个 JSON 字符串,结构包含:

  • hits:命中的结果
    • total:总条数,其中的 value 是具体的总条数值
    • max_score:所有结果中得分最高的文档的相关性算分
    • hits:搜索结果的文档数组,其中的每个文档都是一个 json 对象
      • _source:文档中的原始数据,也是 json 对象

因此,我们解析响应结果,就是逐层解析 JSON 字符串,流程如下:

  • SearchHits:通过 response.getHits() 获取,就是JSON中的最外层的hits,代表命中的结果
    • SearchHits.getTotalHits().value:获取总条数信息
    • SearchHits.getHits():获取 SearchHit 数组,也就是文档数组
      • SearchHit.getSourceAsString():获取文档结果中的 _source,也就是原始的 json 文档数据

完整代码

@SpringBootTest
public class HotelSearchTest {

private RestHighLevelClient client;
@Test
void testMatchAll() throws IOException {

// 1.创建request对象
SearchRequest request = new SearchRequest("hotel");
// 2.准备DSL
request.source().query(QueryBuilders.matchAllQuery());
// 3.发送请求,得到响应
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4.解析响应
SearchHits searchHits = response.getHits();
// 4.1、获取总条数
long total = searchHits.getTotalHits().value;
System.out.println("共搜索到" + total + "条数据");
// 4.2、文档数组
SearchHit[] hits = searchHits.getHits();
// 4.3、遍历文档数组
for (SearchHit hit : hits) {

// 获取文档source
String json = hit.getSourceAsString();
// 反序列化
HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class);
System.out.println("hotelDoc = " + hotelDoc);
}
}
@BeforeEach
void setUp() {

client = new RestHighLevelClient(RestClient.builder(HttpHost.create("http://es服务器ip地址:9200")));
}
@AfterEach
void tearDown() throws IOException {

client.close();
}
}

3.2、match查询

全文检索的 matchmulti_match 查询与 match_all 的 API 基本一致,区别zh是查询条件,也就是 query 的部分。

Untitled

因此,Java代码上的差异主要是 request.source().query() 中的参数了。同样是利用 QueryBuilders 提供的方法:

Untitled

而结果解析的代码则完全一致,可以抽取并共享。

完整代码:

 @Test
void testMatch() throws IOException {

// 1.创建request对象
SearchRequest request = new SearchRequest("hotel");
// 2.准备DSL
request.source().query(QueryBuilders.matchQuery("all", "如家"));
// 3.发送请求,得到响应
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4.解析响应
handleResponse(response);
}
/** * 封装解析结果 * * @param response */
private void handleResponse(SearchResponse response) {

// 4.解析响应
SearchHits searchHits = response.getHits();
// 4.1、获取总条数
long total = searchHits.getTotalHits().value;
System.out.println("总条数:" + total);
// 4.2、获取文档数组
SearchHit[] hits = searchHits.getHits();
// 4.3、遍历文档数组
for (SearchHit hit : hits) {

// 获取文档source
String json = hit.getSourceAsString();
// 反序列化
HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class);
System.out.println("hotelDoc = " + hotelDoc);
}
}

3.3、精确查询

精确查询主要是两者:

  • term:词条精确匹配
  • range:范围查询

与之前的查询相比,差异同样在查询条件,其它都一样。

查询条件构造的API如下:

Untitled

3.4、布尔查询

布尔查询是用 must、must_not、filter 等方式组合其它查询,代码示例如下:

Untitled

完整代码:

 @Test
void testBool() throws IOException {

// 1.创建request对象
SearchRequest request = new SearchRequest("hotel");
// 2.准备DSL
request.source().query(
// 2.1、准备QueryBuilders
QueryBuilders.boolQuery()
// 2.2、must
.must(QueryBuilders.termQuery("city", "上海"))
// 2.3、filter
.filter(QueryBuilders.rangeQuery("price").lte(250)));
// 3.发送请求,得到响应
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4.解析响应
handleResponse(response);
}

3.5、排序、分页

搜索结果的排序和分页是与query同级的参数,因此同样是用 request.source() 来设置。

对应的API如下:

Untitled

完整代码:

 @Test
void testPageAndSort() throws IOException {

// 前端传过来的页码,每页显示条数
int page = 2, size = 8;
// 1.创建request对象
SearchRequest request = new SearchRequest("hotel");
// 2.准备请求参数
// 2.1、query
request.source().query(QueryBuilders.matchAllQuery());
// 2.2、排序(sort)
request.source().sort("price", SortOrder.ASC);
// 2.3、分页(from、size)
request.source().from((page - 1) * size).size(size);
// 3.发送请求,得到响应
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4.解析响应
handleResponse(response);
}

3.6、高亮显示

高亮的代码与之前代码差异较大,有两点:

  • 查询的DSL:除了查询条件,还需要添加高亮条件,同样是与 query 同级。
  • 结果解析:响应结果除了要解析 _source 文档数据,还要解析高亮结果

高亮请求构建

高亮请求的构建API如下:

Untitled

上述代码省略了查询条件部分,但是要注意:高亮查询必须使用全文检索查询,并且要有搜索关键字,将来才可以对关键字进行高亮。

完整代码:

 @Test
void testHighlight() throws IOException {

// 1.创建request对象
SearchRequest request = new SearchRequest("hotel");
// 2.准备请求参数
// 2.1、query,注意必须带关键字查询
request.source().query(QueryBuilders.matchQuery("all", "外滩如家"));
// 2.2、高亮
request.source().highlighter(new HighlightBuilder()
// 需要高亮显示的字段
.field("name")
// 是否需要和查询字段匹配
.requireFieldMatch(false));
// 3.发送请求,得到响应
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4.解析响应
handleResponse(response);
}

高亮结果解析

高亮的结果与查询的文档结果默认是分离的,并不在一起。

因此解析高亮的代码需要额外进行处理:

Untitled

  • 第一步:从结果中获取 source。hit.getSourceAsString(),这部分是非高亮结果,json字符串。还需要反序列为 HotelDoc 对象
  • 第二步:获取高亮结果。hit.getHighlightFields(),返回值是一个 Map,key是高亮字段名称,值是 HighlightField 对象,代表高亮值
  • 第三步:从 Map 中根据高亮字段名称,获取高亮字段值对象 HighlightField
  • 第四步:从 HighlightField 中获取 Fragments,并且转为字符串。这部分就是真正的高亮字符串了
  • 第五步:用高亮的结果替换 HotelDoc 中的非高亮结果

完整代码:

 /** * 封装解析结果 * * @param response */
private void handleResponse(SearchResponse response) {

SearchHits searchHits = response.getHits();
// 4.1.获取总条数
long total = searchHits.getTotalHits().value;
System.out.println("共搜索到" + total + "条数据");
// 4.2.获取文档数组
SearchHit[] hits = searchHits.getHits();
// 4.3.遍历文档数组
for (SearchHit hit : hits) {

// 4.4、获取文档source
String json = hit.getSourceAsString();
// 4.5、反序列化,非高亮的
HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class);
// 4.6、处理高亮结果
// 1、获取高亮map
Map<String, HighlightField> map = hit.getHighlightFields();
if (!CollectionUtils.isEmpty(map)) {

// 2、根据字段名,获取高亮结果
HighlightField highlightField = map.get("name");
if (highlightField != null) {

// 3、获取高亮结果字符串数组中的第1个元素
String hName = highlightField.getFragments()[0].toString();
// 4、覆盖非高亮结果
hotelDoc.setName(hName);
}
}
// 4.7、打印
System.out.println("hotelDoc = " + hotelDoc);
}
}

3.7、地理坐标排序

Untitled

3.8、相关性算分

function_score 查询结构如下:

Untitled

对应的 JavaAPI 如下:

Untitled

4、黑马旅游案例

启动我们提供的 hotel-demo 项目,其默认端口是8089,访问 http://localhost:8090,就能看到项目页面了:

Untitled

4.1、酒店搜索和分页

案例需求:实现黑马旅游的酒店搜索功能,完成关键字搜索和分页

需求分析

在项目的首页,有一个大大的搜索框,还有分页按钮:

Untitled

点击搜索按钮,可以看到浏览器控制台发出了请求:

Untitled

请求参数如下:

Untitled

由此可以知道,这个请求的信息如下:

  • 请求方式:POST
  • 请求路径:/hotel/list
  • 请求参数:JSON对象,包含4个字段:
    • key:搜索关键字
    • page:页码
    • size:每页大小
    • sortBy:排序,目前暂不实现
  • 返回值:分页查询,需要返回分页结果 PageResult,包含两个属性:
    • total:总条数
    • List<HotelDoc>:当前页的数据

实现业务的流程如下:

  • 步骤一:定义实体类,接收请求参数的JSON对象
  • 步骤二:定义 controller 接口,接收页面请求
  • 步骤三:编写业务实现,利用 RestHighLevelClient 实现搜索、分页

定义实体类

实体类有两个,一个是前端的请求参数实体,一个是服务端应该返回的响应结果实体。

1、请求参数

前端请求的json结构如下:

{

"key": "搜索关键字",
"page": 1,
"size": 3,
"sortBy": "default"
}

因此,我们在 cn.itcast.hotel.pojo 包下定义一个实体类:

@Data
public class RequestParams {

// 搜索关键字
private String key;
// 当前页码
private Integer page;
// 每页大小
private Integer size;
// 排序字段
private String sortBy;
}

2、返回值

分页查询,需要返回分页结果 PageResult,包含两个属性:

  • total:总条数
  • List<HotelDoc>:当前页的数据

因此,我们在 cn.itcast.hotel.pojo 中定义返回结果:

@Data
public class PageResult {

// 总条数
private Long total;
// 酒店数据
private List<HotelDoc> hotels;
public PageResult() {

}
public PageResult(Long total, List<HotelDoc> hotels) {

this.total = total;
this.hotels = hotels;
}
}

定义controller

定义一个HotelController,声明查询接口,满足下列要求:

  • 请求方式:POST
  • 请求路径:/hotel/list
  • 请求参数:对象,类型为RequestParam
  • 返回值:PageResult,包含两个属性
    • Long total:总条数
    • List<HotelDoc> hotels:酒店数据

因此,我们在 cn.itcast.hotel.web 中定义 HotelController:

@RestController
@RequestMapping("/hotel")
public class HotelController {

@Autowired
private IHotelService hotelService;
/** * 根据关键字搜索酒店信息 * * @param params * @return */
@PostMapping("/list")
public PageResult search(@RequestBody RequestParams params) {

return hotelService.search(params);
}
}

实现搜索业务

我们在 controller 调用了IHotelService,并没有实现该方法,因此下面我们在 IHotelService 中定义方法并实现业务逻辑。

1、在 cn.itcast.hotel.service 中的 IHotelService 接口中定义一个方法:

public interface IHotelService extends IService<Hotel> {

/** * 根据关键字搜索酒店信息 * * @param params 请求参数对象,包含用户输入的关键字 * @return 酒店文档列表 */
PageResult search(RequestParams params);
}

2、实现搜索业务,肯定离不开 RestHighLevelClient,我们需要把它注册到Spring中作为一个Bean。在 cn.itcast.hotel 中的 HotelDemoApplication中声明这个Bean:

@MapperScan("cn.itcast.hotel.mapper")
@SpringBootApplication
public class HotelDemoApplication {

public static void main(String[] args) {

SpringApplication.run(HotelDemoApplication.class, args);
}
@Bean
public RestHighLevelClient client() {

return new RestHighLevelClient(RestClient.builder(HttpHost.create("http://es服务器ip地址:9200")));
}
}

3、在 cn.itcast.hotel.service.impl 中的 HotelService 中实现 search 方法:

@Service
public class HotelService extends ServiceImpl<HotelMapper, Hotel> implements IHotelService {

@Autowired
private RestHighLevelClient client;
/** * 根据关键字搜索酒店信息 * * @param params 请求参数对象,包含用户输入的关键字 * @return 酒店文档列表 */
@Override
public PageResult search(RequestParams params) {

try {

// 1.创建Request对象
SearchRequest request = new SearchRequest("hotel");
// 2.准备DSL
// 2.1、根据关键字进行搜索 query
String key = params.getKey();
if (key == null || "".equals(key)) {

request.source().query(QueryBuilders.matchAllQuery());
} else {

request.source().query(QueryBuilders.matchQuery("all", key));
}
// 2.2、分页
// 当前页码
int page = params.getPage();
// 每页显示多少条数据
int size = params.getSize();
request.source().from((page - 1) * size).size(size);
// 3.发送请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4.解析响应
return handleResponse(response);
} catch (IOException e) {

throw new RuntimeException(e);
}
}
/** * 对响应结果进行解析 * * @param response * @return */
private PageResult handleResponse(SearchResponse response) {

// 4.解析响应
SearchHits searchHits = response.getHits();
// 4.1、获取总条数
long total = searchHits.getTotalHits().value;
// 4.2、获取文档数组
SearchHit[] hits = searchHits.getHits();
List<HotelDoc> hotels = new ArrayList<>();
// 4.3、遍历文档数据
for (SearchHit hit : hits) {

// 获取文档source
String json = hit.getSourceAsString();
// 反序列化
HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class);
// 放入集合
hotels.add(hotelDoc);
}
// 4.4、封装返回
return new PageResult(total, hotels);
}
}

4.2、酒店结果过滤

需求:添加品牌、城市、星级、价格等过滤功能

需求分析

在页面搜索框下面,会有一些过滤项:

Untitled

传递的参数如图:

Untitled

包含的过滤条件有:

  • brand:品牌值
  • city:城市
  • minPrice~maxPrice:价格范围
  • starName:星级

我们需要做两件事情:

  • 修改 RequestParams 类,添加 brand、city、starName、minPrice、maxPrice 等参数
  • 修改 search 方法的实现,在关键字搜索时,如果 brand 等参数存在,对其做过滤

修改实体类

修改在 cn.itcast.hotel.pojo 包下的实体类 RequestParams:

@Data
public class RequestParams {

// 搜索关键字
private String key;
// 当前页码
private Integer page;
// 每页大小
private Integer size;
// 排序字段
private String sortBy;
// 城市
private String city;
// 品牌
private String brand;
// 星级
private String starName;
// 最小值价格
private Integer minPrice;
// 最大值价格
private Integer maxPrice;
}

修改搜索业务

在HotelService的search方法中,只有一个地方需要修改:requet.source().query(...) 其中的查询条件。

在之前的业务中,只有match查询,根据关键字搜索,现在要添加条件过滤,包括:

  • 品牌(brand)过滤:是keyword类型,用term查询
  • 星级(starName)过滤:是keyword类型,用term查询
  • 价格(price)过滤:是数值类型,用range查询
  • 城市(city)过滤:是keyword类型,用term查询

多个查询条件组合,肯定是用 boolean 查询来组合:

  • 关键字搜索放到 must 中,参与算分
  • 其它过滤条件放到 filter 中,不参与算分

因为条件构建的逻辑比较复杂,这里先封装成一个函数:

Untitled

buildBasicQuery 的代码如下:

 private void buildBasicQuery(RequestParams params, SearchRequest request) {

// 1.构建BooleanQuery
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
// 2.关键字搜索
String key = params.getKey();
if (key == null || "".equals(key)) {

boolQuery.must(QueryBuilders.matchAllQuery());
} else {

boolQuery.must(QueryBuilders.matchQuery("all", key));
}
// 3.城市条件
if (params.getCity() != null && !params.getCity().equals("")) {

boolQuery.filter(QueryBuilders.termQuery("city", params.getCity()));
}
// 4.品牌条件
if (params.getBrand() != null && !params.getBrand().equals("")) {

boolQuery.filter(QueryBuilders.termQuery("brand", params.getBrand()));
}
// 5.星级条件
if (params.getStarName() != null && !params.getStarName().equals("")) {

boolQuery.filter(QueryBuilders.termQuery("starName", params.getStarName()));
}
// 6.价格
if (params.getMinPrice() != null && params.getMaxPrice() != null) {

boolQuery.filter(QueryBuilders.rangeQuery("price").gte(params.getMinPrice()).lte(params.getMaxPrice()));
}
// 7.放入source
request.source().query(boolQuery);
}

4.3、我附近的酒店

需求:我附近的酒店

需求分析

在酒店列表页的右侧,有一个小地图,点击地图的定位按钮,地图会找到你所在的位置:

Untitled

并且,在前端会发起查询请求,将你的坐标发送到服务端:

Untitled

我们要做的事情就是基于这个 location 坐标,然后将酒店结果按照到这个点的距离进行升序排序。实现思路如下:

  • 修改 RequestParams 参数,接收location字段
  • 修改 search 方法的业务逻辑,如果 location 有值,则添加根据 geo_distance 排序的功能

修改实体类

修改在 cn.itcast.hotel.pojo 包下的实体类 RequestParams:

@Data
public class RequestParams {

// 搜索关键字
private String key;
// 当前页码
private Integer page;
// 每页大小
private Integer size;
// 排序字段
private String sortBy;
// 城市
private String city;
// 品牌
private String brand;
// 星级
private String starName;
// 最小值价格
private Integer minPrice;
// 最大值价格
private Integer maxPrice;
// 我当前的地理坐标
private String location;
}

添加距离排序

cn.itcast.hotel.service.impl 包中的 HotelService 类的 search 方法中,添加一个排序功能:

 /** * 根据关键字搜索酒店信息 * * @param params 请求参数对象,包含用户输入的关键字 * @return 酒店文档列表 */
@Override
public PageResult search(RequestParams params) {

try {

// 1.创建Request对象
SearchRequest request = new SearchRequest("hotel");
// 2.准备DSL
// 2.1、query
buildBasicQuery(params, request);
// 2.2、分页
// 当前页码
int page = params.getPage();
// 每页显示多少条数据
int size = params.getSize();
request.source().from((page - 1) * size).size(size);
// 2.3、排序
String location = params.getLocation();
if (location != null && !"".equals(location)) {

request.source().sort(SortBuilders
.geoDistanceSort("location", new GeoPoint(location))
.order(SortOrder.ASC)
.unit(DistanceUnit.KILOMETERS));
}
// 3.发送请求
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// 4.解析响应
return handleResponse(response);
} catch (IOException e) {

throw new RuntimeException(e);
}
}

排序距离显示

重启服务后,测试我的酒店功能:

发现确实可以实现对我附近酒店的排序,不过并没有看到酒店到底距离我多远,这该怎么办?

排序完成后,页面还要获取我附近每个酒店的具体距离值,这个值在响应结果中是独立的:

Untitled

因此,我们在结果解析阶段,除了解析 source 部分以外,还要得到 sort 部分,也就是排序的距离,然后放到响应结果中。

我们要做两件事:

  • 修改HotelDoc,添加排序距离字段,用于页面显示
  • 修改 HotelService 类中的 handleResponse 方法,添加对 sort 值的获取

1、修改HotelDoc类,添加距离字段

@Data
@NoArgsConstructor
public class HotelDoc {

private Long id;
private String name;
private String address;
private Integer price;
private Integer score;
private String brand;
private String city;
private String starName;
private String business;
private String location;
private String pic;
// 排序时的距离值
private Object distance;
public HotelDoc(Hotel hotel) {

this.id = hotel.getId();
this.name = hotel.getName();
this.address = hotel.getAddress();
this.price = hotel.getPrice();
this.score = hotel.getScore();
this.brand = hotel.getBrand();
this.city = hotel.getCity();
this.starName = hotel.getStarName();
this.business = hotel.getBusiness();
this.location = hotel.getLatitude() + ", " + hotel.getLongitude();
this.pic = hotel.getPic();
}
}

2

 /** * 对响应结果进行解析 * * @param response * @return */
private PageResult handleResponse(SearchResponse response) {

// 4.解析响应
SearchHits searchHits = response.getHits();
// 4.1、获取总条数
long total = searchHits.getTotalHits().value;
// 4.2、获取文档数组
SearchHit[] hits = searchHits.getHits();
List<HotelDoc> hotels = new ArrayList<>();
// 4.3、遍历文档数据
for (SearchHit hit : hits) {

// 获取文档source
String json = hit.getSourceAsString();
// 反序列化
HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class);
// 获取排序值(距离的排序)
Object[] sortValues = hit.getSortValues();
if (sortValues.length > 0) {

Object sortValue = sortValues[0];
hotelDoc.setDistance(sortValue);
}
// 放入集合
hotels.add(hotelDoc);
}
// 4.4、封装返回
return new PageResult(total, hotels);
}

3、重启后测试,发现页面能成功显示距离了:

Untitled

4.4、酒店竞价排名

需求:让指定的酒店在搜索结果中排名置顶

需求分析

要让指定酒店在搜索结果中排名置顶,效果如图:

Untitled

页面会给指定的酒店添加广告标记。

那怎样才能让指定的酒店排名置顶呢?

我们之前学习过的 function_score 查询可以影响算分,算分高了,自然排名也就高了。而 function_score 包含3个要素:

  • 过滤条件:哪些文档要加分
  • 算分函数:如何计算 function score
  • 加权方式:function score 与 query score 如何运算

这里的需求是:让指定酒店排名靠前。因此我们需要给这些酒店添加一个标记,这样在过滤条件中就可以根据这个标记来判断是否需要提高算分。

比如,我们给酒店添加一个字段:isAD,Boolean类型:

  • true:是广告
  • false:不是广告

这样 function_score 包含3个要素就很好确定了:

  • 过滤条件:判断 isAD 是否为true
  • 算分函数:我们可以用最简单暴力的 weight,固定加权值
  • 加权方式:可以用默认的相乘,大大提高算分

因此,业务的实现步骤包括:

  1. 给 HotelDoc 类添加 isAD 字段,Boolean类型
  2. 挑选几个你喜欢的酒店,给它的文档数据添加 isAD 字段,值为 true
  3. 修改 search 方法,添加 function score 功能,给 isAD 值为 true 的酒店增加权重

修改 HotelDoc 实体类

@Data
@NoArgsConstructor
public class HotelDoc {

private Long id;
private String name;
private String address;
private Integer price;
private Integer score;
private String brand;
private String city;
private String starName;
private String business;
private String location;
private String pic;
// 排序时的距离值
private Object distance;
// 广告标记
private boolean isAD;
// 注意这两个get和set方法需要手动加上,不然自动生成的方法名是没有带get的,会导致广告图片无法正常显示
public boolean getisAD() {

return isAD;
}
public void setisAD(boolean AD) {

isAD = AD;
}
public HotelDoc(Hotel hotel) {

this.id = hotel.getId();
this.name = hotel.getName();
this.address = hotel.getAddress();
this.price = hotel.getPrice();
this.score = hotel.getScore();
this.brand = hotel.getBrand();
this.city = hotel.getCity();
this.starName = hotel.getStarName();
this.business = hotel.getBusiness();
this.location = hotel.getLatitude() + ", " + hotel.getLongitude();
this.pic = hotel.getPic();
}
}

添加广告标记

接下来,我们挑几个酒店,添加isAD字段,设置为 true:

# 添加isAD字段
POST /hotel/_update/2056126831
{

"doc": {

"isAD": true
}
}
POST /hotel/_update/1989806195
{

"doc": {

"isAD": true
}
}
POST /hotel/_update/2056105938
{

"doc": {

"isAD": true
}
}

添加算分函数查询

接下来我们就要修改查询条件了。之前是用的boolean 查询,现在要改成 function_socre 查询。

我们可以将之前写的 boolean 查询作为原始查询条件放到 query 中,接下来就是添加过滤条件、算分函数、加权模式了。所以原来的代码依然可以沿用。

修改 cn.itcast.hotel.service.impl 包下的 HotelService 类中的 buildBasicQuery 方法,添加算分函数查询:

 private void buildBasicQuery(RequestParams params, SearchRequest request) {

// 1.构建BooleanQuery
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
// 关键字搜索
String key = params.getKey();
if (key == null || "".equals(key)) {

boolQuery.must(QueryBuilders.matchAllQuery());
} else {

boolQuery.must(QueryBuilders.matchQuery("all", key));
}
// 城市条件
if (params.getCity() != null && !params.getCity().equals("")) {

boolQuery.filter(QueryBuilders.termQuery("city", params.getCity()));
}
// 品牌条件
if (params.getBrand() != null && !params.getBrand().equals("")) {

boolQuery.filter(QueryBuilders.termQuery("brand", params.getBrand()));
}
// 星级条件
if (params.getStarName() != null && !params.getStarName().equals("")) {

boolQuery.filter(QueryBuilders.termQuery("starName", params.getStarName()));
}
// 价格
if (params.getMinPrice() != null && params.getMaxPrice() != null) {

boolQuery.filter(QueryBuilders.rangeQuery("price").gte(params.getMinPrice()).lte(params.getMaxPrice()));
}
// 2.算分控制
FunctionScoreQueryBuilder functionScoreQuery =
// 构建 function_socre 查询
QueryBuilders.functionScoreQuery(
// 原始查询,相关性算分的查询
boolQuery,
// function socre的数组
new FunctionScoreQueryBuilder.FilterFunctionBuilder[]{

// 其中的一个function socre元素
new FunctionScoreQueryBuilder.FilterFunctionBuilder(
// 过滤条件
QueryBuilders.termQuery("isAD", true),
// 算分函数
ScoreFunctionBuilders.weightFactorFunction(10))});
// 3.放入source
request.source().query(functionScoreQuery);
}
分类: 营销/媒介投放 关键词: 竞价排名
原文 编辑 投诉 置顶 分享
推荐
快讯
剧透网 展会网 乡村游
营销软件 行业信息


营销 网络营销 自媒体营销 产品推广 营销策划 媒体投放 电商营销 广告联盟 科技 大数据 人工智能 智能硬件 工业互联网 物联网
财经 跨境电商 投资理财 量化交易 价值投资 招商加盟 食品招商 餐饮加盟