ES搜索(二)-- 搜索的背后

Published: 28 Oct 2019 Category: ES

一、搜索

Elasticsearch真正强大之处在于可以从混乱的数据中找出有意义的信息——从大数据到全面的信息。

在ES中,每个文档里的字段都会被索引并被查询。而且不仅如此,在简单查询时,Elasticsearch可以使用所有的索引,以非常快的速度返回结果。这让你永远不必考虑传统数据库的一些东西。

1.1 空搜索

空查询:

GET /_search

响应内容中的字段解释:

{
   "hits" : {
      "total" :       14,
      "hits" : [
        {
          "_index":   "us",
          "_type":    "tweet",
          "_id":      "7",
          "_score":   1,
          "_source": {
             "date":    "2014-09-17",
             "name":    "John Smith",
             "tweet":   "The Query DSL is really powerful and flexible",
             "user_id": 2
          }
       },
        ... 9 RESULTS REMOVED ...
      ],
      "max_score" :   1
   },
   "took" :           4,
   "_shards" : {
      "failed" :      0,
      "successful" :  10,
      "total" :       10
   },
   "timed_out" :      false
}

1.1.1 hits

响应中最重要的部分是hits,它包含了total字段来表示匹配到的文档总数,hits数组还包含了匹配到的前10条数据。

hits数组中的每个结果都包含_index、_type和文档的_id字段,被加入到_source字段中这意味着在搜索结果中我们将可以直接使用全部文档。这不像其他搜索引擎只返回文档ID,需要你单独去获取文档。

每个节点都有一个_score字段,这是相关性得分(relevance score),它衡量了文档与查询的匹配程度。默认的,返回的结果中关联性最大的文档排在首位;这意味着,它是按照_score降序排列的。这种情况下,我们没有指定任何查询,所以所有文档的相关性是一样的,因此所有结果的_score都是取得一个中间值1

max_score指的是所有文档匹配查询中_score的最大值。

1.1.2 took

took告诉我们整个搜索请求花费的毫秒数。

1.1.3 shards

_shards节点告诉我们参与查询的分片数(total字段),有多少是成功的(successful字段),有多少的是失败的(failed字段)。通常我们不希望分片失败,不过这个有可能发生。如果我们遭受一些重大的故障导致主分片和复制分片都故障,那这个分片的数据将无法响应给搜索请求。这种情况下,Elasticsearch将报告分片failed,但仍将继续返回剩余分片上的结果。

1.1.4 timeout

time_out值告诉我们查询超时与否。一般的,搜索请求不会超时。如果响应速度比完整的结果更重要,你可以定义timeout参数为10或者10ms(10毫秒),或者1s(1秒)

GET /_search?timeout=10ms Elasticsearch将返回在请求超时前收集到的结果。

超时不是一个断路器(circuit breaker)(译者注:关于断路器的理解请看警告)。

警告
需要注意的是timeout不会停止执行查询,它仅仅告诉你目前顺利返回结果的节点然后关闭连接。在后台,其他分片可能依旧执行查询,尽管结果已经被发送。
使用超时是因为对于你的业务需求(译者注:SLA,Service-Level Agreement服务等级协议,在此我翻译为业务需求)来说非常重要,而不是因为你想中断执行长时间运行的查询。

1.2 多索引和多类别

通过限制搜索的不同索引或类型,我们可以在集群中跨所有文档搜索。Elasticsearch转发搜索请求到集群中平行的主分片或每个分片的复制分片上,收集结果后选择顶部十个返回给我们。

通常,当然,你可能想搜索一个或几个自定的索引或类型,我们能通过定义URL中的索引或类型达到这个目的,像这样:

/_search: 在所有索引的所有类型中搜索

/gb/_search : 在索引gb的所有类型中搜索

/gb,us/_search: 在索引gb和us的所有类型中搜索

/g*,u*/_search: 在以g或u开头的索引的所有类型中搜索

/gb/user/_search:在索引gb的类型user中搜索

/gb,us/user,tweet/_search:在索引gb和us的类型为user和tweet中搜索

/_all/user,tweet/_search:在所有索引的user和tweet中搜索 search types user and tweet in all indices

1.3 分页

和SQL使用LIMIT关键字返回只有一页的结果一样,Elasticsearch接受from和size参数:

  • size: 结果数,默认10
  • from: 跳过开始的结果数,默认0

