Elasticsearch 搜索的高级功能学习

在文章 Elasticsearch 入门学习 中介绍了 Elasticsearch 的基础概念以及一些常用的 API。这篇文章是继续对 Elasticsearch 中一些高级的搜索功能的学习和总结:

搜索的相关性以及算分机制

什么是相关性算分?

  • 相关性算分描述了一个文档和查询语句的匹配程度,ES 会对查询到的每个文档进行打分,打分的本质就是排序
  • ES5 之前默认的相关性打分采用 TF-IDF 算法,TF-IDF 是信息检索领域最重要的发明,现代搜索引擎都对 TF-IDF 做了大量细微的优化
  • ES6 之后开始采用 BM25 算法(对 TF-IDF 的改进),当 TF 无限增加时, BM 25 算法会使之趋于一个稳定的数值
  • 在 ES 中查询加上 explain=true 可以查看当前查询是如何打分的
  • 影响相关性算分的几个因子:
1. 词频-TF(Term Frequency):检索词在一篇文档中出现的频率,频率越高,权重越大
2. 文档频率-DF(Document Frequency)- 检索词出现的文档数量占总文档数量的比重,DF 越大,出现的文档数越多,说明对应用意义越小,该词的相关性也就越小,
   比如 "and", "is" 这些词出现非常频繁,用户反而不关心
3. 逆向文档频率-IDF(Inverse document frequency)- 因为 DF 的值算出来结果范围会非常大,为了减少 DF 对打分的影响,引入了 IDF,其实就是对 DF 取对数来减少打分影响
4. 字段长度(Field-length)- 搜索的字段越短,相关性越高

如何人为干预相关性算分?

  • 使用 boost 属性来控制 query 权重值:
//第一个 match 查询的权重值是 2,第二个默认是 1
//最终得分并不是在系统得分的基础上乘以 2,这里的权重只是重要性 2 倍的概念,最终结果会被规范化
GET /_search
{
  "query": {
    "bool": {
      "should": [
        {
          "match": {
            "title": {
              "query": "quick brown fox",
              "boost": 2 
            }
          }
        },
        {
          "match": { 
            "content": "quick brown fox"
          }
        }
      ]
    }
  }
}
  • 使用 indices_boost 提升索引权重值:
GET /docs_2014_*/_search 
{
  //分别控制 docs_2014_10,docs_2014_09 两个索引的权重值为 3, 2
  "indices_boost": { 
    "docs_2014_10": 3,
    "docs_2014_09": 2
  },
  "query": {
    "match": {
      "text": "quick brown fox"
    }
  }
}
  • Boosting Query:将查询的 query 与人为干预影响算分权重的 query 解耦:
POST testscore/_search
{
    "query": {
        "boosting" : {
            //指定用于查询的 query,最后返回结果必须满足 positive 对应的条件
            "positive" : { 
                "term" : {
                    "content" : "elasticsearch"
                }
            },
            //指定影响相关性算分的 query,如果查询出来的文档同时满足 negative query,那么最终得分 = positive query 得分 * negative_boost
            "negative" : {
                 "term" : {
                     "content" : "like"
                }
            },
            "negative_boost" : 0.2 (范围是 0 到 1.0)
        }
    }
}
  • constant_score:
// 可以通过 Constant Score 将查询转换成一个 Filtering 查询,这样避免进行相关性算分,并且可以提高查询性能
// filter 可以利用缓存,此时返回的相关性算分是恒定的,都为 1.0
// Constant Score 一般用于结构化的查询
POST /products/_search
{
  "explain": true,
  "query": {
    "constant_score": {
      "filter": {
        "term": {
          "productID.keyword": "XHDK-A-1293-#fJ3"
        }
      }

    }
  }
}

Function Score Query

