3. 检索

3.1. Term Filter

**准备数据** 索引为forum:

POST /forum/_bulk
{ "index": { "_id": 1 }}
{ "articleID" : "XHDK-A-1293-#fJ3", "userID" : 1, "hidden": false, "postDate": "2017-01-01" }
{ "index": { "_id": 2 }}
{ "articleID" : "KDKE-B-9947-#kL5", "userID" : 1, "hidden": false, "postDate": "2017-01-02" }
{ "index": { "_id": 3 }}
{ "articleID" : "JODL-X-1937-#pV7", "userID" : 2, "hidden": false, "postDate": "2017-01-01" }
{ "index": { "_id": 4 }}
{ "articleID" : "QQPX-R-3956-#aD8", "userID" : 2, "hidden": true, "postDate": "2017-01-02" }

**mapping结构**

#请求:
GET /forum/_mapping
#响应:
{
  "forum" : {
    "mappings" : {
      "properties" : {
        "articleID" : {
          "type" : "text",
          "fields" : {
            "keyword" : {
              "type" : "keyword",
              "ignore_above" : 256
            }
          }
        },
        "hidden" : {
          "type" : "boolean"
        },
        "postDate" : {
          "type" : "date"
        },
        "userID" : {
          "type" : "long"
        }
      }
    }
  }
}

postDate默认就是date类型。这里关键说下articleID,它的类型是text,Elasticsearch默认会对 text 类型的字段进行分词,建立倒排索引;其次,还会生成一个keyword字段,这个keyword就是articleID的内容,不会分词,用于建立正排索引, 256 :如果内容过长,只保留256个字符。
_analyze命令理解,默认的articleID字段:

# 请求:分析下aticleID的默认分词结果
GET /forum/_analyze
{
  "field": "articleID",
  "text":"XHDK-A-1293-#fJ3" 
}
# 响应:可以看到"XHDK-A-1293-#fJ3"其实被normalization了
{
  "tokens" : [
    {
      "token" : "xhdk",
      "start_offset" : 0,
      "end_offset" : 4,
      "type" : "<ALPHANUM>",
      "position" : 0
    },
    {
      "token" : "a",
      "start_offset" : 5,
      "end_offset" : 6,
      "type" : "<ALPHANUM>",
      "position" : 1
    },
    {
      "token" : "1293",
      "start_offset" : 7,
      "end_offset" : 11,
      "type" : "<NUM>",
      "position" : 2
    },
    {
      "token" : "fj3",
      "start_offset" : 13,
      "end_offset" : 16,
      "type" : "<ALPHANUM>",
      "position" : 3
    }
  ]
}

articleID.keyword字段:

# 请求:分析下articleID.keyword的默认分词结果
GET /forum/_analyze
{
  "field": "articleID.keyword",
  "text":"XHDK-A-1293-#fJ3"
}
# 响应:可以看到"XHDK-A-1293-#fJ3"压根没被分词,原样返回了
{
  "tokens" : [
    {
      "token" : "XHDK-A-1293-#fJ3",
      "start_offset" : 0,
      "end_offset" : 16,
      "type" : "word",
      "position" : 0
    }
  ]
}

**示例**
例如:根据帖子ID搜索帖子。由于用了term filter语法,所以不会对搜索关键字进行分词:

# 请求:我们不关心相关度分数,所以用了constant_score,将相关度分数置为1
GET /forum/_search
{
    "query" : {
        "constant_score" : { 
            "filter" : {
                "term" : { 
                    "articleID" : "XHDK-A-1293-#fJ3"
                }
            }
        }
    }
}
# 响应:因为搜索关键字"XHDK-A-1293-#fJ3"不分词,而articleID这个字段本身是分词的,所以查不出结果
{
  "took" : 5,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 0,
      "relation" : "eq"
    },
    "max_score" : null,
    "hits" : [ ]
  }
}

articleID 进行 term filter 是查不出结果的,需要使用 articleID.keyword

# 请求:对articleID.keyword进行term filter
GET /forum/_search
{
    "query" : {
        "constant_score" : { 
            "filter" : {
                "term" : { 
                    "articleID.keyword" : "XHDK-A-1293-#fJ3"
                }
            }
        }
    }
}
# 响应:因为articleID.keyword保存在完整text内容,所以可以匹配到
{
  "took" : 0,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 1,
      "relation" : "eq"
    },
    "max_score" : 1.0,
    "hits" : [
      {
        "_index" : "forum",
        "_type" : "article",
        "_id" : "1",
        "_score" : 1.0,
        "_source" : {
          "articleID" : "XHDK-A-1293-#fJ3",
          "userID" : 1,
          "hidden" : false,
          "postDate" : "2017-01-01"
        }
      }
    ]
  }
}