如果你想每页显示5个结果,页码从1到3,那请求如下:

GET /_search?size=5
GET /_search?size=5&from=5
GET /_search?size=5&from=10

应该当心分页太深或者一次请求太多的结果。结果在返回前会被排序。但是记住一个搜索请求常常涉及多个分片。每个分片生成自己排好序的结果,它们接着需要集中起来排序以确保整体排序正确。

在集群系统中深度分页.

为了理解为什么深度分页是有问题的,让我们假设在一个有5个主分片的索引中搜索。当我们请求结果的第一页(结果1到10)时,每个分片产生自己最顶端10个结果然后返回它们给请求节点(requesting node),它再排序这所有的50个结果以选出顶端的10个结果。

现在假设我们请求第1000页——结果10001到10010。工作方式都相同,不同的是每个分片都必须产生顶端的10010个结果。然后请求节点排序这50050个结果并丢弃50040个!

你可以看到在分布式系统中,排序结果的花费随着分页的深入而成倍增长。这也是为什么网络搜索引擎中任何语句不能返回多于1000个结果的原因。

1.4 查询字符串

下面这条语句查询所有类型为tweet并在tweet字段中包含elasticsearch字符的文档:

GET /_all/tweet/_search?q=tweet:elasticsearch

下面语句查找name字段中包含”john”和tweet字段包含”mary”的结果,虽然查询只需增加:

+name:john +tweet:mary

但是百分比编码(percent encoding)(译者注:就是url编码)需要将查询字符串参数变得更加神秘:

GET /_search?q=%2Bname%3Ajohn+%2Btweet%3Amary

”+”前缀表示语句匹配条件必须被满足。类似的”-“前缀表示条件必须不被满足。所有条件如果没有+或-表示是可选的——匹配越多,相关的文档就越多。

_all字段

当你索引一个文档,Elasticsearch把所有字符串字段值连接起来放在一个大字符串中,它被索引为一个特殊的字段_all。例如,当索引这个文档:

{
    "tweet":    "However did I manage before Elasticsearch?",
    "date":     "2014-09-14",
    "name":     "Mary Jones",
    "user_id":  1
} 这好比我们增加了一个叫做_all的额外字段值:

"However did I manage before Elasticsearch? 2014-09-14 Mary Jones 1"

若没有指定字段,查询字符串搜索(即q=xxx)使用_all字段搜索。

可以自定义_all字段;当_all字段不再使用,你可以停用它。

二、搜索的背后

很多搜索都是开箱即用的,为了充分挖掘Elasticsearch的潜力,你需要理解以下三个概念:

概念 解释
映射(Mapping) 数据在每个字段中的解释说明
分析(Analysis) 全文是如何处理的可以被搜索的
领域特定语言查询(Query DSL) Elasticsearch使用的灵活的、强大的查询语言

2.1 映射 (又叫 schema definition)

映射(mapping)机制用于进行字段类型确认,将每个字段匹配为一种确定的数据类型( string, number, booleans, date等)。

GET /gb/_mapping/tweet

{
   "gb": {
      "mappings": {
         "tweet": {
            "properties": {
               "date": {
                  "type": "date",
                  "format": "dateOptionalTime"
               },
               "name": {
                  "type": "string"
               },
               "tweet": {
                  "type": "string"
               },
               "user_id": {
                  "type": "long"
               }
            }
         }
      }
   }
}

Elasticsearch为对字段类型进行猜测,动态生成了字段和类型的映射关系。如返回的信息显示了date字段被识别为date类型。_all因为是默认字段所以没有在此显示,不过我们知道它是string类型。

每一种核心数据类型(strings, numbers, booleans及dates)以不同的方式进行索引,在Elasticsearch中他们是被区别对待的。

更大的区别在于确切值 (exact values)(比如string类型)及全文文本 (full text)之间。

这两者的区别才真的很重要-这是区分搜索引擎和其他数据库的根本差异。

2.1.1 确切值(Exact values) vs. 全文文本(Full text)

Elasticsearch中的数据可以大致分为两种类型:确切值及全文文本。

确切值是确定的,正如它的名字一样。比如一个date或用户ID,也可以包含更多的字符串比如username或email地址。

确切值”Foo”和”foo”就并不相同。确切值2014和2014-09-15也不相同。

