开发中的搜索需求:组合搜索

221 阅读9分钟

前面我们介绍了 Match APITerm API ,但是这两个API并不能满足开发中所有的搜索需求,我们可以通过将API进行组合的方式进行搜索,来满足更复杂的业务需求。

  • Bool Query,布尔查询,可以组合多个过滤语句来过滤文档。
  • Boosting Query,在 positive 块中指定匹配文档的语句,同时降低在 negative 块中也匹配的文档的得分,提供调整相关性算分的能力。
  • constant_score Query,包装了一个过滤器查询,不进行算分。
  • dis_max Query,返回匹配了一个或者多个查询语句的文档,但只将最佳匹配的评分作为相关性算分返回。
  • function_score Query,支持使用函数来修改查询返回的分数。

本篇文章案例以一个销售领域店视角日维度的表作为模型。

PUT sale_store_day
{
  "mappings": {
   "properties": {
     "id":{
       "type": "keyword"
     },
     "storeId":{
       "type": "keyword"
     },
     "stroeName":{
       "type": "text"
     },
     "companyId":{
       "type": "keyword"
     },
     "companyName":{
       "type": "text"
     },
     "storeManager":{
       "type": "text"
     },
     "storeManagerId":{
       "type":"text"
     },
     "todaySalesVolume":{
       "type": "double"
     },
     "createTime":{
       "type": "date"
     },
     "address":{
       "type": "text"
     },
     "updateTime":{
       "type": "date"
     },
     "type":{
       "type": "keyword"
     }
   }
  }
}

PUT sale_store_day/_doc/1
{
  "id":"1",
  "storeId":"11",
  "stroeName":"Iphone specialty store",
  "companyName":"China Shenzhen Apple Agency Store",
  "companyId":"111",
  "storeManager":"helen",
  "storeManagerId":"1-1",
  "todaySalesVolume":"1000.00",
  "createTime":"2023-01-01",
  "updateTime":"2023-04-02",
  "address":"No.202, Zhengfu Street, Zhaoyuan County, Daqing City, Heilongjiang Province",
  "type":"exclusive"
}

PUT sale_store_day/_doc/2
{
  "id":"2",
  "storeId":"22",
  "stroeName":"Iphone authorize store",
  "companyName":"Apple mobile phone company of America",
  "companyId":"222",
  "storeManager":"lisa",
  "storeManagerId":"1-2",
  "todaySalesVolume":"512.00",
  "createTime":"2022-12-02",
  "updateTime":"2023-03-07",
  "address":"No.666 Innovation Avenue, High-tech Zone, Hefei City, Anhui Province",
  "type":"authorize"
}

1.Bool Query

Bool Query 使用一个或者多个布尔查询子句进行构建,每个子句都有一个类型,这些类型如下:

  • must,查询的内容必须在匹配的文档中出现,并且会进行相关性算分。简单来说就是与 AND 等价。
  • filter,查询的内容必须在匹配的文档中出现,但不像 mustfilter 的相关性算分是会被忽略的。因为其子句会在 filter context 中执行,所以其相关性算分会被忽略,并且子句将被考虑用于缓存。简单来说就是与 AND 等价。
  • should,查询的内容应该在匹配的文档中出现,可以指定最小匹配的数量。简单来说就是与 OR 等价。
  • must_not,查询的内容不能在匹配的文档中出现。与 filter 一样其相关性算分也会被忽略。简单来说就是与 NOT 等价。
GET sale_store_day/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "term": {
            "companyId": {
              "value": "222"
            }
          }
        },
        {
          "term": {
            "createTime": {
              "value": "2022-12-02"
            }
          }
        }
      ]
    }
  }
}

上面的SQL使用了must子句来查询文档。must子句中包含了两个term query,分别对公司Id和创建时间进行查询。

除了使用must子句外,还可以使用filter子句和should子句来做实现。使用filter子句来做实现会忽略相关性算分,should子句有一个 minimun_should_match 参数,可以指定最少匹配的查询数量或者百分比。

GET sale_store_day/_search
{
  "query": {
    "bool": {
      "should": [
        {
          "term": {
            "storeManager": {
              "value": "lisa"
            }
          }
        },
        {
          "term": {
            "createTime": {
              "value": "2010-06-01"
            }
          }
        }
      ],
      "minimum_should_match": 1
    }
  }
}

上面的SQL使用了should子句,并且指定should子句至少需要满足一个才能匹配这个文档。

Bool Query 包含至少一个should查询并且没有must,filter的情况下,其默认值是1,否则为0。

扩展一下,其实还可以组合多个子句来实现需求,比如我们使用must子句查询店长名字,使用filter过滤店铺创建时间。

GET sale_store_day/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "term": {
            "storeManager": {
              "value": "lisa"
            }
          }
        }
      ],
      "filter": [
        {
          "term": {
            "date": {
              "value": "2010-06-01"
            }
          }
        }
      ]
    }
  }
}

2.Boosting Query

