Elastic Search学习系列05-映射和分析

Table of Contents

1 前言

我们发现一些奇怪的事情。一些事情看起来被打乱了:在我们的索引中有12条推文,其中只有一条包含日期2014-09-15,但是看一看下面查询命中的总数(total):

curl -X GET "localhost:9200/_search?q=2014              # 12 results&pretty"
curl -X GET "localhost:9200/_search?q=2014-09-15        # 12 results !&pretty"
curl -X GET "localhost:9200/_search?q=date:2014-09-15   # 1  result&pretty"
curl -X GET "localhost:9200/_search?q=date:2014         # 0  results !&pretty"

为什么在_all字段查询日期返回所有推文,而在date字段只查询年份却没有返回结果?为什么我们在_all字段和date字段的查询结果有差别?

推测起来,这是因为数据在_all字段date字段的索引方式不同。所以,通过请求gb索引中tweet类型的映射(或模式定义),让我们看一看Elasticsearch是如何解释我们文档结构的:

curl -X GET "localhost:9200/gb/_mapping/tweet?pretty"

这将得到如下结果:

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

基于对字段类型的猜测,Elasticsearch动态为我们产生了一个映射。这个响应告诉我们date字段被认为是date类型的。由于_all是默认字段,所以没有提及它。但是我们知道_all字段是string类型的。

所以date字段和string字段索引方式不同,因此搜索结果也不一样。其它核心数据类型string、numbers、Booleans也和date的索引方式有稍许不同。

2 精确值VS全文

Elasticsearch中的数据可以概括的分为两类:精确值和全文。

精确值,例如日期或者用户ID,但字符串也可以表示精确值,例如用户名或邮箱地址。对于精确值来讲,Foo和foo是不同的,2014和2014-09-15也是不同的。

另一方面,全文是指文本数据(通常以人类容易识别的语言书写),例如一个推文的内容或一封邮件的内容。

精确值很容易查询。结果是二进制的:要么匹配查询,要么不匹配。这种查询很容易用 SQL 表示:

WHERE name    = "John Smith"
  AND user_id = 2
  AND date    > "2014-09-15"

查询全文数据要微妙的多。我们问的不只是“这个文档匹配查询吗”,而是“该文档匹配查询的程度有多大?”换句话说,该文档与给定查询的相关性如何?

我们很少对全文类型的域做精确匹配。相反,我们希望在文本类型的域中搜索。不仅如此,我们还希望搜索能够理解我们的 意图 :

  • 搜索UK ,会返回包含United Kindom的文档。
  • 搜索jump,会匹配jumped,jumps,jumping,甚至是leap。
  • 搜索johnny walker会匹配Johnnie Walker,johnnie depp应该匹配Johnny Depp 。
  • fox news hunting应该返回福克斯新闻(Foxs News)中关于狩猎的故事,同时,fox hunting news应该返回关于猎狐的故事。

为了促进这类在全文域中的查询,Elasticsearch首先分析文档,之后根据结果创建倒排索引。

3 倒排索引

Elasticsearch使用一种称为倒排索引的结构,它适用于快速的全文搜索。一个倒排索引由文档中所有不重复词的列表构成,对于其中每个词,有一个包含它的文档列表。

例如,假设我们有两个文档,每个文档的content域包含如下内容:

  1. The quick brown fox jumped over the lazy dog
  2. Quick brown foxes leap over lazy dogs in summer

为了创建倒排索引,我们首先将每个文档的content域拆分成单独的词(我们称它为词条或tokens),创建一个包含所有不重复词条的排序列表,然后列出每个词条出现在哪个文档。结果如下所示:

Term Doc_1 Doc_2
Quick   X
The X  
brown X X
dog X  
dogs   X
fox X  
foxes   X
in   X
jumped X  
lazy X X
leap   X
over X X
quick X  
summer   X
the X  

现在,如果我们想搜索quick brown,我们只需要查找包含每个词条的文档:

Term Doc_1 Doc_2
brown X X
quick X  
Total 2 1

两个文档都匹配,但是第一个文档比第二个匹配度更高。如果我们使用仅计算匹配词条数量的简单 相似性算法 ,那么,我们可以说,对于我们查询的相关性来讲,第一个文档比第二个文档更佳。

但是,我们目前的倒排索引有一些问题:

  • Quick和quick以独立的词条出现,然而用户可能认为它们是相同的词。
  • fox和foxes 非常相似, 就像 dog和dogs;他们有相同的词根。
  • jumped和leap, 尽管没有相同的词根,但他们的意思很相近。他们是同义词。