全文文本,从另一个角度来说是文本化的数据(常常以人类的语言书写),比如一篇推文(Twitter的文章)或邮件正文。

确切值是很容易查询的,因为结果是二进制的– 要么匹配,要么不匹配。而对于全文数据的查询来说,却有些微妙.

我们很少确切的匹配整个全文文本。我们想在全文中查询包含查询文本的部分。不仅如此,我们还期望搜索引擎能理解我们的意图:

  • 一个针对”UK”的查询将返回涉及”United Kingdom”的文档
  • 一个针对”jump”的查询同时能够匹配”jumped”,”jumps”,”jumping”甚至”leap”
  • “johnny walker”也能匹配”Johnnie Walker”,”johnnie depp”及”Johnny Depp”
  • “fox news hunting”能返回有关hunting on Fox News的故事,而”fox hunting news”也能返回关于fox hunting的新闻故事。

为了方便在全文文本字段中进行这些类型的查询,Elasticsearch首先对文本分析(analyzes),然后使用结果建立一个倒排索引。

2.1.2 倒排索引

为了创建倒排索引,我们首先切分每个文档的content字段为单独的单词(我们把它们叫做词(terms)或者表征(tokens))

并加入简单的相似度算法(similarity algorithm),计算匹配单词的数目,这样我们就可以说第一个文档比第二个匹配度更高——对于我们的查询具有更多相关性。

但是在我们的倒排索引中还有些问题:

  • “Quick”和”quick”被认为是不同的单词,但是用户可能认为它们是相同的。
  • “fox”和”foxes”很相似,就像”dog”和”dogs”——它们都是同根词。
  • “jumped”和”leap”不是同根词,但意思相似——它们是同义词。

于是我们将词为统一为标准格式:大写转小写;单数变复数;同义词归类。

并对查询字符串也应用相同的标准格式。这个标记化和标准化的过程叫做分析(analysis)。

2.2 分析

分析(analysis)机制用于进行全文文本(Full Text)的分词,以建立供搜索用的反向索引。

分析(analysis)是这样一个过程:

  • 首先,标记化一个文本块为适用于倒排索引单独的词(term)
  • 然后标准化这些词为标准形式,提高它们的“可搜索性”或“查全率”

这个工作是分析器(analyzer)完成的。一个分析器(analyzer)包含:

  • 字符过滤器:首先字符串经过字符过滤器(character filter),它们的工作是在标记化前处理字符串。字符过滤器能够去除HTML标记,或者转换”&”为”and”。

  • 分词器:下一步,分词器(tokenizer)被标记化成独立的词。一个简单的分词器(tokenizer)可以根据空格或逗号将单词分开(译者注:这个在中文中不适用)。

  • 标记过滤:最后,每个词都通过所有标记过滤(token filters),它可以修改词(例如将”Quick”转为小写),去掉词(例如停用词像”a”、”and”、”the”等等),或者增加词(例如同义词像”jump”和”leap”)

Elasticsearch提供很多开箱即用的字符过滤器,分词器和标记过滤器。这些可以组合来创建自定义的分析器以应对不同的需求。

比如:标准分析器、简单分析器、空格分析器、语言分析器。

可以用测试API去体验内置的分析器:

GET /_analyze?analyzer=standard&text=Text to analyze

结果中每个节点在代表一个词:

{
   "tokens": [
      {
         "token":        "text",
         "start_offset": 0,
         "end_offset":   4,
         "type":         "<ALPHANUM>",
         "position":     1
      },
      {
         "token":        "to",
         "start_offset": 5,
         "end_offset":   7,
         "type":         "<ALPHANUM>",
         "position":     2
      },
      {
         "token":        "analyze",
         "start_offset": 8,
         "end_offset":   15,
         "type":         "<ALPHANUM>",
         "position":     3
      }
   ]
} token是一个实际被存储在索引中的词。position指明词在原文本中是第几个出现的。start_offset和end_offset表示词在原文本中占据的位置。

analyze API 对于理解Elasticsearch索引的内在细节是个非常有用的工具。

指定分析器:当Elasticsearch在你的文档中探测到一个新的字符串字段,它将自动设置它为全文string字段并用standard分析器分析。也许你想使用一个更适合这个数据的语言分析器。或者,你只想把字符串字段当作一个普通的字段——不做任何分析,只存储确切值,就像字符串类型的用户ID或者内部状态字段或者标签。为了达到这种效果,我们必须通过映射(mapping)人工设置这些字段。