Boosting Query 可以指定两个块:positive 块和 negative 块。可以在 positive 块来指定匹配文档的语句,而在 negative 块中匹配的文档其相关性算分将会降低。相关性算分降低的程度将由 negative_boost 参数决定,其取值范围为:[0.0, 1.0]。

GET sale_store_day/_search
{
  "query": {
    "boosting": {
      "positive": {
        "term": {
          "address": {
            "value": "AnHui"
          }
        }
      },
      "negative": {
        "term": {
          "address": {
            "value": "hefei"
          }
        }
      },
      "negative_boost": 0.5
    }
  }
}

上面的SQL,查询地址包含安徽的文档,并且想让含有 "hefei" 字样的文档的相关性算分降低一半。

negative 块中匹配的文档,其相关性算分为:在 positive 中匹配时的算分 * negative_boost

3.constant_score Query

constant_score Query 前面的文章简单提到过,它包装了一个过滤器查询,忽略相关性打分。使用 constant_score 可以将query转化为filter,可以忽略相关性打分环节,并且filter可以有效利用缓存,从而提升查询效率。

GET /sale_store_day/_search
{
  "query": {
    "constant_score": {
      "filter": {
        "range": {
          "todaySalesVolume": {
            "gte": 1000,
            "lte": 2000
          }
        }
      }
    }
  }
}

上面的SQL我们查询的是今日销量在[1000,2000)之间的店。

4.dis_max Query

disjunction max query 简称 dis_max,是分离最大化查询的意思。

  • disjunction(分离):表示把同一个文档中的每一个字段上的查询都分开,分别进行算分操作。
  • max(最大化):将多个字段查询的得分的最大值作为最终评分返回。

所以 disjunction max query 的效果是:将所有与任一查询匹配的文档作为结果返回,但是只将最佳匹配的得分作为查询的算分结果进行返回。不过其他匹配的字段可以使用 "tie_breaker" 参数来进行“维权”。

GET sale_store_day/_search
{
  "query": {
    "dis_max": {
      "queries": [
        {
          "term": {
            "storeManager": {
              "value": "lisa"
            }
          }
        },
        {
          "term": {
            "address": {
              "value": "hefei"
            }
          }
        }
      ],
      "tie_breaker": 0.9
    }
  }
}

mutil-match 也有一个 tie_breaker 参数,其实和这里一样,当指定 "tie_breaker" 的时候,算分结果将会由以下算法来决定:

  1. 令算分最高的字段的得分为 s1
  2. 令其他匹配的字段的算分 * tie_breaker 的和为 s2
  3. 最终算分为:s1 + s2

"tie_breaker" 的取值范围为:[0.0, 1.0]。

  1. 当其为 0.0 的时候,按照上述公式来计算,表示使用最佳匹配字段的得分作为相关性算分。
  2. 当其为 1.0 的时候,表示所有字段的得分同等重要。
  3. 当其在 0.0 到 1.0 之间的时候,代表其他字段的得分也需要参与到总得分的计算当中去。通俗来说就是其他字段可以使用 "tie_breaker" 来进行“维权” 。

5.function_score Query

function_score Query 允许你在查询结束以后去修改每一个匹配文档的相关性算分,所以使用算分函数可以改变或者替换原来的相关性算分结果。

function_score Query 提供了以下几种算分函数:

  • script_score:利用自定义脚本完全控制算分逻辑。
  • weight:为每一个文档设置一个简单且不会被规范化的权重。
  • random_score:为每个用户提供一个不同的随机算分,对结果进行排序。
  • field_value_factor:使用文档字段的值来影响算分,例如将好评数量这个字段作为考虑因数。
  • decay functions:衰减函数,以某个字段的值为标准,距离指定值越近,算分就越高。例如我想让书本价格越接近 10 元,算分越高排序越靠前。

function_score Query 可以组合多种函数。此时,可以根据不同的过滤条件设置不同的打分函数。

GET sale_store_day/_search
{
  "query": {
    "function_score": {
      "query": { "match_all": {} },
      "boost": "5", 
      "functions": [
        {
          "filter": { "match": { "storeManager": "lisa" } },
          "random_score": {}, 
          "weight": 23
        },
        {
          "filter": { "match": { "storeManager": "helen" } },
          "weight": 42
        }
      ],
      "max_boost": 42,
      "score_mode": "max",
      "boost_mode": "multiply",
      "min_score": 42
    }
  }
}

每个函数的过滤查询产生的分数无关紧要。

5.1 field_value_factor

field_value_factor 用文档某个字段的值来影响相关性算分,适合它的需求: 价格优惠的优先推荐、点赞数多的优先推荐、购买量多的优先推荐等。

field_value_factor 提供了以下几个参数选项:

  • field:文档的字段。
  • factor:指定文档的值将会乘以这个因子,默认为 1。
  • modifier:修改最终值的函数,其值可以为:none、log、log1p、log2p、ln、ln1p、ln2p、square、 sqrt、reciprocal,默认为 none。

For Example:

假如我想让店铺的销量影响相关性算分,随着销量的增加,相关性算分将相应地升高,要满足这个需求可以这样做:

GET sale_store_day/_search
{
  "query": {
    "function_score": {
      "query": {
        "term": {
          "storeManager": {
            "value": "lisa"
          }
        }
      },
      "field_value_factor": {
        "field": "todaySalesVolume",
        "factor": 1.5,
        "modifier": "square",
        "missing": 1
      },
      "boost_mode": "multiply"
    }
  }
}

对于上面的SQL,我们使用店铺的今日销量字段来影响相关性算分,其中 factor 为 1.5,将会乘以今日销量。而 modifier 使用的是 square,其作用类似于 x^2,这里 x 的值就是 price * factor 了。boost_mode 为 multiply,其作用是使得旧算分与 field_value_factor 产生的算分相乘。

所以最终得分的计算过程如下:新算分 = 匹配过程产生的旧算分 * square(1.2 * doc['todaySalesVolume'].value)

对于 boost_mode 参数,它的值有以下几种:

  1. multiply:算分与函数值的积,multiply 是默认值。
  2. replace:使用函数值作为最终的算分结果。
  3. sum:算分与函数值的和。
  4. avg:算分与函数值的平均数。
  5. min:在算分与函数值中取最小值。
  6. max:在算分与函数值中去最大值。

5.2 random_score

random_score 适合随机给用户推荐数据,并且一段时间内同一个用户访问的时候,这部分内容的排序都是一样的需求。

For Example:

GET sale_store_day/_search
{
  "query": {
    "function_score": {
      "random_score": {
        "seed": 12345,
        "field": "_seq_no"
      }
    }
  }
}

当 seed 的值不变的时候,随机内容的排序结果将不会变化。

在使用 random_score 算分函数的时候,需要指定 seed 和 field,如果只指定 seed,需要在 _id 字段上加载 fielddata,这样将会消耗大量的内存

一般来说,使用 "_seq_no" 作为 field 的值是比较推荐的,但是如果 seed 不变的情况下,文档被更新了,这个时候文档的 "_seq_no" 是会变化的,将会导致排序结果的变化。

在同一个 Index 里,每次文档写入时 "_seq_no" 都会自增,所以 "_seq_no" 代表着当前写入的顺序。

5.3 weight

权重允许您将分数乘以提供的权重,有时在特定的查询上设置的提升至被规范化了,而对于权重则不会被规范化,数字值的类型是float。

GET sale_store_day/_search
{
  "query": {
    "function_score": {
      "query": {
        "bool": {
          "must": [
            {
              "match": {
                "id": "lisa"
              }
            },
            {
              "match": {
                "storeManager": "lisa"
              }
            },
            {
              "bool": {
                "should": [
                  {
                    "wildcard": {
                      "stroeName": "*lisa*"
                    }
                  },
                  {
                    "wildcard": {
                      "companyName": "*lisa*"
                    }
                  }
                ]
              }
            }
          ]
        }
      },
      "boost": 1,
      "functions": [
        {
          "filter": {
            "wildcard": {
              "id": "*huidong*"
            }
          },
          "weight": 80
        },
        {
          "filter": {
            "wildcard": {
              "storeManager": "*huidong*"
            }
          },
          "weight": 60
        },
        {
          "filter": {
            "wildcard": {
              "stroeName": "*huidong*"
            }
          },
          "weight": 100
        },
        {
          "filter": {
            "wildcard": {
              "companyName": "*huidong*"
            }
          },
          "weight": 100
        }
      ]
    }
  }
}

5.4 script_score

script_score 函数允许包装另一个查询,并通过使用脚本表达式从文档中的其他数值字段值派生的计算来定制它的得分。

GET sale_store_day/_search
{
  "query": {
    "function_score": {
      "query": {
        "match": { "storeManager": "lisa" }
      },
      "script_score": {
        "script": {
          "source": "Math.log(2 + doc['todaySalesVolume'].value)"
        }
      }
    }
  }
}
  • 在Elasticsearch中,所有文档分数都是正的32位浮点数。
  • 如果script_score函数产生更高精度的分数,它将被转换为最接近的32位浮点数。
  • 分数必须是非负的,否则,Elasticsearch会返回一个错误。

除了不同的脚本字段值和表达式之外,还可以使用_score脚本参数来检索基于包装查询的分数。

脚本编译被缓存以加快执行速度。如果脚本有需要考虑的参数,最好重用同一个脚本,并为其提供参数:

GET sale_store_day/_search
{
  "query": {
    "function_score": {
      "query": {
        "match": { "storeManager": "lisa" }
      },
      "script_score": {
        "script": {
          "params": {
            "a": 5,
            "b": 1.2
          },
          "source": "params.a / Math.pow(params.b, doc['todaySalesVolume'].value)"
        }
      }
    }
  }
}

custom_score Query不同,查询的分数会乘以脚本评分的结果。如果不希望出现这种情况,可以设置 "boost_mode":"replace"


更多细节参考官方文档