使用前面的索引搜索+Quick+fox不会得到任何匹配文档。(记住,+前缀表明这个词必须存在。)只有同时出现Quick和fox的文档才满足这个查询条件,但是第一个文档包含quick fox,第二个文档包含Quick foxes 。

我们的用户可以合理的期望两个文档与查询匹配。我们可以做的更好。

如果我们将词条规范为标准模式,那么我们可以找到与用户搜索的词条不完全一致,但具有足够相关性的文档。例如:

  • Quick可以小写化为quick 。
  • foxes可以词干提取–变为词根的格式–为fox。类似的,dogs可以为提取为dog。
  • jumped和leap是同义词,可以索引为相同的单词jump。

现在索引看上去像这样:

Term Doc_1 Doc_2
brown X X
dog X X
fox X X
in   X
jump X X
lazy X X
over X X
quick X X
summer   X
the X X

这还远远不够。我们搜索+Quick+fox仍然会失败,因为在我们的索引中,已经没有Quick了。但是,如果我们对搜索的字符串使用与content域相同的标准化规则,会变成查询+quick+fox,这样两个文档都会匹配!

这非常重要。你只能搜索在索引中出现的词条,所以索引文本和查询字符串必须标准化为相同的格式。

分词和标准化的过程称为分析。

4 分析和分析器

分析包含下面的过程:

  • 首先,将一块文本分成适合于倒排索引的独立的词条
  • 之后,将这些词条统一化为标准格式以提高它们的“可搜索性”,或者recall

分析器执行上面的工作。 分析器 实际上是将三个功能封装到了一个包里:

  • 字符过滤器
    • 首先,字符串按顺序通过每个字符过滤器 。他们的任务是在分词前整理字符串。一个字符过滤器可以用来去掉HTML,或者将&转化成and。
  • 分词器
    • 其次,字符串被分词器分为单个的词条。一个简单的分词器遇到空格和标点的时候,可能会将文本拆分成词条。
  • Token过滤器
    • 最后,词条按顺序通过每个token过滤器。这个过程可能会改变词条(例如,小写化Quick),删除词条(例如,像a,and,the等无用词),或者增加词条(例如,像jump和leap这种同义词)。

Elasticsearch提供了开箱即用的字符过滤器、分词器和token过滤器。

4.1 内置分析器

Elasticsearch还附带了可以直接使用的预包装的分析器。接下来我们会列出最重要的分析器。为了证明它们的差异,我们看看每个分析器会从下面的字符串得到哪些词条:

"Set the shape to semi-transparent by calling set_trans(5)"

4.1.1 标准分析器

标准分析器是Elasticsearch默认使用的分析器。它是分析各种语言文本最常用的选择。它根据 Unicode联盟定义的单词边界划分文本。删除绝大部分标点。最后,将词条小写。它会产生

set, the, shape, to, semi, transparent, by, calling, set_trans, 5

4.1.2 简单分析器

简单分析器在任何不是字母的地方分隔文本,将词条小写。它会产生

set, the, shape, to, semi, transparent, by, calling, set, trans

4.1.3 空格分析器

空格分析器在空格的地方划分文本。它会产生

Set, the, shape, to, semi-transparent, by, calling, set_trans(5)

4.1.4 语言分析器

特定语言分析器可用于很多语言。它们可以考虑指定语言的特点。例如,英语分析器附带了一组英语无用词(常用单词,例如and或者the,它们对相关性没有多少影响),它们会被删除。 由于理解英语语法的规则,这个分词器可以提取英语单词的词干 。

英语分词器会产生下面的词条:

set, shape, semi, transpar, call, set_tran, 5

注意看transparent、calling和set_trans已经变为词根格式。

4.2 什么时候使用分析器

当我们索引一个文档,它的全文域被分析成词条以用来创建倒排索引。但是,当我们在全文域搜索 的时候,我们需要将查询字符串通过相同的分析过程,以保证我们搜索的词条格式与索引中的词条格式一致。

全文查询,理解每个域是如何定义的,因此它们可以做正确的事:

  • 当你查询一个全文域时, 会对查询字符串应用相同的分析器,以产生正确的搜索词条列表。
  • 当你查询一个精确值域时,不会分析查询字符串,而是搜索你指定的精确值。

现在你可以理解在前面的查询为什么返回那样的结果:

  • date 域包含一个精确值:单独的词条2014-09-15。
  • _all 域是一个全文域,所以分词进程将日期转化为三个词条:2014,09,和15。

4.3 测试分析器

有些时候很难理解分词的过程和实际被存储到索引中的词条,为了理解发生了什么,你可以使用analyze API来看文本是如何被分析的。在消息体里,指定分析器和要分析的文本:

curl -X GET "localhost:9200/_analyze?pretty" -H 'Content-Type: application/json' -d'
{
  "analyzer": "standard",
  "text": "Text to analyze"
}
'

结果中每个元素代表一个单独的词条:

{
  "tokens" : [
    {
      "token" : "text",
      "start_offset" : 0,
      "end_offset" : 4,
      "type" : "<ALPHANUM>",
      "position" : 0
    },
    {
      "token" : "to",
      "start_offset" : 5,
      "end_offset" : 7,
      "type" : "<ALPHANUM>",
      "position" : 1
    },
    {
      "token" : "analyze",
      "start_offset" : 8,
      "end_offset" : 15,
      "type" : "<ALPHANUM>",
      "position" : 2
    }
  ]
}

token是实际存储到索引中的词条。position指明词条在原始文本中出现的位置。start_offset和end_offset指明字符在原始字符串中的位置。

每个分析器的type值都不一样,可以忽略它们。它们在Elasticsearch中的唯一作用在于​keep_types token过滤器​。

analyze API是一个有用的工具,它有助于我们理解Elasticsearch索引内部发生了什么。

4.4 指定分析器

当Elasticsearch在你的文档中检测到一个新的字符串域,它会自动设置其为一个全文字符串域,使用标准分析器对它进行分析。

你不希望总是这样。可能你想使用一个不同的分析器,适用于你的数据使用的语言。有时候你想要一个字符串域就是一个字符串域—​不使用分析,直接索引你传入的精确值,例如用户ID或者一个内部的状态域或标签。

要做到这一点,我们必须手动指定这些域的映射。

5 映射

为了能够将时间域视为时间,数字域视为数字,字符串域视为全文或精确值字符串, Elasticsearch 需要知道每个域中数据的类型。这个信息包含在映射中。

5.1 核心简单域类型

Elasticsearch 支持如下简单域类型:

  • 字符串: string
  • 整数 : byte,short,integer,long
  • 浮点数: float,double
  • 布尔型: boolean
  • 日期: date

当索引一个包含新域的文档—​之前未曾出现–Elasticsearch会使用动态映射,通过JSON中基本数据类型,尝试猜测域类型,使用如下规则:

JSON type 域 type
布尔型:true或者false boolean
整数:123 long
浮点数:123.45 double
字符串,有效日期:2014-09-15 date
字符串:foo bar string

这意味着如果你通过引号("123")索引一个数字,它会被映射为string类型,而不是long。但是,如果这个域已经映射为long,那么Elasticsearch会尝试将这个字符串转化为long ,如果无法转化,则抛出一个异常。

5.2 查看映射

通过/_mapping,我们可以查看Elasticsearch在一个或多个索引中的一个或多个类型的映射。

错误的映射,例如将age域映射为string类型,而不是integer,会导致查询出现令人困惑的结果。

5.3 自定义域映射

尽管在很多情况下基本域数据类型已经够用,但你经常需要为单独域自定义映射,特别是字符串域。自定义映射允许你执行下面的操作:

  • 全文字符串域和精确值字符串域的区别
  • 使用特定语言分析器
  • 优化域以适应部分匹配
  • 指定自定义数据格式
  • 还有更多

域最重要的属性是type。对于不是string的域,你一般只需要设置type:

{
    "number_of_clicks": {
        "type": "integer"
    }
}

默认,string类型域会被认为包含全文。就是说,它们的值在索引前,会通过一个分析器,针对于这个域的查询在搜索前也会经过一个分析器。

string域映射的两个最重要属性是index和analyzer 。

index

index属性控制怎样索引字符串。它可以是下面三个值:

  • analyzed
    • 首先分析字符串,然后索引它。换句话说,以全文索引这个域。
  • not_analyzed
    • 索引这个域,所以它能够被搜索,但索引的是精确值。不会对它进行分析。
  • no
    • 不索引这个域。这个域不会被搜索到。

string域index属性默认是analyzed。如果我们想映射这个字段为一个精确值,我们需要设置它为not_analyzed :

{
    "tag": {
        "type":     "string",
        "index":    "not_analyzed"
    }
}

analyzer

对于analyzed字符串域,用analyzer属性指定在搜索和索引时使用的分析器。默认,Elasticsearch使用standard分析器, 但你可以指定一个内置的分析器替代它,例如whitespace、simple和english:

{
    "tweet": {
        "type":     "string",
        "analyzer": "english"
    }
}

5.4 更新映射

当你首次创建一个索引的时候,可以指定类型的映射。你也可以使用/_mapping为新类型(或者为存在的类型更新映射)增加映射。

尽管你可以增加一个存在的映射,你不能修改存在的域映射。如果一个域的映射已经存在,那么该域的数据可能已经被索引。如果你意图修改这个域的映射,索引的数据可能会出错,不能被正常的搜索。