2.3 一些例子

当我们在_all字段查询2014,它一个匹配到12条推文,因为这些推文都包含词2014:

GET /_search?q=2014              # 12 results

当我们在_all字段中查询2014-09-15,首先分析查询字符串,产生匹配任一词2014、09或15的查询语句,它依旧匹配12个推文,因为它们都包含词2014。

GET /_search?q=2014-09-15        # 12 results !

当我们在date字段中查询2014-09-15,它查询一个确切的日期,然后只找到一条推文:

GET /_search?q=date:2014-09-15   # 1  result

当我们在date字段中查询2014,没有找到文档,因为没有文档包含那个确切的日期:

GET /_search?q=date:2014         # 0  results !

2.4 结构化查询Query DSL (和过滤语句)

结构化查询是一种灵活的,多表现形式的查询语言。Elasticsearch在一个简单的JSON接口中用结构化查询来展现Lucene绝大多数能力。

(其实就是将查询条件放置在json中)

复合子句能合并任意其他查询子句,包括其他的复合子句。这就意味着复合子句可以相互嵌套,从而实现非常复杂的逻辑。

以下实例查询的是邮件正文中含有“business opportunity”字样的星标邮件或收件箱中正文中含有“business opportunity”字样的非垃圾邮件:

{
    "bool": {
        "must": { "match":      { "email": "business opportunity" }},
        "should": [
             { "match":         { "starred": true }},
             { "bool": {
                   "must":      { "folder": "inbox" }},
                   "must_not":  { "spam": true }}
             }}
        ],
        "minimum_should_match": 1
    }
} 不用担心这个例子的细节,我们将在后面详细解释它。重点是复合子句可以合并多种子句为一个单一的查询,无论是叶子子句还是其他的复合子句。

查询与过滤语句非常相似,但是它们由于使用目的不同而稍有差异。

2.4.1 最重要的查询过滤语句

term 过滤

term主要用于精确匹配哪些值,比如数字,日期,布尔值或not_analyzed的字符串(未经分析的文本数据类型):

{ "term": { "age":    26           }}
{ "term": { "date":   "2014-09-01" }}
{ "term": { "public": true         }}
{ "term": { "tag":    "full_text"  }}

terms 过滤

terms跟term有点类似,但terms允许指定多个匹配条件。如果某个字段指定了多个值,那么文档需要一起去做匹配:

{
    "terms": {
        "tag": [ "search", "full_text", "nosql" ]
        }
}

exists和missing过滤

exists和missing过滤可以用于查找文档中是否包含指定字段或没有某个字段,类似于SQL语句中的IS_NULL条件

{
    "exists":   {
        "field":    "title"
    }
} 这两个过滤只是针对已经查出一批数据来,但是想区分出某个字段是否存在的时候使用。

bool 过滤

bool 过滤可以用来合并多个过滤条件查询结果的布尔逻辑,它包含一下操作符:

  • must ::多个查询条件的完全匹配,相当于and。
  • must_not ::多个查询条件的相反匹配,相当于not。
  • should ::至少有一个查询条件匹配,相当于or。 这些参数可以分别继承一个过滤条件或者一个过滤条件的数组:

    { “bool”: { “must”: { “term”: { “folder”: “inbox” }}, “must_not”: { “term”: { “tag”: “spam” }}, “should”: [ { “term”: { “starred”: true }}, { “term”: { “unread”: true }} ] } }

match_all 查询

使用match_all可以查询到所有文档,是没有查询条件下的默认语句。

{
    "match_all": {}
}

此查询常用于合并过滤条件。比如说你需要检索所有的邮箱,所有的文档相关性都是相同的,所以得到的_score为1

match 查询

match查询是一个标准查询,不管你需要全文本查询还是精确查询基本上都要用到它。 如果你使用match查询一个全文本字段,它会在真正查询之前用分析器先分析match一下查询字符:

{
    "match": {
        "tweet": "About Search"
    }
}

如果用match下指定了一个确切值,在遇到数字,日期,布尔值或者not_analyzed的字符串时,它将为你搜索你给定的值:

{ "match": { "age":    26           }}
{ "match": { "date":   "2014-09-01" }}
{ "match": { "public": true         }}
{ "match": { "tag":    "full_text"  }}