**底层原理**

  • 匹配document

term filter在执行时,首先会在倒排索引中匹配filter条件,也就是我们的搜索关键字,用于获取document list。我们以postDatge来举个例子,如果查找“2017-02-02”,会发现”2017-02-02“对应的document list是doc2、doc3:

word doc1 doc2 doc3
2017-01-01 Y Y N
2017-02-02 N Y Y
2017-03-03 Y Y Y
  • 构建bitset

为每个fiter条件构建 bitset 。bitset 就是一个二进制的数组,数组每个元素都是 01 ,用来标识某个 doc 对这个 filter 条件是否匹配,匹配就是1,否则为0。例如:doc1不匹配”2017-02-02”,而doc2和do3是匹配的,所以”2017-02-02”这个filter条件的bitset就是[0, 1, 1]。

  • 遍历bitset

由于在一个search请求中,可以有多个filter条件,而filter条件都会对应一个bitset。所以这一步,ES会从最稀疏的bitset开始遍历,优先过滤掉尽可能多的数据。比如我们的filter条件是postDate=2017-01-01,userID=1,对应的bitset是:
postDate: [0, 0, 1, 1, 0, 0]
userID: [0, 1, 0, 1, 0, 1]
那么遍历完两个bitset之后,找到匹配所有filter条件只有doc4,就将其作为结果返回给client了。

  • 缓存bitset

Elasticsearch会将一些频繁访问的filter条件和它对应的bitset缓存在内存中,这样就可以提高检索效率了。

注:如果 document 保存在某个很小的 segment 上的话(segment记录数<1000,或segment大小<index总大小的3%),Elasticsearch就不会对其缓存。因为 segment 很小的话,会在后台被自动合并,那么缓存也没有什么意义了,因问 segment 很快就消失了。 这里就可以看出,filter 为什么比 query 的性能更好了,filter 除了不需要计算相关度分数并按其排序外,filter 还会缓存检索结果对应的 bitset。

  • bitset更新

如果document有新增或修改,那么filter条件对应的cached bitset会被自动更新。例如:假设postDate=2017-01-01对应的 bitset 为[0, 0, 1, 0]。
新增一条doc5 id=5,postDate=2017-01-01,那postDate=2017-01-01这个filter的bitset会全自动更新成[0, 0, 1, 0, 1];
修改doc1 id=1,postDate=2016-12-30,那postDate=2016-01-01这个filter的bitset会全自动更新成[1, 0, 1, 0, 1];

3.2. Multi-Field

全文检索不是搜索exact value,而是对检索关键字进行分词后,实现倒排索引检索。多字段搜索,在多个不同的field中检索关键字。
全文检索时,如果需要针对多个field进行检索,我们一般会使用 match querymulti_match 语法。默认情况下,Elasticsearch进行这类多字段检索的策略是 most_fields
**准备数据**

{
    "articleID" : "XHDK-A-1293-#fJ3",
    "userID" : 1,
    "hidden" : false,
    "postDate" : "2017-01-01",
    "tag" : [
        "java",
        "hadoop"
    ],
    "view_cnt" : 30,
    "title" : "this is java and elasticsearch blog"
},
{
    "articleID" : "KDKE-B-9947-#kL5",
    "userID" : 1,
    "hidden" : false,
    "postDate" : "2017-01-02",
    "tag" : [
        "java"
    ],
    "view_cnt" : 50,
    "title" : "this is java blog"
},
{
    "articleID" : "JODL-X-1937-#pV7",
    "userID" : 2,
    "hidden" : false,
    "postDate" : "2017-01-01",
    "tag" : [
        "hadoop"
    ],
    "view_cnt" : 100,
    "title" : "this is elasticsearch blog"
},
{
    "articleID" : "QQPX-R-3956-#aD8",
    "userID" : 2,
    "hidden" : true,
    "postDate" : "2017-01-02",
    "tag" : [
    "java",
        "elasticsearch"
    ],
    "view_cnt" : 80,
    "title" : "this is java, elasticsearch, hadoop blog"
}

**示例**

# 请求:搜索title中包含关键字“java elasticsearch”的记录
GET /forum/_search
{
    "query": {
        "match": {
            "title": "java elasticsearch"
        }
    }
}

全文检索时,会对搜索关键字进行拆分,上述”title”字段默认就是text类型,所以最终会以倒排索引的方式查询,只有记录中的“title”包含了“java”或“elasticsearch”,都会被检索出来。使用bool组合多个搜索条件:

GET /forum/_search
{
  "query": {
    "bool": {
      "must":     { "match": { "title": "java" }},
      "must_not": { "match": { "title": "spark"  }},
      "should": [
                  { "match": { "title": "hadoop" }},
                  { "match": { "title": "elasticsearch"   }}
      ]
    }
  }

**minimum_should_match**
指定的关键字中,必须至少匹配其中的多少个关键字,才能作为结果返回,可以利用minimum_should_match参数:

GET /forum/article/_search
{
  "query": {
    "match": {
      "title": {
        "query": "java elasticsearch spark hadoop",
        "minimum_should_match": "75%"
      }
    }
  }
}

上述查询到的结果中,至少会包含“java“、“elasticsearch“、“spark“、“hadoop”中的三个。


**boost权重**
对检索关键字拆分后的某些词被优先检索。Elasticsearch进行相关度分数计算时,权重越大,相应的 relevance score 会越高,也就会优先被返回。默认情况下,搜索条件的权重都是1。例如:检索出title包含hadoop或elasticsearch的记录,hadoop优先搜索出来,设置hadoop的权重更大:

GET /forum/_search 
{
  "query": {
    "bool": {
      "should": [
        {
          "match": {
            "title": {
              "query": "hadoop",
              "boost": 5
            }
          }
        },
        {
          "match": {
            "title": {
              "query": "elasticsearch",
              "boost": 2
            }
          }
        }
      ]
    }
  }
}

注:如果index有多个shard的话,搜索结果可能不准确。因为对于一个搜索请求, coordinate node 可能会将其转发给任意一个 shard 。Elasticsearch 在计算相关度分数时,采用了 TF/IDF 算法,该算法需要知道关键字在所有 document 中出现的次数,而每个 shard 只包含了部分 document , TF/IDF 算法计算时只采用了当前 shard 中的所有 document 数,所以对于不同 shard 计算出的相关度分数可能都是不同的。


**底层原理**
使用match query进行检索时,Elasticsearch底层会转换成term形式。例如:

GET /forum/_search
{
    "query": {
        "match": {
            "title": {
                "query": "java elasticsearch",
                "operator": "and"
               }
        }
    }
}

Elasticsearch会将其转换成如下term形式:

{
  "bool": {
    "should": [
      { "term": { "title": "java" }},
      { "term": { "title": "elasticsearch"   }}
    ]
  }
}

3.2.1. best_fields策略

对多个filed进行搜索匹配时,挑选某个field匹配度最高的分数,最高分相同的情况下,考虑其他query的分数。

3.2.1.1. multi-field搜索

示例:

# 1
{ "doc" : {"title" : "this is java and elasticsearch blog","content" : "i like to write best elasticsearch article"} }
# 2
{ "doc" : {"title" : "this is java blog","content" : "i think java is the best programming language"} }
# 3
{ "doc" : {"title" : "this is elasticsearch blog","content" : "i am only an elasticsearch beginner"} }
# 4
{ "doc" : {"title" : "this is java, elasticsearch, hadoop blog","content" : "elasticsearch and hadoop are all very good solution, i am a beginner"} }
# 5
{ "doc" : {"title" : "this is spark blog","content" : "spark is best big data solution based on scala ,an programming language similar to java"} }

搜索title或content中包含 javasolution 关键字的帖子,这其实就是典型的multi-field搜索:

# should相当于SQL语法中的OR
GET /forum/_search
{
    "query": {
        "bool": {
            "should": [
                { "match": { "title": "java solution" }},
                { "match": { "content": "java solution" }}
            ]
        }
    }
}

doc5 content字段既包含“java”又包含“solution”应该是匹配度最高。但doc5的相关度分数(relevance score)并不是最高,multi-field搜索默认情况下,Elasticsearch采用的是 most_fields 策略,算法大致为:

  1. 计算每个query的分数,然后求和。对于上述搜索,就是“should”中的两个field检索条件,比如doc4计算的结果分别是1.1和1.2,相加为2.3;
  2. 计算matched query的数量,比如对于doc4,两个field都能匹配到,数量就是2;
  3. sum(每个query的分数)* count(matched query) / count(总query数量) 作为最终相关度分数。

对于doc4,上述算法的计算结果就是:(1.1+1.2) x 2/2=2.3;而对于doc5,title字段是匹配不到结果的,所以matched query=1,doc5的最终分数可能是(0+2.3) x 1/2=1.15,所以检索结果排在了doc4后面。

3.2.1.2. dis_max

一个 field 匹配到了尽可能多的关键词,其分数更高;而不是尽可能多的 field 匹配到了少数的关键词,却排在了前面。
Elasticsearch提供了 dis_max 语法,可以直接取多个 query 中,分数最高的那一个 query 的分数,例如:

GET /forum/_search
{
    "query": {
        "dis_max": {
            "queries": [
                { "match": { "title": "java solution" }},
                { "match": { "content":  "java solution" }}
            ]
        }
    }
}

比如对于上述的doc4,两个field检索的最终分数分别为1.1和1.2,那就取最大值1.2:

{ "match": { "title": "java solution" }} -> 1.1
{ "match": { "content":  "java solution" }} -> 1.2

对于doc5,针对“title”的检索没有匹配结果,分数为0,但“content”的分数为2.3,所以取最大值2.3:

{ "match": { "title": "java solution" }} -> 0
{ "match": { "content":  "java solution" }} -> 2.3

3.2.1.3. tie_breaker

dis_max只取多个query中,分数最高query的分数,而完全不考虑其它query的分数。但有时这并不能满足我们的需求,举个例子,我们希望检索title字段包含“java solution”或”content”字段包含“java solution”的帖子,最终满足条件的每个doc的匹配结果如下:

  • doc1,title中包含“java“,content不包含“java“、“solution“任何一个关键词;
  • doc2,title中不包含任何一个关键词,content中包含“solution”;
  • doc3,title中包含“java“,content中包含“solution“。

最终搜索结果是,doc1和doc2排在了doc3的前面。此时我们可以利用 tie_breaker 参数将其他 query 的分数也考虑进去:

GET /forum/_search
{
    "query": {
        "dis_max": {
            "queries": [
                { "match": { "title": "java solution" }},
                { "match": { "content":  "java solution" }}
            ],
            "tie_breaker": 0.3
        }
    }
}

tie_breaker0-1 之间,其意义在于:其他query的分数乘以 tie_breaker ,然后再与最高分数的那个query进行计算,得到最终分数。

3.2.1.4. multi_match搜索

dis_maxtie_breakerbese_fields 策略的核心实现原理了。Elasticsearch还提供了 multi_match 搜索,简化 bese_fields 策略:

GET /forum/_search
{
  "query": {
    "multi_match": {
        "query":                "java solution",
        "type":                 "best_fields", 
        "fields":               [ "title", "content" ],
        "tie_breaker":          0.3,
        "minimum_should_match": "50%" 
    }
  } 
}

dis_maxtie_breaker 和来实现同样的效果,则是下面这样,可以看到multi_match确实简化了编码:

GET /forum/_search
{
  "query": {
    "dis_max": {
      "queries":  [
        {
          "match": {
            "title": {
              "query": "java solution",
              "minimum_should_match": "50%"
            }
          }
        },
        {
          "match": {
            "body": {
              "query": "java solution",
              "minimum_should_match": "50%"
            }
          }
        }
      ],
      "tie_breaker": 0.3
    }
  } 
}

3.2.1.5. 优缺点

best_fields策略是最常用,也是最符合人类思维的搜索策略。Google、Baidu之类的搜索引擎,默认就是用的这种策略。

  • 优点 通过best_fields策略,以及综合考虑其他field,还有minimum_should_match支持,可以尽可能精准地将匹配的结果推送到最前面。
  • 缺点 除了那些精准匹配的结果,其他差不多大的结果,排序结果不是太均匀,没有什么区分度了。

    3.2.2. most_fields策略

    multi-field 搜索时的默认策略,其实就是综合多个 field 一起进行搜索,尽可能多地让所有query参与到总分的计算中,结果不一定精准。例如:某个document的一个field虽然包含更多的关键字,但是因为其他document有更多field匹配到了,所以其它的doc会排在前面。

    GET /forum/_search
    {
    "query": {
      "multi_match": {
          "query":"java solution",
          "type":"most_fields", 
          "fields":[ "title", "content" ]
      }
    } 
    }
    

    **优缺点**

  • 优点 将尽可能匹配更多field的结果推送到最前面,整个排序结果是比较均匀的。

  • 缺点 可能那些精准匹配的结果,无法推送到最前面。

    3.2.3. cross-fields策略

    cross-fields搜索,就是跨多个field去搜索一个标识。比如姓名字段可以散落在多个field中,first_name和last_name,地址字段可以散落在country、province、city中,那么搜索人名或者地址,就是cross-fields搜索。
    要进行cross-fields搜索,我们可能会立马想到使用上面讲的most_fields策略,因为multi_fields会考虑多个field匹配的分数,而cross-fields搜索本身刚好就是多个field检索的问题。
    示例cross-fields搜索,假设有以下用户信息:

    # 1
    { "doc" : {"author_first_name" : "Peter", "author_last_name" : "Smith"} }
    # 2
    { "doc" : {"author_first_name" : "Smith", "author_last_name" : "Williams"} }
    # 3
    { "doc" : {"author_first_name" : "Jack", "author_last_name" : "Ma"} }
    # 4
    { "doc" : {"author_first_name" : "Robbin", "author_last_name" : "Li"} }
    # 5
    { "doc" : {"author_first_name" : "Tonny", "author_last_name" : "Peter Smith"} }
    

    检索姓名中包含“Peter Smith”的用户信息:

    GET /forum/_search
    {
    "query": {
      "multi_match": {
        "query":       "Peter Smith",
        "type":        "most_fields",
        "fields":      [ "author_first_name", "author_last_name" ]
      }
    }
    }
    

    检索出的结果包含:doc1、doc2、doc5,我们希望的结果应该是doc5排在最前面,然后是doc1,最后才是doc2,即doc5>doc1>doc2,但事实上,doc5可能会排在最后。之所以会出现这种情况,跟 TF/IDF 算法有关。
    使用multi_match提供的cross-fields策略:

    GET /forum/_search
    {
    "query": {
      "multi_match": {
        "query": "Peter Smith",
        "type": "cross_fields", 
        "operator": "and",
        "fields": ["author_first_name", "author_last_name"]
      }
    }
    }
    

    使用 cross-fields 策略进行多字段检索时,会要求关键字拆分后的每个 term 必须出现在被检索的字段中。比如上面我们检索“Peter Smith”时,会拆成“Peter”和“Smith”两个term,那就要求:

  • Peter必须在author_first_name或author_last_name中出现;

  • Smith必须在author_first_name或author_last_name中出现。

    3.3. Proximity Match

    近似匹配 包含两种类型: phrase matchproximity match

    # doc1
    java is my favourite programming language, and I also think spark is a very good big data system.
    # doc2
    java spark are very related, because scala is spark's programming language and scala is also based on jvm like java.
    

    索出包含“java spark”关键字的doc,但是必须满足以下任一条件:

  • java spark,就靠在一起,中间不能插入任何其他字符;

  • java和spark两个单词靠的越近,doc的分数越高,排名越靠前。

    3.3.1. phrase match

    短语匹配 将搜索词多个term作为一个短语,只有包含短语的doc为结果返回。与match query不同,任何一个term匹配就会返回结果,Phrase Match的基本语法如下:

    GET /forum/_search
    {
      "query": {
          "match_phrase": {
              "title": {
                  "query": "java spark",
                  "slop":  1
              }
          }
      }
    }
    
  • 基本原理

Elasticsearch在建立倒排索引时,会记录每个term在文本内容中的位置。比如我们有下面两条doc:

doc1: hello world, java spark        
doc2: hi, spark java

建立完的倒排索引包含以下内容,hello 这个 term 出现在 doc1 的 position 0,以此类推:

Term Doc1中的位置 Doc2中的位置
hello doc1(0) N
wolrd doc1(1) N
java doc1(2) doc2(2)
spark doc1(3) doc2(1)

当使用Phrase Match(短语匹配)时,步骤如下:

  1. Elasticsearch会首先对短语分词,”java spark”拆分为”java”和”spark” ;
  2. 筛选出“java”和“spark”都存在的doc,也就是说doc必须包含短语中的所有term,那doc1和doc2都满足;
  3. 后一个短语的position必须比前一个大1,即“spark”的position要比java的position大1,那只有doc1满足条件。
  • slop参数

Phrase Match有一个很重要的参数——slop,表示短语中的term,最多经过几次移动才能与一个document匹配,这个移动次数,就是slop。举个例子,假如有下面这样一条doc,搜索的短语是”spark data”:
spark is best big data solution based on scala.
slop=3时,就可以匹配到,可以看下面的移动步骤:

spark         is         best         big         data...
spark        data
                 -->data
                             -->data
                                          -->data

注:移动的方向可以是双向的,比如搜索的短语是”data spark”。slop=5时,也可以匹配到:

spark         is         best         big         data...
data        spark
spark  <-->    data
spark             -->data
spark                          -->data
spark                                       -->data

当使用Phrase Match时,term靠的越近,相关度分数会越高。

  • rescore

重打分 通常与match query配合使用。例如:

GET /forum/_search 
{
  "query": {
    "match": {
      "content": "java spark"
    }
  },
  "rescore": {
    "window_size": 50,
    "query": {
      "rescore_query": {
        "match_phrase": {
          "content": {
            "query": "java spark",
            "slop": 50
          }
        }
      }
    }
  }
}

对math query匹配到的结果重新打分,上述window_size表示取前50条记录进行打分,配合phrase match可以使term越接近的短语分数更高,从而既提供了精确度,又提升了召回率。

所谓召回率,就是进行检索时,返回的document的数量,数量越多,召回率越高。近似匹配通常会和match query搭配使用以提升召回率。

  • 搜索推荐

利用 match_phrase_prefix 进行搜索推荐。搜索推荐的原理跟 match_phrase 类似,唯一的区别就是把检索词中的最后一个 term 作为前缀去搜索。

GET /forum/_search 
{
  "query": {
    "match_phrase_prefix": {
      "content": {
          "query": "java s",
          "slop": 10,
          "max_expansions": 5
      }
    }
  }
}

默认情况下,前缀要扫描所有的倒排索引中的term,去查找”s”打头的单词,但是这样性能太差,所以可以用 max_expansions 限定,”s”前缀最多匹配多少个term,不再继续搜索倒排索引了。

不推荐使用 match_phrase_prefix 来实现搜索推荐,因为Elasticsearch会在扫描倒排索引时实时进行前缀匹配,性能很差。如果要实现搜索推荐功能,建议使用 ngram 分词机制。

3.3.2. proximity match

phrase matchproximity match 底层原理一样,都是通过倒排索引去匹配搜索关键词中的各个term的位置, proximity match 可以看成是加了 slop 参数的 phrase match

3.4. N-Gram

搜索推荐 N-Gram是大词汇连续语音识别中常用的一种语言模型。
对于单词“quick”,做如下拆分,“quick” term就被拆分成了5种长度下的ngram,每种长度下的拆分项都是一个ngram:

# ngram.length=1
q u i c k
# ngram.length=2
qu ui ic ck
# ngram.length=3
qui uic ick
# ngram.length=4
quic uick
# ngram.length=5
quick

Elasticsearch 使用 edge ngram 分词方法,比如我们有两个下面这样的document:

# doc1
hello world
# doc2
hello what

Elasticsearch对文本中每个 term,按照 edge ngram 机制建立倒排索引:

term doc1 doc2
h Y Y
he Y Y
hel Y Y
hell Y Y
hello Y Y
w Y Y
wo Y N
wor Y N
worl Y N
world Y N
wh N Y
wha N Y
what N Y

检索“hello w”时,首先会对“hello”这个term检索,发现doc1和doc2都有,然后对“w”这个term检索,发现doc1和doc2也都有,所以doc1和duc2都会被返回,这样就实现了搜索推荐。检索时完全利用到了倒排索引,并没有去做前缀匹配,ngram机制实现的搜素推荐效率非常高
**示例**
建立索引,min_gram和max_gram用于控制ngram的长度:

PUT /my_index
{
    "settings": {
        "analysis": {
            "filter": {
                "autocomplete_filter": { 
                    "type":     "edge_ngram",
                    "min_gram": 1,
                    "max_gram": 20
                }
            },
            "analyzer": {
                "autocomplete": {
                    "type":      "custom",
                    "tokenizer": "standard",
                    "filter": [
                        "lowercase",
                        "autocomplete_filter" 
                    ]
                }
            }
        }
    }
}

查看下分词结果:

GET /my_index/_analyze
{
  "analyzer": "autocomplete",
  "text": "quick brown"
}

对需要实现搜索推荐的字段,修改其字段使用的分词器就完成了:

PUT /my_index/_mapping
{
  "properties": {
      "title": {
          "type":     "string",
          "analyzer": "autocomplete",
          "search_analyzer": "standard"
      }
  }
}

上面analyzer的意思是对title字段的内容建立索引时,使用autocomplete这个分词器,也就是ngram分词;search_analyzer的意思是,对于我们的检索词,比如“hello w”,还是用标准的standard分词器拆分。

3.5. 相关度分数算法

使用 Elasticsearch 检索的过程中,结果都会包含一个相关度分数 relevance score ,基于 TF/IDFvector space model 实现。底层其实调用了 Lucenepractical scoring 函数来完成分数的计算。进行相关度分数计算时,核心步骤就包含三点:

  1. boolean model进行document过滤。
  2. TF/IDF算法计算单个term的分数。
  3. vector space model整合最终的相关度分数。

    3.5.1. bool model

    Elasticsearch中的各种doc过滤语法,例如:bool命令中的must/should/must not等等。核心目的就是过滤出包含检索关键字的document,提升后续分数计算的性能。这一过程仅仅是过滤,不进行分数计算。

    3.5.2. TF/IDF算法

    term frequency/inverse document frequency 算法,Elasticsearch计算相关度分数的基础,用于计算单个term在document中的分数。例如:我们的检索关键字是”hello world“,index中包含如下的document:
    # doc1
    hello you, and world is very good
    
    TF/IDF算法会对”hello”这个term计算出一个doc1分数,对”world“再计算出一个doc1分数。至于”hello world“这整个关键字在doc1中的综合分数,TF/IDF是不管的。核心思想:
  • term frequency 表示检索的term,在单个document中的各个词条中出现的频次,出现的次数越多,该document相关度越高;
  • inverse document frequency 表示检索的term,在该索引的所有document中出现的频次,出现的次数越少,包含该term的document相关度越高;
  • Field-length norm 表示document的field内容长度越短,相关度越高。

    3.5.2.1. term frequency

    关键字“hello world”,索引中包含下面两条document:
    # doc1
    hello you, and world is very good
    # doc2
    hello, how are you
    
    “hello world”进行分词后,拆成两个term——“hello”和“world”,显然doc1既包含“hello”又包含“world”,doc2只包含“hello”,所以doc1更相关。

    3.5.2.2. inverse document frequency

    检索请求还是“hello world”,假设索引中一共包含1000条document,我们只列出其中两条:
    # doc1
    hello, today is very good
    # doc2
    hi world, how are you
    
    根据term frequency规则,doc1和doc2的相关度应该是相同的,但是如果‘hello“在该索引的1000条document中,有800条document都包含它而‘world“只有200条document包含,那么doc2的相关度就比doc1更高。

    term在整个document列表中出现的次数越多,包含它的doc相关度反而越低。因为出现的次数越少,说明包含那个term的doc的区分度越高。

3.5.2.3. Field-length norm

检索请求还是“hello world”,假设索引中一共包含2条document:

# doc1
{ "title": "hello article", "content": "babaaba 1万个单词" }
# doc2
{ "title": "my article", "content": "blablabala 1万个单词,hi world" }

doc1中,”hello“出现在title字段,doc2中,”world“出现在content字段,显然title字段的内容长度远小于content字段的内容长度,所以doc1的相关度比doc2更高。
**示例**
通过explain命令,查看Elasticsearch对某个query的评分计算:

GET /test_index/_search?explain
{
  "query": {
    "match": {
      "test_field": "test hello"
    }
  }
}

通过如下命令查看某个document是如何被一个query匹配上,比如下面是查看id为6的document是如何被匹配上的:

GET /test_index/6/_explain
{
  "query": {
    "match": {
      "test_field": "test hello"
    }
  }
}

3.5.3. 向量空间模型算法

TF/IDF 算法只能计算单个 termdocument 中的分数。依靠 vector space model 计算整个搜索关键词在各个doc中的综合分数。其核心思想是计算两个向量,然后相乘得到最终分:

  1. query vector term在所有document的分数。
  2. doc vector term在各个document的分数。
  3. 计算doc vector对于query vector的弧度(其实就是线性代数中的向量运算)。

    3.5.3.1. query vector

    根据TF/IDF算法的结果计算出query vector,是每一个term对所有document的综合评分。假设index中包含3条document,搜索关键字是“hello world”:
    # doc1
    hello, today is very good
    # doc2
    hi world, how are you
    # doc3
    hello world
    
    对于hello term vector space model 算法会算出它对所有doc的评分,例如为2;world这个term,基于所有doc的评分是5,那么query vector = [2, 5]。

    query vector的计算过程不用去深究,底层涉及线性代数之类的高等数学知识,我们只要知道vector space model会计算出这样一个vector,vector包含了每个term对所有document的评分就行了。

3.5.3.2. doc vector

每个term在各个document中的分数组成的一个向量。例如:“hello”在doc1中的分数是2,doc2中是0,doc3中是2;“world”在doc1中的分数是0,doc2中是5,doc3中是5,那么最终计算出的doc vector是下面这样的:

[2 , 0]
[0 , 5]
[2 , 5]

3.5.3.3. 弧度计算

根据doc vector和query vector进行向量运算,最终得到每个doc对多个term的总分数。

3.5.4. Lucene相关度分数函数

Lucene中 practical scoring 函数,综合了上面我们讲的TF/IDF算法和vector space model:

score(q,d)  =  
  queryNorm(q)  
  · coord(q,d)    
  · ∑ (           
    tf(t in d)   
      · idf(t)2      
      · t.getBoost() 
      · norm(t,d)    
  ) (t in q)

query(入参q),对一个doc(入参d)的最终总评分,也就是搜索关键字对某个document的相关度分数:

  • queryNorm(q) 用来让一个doc的分数处于一个合理的区间内,不要太离谱。
  • coord(q,d) 对更加匹配的doc,进行一些分数上的成倍奖励。
  • tf(t in d) 计算每个term对doc的分数,就是TF/IDF算法中的term frequency步骤。
  • idf(t)2 计算每个term对doc的分数,就是TF/IDF算法中的inverse document frequency步骤。
  • t.getBoost() 计入字段权重。
  • norm(t,d) 计算每个term对doc的分数,就是TF/IDF算法中的Field-length norm步骤。

    3.6. 相关度分数调优

    调优4种方案: query-time boost negative boost constant_score function_score

    3.6.1. query-time boost

    利用boost增强某个query权重,例如:查询有两个搜索条件,针对title字段查询添加boost参数,使其权重更大,title在匹配doc中分数占比会更大:

    GET /forum/_search
    {
    "query": {
      "bool": {
        "should": [
          {
            "match": {
              "title": {
                "query": "java spark",
                "boost": 2
              }
            }
          },
          {
            "match": {
              "content": "java spark"
            }
          }
        ]
      }
    }
    }
    

    3.6.2. negative boost

    主要用于减少某些字段的权重 query-time boost 的反向参数。例如:搜索content字段中包含”java”,但不包含spark的”document”:

    GET /forum/_search 
    {
    "query": {
      "bool": {
        "must": [
          {
            "match": {
              "content": "java"
            }
          }
        ],
        "must_not": [
          {
            "match": {
              "content": "spark"
            }
          }
        ]
      }
    }
    }
    

    不完全排除某个关键字,如果字段中包含某个关键字,就降低它的分数,比如上面的spark。对于这种需求,可以使用negative_boost,包含了negative term的document,其分数会乘以negative boost:

    GET /forum/_search 
    {
    "query": {
      "boosting": {
        "positive": {
          "match": {
            "content": "java"
          }
        },
        "negative": {
          "match": {
            "content": "spark"
          }
        },
        "negative_boost": 0.2
      }
    }
    }
    

    3.6.3. constant_score

    不需要相关度评分,使用 constant_scorefilter ,所有的doc分数都是 1

    GET /forum/_search 
    {
    "query": {
      "bool": {
        "should": [
          {
            "constant_score": {
              "query": {
                "match": {
                  "title": "java"
                }
              }
            }
          },
          {
            "constant_score": {
              "query": {
                "match": {
                  "title": "spark"
                }
              }
            }
          }
        ]
      }
    }
    }
    

    3.6.3. function_score

    自定义相关度分数的算法。例如:某个帖子的人越多,那么该帖子的分数就越高,帖子浏览数定义 follower_num 字段:

    GET /forum/_search
    {
    "query": {
      "function_score": {
        "query": {
          "multi_match": {
            "query": "java spark",
            "fields": ["tile", "content"]
          }
        },
        "field_value_factor": {
          "field": "follower_num",
          "modifier": "log1p",
          "factor": 0.5
        },
        "boost_mode": "sum",
        "max_boost": 2
      }
    }
    }
    

    请求解析

  • log1p 是一个函数,用于对字段分数进行修正:new_score = old_score log(1 + factor follower_num)。

  • boost_mode 决定最终doc分数与指定字段的值如何计算: multiply sum min max replace
  • max_boost 用于限制计算出来的分数不要超过 max_boost 指定的值。

    3.7. fuzzy

    模糊搜索 自动将拼写错误的搜索文本进行纠正,纠正以后去尝试匹配索引中的数据。
    主要有两种使用的方式。更多的是直接在 match query 中使用 **fuzziness**
    **示例**
    GET /my_index/_search 
    {
    "query": {
      "fuzzy": {
        "text": {
          "value": "surprize",
          "fuzziness": 2
        }
      }
    }
    }
    
    fuzziness表示模糊度,其数值就是最多可以纠正几个字母。
    另一种使用fuzzy模糊搜索的语法如下:
    GET /my_index/_search 
    {
    "query": {
      "match": {
        "text": {
          "query": "SURPIZE ME",
          "fuzziness": "AUTO",
          "operator": "and"
        }
      }
    }
    }