如果对我们前面介绍到的算分结果不满意,ES 还提供了 Function Score Query 在查询结束后,对每一个匹配到的文档在原先算分的基础上进行一系列算分,然后重新排序,是用来控制评分过程的终极武器。ES 提供了下面几种默认的计算分值的函数:

  • Weight:和上面查询时设置 boost 权重类似,区别在于该权重不会被规范化,当某个文档的 weight 为 2 时,最终结果就是 2 * _score
  • Field Value Factor:允许你使用文档中某些字段参与相关性算分,例如可以将字段“热度” 和“点赞数”作为算分的参考因素来修改 _score
POST /blogs/_search
{
  "query": {
    "function_score": {
      "query": {  // Multi Match Query
        "multi_match": {
          "query":    "popularity",
          "fields": [ "title", "content" ]
        }
      },
      // 新的算分 = _score * log(1 + votes值 * factor)
      "field_value_factor": {  
        "field": "votes", //字段 votes 来影响算分
        //计算函数,可以是 none,log,log1p,log2p,相关请参考(https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-function-score-query.html)
        "modifier": "log1p", 
        "factor": 0.1 //影响系数
      }
    }
  }
}
  • Random Score:为每一个用户返回一个 0 到 1 随机算分结果,可以用于对不同用户的个性化推荐的场景
//希望每个用户看到不同的随机分数,但是排序的相对顺序保持一致
POST /blogs/_search
{
  "query": {
    "function_score": {
      "random_score": {
        "seed": 911119
      }
    }
  }
}
  • Decay functions:衰减函数
1. 衰减函数以某个字段的值作为参考,距离某个值越近,得分越高。
2. 衰减函一共有三种衰减函数:linear(线性)/exp(指数)/gauss(高斯)
3. 衰减函可以操作数值、时间以及 经纬度地理坐标点这样的字段
4. 例如结合 publish_date 获得最近发布的文档,结合 geo_location 获得更接近某个具体经纬度(lat/lon)地点的文档,结合 price 获得更接近某个特定价格的文档
  • Script Score:自定义算分函数
1. Script Score 支持编写脚本来计算相关性,函数提供了巨大的灵活性,我们可以通过脚本访问文档里的所有字段、当前评分甚至词频、逆向文档频率和字段长度正则值这样的信息
2. 解决了字段类型的限制:Field Value Factor 一般只用于数字类型,而 Decay functions 一般只用于数字、位置和时间类型

POST /blogs/_search
{
    "query": {
        "function_score": {
            "query": {
                "match": { "message": "elasticsearch" }
            },
            "script_score" : {
                "script" : {
                  "source": "Math.log(2 + doc['likes'].value)"
                }
            }
        }
    }
}

Function Score Query 可以同时使用以上五个函数的任何组合查询,我们可以指定以下几个参数来控制最终的组合结果:

  • score_mode:组合类型,定义如何组合多个函数的查询结果:
multiply          分数相乘(默认)
sum               分数相加
avg               分数平均
first             第一个方法匹配过滤被应用
max               最大的分数
min               最小的分数
  • max_boost: 计算结果的上限,最终分数结果不能超过该值

搜索的分类

Elasticsearch 入门学习 文章中,就 rest API 使用角度上将文档的搜索分为 URI Search 和 Request Body Search 两种方式。如果我们从用户的搜索和分析角度出发,当用户输入查询字符串进行搜索时,用户是希望把这个查询字符串作为一个整体去搜索呢?还是希望先对这个查询字符串进行分词,然后对每个分词项进行呢?从这个角度出发,我们将搜索分为词项查询和全文查询:

词项查询

  • 词项是表达语义的最小单位,搜索和利用语言模型进行自然语言处理都会使用到词项的概念
  • 在 ES 中 ,词项查询不做分词处理,会直接将输入作为一个整体去倒排索引中查询
  • 词项查询是对倒排索引的词项精确匹配,它不会对词的多样性进行处理,比如大小写转换之类的
  • 在使用词项查询时,因为是精确匹配,所以我们一般知道自己要找什么,因此可以通过 Constant Score 将查询转换成一个 Filtering 查询,避免去进行相关性算分,这样既可以充分利用缓存,同时提高查询性能
  • 下面的几种查询都属于词项查询,这几种查询在上一篇文章中都要介绍:
1. Term Query:词项精确匹配
2. Range Query:范围查询
3. Exists Query: 是否存在判断查询
4. Prefix Query:前缀查询
5. Wildcard Query:通配符查询
  • 词项查询的相关示例:
//使用 term 查询 productID = "XHDK-A-1293-#fJ3" 的文档,如果在插入时 productID 作了分词处理,那么此时可能在倒排索引中检索不到任何东西
POST /products/_search
{
  "query": {
    "term": {   
      "productID": {
        "value": "XHDK-A-1293-#fJ3"
      }
    }
  }
}

//为了通过 term 查询匹配到 "XHDK-A-1293-#fJ3",我们可以使用 productID.keyword 来进行完全匹配,这样可以保证一定能匹配到
POST /products/_search
{
  //"explain": true,
  "query": {
    "term": {
      "productID.keyword": {
        "value": "XHDK-A-1293-#fJ3"
      }
    }
  }
}

//使用 {field.keyword} 字段去查询,前提是 productID 的字段元信息定义中包含了 fields 为 keyword 的属性
mappings = {
    "properties" : {
        "projectID" : {
          "type" : "text",
          "fields" : {  
            "keyword" : { //如果加了 keyword 属性,那么在对 projectID 进行分词建立倒排索引的基础上,还会把 projectID 整体作为一个词项建立倒排索引
              "type" : "keyword",
              "ignore_above" : 256
            }
          }
        }
    }
}

全文查询

  • 基于全文的查询,索引和搜索时都会进行分词,待查询字符串会先由分词器进行分词然后生成一个供查询的词项列表
  • 全文的查询会对分词项列表中每个分词进行底层查询,最后在上层进行结果的合并,并为每个查询到的文档生成一个算分
  • 下面几种查询都属于全文查询:
1. Match Query:匹配查询,拆分成多个独立的词进行查询然后汇总
2. Match Phrase Query:语句查询
3. Query String Query:通过在 URL 中使用 q 参数进行查询,相关说明参考上一篇文章 [Elasticsearch 入门学习](https://zhuanlan.zhihu.com/p/104215274)
4. Match Phrase Prefix Query:和 Match Phrase Query 类似,只是在匹配时,容许对最后一个词的前缀进行匹配 
5. Multi Match Query:同时对多个字段搜索匹配
  • 什么是 Match Phrase Query
1. Match Phrase Query 是一种为了找到彼此邻近搜索词的查询方法,主要用于对词语位置敏感的查询场景
2. 同样是先对查询字符串进行分词成一个词项列表,然后进行搜索,但是最后只保留那些包含全部词项,且相对位置与搜索词项相同的文档
3. Match Phrase Query 默认情况下对位置要求非常严格,默认情况下,除了标点符号和空格外,单词和顺序要求完全一样,
   这样比如搜索 "I like riding" 是没办法匹配到 "I like swimming and riding!" 的文档的,因为中间加了两个词 "swimming" 和 "and"
4. Match Phrase Query 提供了可选参数 slop 来解决上面第三点存在的问题,slop 表示为了让查询字符串和文档匹配你需要移动词条多少次,
   比如针对上面的 "I like riding" 的查询,只需要将 "riding" 词条向后移动两次就可以匹配成功,因此设置 slop 可以设置为 2

POST groups/_search
{
  "query": {
    "match_phrase": {
      "names": {
        "query": "I like riding",
        "slop": 2
      }
    }
  }
}
  • Match Phrase Prefix Query 示例:
POST groups/_search
{
  "query": {
    "match_phrase": {
      "names": {
        "query": "I like ri", //最后一次 item 是 "ri", 最后一个词可以匹配以 "ri" 开头的单词
        "slop": 2,
        "max_expansions": 10 //max_expansions 控制着可以与前缀匹配的词的数量,默认值是 50
      }
    }
  }
}

结构化查询

  • 结构化查询(Structured search) 是指对那些具有内在结构数据的查询,是对查询的另外一个层次划分
  • 比如日期、时间和数字都是结构化的,因为它们有精确的格式,常用于范围以及值大小的比较查询
  • 有些文本在某些场景下也是结构化的,是有限的离散的词语的集合,比如性别只有”男“和”女“
  • 结构化查询的结果:要么存于集合之中,要么存在集合之外
  • 结构化查询没有”相似“的概念,所以一般情况下不会给该类查询进行打分,提高性能
  • 结构化文本一般进行精确匹配或者 Prefix 前缀匹配
//对日期的结构化查询
POST products/_search
{
    "query" : {
        "constant_score" : { //不进行打分
            "filter" : {
                "range" : {
                    "date" : {
                      "gte" : "now-1y" // date 大于等于今年(y-年,M-月,w-周,d-天,H/h-小时,m-分钟,s-秒) 
                    }
                }
            }
        }
    }
}

复合查询

复合查询就是将一些简单的查询组合在一起作为查询条件进行文档检索,主要有两种复合查询:

Bool Query

  • Bool 查询是一个或者多个查询子句的组合
  • 可以使用下面四个选项来控制组合的类型:
must:必须匹配,贡献算分
should: 选择性匹配,文档可以匹配也可以不匹配 should 下的查询条件,匹配的文档评分会更高,可以通过参数 minimum_should_match 来控制 should 最小的匹配项数,默认是 0
must_not:必须不匹配
filter:必须匹配,与 must 类似,不同的是 filter 中的条件不参与算分
  • must、should、must_not、filter 的值都是 JSON 数组,可以添加多个查询条件,包括词项搜索和全文搜索
  • 查询语句的结构,会对相关性算分产生影响,同一层级的竞争字段,具有相同的权重,所以通过修改嵌套 Bool 查询,可以改变对算分的影响
// 多个 Bool 查询嵌套的例子
POST /products/_search
{
  "_source": "topics",
  "from": 0,
  "size": 100,
  "query": {
    "bool": {
      "should": [
       {
          "bool": {
            "must": [
              { "term": { "topics": 1}  },
              { "term": { "topics": 2}  }
            ]
          }
        },
        {
          "bool": {
            "must": [
              {"term": { "topics": 3 } },
              {"term": { "topics": 4}}
            ]
          }
        }
      ],
      "minimum_should_match": 1
    }
  }
}

Disjunction Max Query

Disjunction Max Query 和 Bool Query 有联系也有区别,Disjunction Max Query 支持多并发查询,Disjunction Max Query 是通过获取所有字段中最高评分字段所匹配的结果作为返回。

下面两种方式匹配效果相同:

//对于返回的每个文档,最终评分按照 title 或者 body 中匹配的最高分作为返回
POST blogs/_search
{
    "query": {
        "dis_max": {   // 将字段上最匹配的评分的最终结果返回
            "queries": [
                { "match": { "title": "Quick pets" }},
                { "match": { "body":  "Quick pets" }}
            ],
            "tie_breaker": 0.2 // 如果最高评分的分词字段评分相同,我们可以使用 tie_breaker 系数来乘以其它字段的评分并想加来求最大值
        }
    }
}


POST blogs/_search
{
  "query": {
    "multi_match": {
      "type": "best_fields",
      "query": "Quick pets",
      "fields": ["title","body"],
      "tie_breaker": 0.2
    }
  }
}

Suggester API

我们在使用谷歌或者百度搜索时,为了帮助用户在搜索过程中,会发现它们提供了自动补全和自动纠错功能,,帮助用户提高搜索的匹配程度和使用体验,同样 ES 也提供了 Suggerster API 来实现该功能。

Suggerster API 的原理其实是将输入的文本分解成 Token,然后在索引的字典里找到相似的 Term 并返回。每一个词语会给出多个推荐结果,每个推荐结果都会有一个相似性算分和出现频率。

Suggester 分类

ES 提供了四种类别的 Suggerter:

  • Term Suggerster:对给入的文本进行分词,为每个词进行模糊查询提供词项建议
/* suggest_mode 一共有三种:
1. Missing:如果索引中存在,则不提供建议
2. Popular: 推荐出现频率更高的词语
3. Always:无论是否存在,都提供建议
*/
POST /articles/_search
{
  "suggest": {
    "term-suggestion": {
      "text": "lucen rock", //要搜索的文本
      "term": {
        "suggest_mode": "missing", //如果索引中已经存在该词语,则不提供建议
        "field": "body"  //从字段 body 中搜索,不存在则从 body 中找出合理的建议词语给用户
      }
    }
  }
}
  • Phrase Suggerster:在 Term Suggerster 的基础上增加了一些额外的逻辑和参数,会考量多个 Term 之间的关系,比如是否同时出现在索引的原文里,相邻程度,以及词频等
POST /articles/_search
{
  "suggest": {
    "my-suggestion": {
      "text": "lucne and elasticsear rock hello world ",
      "phrase": {
        "field": "body",
        "max_errors":2,  // 最多可以拼错的 Terms 数
        "confidence":0,  // 用来限制返回的结果数,只有相关性评分大于 confidence 才会返回,默认值是 1.0
        "direct_generator":[{
          "field":"body",
          "suggest_mode":"always" //和Term suggester一样,包括三种:missing,popular,always
        }],
        //高亮显示
        "highlight": {
          "pre_tag": "<em>",
          "post_tag": "</em>"
        }
      }
    }
  }
}
  • Complete Suggerster:主要应用场景是自动补全,实时性对性能要求非常高,所以该查询并非通过倒排索引来完成,而是将 analyze 过的数据编码成 FST 和索引一起存放,对于一个 open 状态的索引,FST 会被 ES 整个装载到内存里,进行前缀查找速度极快
//需要强调的是,Complete Suggerster 查询字段类型必须定义为为:completion
POST articles/_search?pretty
{
  "size": 0,
  "suggest": {
    "article-suggester": {
      "prefix": "elk ", // 查询 title_completion 字段以 elk 开头的所有文档
      "completion": {
        "field": "title_completion"
      }
    }
  }
}
  • Context Suggerster:是 Complete Suggerster 的扩展,基于上下文感知的推荐,可以定义两种类型的上下文:
1. Category:任意字符串
2. Geo:地理位置信息

实现 Context Suggerster 的具体步骤如下:

1. 定制一个 mapping
PUT comments/_mapping
{
  "properties": {
    "comment_autocomplete":{
      "type": "completion",  //字段类型必须为 completion
      "contexts":[{
        "type":"category",  //上下文类型为 Category
        "name":"comment_category" //上下文名称为 comment_category
      }]
    },
    "comment":{
      "type": "text"
    }
  }
}

2. 增加数据时为每个文档加入 Context 信息
POST comments/_doc
{
  "comment":"I love the star war movies",
  "comment_autocomplete":{
    "input":["star wars"],
    "contexts":{
      "comment_category":"movies" //如果上下文是”movies“的情况下,就去匹配"star wars"
    }
  }
}

3. 结合 Context 进行 Suggestion 查询
POST comments/_search
{
  "suggest": {
    //在上下文是"movies"的情况下去搜索前缀为"sta"的文档
    "MY_SUGGESTION": {
      "prefix": "sta",
      "completion":{
        "field":"comment_autocomplete",
        "contexts":{
          "comment_category":"movies"
        }
      }
    }
  }
}

Suggester 使用总结和建议

对于几种 Suggester 不同方面的对比:

  • 精准度:Completion > Phrase > Term
  • 召回率:Term > Phrase > Commpletion,召回率是指查询的文档数量
  • 性能:Completion > Phrase > Term

在推荐和纠错搜索的过程中,一般使用 Completion Suggester 进行前缀匹配,如果 Completion Suggester 没有给出合理的推荐,那么可能猜测用户是否输入错误,可以尝试使用 Phrase Suggester 进行匹配,最后找不到合理的推荐可以使用 Term Suggester。

开发中的搜索一些优化方案

Search Template

可以通过定义 Search Template 来解耦程序,明确开发人员,搜索工程师的职责,前端工程师只需要定义查询模板,真正如何查询交给搜索工程师来定义,搜索工程师使用前端工程师定义好的查询模板来进行查询,实现程序的解耦:

//通过脚本的方式使用定义好的query模板进行查询
POST _scripts/tmdb
{
  "script": {
    "lang": "mustache",
    "source": {
      "_source": [
        "title","overview"
      ],
      "size": 20,
      "query": {
        "multi_match": {
          "query": "{{q}}",  //通过{{}}的方式使用模板查询
          "fields": ["title","overview"]
        }
      }
    }
  }
}

//定义 query 模板
POST tmdb/_search/template
{
    "id":"tmdb",
    "params": {
        "q": "basketball with cartoon aliens"
    }
}

Index Alias

有些情况下索引名称可能会根据时间动态更新,我们可以使用 Index Alias 给索引创建别名,这样每次索引名称更改后,只需要修改别名就可以了,不需要修改代码中的索引名称

还可以针对一些查询过滤条件重新定义别名:

//对 rating 大于等于 4 文档设置别名 movies-lastest-highrate
POST _aliases
{
  "actions": [
    {
      "add": {
        "index": "movies-2019",
        "alias": "movies-lastest-highrate",
        "filter": {
          "range": {
            "rating": {
              "gte": 4
            }
          }
        }
      }
    }
  ]
}

//这里查询只能查出 rating 大于等于 4 的文档
POST movies-lastest-highrate/_search
{
  "query": {
    "match_all": {}
  }
}

跨集群的搜索

单机群水平扩展的痛点:当节点的 meta 信息(节点,索引,集群状态)过多时,会导致更新压力变大,单个 Active Master 成为性能瓶颈,导致整个集群无法正常工作,因而这个时候就需要实现跨集群搜索访问,ES 5.3 版本以后引入了新的跨集群搜索的功能,以下是实现跨集群搜索步骤:

  1. 启动多个集群
bin/elasticsearch -E node.name=cluster0node -E cluster.name=cluster0 -E path.data=cluster0_data -E discovery.type=single-node -E http.port=9200 -E transport.port=9300
bin/elasticsearch -E node.name=cluster1node -E cluster.name=cluster1 -E path.data=cluster1_data -E discovery.type=single-node -E http.port=9201 -E transport.port=9301
bin/elasticsearch -E node.name=cluster2node -E cluster.name=cluster2 -E path.data=cluster2_data -E discovery.type=single-node -E http.port=9202 -E transport.port=9302
  1. 在每个集群上作动态的设置多个集群的关联性:
PUT _cluster/settings
{
  "persistent": {
    "cluster": {
      "remote": {
        "cluster0": {
          "seeds": [
            "127.0.0.1:9300"
          ],
          "transport.ping_schedule": "30s"
        },
        "cluster1": {
          "seeds": [
            "127.0.0.1:9301"
          ],
          "transport.compress": true,
          "skip_unavailable": true
        },
        "cluster2": {
          "seeds": [
            "127.0.0.1:9302"
          ]
        }
      }
    }
  }
}
  1. 进行跨集群查询
//在集群cluster0上对三个集群的 index = users 的索引进行搜索进行搜索:
GET /users,cluster1:users,cluster2:users/_search
{
  "query": {
    "range": {
      "age": {
        "gte": 20,
        "lte": 40
      }
    }
  }
}

参考文献

编辑于 2022-08-30 09:08