提示:做精确匹配搜索时,你最好用过滤语句,因为过滤语句可以缓存数据。

multi_match 查询

multi_match查询允许你做match查询的基础上同时搜索多个字段:

{
    "multi_match": {
        "query":    "full text search",
        "fields":   [ "title", "body" ]
    }
}

bool 查询

bool查询与bool过滤相似,用于合并多个查询子句。不同的是,bool过滤可以直接给出是否匹配成功,而bool查询要计算每一个查询子句的_score(相关性分值)。

  • must:: 查询指定文档一定要被包含。
  • must_not:: 查询指定文档一定不要被包含。
  • should:: 查询指定文档,有则可以为文档相关性加分。

以下查询将会找到title字段中包含”how to make millions”,并且”tag”字段没有被标为spam。如果有标识为”starred”或者发布日期为2014年之前,那么这些匹配的文档将比同类网站等级高:

{
    "bool": {
        "must":     { "match": { "title": "how to make millions" }},
        "must_not": { "match": { "tag":   "spam" }},
        "should": [
            { "match": { "tag": "starred" }},
            { "range": { "date": { "gte": "2014-01-01" }}}
        ]
    }
}

提示:如果bool查询下没有must子句,那至少应该有一个should子句。但是如果有must子句,那么没有should子句也可以进行查询。

2.4.2 查询与过滤条件的合并

查询语句和过滤语句可以放在各自的上下文中。在ElasticSearch API中我们会看到许多带有query或filter的语句。这些语句既可以包含单条query语句,也可以包含一条filter子句。换句话说,这些语句需要首先创建一个query或filter的上下文关系。

searchAPI中只能包含query语句,所以我们需要用filtered来同时包含”query”和”filter”子句:

{
    "filtered": {
        "query":  { "match": { "email": "business opportunity" }},
        "filter": { "term":  { "folder": "inbox" }}
    }
}

我们在外层再加入query的上下文关系:

GET /_search
{
    "query": {
        "filtered": {
            "query":  { "match": { "email": "business opportunity" }},
            "filter": { "term": { "folder": "inbox" }}
        }
    }
}

如果一条查询语句没有指定查询范围,那么它默认使用match_all查询。

2.4.3 验证查询

复合查询语句太复杂,特别是与不同的分析器和字段映射相结合后,就会有些难度。

validate API 可以验证一条查询语句是否合法。

GET /gb/tweet/_validate/query
{
   "query": {
      "tweet" : {
         "match" : "really powerful"
      }
   }
}

以上请求的返回值告诉我们这条语句是非法的:

{
  "valid" :         false,
  "_shards" : {
    "total" :       1,
    "successful" :  1,
    "failed" :      0
  }
}

理解错误信息

想知道语句非法的具体错误信息,需要加上explain参数:

GET /gb/tweet/_validate/query?explain <1>
{
   "query": {
      "tweet" : {
         "match" : "really powerful"
      }
   }
} <1> explain参数可以提供语句错误的更多详情。

很显然,我们把query语句的match与字段名位置弄反了:

{
  "valid" :     false,
  "_shards" :   { ... },
  "explanations" : [ {
    "index" :   "gb",
    "valid" :   false,
    "error" :   "org.elasticsearch.index.query.QueryParsingException:
                 [gb] No query registered for [tweet]"
  } ]
}

理解查询语句

如果是合法语句的话,使用explain参数可以返回一个带有查询语句的可阅读描述,可以帮助了解查询语句在ES中是如何执行的:

GET /_validate/query?explain
{
   "query": {
      "match" : {
         "tweet" : "really powerful"
      }
   }
} explanation 会为每一个索引返回一段描述,因为每个索引会有不同的映射关系和分析器:

{
  "valid" :         true,
  "_shards" :       { ... },
  "explanations" : [ {
    "index" :       "us",
    "valid" :       true,
    "explanation" : "tweet:really tweet:powerful"
  }, {
    "index" :       "gb",
    "valid" :       true,
    "explanation" : "tweet:really tweet:power"
  } ]
}

从返回的explanation你会看到match是如何为查询字符串”really powerful”进行查询的,首先,它被拆分成两个独立的词分别在tweet字段中进行查询。

而且,在索引us中这两个词为”really”和”powerful”,在索引gb中被拆分成”really”和”power”。这是因为我们在索引gb中使用了english分析器。

REF

ES搜索