ElasticSearch权威指南:深入搜索(上)

2021-10-22 09:16:59 浏览数 (1)

在 基础入门 中涵盖了基本工具并对它们有足够详细的描述,这让我们能够开始用 Elasticsearch 搜索数据。 用不了多长时间,就会发现我们想要的更多:希望查询匹配更灵活,排名结果更精确,不同问题域下搜索更具体。

想要进阶,只知道如何使用 match 查询是不够的,我们需要理解数据以及如何能够搜索到它们。本章会解释如何索引和查询我们的数据让我们能利用词的相似度(word proximity)、部分匹配(partial matching)、模糊匹配(fuzzy matching)以及语言感知(language awareness)这些优势。

理解每个查询如何贡献相关度评分 _score有助于调试我们的查询:确保我们认为的最佳匹配文档出现在结果首页,以及削减结果中几乎不相关的 “长尾(long tail)”。

搜索不仅仅是全文搜索:我们很大一部分数据都是结构化的,如日期和数字。 我们会以说明结构化搜索与全文搜索最高效的结合方式开始本章的内容。

一、结构化搜索

结构化搜索(Structured search) 是指有关探询那些具有内在结构数据的过程。比如日期、时间和数字都是结构化的:它们有精确的格式,我们可以对这些格式进行逻辑操作。比较常见的操作包括比较数字或时间的范围,或判定两个值的大小。

文本也可以是结构化的。如彩色笔可以有离散的颜色集合: 红(red)绿(green)蓝(blue) 。一个博客可能被标记了关键词 分布式(distributed)搜索(search) 。电商网站上的商品都有 UPCs(通用产品码 Universal Product Codes)或其他的唯一标识,它们都需要遵从严格规定的、结构化的格式。

在结构化查询中,我们得到的结果 总是 非是即否,要么存于集合之中,要么存在集合之外。结构化查询不关心文件的相关度或评分;它简单的对文档包括或排除处理。

这在逻辑上是能说通的,因为一个数字不能比其他数字 适合存于某个相同范围。结果只能是:存于范围之中,抑或反之。同样,对于结构化文本来说,一个值要么相等,要么不等。没有 更似 这种概念。

1.精确值查找

当进行精确值查找时, 我们会使用过滤器(filters)。过滤器很重要,因为它们执行速度非常快,不会计算相关度(直接跳过了整个评分阶段)而且很容易被缓存。我们会在本章后面的 过滤器缓存 中讨论过滤器的性能优势,不过现在只要记住:请尽可能多的使用过滤式查询。

1. term查询

我们首先来看最为常用的 term 查询, 可以用它处理数字(numbers)、布尔值(Booleans)、日期(dates)以及文本(text)。

让我们以下面的例子开始介绍,创建并索引一些表示产品的文档,文档里有字段 `price` 和 `productID` ( `价格` 和 `产品ID` ):

代码语言:javascript复制
POST /my_store/products/_bulk
{ "index": { "_id": 1 }}
{ "price" : 10, "productID" : "XHDK-A-1293-#fJ3" }
{ "index": { "_id": 2 }}
{ "price" : 20, "productID" : "KDKE-B-9947-#kL5" }
{ "index": { "_id": 3 }}
{ "price" : 30, "productID" : "JODL-X-1937-#pV7" }
{ "index": { "_id": 4 }}
{ "price" : 30, "productID" : "QQPX-R-3956-#aD8" }

我们想要做的是查找具有某个价格的所有产品,有关系数据库背景的人肯定熟悉 SQL,如果我们将其用 SQL 形式表达,会是下面这样:

代码语言:javascript复制
SELECT document
FROM   products
WHERE  price = 20

在 Elasticsearch 的查询表达式(query DSL)中,我们可以使用 term 查询达到相同的目的。 term 查询会查找我们指定的精确值。作为其本身,term查询是简单的。它接受一个字段名以及我们希望查找的数值:

代码语言:javascript复制
{
    "term" : {
        "price" : 20
    }
}

通常当查找一个精确值的时候,我们不希望对查询进行评分计算。只希望对文档进行包括或排除的计算,所以我们会使用 constant_score 查询以非评分模式来执行 term 查询并以一作为统一评分。

最终组合的结果是一个 constant_score 查询,它包含一个 term 查询:

代码语言:javascript复制
GET /my_store/products/_search
{
    "query" : {
        "constant_score" : {   #我们用 constant_score 将 term 查询转化成为过滤器
            "filter" : {
                "term" : {   #我们之前看到过的 term 查询 
                    "price" : 20
                }
            }
        }
    }
}

Java代码:
ConstantScoreQueryBuilder constantScoreQueryBuilder = new ConstantScoreQueryBuilder(QueryBuilders.termsQuery("casediagnose", q));
builder.query(constantScoreQueryBuilder);

执行后,这个查询所搜索到的结果与我们期望的一致:只有文档 2 命中并作为结果返回(因为只有 2 的价格是 20 ):

代码语言:javascript复制
"hits" : [
    {
        "_index" : "my_store",
        "_type" :  "products",
        "_id" :    "2",
        "_score" : 1.0,   #查询置于 filter 语句内不进行评分或相关度的计算,所以所有的结果都会返回一个默认评分 1 。 
        "_source" : {
          "price" :     20,
          "productID" : "KDKE-B-9947-#kL5"
        }
    }
]

2. term查询文本

如本部分开始处提到过的一样 ,使用 term 查询匹配字符串和匹配数字一样容易。如果我们想要查询某个具体 UPC ID 的产品,使用 SQL 表达式会是如下这样:

代码语言:javascript复制
SELECT product
FROM   products
WHERE  productID = "XHDK-A-1293-#fJ3"

转换成查询表达式(query DSL),同样使用 term 查询,形式如下:

代码语言:javascript复制
GET /my_store/products/_search
{
    "query" : {
        "constant_score" : {
            "filter" : {
                "term" : {
                    "productID" : "XHDK-A-1293-#fJ3"
                }
            }
        }
    }
}

但这里有个小问题:我们无法获得期望的结果。为什么呢?问题不在 term 查询,而在于索引数据的方式。 如果我们使用 analyze API (分析 API),我们可以看到这里的 UPC 码被拆分成多个更小的 token :

代码语言:javascript复制
GET /my_store/_analyze
{
  "field": "productID",
  "text": "XHDK-A-1293-#fJ3"
}
代码语言:javascript复制
{
  "tokens" : [ {
    "token" :        "xhdk",
    "start_offset" : 0,
    "end_offset" :   4,
    "type" :         "<ALPHANUM>",
    "position" :     1
  }, {
    "token" :        "a",
    "start_offset" : 5,
    "end_offset" :   6,
    "type" :         "<ALPHANUM>",
    "position" :     2
  }, {
    "token" :        "1293",
    "start_offset" : 7,
    "end_offset" :   11,
    "type" :         "<NUM>",
    "position" :     3
  }, {
    "token" :        "fj3",
    "start_offset" : 13,
    "end_offset" :   16,
    "type" :         "<ALPHANUM>",
    "position" :     4
  } ]
}