我们可以更新一个映射来添加一个新域,但不能将一个存在的域从analyzed改为not_analyzed。

在tweet映射增加一个新的名为tag的not_analyzed的文本域,使用_mapping:

curl -X PUT "localhost:9200/gb/_mapping/tweet?pretty" -H 'Content-Type: application/json' -d'
{
  "properties" : {
    "tag" : {
      "type" :    "string",
      "index":    "not_analyzed"
    }
  }
}
'

注意,我们不需要再次列出所有已存在的域,因为无论如何我们都无法改变它们。新域已经被合并到存在的映射中。

5.5 测试映射

你可以使用analyze API测试字符串域的映射。

6 复杂域类型

除了简单标量数据类型,JSON还有null值,数组,和对象,这些Elasticsearch都是支持的。

6.1 多值域

很有可能,我们希望tag域包含多个标签。我们可以以数组的形式索引标签:

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

对于数组,没有特殊的映射需求。任何域都可以包含0、1或者多个值,就像全文域分析得到多个词条。

这暗示数组中所有的值必须是相同数据类型的。你不能将日期和字符串混在一起。如果你通过索引数组来创建新的域,Elasticsearch会用数组中第一个值的数据类型作为这个域的类型。

当你从Elasticsearch得到一个文档,每个数组的顺序和你当初索引文档时一样。你得到的_source域,包含与你索引的一模一样的JSON文档。

但是,数组是以多值域索引的—可以搜索,但是无序的。在搜索的时候,你不能指定“第一个”或者“最后一个”。更确切的说,把数组想象成装在袋子里的值。

6.2 空域

当然,数组可以为空。这相当于存在零值。事实上,在Lucene中是不能存储null值的,所以我们认为存在null值的域为空域。

下面三种域被认为是空的,它们将不会被索引:

"null_value":               null,
"empty_array":              [],
"array_with_null_value":    [ null ]

6.3 多层级对象

对于一个JSON原生数据类是对象–在其他语言中称为哈希,哈希map,字典或者关联数组。

内部对象经常用于嵌入一个实体或对象到其它对象中。例如,与其在tweet文档中包含user_name和user_id域,我们也可以这样写:

{
    "tweet":            "Elasticsearch is very flexible",
    "user": {
        "id":           "@johnsmith",
        "gender":       "male",
        "age":          26,
        "name": {
            "full":     "John Smith",
            "first":    "John",
            "last":     "Smith"
        }
    }
}

6.4 内部对象的映射

Elasticsearch会动态监测新的对象域并映射它们为 对象,在properties属性下列出内部域:

{
  "gb": {
    "tweet": { 
      "properties": {
        "tweet":            { "type": "string" },
        "user": { 
          "type":             "object",
          "properties": {
            "id":           { "type": "string" },
            "gender":       { "type": "string" },
            "age":          { "type": "long"   },
            "name":   { 
              "type":         "object",
              "properties": {
                "full":     { "type": "string" },
                "first":    { "type": "string" },
                "last":     { "type": "string" }
              }
            }
          }
        }
      }
    }
  }
}

user和name域的映射结构与tweet类型的相同。事实上,type映射只是一种特殊的对象映射,我们称之为根对象。除了它有一些文档元数据的特殊顶级域,例如_source和_all域,它和其他对象一样。

6.5 内部对象是如何索引的

Lucene不理解内部对象。Lucene文档是由一组键值对列表组成的。为了能让Elasticsearch有效地索引内部类,它把我们的文档转化成这样:

{
    "tweet":            [elasticsearch, flexible, very],
    "user.id":          [@johnsmith],
    "user.gender":      [male],
    "user.age":         [26],
    "user.name.full":   [john, smith],
    "user.name.first":  [john],
    "user.name.last":   [smith]
}

内部域可以通过名称引用(例如,first)。为了区分同名的两个域,我们可以使用全路径(例如,user.name.first)或type名加路径(tweet.user.name.first)。

在前面简单扁平的文档中,没有user和user.name域。Lucene索引只有标量和简单值,没有复杂数据结构。

6.6 内部对象数组

最后,考虑包含内部对象的数组是如何被索引的。 假设我们有个followers数组:

{
    "followers": [
        { "age": 35, "name": "Mary White"},
        { "age": 26, "name": "Alex Jones"},
        { "age": 19, "name": "Lisa Smith"}
    ]
}

这个文档会像我们之前描述的那样被扁平化处理,结果如下所示:

{
    "followers.age":    [19, 26, 35],
    "followers.name":   [alex, jones, lisa, smith, mary, white]
}