这里有几点需要注意:

  • Elasticsearch 用 4 个不同的 token 而不是单个 token 来表示这个 UPC 。
  • 所有字母都是小写的。
  • 丢失了连字符和哈希符( # )。

所以当我们用 term 查询查找精确值 XHDK-A-1293-#fJ3 的时候,找不到任何文档,因为它并不在我们的倒排索引中,正如前面呈现出的分析结果,索引里有四个 token 。

显然这种对 ID 码或其他任何精确值的处理方式并不是我们想要的。

为了避免这种问题,我们需要告诉 Elasticsearch 该字段具有精确值,要将其设置成 not_analyzed 无需分析的。 我们可以在 自定义字段映射 中查看它的用法。为了修正搜索结果,我们需要首先删除旧索引(因为它的映射不再正确)然后创建一个能正确映射的新索引:

代码语言:javascript复制
DELETE /my_store   #删除索引是必须的,因为我们不能更新已存在的映射。 

PUT /my_store   #在索引被删除后,我们可以创建新的索引并为其指定自定义映射。
{
    "mappings" : {
        "products" : {
            "properties" : {
                "productID" : {
                    "type" : "string",
                    "index" : "not_analyzed"   #这里我们告诉 Elasticsearch ,我们不想对 productID 做任何分析。 
                }
            }
        }
    }

}

现在我们可以为文档重建索引:

代码语言:javascript复制
POST /my_store/products/_bulk
{ "index": { "_id": 1 }}
{ "price" : 10, "productID" : "XHDK-A-1293-#fJ3" }
{ "index": { "_id": 2 }}
{ "price" : 20, "productID" : "KDKE-B-9947-#kL5" }
{ "index": { "_id": 3 }}
{ "price" : 30, "productID" : "JODL-X-1937-#pV7" }
{ "index": { "_id": 4 }}
{ "price" : 30, "productID" : "QQPX-R-3956-#aD8" }

此时, term 查询就能搜索到我们想要的结果,让我们再次搜索新索引过的数据(注意,查询和过滤并没有发生任何改变,改变的是数据映射的方式):

代码语言:javascript复制
GET /my_store/products/_search
{
    "query" : {
        "constant_score" : {
            "filter" : {
                "term" : {
                    "productID" : "XHDK-A-1293-#fJ3"
                }
            }
        }
    }
}

因为 productID 字段是未分析过的, term 查询不会对其做任何分析,查询会进行精确查找并返回文档 1 。成功!

3. 内部过滤器的操作

在内部,Elasticsearch 会在运行非评分查询的时执行多个操作:

  1. 查找匹配文档:term 查询在倒排索引中查找 XHDK-A-1293-#fJ3 然后获取包含该 term 的所有文档。本例中,只有文档 1 满足我们要求。
  2. 创建 bitset:过滤器会创建一个 bitset (一个包含 0 和 1 的数组),它描述了哪个文档会包含该 term 。匹配文档的标志位是 1 。本例中,bitset 的值为 [1,0,0,0] 。在内部,它表示成一个 "roaring bitmap",可以同时对稀疏或密集的集合进行高效编码。
  3. 迭代 bitset(s):一旦为每个查询生成了 bitsets ,Elasticsearch 就会循环迭代 bitsets 从而找到满足所有过滤条件的匹配文档的集合。执行顺序是启发式的,但一般来说先迭代稀疏的 bitset (因为它可以排除掉大量的文档)。
  4. 增量使用计数:Elasticsearch 能够缓存非评分查询从而获取更快的访问,但是它也会不太聪明地缓存一些使用极少的东西。非评分计算因为倒排索引已经足够快了,所以我们只想缓存那些我们 知道 在将来会被再次使用的查询,以避免资源的浪费。 为了实现以上设想,Elasticsearch 会为每个索引跟踪保留查询使用的历史状态。如果查询在最近的 256 次查询中会被用到,那么它就会被缓存到内存中。当 bitset 被缓存后,缓存会在那些低于 10,000 个文档(或少于 3% 的总索引数)的段(segment)中被忽略。这些小的段即将会消失(segment合并),所以为它们分配缓存是一种浪费。

实际情况并非如此(执行有它的复杂性,这取决于查询计划是如何重新规划的,有些启发式的算法是基于查询代价的),理论上非评分查询先于评分查询执行。非评分查询任务旨在降低那些将对评分查询计算带来更高成本的文档数量,从而达到快速搜索的目的。

从概念上记住非评分计算是首先执行的,这将有助于写出高效又快速的搜索请求。

2.组合过滤器

前面的两个例子都是单个过滤器(filter)的使用方式。 在实际应用中,我们很有可能会过滤多个值或字段。比方说,怎样用 Elasticsearch 来表达下面的 SQL ?

代码语言:javascript复制
SELECT product
FROM   products
WHERE  (price = 20 OR productID = "XHDK-A-1293-#fJ3")
  AND  (price != 30)

这种情况下,我们需要 bool (布尔)过滤器。 这是个 复合过滤器(compound filter) ,它可以接受多个其他过滤器作为参数,并将这些过滤器结合成各式各样的布尔(逻辑)组合。

1. bool过滤器

一个 bool 过滤器由三部分组成:

代码语言:javascript复制
{
   "bool" : {
      "must" :     [],
      "should" :   [],
      "must_not" : [],
   }
}
  • must:所有的语句都 必须(must) 匹配,与 AND 等价。
  • must_not:所有的语句都 不能(must not) 匹配,与 NOT 等价。
  • should:至少有一个语句要匹配,与 OR 等价。

就这么简单! 当我们需要多个过滤器时,只须将它们置入 bool 过滤器的不同部分即可。

一个 bool 过滤器的每个部分都是可选的(例如,我们可以只有一个must 语句),而且每个部分内部可以只有一个或一组过滤器。

用 Elasticsearch 来表示本部分开始处的 SQL 例子,将两个term过滤器置入bool过滤器的 should 语句内,再增加一个语句处理 NOT 非的条件:

代码语言:javascript复制
GET /my_store/products/_search
{
   "query" : {
      "filtered" : {   #注意,我们仍然需要一个 filtered 查询将所有的东西包起来。 
         "filter" : {
            "bool" : {
              "should" : [
                 { "term" : {"price" : 20}},   #在 should 语句块里面的两个 term 过滤器与 bool 过滤器是父子关系,两个 term 条件需要匹配其一。 
                 { "term" : {"productID" : "XHDK-A-1293-#fJ3"}} 
              ],
              "must_not" : {  #如果一个产品的价格是 30 ,那么它会自动被排除,因为它处于 must_not 语句里面。 
                 "term" : {"price" : 30} 
              }
           }
         }
      }
   }
}

我们搜索的结果返回了 2 个命中结果,两个文档分别匹配了 bool 过滤器其中的一个条件:

代码语言:javascript复制
"hits" : [
    {
        "_id" :     "1",
        "_score" :  1.0,
        "_source" : {
          "price" :     10,
          "productID" : "XHDK-A-1293-#fJ3"   #与 term 过滤器中 productID = "XHDK-A-1293-#fJ3" 条件匹配 
        }
    },
    {
        "_id" :     "2",
        "_score" :  1.0,
        "_source" : {
          "price" :     20, 
          "productID" : "KDKE-B-9947-#kL5"  #与 term 过滤器中 price = 20 条件匹配 
        }
    }
]

2. 嵌套bool过滤器

尽管bool是一个复合的过滤器,可以接受多个子过滤器,需要注意的是 bool 过滤器本身仍然还只是一个过滤器。 这意味着我们可以将一个bool过滤器置于其他 bool 过滤器内部,这为我们提供了对任意复杂布尔逻辑进行处理的能力。

对于以下这个 SQL 语句:

代码语言:javascript复制
SELECT document
FROM   products
WHERE  productID      = "KDKE-B-9947-#kL5"
  OR (     productID = "JODL-X-1937-#pV7"
       AND price = 30 )

我们将其转换成一组嵌套的 bool 过滤器:

代码语言:javascript复制
GET /my_store/products/_search
{
   "query" : {
      "filtered" : {
         "filter" : {
            "bool" : {
              "should" : [  #因为 term 和 bool 过滤器是兄弟关系,他们都处于外层的布尔逻辑 should 的内部,返回的命中文档至少须匹配其中一个过滤器的条件。 
                { "term" : {"productID" : "KDKE-B-9947-#kL5"}}, 
                { "bool" : { 
                  "must" : [  #这两个 term 语句作为兄弟关系,同时处于 must 语句之中,所以返回的命中文档要必须都能同时匹配这两个条件。 
                    { "term" : {"productID" : "JODL-X-1937-#pV7"}}, 
                    { "term" : {"price" : 30}} 
                  ]
                }}
              ]
           }
         }
      }
   }
}

得到的结果有两个文档,它们各匹配 should 语句中的一个条件:

代码语言:javascript复制
"hits" : [
    {
        "_id" :     "2",
        "_score" :  1.0,
        "_source" : {
          "price" :     20,
          "productID" : "KDKE-B-9947-#kL5"   #这个 productID 与外层的 bool 过滤器 should 里的唯一一个 term 匹配。
        }
    },
    {
        "_id" :     "3",
        "_score" :  1.0,
        "_source" : {
          "price" :      30,   #这两个字段与嵌套的 bool 过滤器 must 里的两个 term 匹配。
          "productID" : "JODL-X-1937-#pV7" 
        }
    }
]

这只是个简单的例子,但足以展示布尔过滤器可以用来作为构造复杂逻辑条件的基本构建模块。

3.查找多个精确值

term 查询对于查找单个值非常有用,但通常我们可能想搜索多个值。 如果我们想要查找价格字段值为 20 或20或30 的文档该如何处理呢?

不需要使用多个 term 查询,我们只要用单个 terms 查询(注意末尾的 s ), terms 查询好比是 term 查询的复数形式(以英语名词的单复数做比)。

它几乎与 term 的使用方式一模一样,与指定单个价格不同,我们只要将 term 字段的值改为数组即可:

代码语言:javascript复制
{
    "terms" : {
        "price" : [20, 30]
    }
}

term 查询一样,也需要将其置入 filter 语句的常量评分查询中使用:

代码语言:javascript复制
GET /my_store/products/_search
{
    "query" : {
        "constant_score" : {  #这个 terms 查询被置于 constant_score 查询中 
            "filter" : {
                "terms" : { 
                    "price" : [20, 30]
                }
            }
        }
    }
}

运行结果返回第二、第三和第四个文档:

代码语言:javascript复制
"hits" : [
    {
        "_id" :    "2",
        "_score" : 1.0,
        "_source" : {
          "price" :     20,
          "productID" : "KDKE-B-9947-#kL5"
        }
    },
    {
        "_id" :    "3",
        "_score" : 1.0,
        "_source" : {
          "price" :     30,
          "productID" : "JODL-X-1937-#pV7"
        }
    },
    {
        "_id":     "4",
        "_score":  1.0,
        "_source": {
           "price":     30,
           "productID": "QQPX-R-3956-#aD8"
        }
     }
]

1. 包含而不是相等

一定要了解termterms包含(contains) 操作,而非 等值(equals) (判断)。 如何理解这句话呢?

如果我们有一个 term(词项)过滤器 { "term" : { "tags" : "search" } } ,它会与以下两个文档 同时 匹配:

代码语言:javascript复制
{ "tags" : ["search"] }
{ "tags" : ["search", "open_source"] }   #尽管第二个文档包含除 search 以外的其他词,它还是被匹配并作为结果返回。 

回忆一下term 查询是如何工作的? Elasticsearch 会在倒排索引中查找包括某 term 的所有文档,然后构造一个 bitset 。在我们的例子中,倒排索引表如下:

Token

DocIDs

open_source

2

search

1,2

term 查询匹配标记 search 时,它直接在倒排索引中找到记录并获取相关的文档 ID,如倒排索引所示,这里文档 1 和文档 2 均包含该标记,所以两个文档会同时作为结果返回。

由于倒排索引表自身的特性,整个字段是否相等会难以计算,如果确定某个特定文档是否 只(only) 包含我们想要查找的词呢?首先我们需要在倒排索引中找到相关的记录并获取文档 ID,然后再扫描 倒排索引中的每行记录 ,查看它们是否包含其他的 terms 。 可以想象,这样不仅低效,而且代价高昂。正因如此, termterms必须包含(must contain) 操作,而不是 必须精确相等(must equal exactly)

2. 精确相等

如果一定期望得到我们前面说的那种行为(即整个字段完全相等),最好的方式是增加并索引另一个字段, 这个字段用以存储该字段包含词项的数量,同样以上面提到的两个文档为例,现在我们包括了一个维护标签数的新字段:

代码语言:javascript复制
{ "tags" : ["search"], "tag_count" : 1 }
{ "tags" : ["search", "open_source"], "tag_count" : 2 }

一旦增加这个用来索引项 term 数目信息的字段,我们就可以构造一个 constant_score 查询,来确保结果中的文档所包含的词项数量与要求是一致的:

代码语言:javascript复制
GET /my_index/my_type/_search
{
    "query": {
        "constant_score" : {
            "filter" : {
                 "bool" : {
                    "must" : [
                        { "term" : { "tags" : "search" } },   #查找所有包含 term search 的文档。
                        { "term" : { "tag_count" : 1 } }   #确保文档只有一个标签。
                    ]
                }
            }
        }
    }
}

这个查询现在只会匹配具有单个标签 search 的文档,而不是任意一个包含 search 的文档。

4.范围

本章到目前为止,对于数字,只介绍如何处理精确值查询。 实际上,对数字范围进行过滤有时会更有用。例如,我们可能想要查找所有价格大于 20 且小于20且小于40 美元的产品。

在 SQL 中,范围查询可以表示为:

代码语言:javascript复制
SELECT document
FROM   products
WHERE  price BETWEEN 20 AND 40

Elasticsearch 有 range 查询, 不出所料地,可以用它来查找处于某个范围内的文档:

代码语言:javascript复制
"range" : {
    "price" : {
        "gte" : 20,
        "lte" : 40
    }
}

range 查询可同时提供包含(inclusive)和不包含(exclusive)这两种范围表达式,可供组合的选项如下:

  • gt: > 大于(greater than)
  • lt: < 小于(less than)
  • gte: >= 大于或等于(greater than or equal to)
  • lte: <= 小于或等于(less than or equal to)

下面是一个范围查询的例子:

代码语言:javascript复制
GET /my_store/products/_search
{
    "query" : {
        "constant_score" : {
            "filter" : {
                "range" : {
                    "price" : {
                        "gte" : 20,
                        "lt"  : 40
                    }
                }
            }
        }
    }
}

如果想要范围无界(比方说 >20 ),只须省略其中一边的限制:

代码语言:javascript复制
"range" : {
    "price" : {
        "gt" : 20
    }
}

1. 日期范围

range 查询同样可以应用在日期字段上:

代码语言:javascript复制
"range" : {
    "timestamp" : {
        "gt" : "2014-01-01 00:00:00",
        "lt" : "2014-01-07 00:00:00"
    }
}

当使用它处理日期字段时, range 查询支持对 日期计算(date math) 进行操作,比方说,如果我们想查找时间戳在过去一小时内的所有文档:

代码语言:javascript复制
"range" : {
    "timestamp" : {
        "gt" : "now-1h"
    }
}

这个过滤器会一直查找时间戳在过去一个小时内的所有文档,让过滤器作为一个时间 滑动窗口(sliding window) 来过滤文档。

日期计算还可以被应用到某个具体的时间,并非只能是一个像 now 这样的占位符。只要在某个日期后加上一个双管符号 (||) 并紧跟一个日期数学表达式就能做到:

代码语言:javascript复制
"range" : {
    "timestamp" : {
        "gt" : "2014-01-01 00:00:00",
        "lt" : "2014-01-01 00:00:00|| 1M"   #早于 2014 年 1 月 1 日加 1 月(2014 年 2 月 1 日 零时)
    }
}

日期计算是 日历相关(calendar aware) 的,所以它不仅知道每月的具体天数,还知道某年的总天数(闰年)等信息。更详细的内容可以参考: 时间格式参考文档 。

2. 字符串范围

range 查询同样可以处理字符串字段,字符串范围可采用字典顺序(lexicographically)或字母顺序(alphabetically)。例如,下面这些字符串是采用字典序(lexicographically)排序的:

  • 5, 50, 6, B, C, a, ab, abb, abc, b

在倒排索引中的词项就是采取字典顺序(lexicographically)排列的,这也是字符串范围可以使用这个顺序来确定的原因。

如果我们想查找从 ab (不包含)的字符串,同样可以使用 range 查询语法:

代码语言:javascript复制
"range" : {
    "title" : {
        "gte" : "a",
        "lt" :  "b"
    }
}

注意基数 数字和日期字段的索引方式使高效地范围计算成为可能。 但字符串却并非如此,要想对其使用范围过滤,Elasticsearch 实际上是在为范围内的每个词项都执行term 过滤器,这会比日期或数字的范围过滤慢许多。 字符串范围在过滤 低基数(low cardinality) 字段(即只有少量唯一词项)时可以正常工作,但是唯一词项越多,字符串范围的计算会越慢。

5.处理null值

回想在之前例子中,有的文档有名为 tags (标签)的字段,它是个多值字段, 一个文档可能有一个或多个标签,也可能根本就没有标签。如果一个字段没有值,那么如何将它存入倒排索引中的呢?

这是个有欺骗性的问题,因为答案是:什么都不存。让我们看看之前内容里提到过的倒排索引:

Token

DocIDs

open_source

2

search

1,2

如何将某个不存在的字段存储在这个数据结构中呢?无法做到!简单的说,一个倒排索引只是一个 token 列表和与之相关的文档信息,如果字段不存在,那么它也不会持有任何 token,也就无法在倒排索引结构中表现。最终,这也就意味着 ,null, [](空数组)和[null] 所有这些都是等价的,它们无法存于倒排索引中。

显然,世界并不简单,数据往往会有缺失字段,或有显式的空值或空数组。为了应对这些状况,Elasticsearch 提供了一些工具来处理空或缺失值。

1. 存在查询

第一件武器就是exists 存在查询。 这个查询会返回那些在指定字段有任何值的文档,让我们索引一些示例文档并用标签的例子来说明:

代码语言:javascript复制
POST /my_index/posts/_bulk
{ "index": { "_id": "1"              }}
{ "tags" : ["search"]                }    #tags 字段有 1 个值。 
{ "index": { "_id": "2"              }}
{ "tags" : ["search", "open_source"] }    #tags 字段有 2 个值。
{ "index": { "_id": "3"              }}
{ "other_field" : "some data"        }    #tags 字段缺失。 
{ "index": { "_id": "4"              }}
{ "tags" : null                      }    #tags 字段被置为 null 。
{ "index": { "_id": "5"              }}
{ "tags" : ["search", null]          }    #tags 字段有 1 个值和 1 个 null 。

以上文档集合中 tags 字段对应的倒排索引如下:

Token

DocIDs

open_source

2

search

1,2,5

我们的目标是找到那些被设置过标签字段的文档,并不关心标签的具体内容。只要它存在于文档中即可,用 SQL 的话就是用 IS NOT NULL 非空进行查询:

代码语言:javascript复制
SELECT tags
FROM   posts
WHERE  tags IS NOT NULL

在 Elasticsearch 中,使用 exists 查询的方式如下:

代码语言:javascript复制
GET /my_index/posts/_search
{
    "query" : {
        "constant_score" : {
            "filter" : {
                "exists" : { "field" : "tags" }
            }
        }
    }
}

Java代码:
QueryBuilders.existsQuery("tags");

这个查询返回 3 个文档:

代码语言:javascript复制
"hits" : [
    {
      "_id" :     "1",
      "_score" :  1.0,
      "_source" : { "tags" : ["search"] }
    },
    {
      "_id" :     "5",
      "_score" :  1.0,
      "_source" : { "tags" : ["search", null] }   #尽管文档 5 有 null 值,但它仍会被命中返回。字段之所以存在,是因为标签有实际值( search )可以被索引,所以 null 对过滤不会产生任何影响。 
    },
    {
      "_id" :     "2",
      "_score" :  1.0,
      "_source" : { "tags" : ["search", "open source"] }
    }
]

显而易见,只要 tags 字段存在项(term)的文档都会命中并作为结果返回,只有 3 和 4 两个文档被排除。

2. 缺失查询

这个 missing 查询本质上与 exists 恰好相反: 它返回某个特定无值字段的文档,与以下 SQL 表达的意思类似:

代码语言:javascript复制
SELECT tags
FROM   posts
WHERE  tags IS NULL

我们将前面例子中 exists 查询换成 missing 查询:

代码语言:javascript复制
GET /my_index/posts/_search
{
    "query" : {
        "constant_score" : {
            "filter": {
                "missing" : { "field" : "tags" }
            }
        }
    }
}

按照期望的那样,我们得到 3 和 4 两个文档(这两个文档的 tags 字段没有实际值):

代码语言:javascript复制
"hits" : [
    {
      "_id" :     "3",
      "_score" :  1.0,
      "_source" : { "other_field" : "some data" }
    },
    {
      "_id" :     "4",
      "_score" :  1.0,
      "_source" : { "tags" : null }
    }
]

有时候我们需要区分一个字段是没有值,还是它已被显式的设置成了 null 。在之前例子中,我们看到的默认的行为是无法做到这点的;数据被丢失了。不过幸运的是,我们可以选择将显式的 null 值替换成我们指定 占位符(placeholder) 。

在为字符串(string)、数字(numeric)、布尔值(Boolean)或日期(date)字段指定映射时,同样可以为之设置 null_value 空值,用以处理显式 null 值的情况。不过即使如此,还是会将一个没有值的字段从倒排索引中排除。

当选择合适的 null_value 空值的时候,需要保证以下几点:

  • 它会匹配字段的类型,我们不能为一个 date 日期字段设置字符串类型的 null_value
  • 它必须与普通值不一样,这可以避免把实际值当成 null 空的情况。

4. 对象上的存在与缺失

不仅可以过滤核心类型, exists and missing 查询 还可以处理一个对象的内部字段。以下面文档为例:

代码语言:javascript复制
{
   "name" : {
      "first" : "John",
      "last" :  "Smith"
   }
}

我们不仅可以检查 name.firstname.last 的存在性,也可以检查 name ,不过在 映射 中,如上对象的内部是个扁平的字段与值(field-value)的简单键值结构,类似下面这样:

代码语言:javascript复制
{
   "name.first" : "John",
   "name.last"  : "Smith"
}

那么我们如何用 existsmissing 查询 name 字段呢? name 字段并不真实存在于倒排索引中。

原因是当我们执行下面这个过滤的时候:

代码语言:javascript复制
{
    "exists" : { "field" : "name" }
}

实际执行的是:

代码语言:javascript复制
{
    "bool": {
        "should": [
            { "exists": { "field": "name.first" }},
            { "exists": { "field": "name.last" }}
        ]
    }
}

这也就意味着,如果 firstlast 都是空,那么 name 这个命名空间才会被认为不存在。

6.关于缓存

  • Query context:在查询上下文中使用的查询将计算相关性分数,不会被缓存。 只要过滤器上下文不适用,就使用查询上下文。
  • Filter context:在过滤器上下文中使用的查询将不会计算相关性分数,并且可以缓存。

过滤器上下文由以下引入:

  • constant_score查询
  • bool查询中的must_notfilter参数
  • function_score查询中的filterfilters参数
  • 任何叫filter的API,例如post_filter搜索参数,或者在聚合和索引别名中(any API called filter, such as the post_filter search parameter, or in aggregations or index aliases)

在本章前面(过滤器的内部操作)中,我们已经简单介绍了过滤器是如何计算的。 其核心实际是采用一个 bitset 记录与过滤器匹配的文档。Elasticsearch 积极地把这些 bitset 缓存起来以备随后使用。一旦缓存成功,bitset 可以复用 任何 已使用过的相同过滤器,而无需再次计算整个过滤器。

这些 bitsets 缓存是“智能”的:它们以增量方式更新。当我们索引新文档时,只需将那些新文档加入已有 bitset,而不是对整个缓存一遍又一遍的重复计算。和系统其他部分一样,过滤器是实时的,我们无需担心缓存过期问题。

1. 独立的过滤器缓存

属于一个查询组件的 bitsets 是独立于它所属搜索请求其他部分的。这就意味着,一旦被缓存,一个查询可以被用作多个搜索请求。bitsets 并不依赖于它所存在的查询上下文。这样使得缓存可以加速查询中经常使用的部分,从而降低较少、易变的部分所带来的消耗。同样,如果单个请求重用相同的非评分查询,它缓存的 bitset 可以被单个搜索里的所有实例所重用。

让我们看看下面例子中的查询,它查找满足以下任意一个条件的电子邮件:

  • 在收件箱中,且没有被读过的
  • 不在 收件箱中,但被标注重要的
代码语言:javascript复制
GET /inbox/emails/_search
{
  "query": {
      "constant_score": {
          "filter": {
              "bool": {
                 "should": [
                    { "bool": {
                          "must": [
                             { "term": { "folder": "inbox" }},   #两个过滤器是相同的,所以会使用同一 bitset 。
                             { "term": { "read": false }}
                          ]
                    }},
                    { "bool": {
                          "must_not": {
                             "term": { "folder": "inbox" }   #两个过滤器是相同的,所以会使用同一 bitset 。
                          },
                          "must": {
                             "term": { "important": true }
                          }
                    }}
                 ]
              }
            }
        }
    }
}

尽管其中一个收件箱的条件是 must 语句,另一个是 must_not 语句,但他们两者是完全相同的。这意味着在第一个语句执行后, bitset 就会被计算然后缓存起来供另一个使用。当再次执行这个查询时,收件箱的这个过滤器已经被缓存了,所以两个语句都会使用已缓存的 bitset 。

这点与查询表达式(query DSL)的可组合性结合得很好。它易被移动到表达式的任何地方,或者在同一查询中的多个位置复用。这不仅能方便开发者,而且对提升性能有直接的益处。

2. 自动缓存行为

在 Elasticsearch 的较早版本中,默认的行为是缓存一切可以缓存的对象。这也通常意味着系统缓存 bitsets 太富侵略性,从而因为清理缓存带来性能压力。不仅如此,尽管很多过滤器都很容易被评价,但本质上是慢于缓存的(以及从缓存中复用)。缓存这些过滤器的意义不大,因为可以简单地再次执行过滤器。

检查一个倒排是非常快的,然后绝大多数查询组件却很少使用它。例如 term 过滤字段 "user_id" :如果有上百万的用户,每个具体的用户 ID 出现的概率都很小。那么为这个过滤器缓存 bitsets 就不是很合算,因为缓存的结果很可能在重用之前就被剔除了。这种缓存的扰动对性能有着严重的影响。更严重的是,它让开发者难以区分有良好表现的缓存以及无用缓存。

为了解决问题,Elasticsearch 会基于使用频次自动缓存查询。如果一个非评分查询在最近的 256 次查询中被使用过(次数取决于查询类型),那么这个查询就会作为缓存的候选。但是,并不是所有的片段都能保证缓存 bitset 。只有那些文档数量超过 10,000 (或超过总文档数量的 3% )才会缓存 bitset 。因为小的片段可以很快的进行搜索和合并,这里缓存的意义不大。

一旦缓存了,非评分计算的 bitset 会一直驻留在缓存中直到它被剔除。剔除规则是基于 LRU 的:一旦缓存满了,最近最少使用的过滤器会被剔除。

org.elasticsearch.index.queries.FilterBuilders从ElasticSearch2.0开始已被删除,作为查询和过滤器组合的一部分。 这些过滤器现在可以在QueryBuilders中使用具有相同名称的方法。所有可以接受FilterBuilder的方法现在也可以接受QueryBuilder。

代码语言:javascript复制
以前使用方式:

FilterBuilders.boolFilter()  
    .must(FilterBuilders.termFilter("name", "张三"))  
    .mustNot(FilterBuilders.rangeFilter("age").from(28).to(30))  
    .should(FilterBuilders.termFilter("city", "北京"));

现在可以使用如下方式:

BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();
boolQueryBuilder.must(QueryBuilders.termQuery("name", "张三"));
boolQueryBuilder.must(QueryBuilders.rangeQuery("age").from(28).to(30));
boolQueryBuilder.must(QueryBuilders.termQuery("city", "北京");
ConstantScoreQueryBuilder queryBuilder = QueryBuilders.constantScoreQuery(boolQueryBuilder);

以前可以通过_cache选项来控制哪些过滤器被缓存,并提供一个自定义的_cache_key。 这些选项已被弃用,如果存在,将被忽略。 过滤器上下文中使用的查询子句现在可以自动缓存。该算法考虑到使用频率,查询执行成本以及构建过滤器的成本。 terms过滤器查找机制不在缓存文档内容.现在依赖于文件系统缓存.如果查找索引不是太大,建议通过设置index.auto_expand_replicas:0-all将其复制到所有节点,以消除网络开销。

filtered查询 与 query过滤器 废弃:query过滤器已经废弃不再需要 – 所有查询都可以在查询或过滤器上下文中使用。

代码语言:javascript复制
filtered查询已被弃用。 filtered查询如下:

GET _search
{
  "query": {
    "filtered": {
      "query": {
        "match": {
          "text": "quick brown fox"
        }
      },
      "filter": {
        "term": {
          "status": "published"
        }
      }
    }
  }
}
代码语言:javascript复制
将查询和过滤器转换为bool查询中的must和filter参数:

GET _search
{
  "query": {
    "bool": {
      "must": {
        "match": {
          "text": "quick brown fox"
        }
      },
      "filter": {
        "term": {
          "status": "published"
        }
      }
    }
  }
}

二、全文搜索

我们已经介绍了搜索结构化数据的简单应用示例,现在来探寻 全文搜索(full-text search) :怎样在全文字段中搜索到最相关的文档。

全文搜索两个最重要的方面是:

  • 相关性(Relevance):它是评价查询与其结果间的相关程度,并根据这种相关程度对结果排名的一种能力,这种计算方式可以是 TF/IDF 方法(参见 相关性的介绍)、地理位置邻近、模糊相似,或其他的某些算法。
  • 分析(Analysis):它是将文本块转换为有区别的、规范化的 token 的一个过程,(参见 分析的介绍) 目的是为了(a)创建倒排索引以及(b)查询倒排索引。

一旦谈论相关性或分析这两个方面的问题时,我们所处的语境是关于查询的而不是过滤。

1. 基于词项&基于全文

所有查询会或多或少的执行相关度计算,但不是所有查询都有分析阶段。 和一些特殊的完全不会对文本进行操作的查询(如 boolfunction_score )不同,文本查询可以划分成两大家族:

1. 基于词项的查询

termfuzzy 这样的底层查询不需要分析阶段,它们对单个词项进行操作。term 查询词项 Foo 只要在倒排索引中查找 准确词项 ,并且用 TF/IDF 算法为每个包含该词项的文档计算相关度评分 _score

代码语言:javascript复制
POST drug/_search
{
  "query": {
    "fuzzy": {
      "indextype": {
        "value": "teamDrugRef"
      }
    }
  }
}

fuzzy在es中可以理解为模糊查询,就模糊查询而言es的fuzzy实现了一种复杂度和效果比较折中的查询能力。

记住 term 查询只对倒排索引的词项精确匹配,这点很重要,它不会对词的多样性进行处理(如, fooFOO )。这里,无须考虑词项是如何存入索引的。如果是将 ["Foo","Bar"] 索引存入一个不分析的( not_analyzed )包含精确值的字段,或者将 Foo Bar 索引到一个带有 whitespace 空格分析器的字段,两者的结果都会是在倒排索引中有 FooBar 这两个词。

2. 基于全文的查询

matchquery_string 这样的查询是高层查询,它们了解字段映射的信息:

  • 如果查询 日期(date)整数(integer) 字段,它们会将查询字符串分别作为日期或整数对待。
  • 如果查询一个( not_analyzed )未分析的精确值字符串字段, 它们会将整个查询字符串作为单个词项对待。
  • 但如果要查询一个( analyzed )已分析的全文字段, 它们会先将查询字符串传递到一个合适的分析器,然后生成一个供查询的词项列表。

一旦组成了词项列表,这个查询会对每个词项逐一执行底层的查询,再将结果合并,然后为每个文档生成一个最终的相关度评分。

我们将会在随后章节中详细讨论这个过程。

我们很少直接使用基于词项的搜索,通常情况下都是对全文进行查询,而非单个词项,这只需要简单的执行一个高层全文查询(进而在高层查询内部会以基于词项的底层查询完成搜索)。

当我们想要查询一个具有精确值的 not_analyzed 未分析字段之前, 需要考虑,是否真的采用评分查询,或者非评分查询会更好。 单词项查询通常可以用是、非这种二元问题表示,所以更适合用过滤, 而且这样做可以有效利用缓存:

代码语言:javascript复制
GET /_search {     "query": {         "constant_score": {             "filter": {                 "term": { "gender": "female" }             }         }     } }

2.匹配查询

匹配查询 match 是个 核心 查询。无论需要查询什么字段,match 查询都应该会是首选的查询方式。 它是一个高级 全文查询 ,这表示它既能处理全文字段,又能处理精确字段。

这就是说, match 查询主要的应用场景就是进行全文搜索,我们以下面一个简单例子来说明全文搜索是如何工作的:

1. 索引一些数据

首先,我们使用 bulk API 创建一些新的文档和索引:

代码语言:javascript复制
DELETE /my_index   #删除已有的索引。 

PUT /my_index
{ "settings": { "number_of_shards": 1 }}   #稍后,我们会在 被破坏的相关性! 中解释只为这个索引分配一个主分片的原因。

POST /my_index/my_type/_bulk
{ "index": { "_id": 1 }}
{ "title": "The quick brown fox" }
{ "index": { "_id": 2 }}
{ "title": "The quick brown fox jumps over the lazy dog" }
{ "index": { "_id": 3 }}
{ "title": "The quick brown fox jumps over the quick dog" }
{ "index": { "_id": 4 }}
{ "title": "Brown fox brown dog" }

2. 单个词查询

我们用第一个示例来解释使用 match 查询搜索全文字段中的单个词:

代码语言:javascript复制
GET /my_index/my_type/_search
{
    "query": {
        "match": {
            "title": "QUICK!"
        }
    }
}

Elasticsearch 执行上面这个 match 查询的步骤是:

  1. 检查字段类型 。 标题 title 字段是一个 string 类型( analyzed )已分析的全文字段,这意味着查询字符串本身也应该被分析。
  2. 分析查询字符串 。 将查询的字符串 QUICK! 传入标准分析器中,输出的结果是单个项 quick 。因为只有一个单词项,所以 match 查询执行的是单个底层 term 查询。
  3. 查找匹配文档 。 用 term 查询在倒排索引中查找 quick 然后获取一组包含该项的文档,本例的结果是文档:1、2 和 3 。
  4. 为每个文档评分 。 用term查询计算每个文档相关度评分 _score ,这是种将 词频(term frequency,即词 quick 在相关文档的 title 字段中出现的频率)和反向文档频率(inverse document frequency,即词 quick 在所有文档的 title 字段中出现的频率),以及字段的长度(即字段越短相关度越高)相结合的计算方式。参见 相关性的介绍 。

这个过程给我们以下(经缩减)结果:

代码语言:javascript复制
"hits": [
 {
    "_id":      "1",  
    "_score":   0.5,   #文档 1 最相关,因为它的 title 字段更短,即 quick 占据内容的一大部分。
    "_source": {
       "title": "The quick brown fox"
    }
 },
 {
    "_id":      "3",
    "_score":   0.44194174,   #文档 3 比 文档 2 更具相关性,因为在文档 3 中 quick 出现了两次。 
    "_source": {
       "title": "The quick brown fox jumps over the quick dog"
    }
 },
 {
    "_id":      "2",
    "_score":   0.3125,   #文档 3 比 文档 2 更具相关性,因为在文档 3 中 quick 出现了两次。 
    "_source": {
       "title": "The quick brown fox jumps over the lazy dog"
    }
 }
]

3.多词查询

如果我们一次只能搜索一个词,那么全文搜索就会不太灵活,幸运的是 match 查询让多词查询变得简单:

代码语言:javascript复制
GET /my_index/my_type/_search
{
    "query": {
        "match": {
            "title": "BROWN DOG!"
        }
    }
}

上面这个查询返回所有四个文档:

代码语言:javascript复制
{
  "hits": [
     {
        "_id":      "4",
        "_score":   0.73185337,   #文档 4 最相关,因为它包含词 "brown" 两次以及 "dog" 一次。 
        "_source": {
           "title": "Brown fox brown dog"
        }
     },
     {
        "_id":      "2",
        "_score":   0.47486103,   #文档 2、3 同时包含 brown 和 dog 各一次,而且它们 title 字段的长度相同,所以具有相同的评分。 
        "_source": {
           "title": "The quick brown fox jumps over the lazy dog"
        }
     },
     {
        "_id":      "3",
        "_score":   0.47486103,   #文档 2、3 同时包含 brown 和 dog 各一次,而且它们 title 字段的长度相同,所以具有相同的评分。 
        "_source": {
           "title": "The quick brown fox jumps over the quick dog"
        }
     },
     {
        "_id":      "1",
        "_score":   0.11914785,   #文档 1 也能匹配,尽管它只有 brown 没有 dog 。 
        "_source": {
           "title": "The quick brown fox"
        }
     }
  ]
}

因为 match 查询必须查找两个词( ["brown","dog"] ),它在内部实际上先执行两次 term 查询,然后将两次查询的结果合并作为最终结果输出。为了做到这点,它将两个 term 查询包入一个 bool 查询中,详细信息见 布尔查询。

以上示例告诉我们一个重要信息:即任何文档只要 title 字段里包含 指定词项中的至少一个词 就能匹配,被匹配的词项越多,文档就越相关。

1. 底层原理

Elasticsearch 在搜索的时候,底层会自动转换。

代码语言:javascript复制
普通match如何转换为term should:

{
    "match": { "title": "java elasticsearch"}
}
使用诸如上面的match query进行多值搜索的时候,es会在底层自动将这个match query转换为bool的语法
bool should,指定多个搜索词,同时使用term query

{
  "bool": {
    "should": [
      { "term": { "title": "java" }},
      { "term": { "title": "elasticsearch"   }}
    ]
  }
}
代码语言:javascript复制
and match如何转换为term must

{
    "match": {
        "title": {
            "query":    "java elasticsearch",
            "operator": "and"
        }
    }
}

{
  "bool": {
    "must": [
      { "term": { "title": "java" }},
      { "term": { "title": "elasticsearch"   }}
    ]
  }
}
代码语言:javascript复制
minimum_should_match如何转换:

{
    "match": {
        "title": {
            "query": "java elasticsearch hadoop spark",
            "minimum_should_match": "75%"
        }
    }
}

{
  "bool": {
    "should": [
      { "term": { "title": "java" }},
      { "term": { "title": "elasticsearch"   }},
      { "term": { "title": "hadoop" }},
      { "term": { "title": "spark" }}
    ],
    "minimum_should_match": 3 
  }
}

2. 提高精度

任意 查询词项匹配文档可能会导致结果中出现不相关的长尾。 这是种散弹式搜索。可能我们只想搜索包含 所有 词项的文档,也就是说,不去匹配 brown OR dog ,而通过匹配 brown AND dog 找到所有文档。

match 查询还可以接受 operator 操作符作为输入参数,默认情况下该操作符是 or 。我们可以将它修改成 and 让所有指定词项都必须匹配:

代码语言:javascript复制
GET /my_index/my_type/_search
{
    "query": {
        "match": {
            "title": {    #match 查询的结构需要做稍许调整才能使用 operator 操作符参数。     
                "query":    "BROWN DOG!",
                "operator": "and"
            }
        }
    }
}

这个查询可以把文档 1 排除在外,因为它只包含两个词项中的一个。

3. 控制精度

所有任意 间二选一有点过于非黑即白。 如果用户给定 5 个查询词项,想查找只包含其中 4 个的文档,该如何处理?将 operator 操作符参数设置成 and 只会将此文档排除。

有时候这正是我们期望的,但在全文搜索的大多数应用场景下,我们既想包含那些可能相关的文档,同时又排除那些不太相关的。换句话说,我们想要处于中间某种结果。

match 查询支持 minimum_should_match 最小匹配参数, 这让我们可以指定必须匹配的词项数用来表示一个文档是否相关。我们可以将其设置为某个具体数字,更常用的做法是将其设置为一个百分数,因为我们无法控制用户搜索时输入的单词数量:

代码语言:javascript复制
GET /my_index/my_type/_search
{
  "query": {
    "match": {
      "title": {
        "query":                "quick brown dog",
        "minimum_should_match": "75%"
      }
    }
  }
}

当给定百分比的时候, minimum_should_match 会做合适的事情:在之前三词项的示例中, 75% 会自动被截断成 66.6% ,即三个里面两个词。无论这个值设置成什么,至少包含一个词项的文档才会被认为是匹配的。

类型

描述

整数

3

无论可选子句的数量如何,都表示固定值。

负整数

-2

表示可选子句的总数减去此数字应该是必需的。

百分比

75%

表示可选子句总数的百分比是必需的。从百分比计算的数字向下舍入并用作最小值。

负百分比

-25%

表示可以丢失可选子句总数的百分比。从百分比计算的数字向下舍入,然后从总数中减去以确定最小值。

组合

3<90%

正整数,后跟小于号,后跟任何前面提到的说明符是条件规范。它表示如果可选子句的数量等于(或小于)整数,则它们都是必需的,但如果它大于整数,则适用规范。在这个例子中:如果有1到3个条款,则它们都是必需的,但是对于4个或更多条款,只需要90%。

多种组合

2<-25% 9<-3

多个条件规范可以用空格分隔,每个空格仅对大于之前的数字有效。在这个例子中:如果需要1或2个子句,如果有3-9个子句,则除了25%之外都需要,如果有9个以上的子句,则除了3个子句外都需要。

处理百分比时,负值可用于在边缘情况下获得不同的行为。在处理4个条款时,75%和-25%的含义相同,但在处理5个条款时,75%表示需要3个,但-25%表示需要4个。 无论计算到达的是什么数字,都将永远不会使用大于可选子句数的值或小于1的值。(即:无论计算结果的结果有多低或多高,所需匹配的最小数量永远不会低于1或大于子句数。

参数 minimum_should_match 的设置非常灵活,可以根据用户输入词项的数目应用不同的规则。完整的信息参考文档 https://www.elastic.co/guide/en/elasticsearch/reference/5.6/query-dsl-minimum-should-match.html#query-dsl-minimum-should-match

为了完全理解 match 是如何处理多词查询的,我们就需要查看如何使用 bool 查询将多个查询条件组合在一起。

4.组合查询

在 组合过滤器 中,我们讨论过如何使用bool过滤器通过 andornot 逻辑组合将多个过滤器进行组合。在查询中,bool查询有类似的功能,只有一个重要的区别。

过滤器做二元判断:文档是否应该出现在结果中?但查询更精妙,它除了决定一个文档是否应该被包括在结果中,还会计算文档的 相关程度

与过滤器一样, bool 查询也可以接受 mustmust_notshould 参数下的多个查询语句。比如:

代码语言:javascript复制
GET /my_index/my_type/_search
{
  "query": {
    "bool": {
      "must":     { "match": { "title": "quick" }},
      "must_not": { "match": { "title": "lazy"  }},
      "should": [
                  { "match": { "title": "brown" }},
                  { "match": { "title": "dog"   }}
      ]
    }
  }
}

以上的查询结果返回 title 字段包含词项 quick 但不包含 lazy 的任意文档。目前为止,这与 bool 过滤器的工作方式非常相似。

区别就在于两个 should 语句,也就是说:一个文档不必包含 browndog 这两个词项,但如果一旦包含,我们就认为它们 更相关

代码语言:javascript复制
{
  "hits": [
     {
        "_id":      "3",
        "_score":   0.70134366,   #文档 3 会比文档 1 有更高评分是因为它同时包含 brown 和 dog 。
        "_source": {
           "title": "The quick brown fox jumps over the quick dog"
        }
     },
     {
        "_id":      "1",
        "_score":   0.3312608,
        "_source": {
           "title": "The quick brown fox"
        }
     }
  ]
}

1. 评分计算

bool 查询会为每个文档计算相关度评分_score , 再将所有匹配的 mustshould 语句的分数 _score求和,最后除以mustshould 语句的总数。

must_not 语句不会影响评分; 它的作用只是将不相关的文档排除。

2. 控制精度

所有 must 语句必须匹配,所有 must_not 语句都必须不匹配,但有多少 should 语句应该匹配呢? 默认情况下,没有 should 语句是必须匹配的,只有一个例外:那就是当没有 must 语句的时候,至少有一个 should 语句必须匹配。

就像我们能控制 match 查询的精度 一样,我们可以通过 minimum_should_match 参数控制需要匹配的 should 语句的数量, 它既可以是一个绝对的数字,又可以是个百分比:

代码语言:javascript复制
GET /my_index/my_type/_search
{
  "query": {
    "bool": {
      "should": [
        { "match": { "title": "brown" }},
        { "match": { "title": "fox"   }},
        { "match": { "title": "dog"   }}
      ],
      "minimum_should_match": 2   #这也可以用百分比表示。 
    }
  }
}

这个查询结果会将所有满足以下条件的文档返回: title 字段包含 "brown" AND "fox""brown" AND "dog""fox" AND "dog" 。如果有文档包含所有三个条件,它会比只包含两个的文档更相关。

5.如何使用布尔匹配

目前为止,可能已经意识到多词 match 查询只是简单地将生成的 term 查询包裹 在一个 bool 查询中。如果使用默认的 or 操作符,每个 term 查询都被当作 should 语句,这样就要求必须至少匹配一条语句。以下两个查询是等价的:

代码语言:javascript复制
{
    "match": { "title": "brown fox"}
}
代码语言:javascript复制
{
  "bool": {
    "should": [
      { "term": { "title": "brown" }},
      { "term": { "title": "fox"   }}
    ]
  }
}

如果使用 and 操作符,所有的 term 查询都被当作 must 语句,所以 所有(all) 语句都必须匹配。以下两个查询是等价的:

代码语言:javascript复制
{
    "match": {
        "title": {
            "query":    "brown fox",
            "operator": "and"
        }
    }
}
代码语言:javascript复制
{
  "bool": {
    "must": [
      { "term": { "title": "brown" }},
      { "term": { "title": "fox"   }}
    ]
  }
}

如果指定参数 minimum_should_match ,它可以通过 bool 查询直接传递,使以下两个查询等价:

代码语言:javascript复制
{
    "match": {
        "title": {
            "query":                "quick brown fox",
            "minimum_should_match": "75%"
        }
    }
}
代码语言:javascript复制
{
  "bool": {
    "should": [
      { "term": { "title": "brown" }},
      { "term": { "title": "fox"   }},
      { "term": { "title": "quick" }}
    ],
    "minimum_should_match": 2   #因为只有三条语句,match 查询的参数 minimum_should_match 值 75% 会被截断成 2 。即三条 should 语句中至少有两条必须匹配。 
  }
}

当然,我们通常将这些查询用 match 查询来表示,但是如果了解 match 内部的工作原理,我们就能根据自己的需要来控制查询过程。有些时候单个 match 查询无法满足需求,比如为某些查询条件分配更高的权重。我们会在下一小节中看到这个例子。

6.查询语句提升权重

当然 bool 查询不仅限于组合简单的单个词 match 查询, 它可以组合任意其他的查询,以及其他 bool 查询。 普遍的用法是通过汇总多个独立查询的分数,从而达到为每个文档微调其相关度评分 _score 的目的。

假设想要查询关于 “full-text search(全文搜索)” 的文档, 但我们希望为提及 “Elasticsearch” 或 “Lucene” 的文档给予更高的 权重 ,这里 更高权重 是指如果文档中出现 “Elasticsearch” 或 “Lucene” ,它们会比没有的出现这些词的文档获得更高的相关度评分 _score ,也就是说,它们会出现在结果集的更上面。

一个简单的 bool 查询 允许我们写出如下这种非常复杂的逻辑:

代码语言:javascript复制
GET /_search
{
    "query": {
        "bool": {
            "must": {
                "match": {
                    "content": { 
                        "query":    "full text search",  #content 字段必须包含 full 、 text 和 search 所有三个词。 
                        "operator": "and"
                    }
                }
            },
            "should": [   #如果 content 字段也包含 Elasticsearch 或 Lucene ,文档会获得更高的评分 _score 。
                { "match": { "content": "Elasticsearch" }},
                { "match": { "content": "Lucene"        }}
            ]
        }
    }
}

should 语句匹配得越多表示文档的相关度越高。目前为止还挺好。但是如果我们想让包含 Lucene 的有更高的权重,并且包含 Elasticsearch 的语句比 Lucene 的权重更高,该如何处理?

我们可以通过指定 boost 来控制任何查询语句的相对的权重, boost 的默认值为 1 ,大于 1 会提升一个语句的相对权重。所以下面重写之前的查询:

代码语言:javascript复制
GET /_search
{
    "query": {
        "bool": {
            "must": {
                "match": {    #这些语句使用默认的 boost 值 1 。 
                    "content": {
                        "query":    "full text search",
                        "operator": "and"
                    }
                }
            },
            "should": [
                { "match": {
                    "content": {
                        "query": "Elasticsearch",
                        "boost": 3   #这条语句更为重要,因为它有最高的 boost 值。 
                    }
                }},
                { "match": {
                    "content": {
                        "query": "Lucene",
                        "boost": 2   #这条语句比使用默认值的更重要,但它的重要性不及 Elasticsearch 语句。 
                    }
                }}
            ]
        }
    }
}

boost 参数被用来提升一个语句的相对权重( boost 值大于 1 )或降低相对权重( boost 值处于 0 到 1 之间),但是这种提升或降低并不是线性的,换句话说,如果一个 boost 值为 2 ,并不能获得两倍的评分 _score 相反,新的评分 _score 会在应用权重提升之后被 归一化 ,每种类型的查询都有自己的归一算法,细节超出了本书的范围,所以不作介绍。简单的说,更高的 boost 值为我们带来更高的评分 _score 。 如果不基于 TF/IDF 要实现自己的评分模型,我们就需要对权重提升的过程能有更多控制,可以使用 function_score 查询操纵一个文档的权重提升方式而跳过归一化这一步骤。

更多的组合查询方式会在下章多字段搜索中介绍,但在此之前,让我们先看另外一个重要的查询特性:文本分析(text analysis)。

7.控制分析

查询只能查找倒排索引表中真实存在的项, 所以保证文档在索引时与查询字符串在搜索时应用相同的分析过程非常重要,这样查询的项才能够匹配倒排索引中的项。

尽管是在说文档 ,不过分析器可以由每个字段决定。 每个字段都可以有不同的分析器,既可以通过配置为字段指定分析器,也可以使用更高层的类型(type)、索引(index)或节点(node)的默认配置。在索引时,一个字段值是根据配置或默认分析器分析的。

例如为 my_index 新增一个字段:

代码语言:javascript复制
PUT /my_index/_mapping/my_type
{
    "my_type": {
        "properties": {
            "english_title": {
                "type":     "string",
                "analyzer": "english"
            }
        }
    }
}

现在我们就可以通过使用 analyze API 来分析单词 Foxes ,进而比较 english_title 字段和 title 字段在索引时的分析结果:

代码语言:javascript复制
GET /my_index/_analyze
{
  "field": "my_type.title",    #字段 title ,使用默认的 standard 标准分析器,返回词项 foxes 。 
  "text": "Foxes"
}

GET /my_index/_analyze
{
  "field": "my_type.english_title",     #字段 english_title ,使用 english 英语分析器,返回词项 fox 。 
  "text": "Foxes"
}

这意味着,如果使用底层 term 查询精确项 fox 时, english_title 字段会匹配但 title 字段不会。

如同 match 查询这样的高层查询知道字段映射的关系,能为每个被查询的字段应用正确的分析器。 可以使用 validate-query API 查看这个行为:

代码语言:javascript复制
GET /my_index/my_type/_validate/query?explain
{
    "query": {
        "bool": {
            "should": [
                { "match": { "title":         "Foxes"}},
                { "match": { "english_title": "Foxes"}}
            ]
        }
    }
}

返回语句的 explanation 结果:

代码语言:javascript复制
(title:foxes english_title:fox)

match 查询为每个字段使用合适的分析器,以保证它在寻找每个项时都为该字段使用正确的格式。

1. 默认分析器

虽然我们可以在字段层级指定分析器, 但是如果该层级没有指定任何的分析器,那么我们如何能确定这个字段使用的是哪个分析器呢?

分析器可以从三个层面进行定义:按字段(per-field)、按索引(per-index)或全局缺省(global default)。Elasticsearch 会按照以下顺序依次处理,直到它找到能够使用的分析器。

索引时的顺序如下:

  • 字段mapping里定义的 analyzer ,否则
  • 索引设置中名为 default 的分析器,默认为standard 标准分析器

在搜索时,顺序有些许不同:

  • 查询自己定义的analyzer ,否则
  • 字段映射里定义的analyzer ,否则
  • 索引设置中名为default 的分析器,默认为standard 标准分析器

有时,在索引时和搜索时使用不同的分析器是合理的。 我们可能要想为同义词建索引(例如,所有 quick 出现的地方,同时也为 fastrapidspeedy 创建索引)。但在搜索时,我们不需要搜索所有的同义词,取而代之的是寻找用户输入的单词是否是 quickfastrapidspeedy

为了区分,Elasticsearch 也支持一个可选的 search_analyzer 映射,它仅会应用于搜索时( analyzer 还用于索引时)。还有一个等价的 default_search 映射,用以指定索引层的默认配置。

如果考虑到这些额外参数,一个搜索时的 完整 顺序会是下面这样:

  • 查询自己定义的 analyzer ,否则
  • 字段mapping里定义的 search_analyzer ,否则
  • 字段mapping里定义的 analyzer ,否则
  • 索引设置中名为 default_search 的分析器,默认为
  • 索引设置中名为 default 的分析器,默认为
  • standard 标准分析器

2. 分析器配置实践

就可以配置分析器地方的数量而言是十分惊人的, 但是实际非常简单。

3. 配置简单

多数情况下,会提前知道文档会包括哪些字段。最简单的途径就是在创建索引或者增加类型映射时,为每个全文字段设置分析器。这种方式尽管有点麻烦,但是它让我们可以清楚的看到每个字段每个分析器是如何设置的。

通常,多数字符串字段都是 not_analyzed 精确值字段,比如标签(tag)或枚举(enum),而且更多的全文字段会使用默认的 standard 分析器或 english 或其他某种语言的分析器。这样只需要为少数一两个字段指定自定义分析:或许标题 title 字段需要以支持 输入即查找(find-as-you-type) 的方式进行索引。

可以在索引级别设置中,为绝大部分的字段设置你想指定的 default 默认分析器。然后在字段级别设置中,对某一两个字段配置需要指定的分析器。

对于和时间相关的日志数据,通常的做法是每天自行创建索引,由于这种方式不是从头创建的索引,仍然可以用 索引模板(Index Template) 为新建的索引指定配置和映射。

8.被破坏的相关度

在讨论更复杂的 多字段搜索 之前,让我们先快速解释一下为什么只在主分片上 创建测试索引 。

用户会时不时的抱怨无法按相关度排序并提供简短的重现步骤: 用户索引了一些文档,运行一个简单的查询,然后发现明显低相关度的结果出现在高相关度结果之上。

为了理解为什么会这样,可以设想,我们在两个主分片上创建了索引和总共 10 个文档,其中 6 个文档有单词 foo 。可能是分片 1 有其中 3 个 foo 文档,而分片 2 有其中另外 3 个文档,换句话说,所有文档是均匀分布存储的。

在 什么是相关度?中,我们描述了 Elasticsearch 默认使用的相似度算法,这个算法叫做 词频/逆向文档频率 或 TF/IDF 。词频是计算某个词在当前被查询文档里某个字段中出现的频率,出现的频率越高,文档越相关。 逆向文档频率某个词在索引内所有文档出现的百分数 考虑在内,出现的频率越高,它的权重就越低。

但是由于性能原因, Elasticsearch 不会计算索引内所有文档的 IDF 。 相反,每个分片会根据 该分片 内的所有文档计算一个本地 IDF 。

因为文档是均匀分布存储的,两个分片的 IDF 是相同的。相反,设想如果有 5 个 foo 文档存于分片 1 ,而第 6 个文档存于分片 2 ,在这种场景下, foo 在一个分片里非常普通(所以不那么重要),但是在另一个分片里非常出现很少(所以会显得更重要)。这些 IDF 之间的差异会导致不正确的结果。

在实际应用中,这并不是一个问题,本地和全局的 IDF 的差异会随着索引里文档数的增多渐渐消失,在真实世界的数据量下,局部的 IDF 会被迅速均化,所以上述问题并不是相关度被破坏所导致的,而是由于数据太少。

为了测试,我们可以通过两种方式解决这个问题。第一种是只在主分片上创建索引,正如 match 查询 里介绍的那样,如果只有一个分片,那么本地的 IDF 就是 全局的 IDF。

第二个方式就是在搜索请求后添加 ?search_type=dfs_query_then_fetchdfs 是指 分布式频率搜索(Distributed Frequency Search) , 它告诉 Elasticsearch ,先分别获得每个分片本地的 IDF ,然后根据结果再计算整个索引的全局 IDF 。

不要在生产环境上使用 dfs_query_then_fetch 。完全没有必要。只要有足够的数据就能保证词频是均匀分布的。没有理由给每个查询额外加上 DFS 这步。

0 人点赞