QueryDSL

类似SQL的概念,Elasticsearch也提供了一整套查询语言,QueryDSL。ES中,几乎所有的搜索、过滤、排序、分页操作都通过QueryDSL来表达。这篇笔记我们将继续深入学习ES中的数据检索相关概念和QueryDSL的用法。

创建测试索引

后面和查询相关的操作演示我们都将基于如下的products索引实现。这个索引中,namedescription是主要用于全文检索的文本字段,它们都配置了IK分词器,其中name还用fields附加了一个keyword子字段,这样name既可以全文检索也可以用name.keyword做精确匹配和聚合排序。

PUT /products
{
  "mappings": {
    "properties": {
      "name": {
        "type": "text",
        "analyzer": "ik_max_word",
        "search_analyzer": "ik_smart",
        "fields": {
          "keyword": {
            "type": "keyword"
          }
        }
      },
      "description": {
        "type": "text",
        "analyzer": "ik_max_word",
        "search_analyzer": "ik_smart"
      },
      "category": { "type": "keyword" },
      "brand": { "type": "keyword" },
      "price": { "type": "double" },
      "stock": { "type": "integer" },
      "tags": { "type": "keyword" },
      "is_deleted": { "type": "boolean" },
      "created_at": {
        "type": "date",
        "format": "yyyy-MM-dd HH:mm:ss"
      },
      "location": { "type": "geo_point" }
    }
  }
}

Query Context和Filter Context

在学习具体查询语法之前,我们还需要先了解QueryDSL中的两个非常重要但容易被混淆的概念,Query Context(查询上下文)Filter Context(过滤上下文)

Query Context:用于计算相关性评分和排序,回答“这个文档匹配这次查询的程度”,评分越高说明越相关,查询结果默认按评分降序排列。matchmulti_match等全文检索查询通常都运行在Query Context中。

Filter Context:不计算评分而是做匹配过滤,适合精确过滤场景,例如termrangeexists等。Filter Context可被ES缓存,性能更高。

从实际使用角度来说,在QueryDSL中直接写的查询语句属于Query Context;在bool查询的filtermust_not子句下写的属于Filter Context,某些场景下,一个条件可能放进Query Context和Filter Context表现类似,但二者绝不能混为一谈,在实际开发中,我们通常会将需要模糊检索的放进Query Context,将不需要参与打分的条件(比如状态过滤、时间范围过滤)放到filter中,这样我们编写的QueryDSL才是语义准确且高性能的。

Match Query

Match Query是最常用的全文检索查询,它会对查询词进行分词后与倒排索引匹配,适合搜索text类型字段。Match Query最基础的用法如下。

GET /products/_search
{
  "query": {
    "match": {
      "name": "华为手机"
    }
  }
}

如上查询,ES会将“华为手机”经过IK分词器处理拆分为“华为”、“手机”等词条,然后查找包含这些词条的文档,默认只要匹配其中任意一个词条就会返回。当然,这也意味着查询结果中“华为手机”会排在最前面,但“华为电脑”、“苹果手机”仍会出现在返回结果中的靠后位置。

如果希望文档必须同时包含所有词条,可以设置operatorand,下面这个查询语句意味着产品的名字必须同时包含“华为”和“手机”才会出现在结果列表中。

GET /products/_search
{
  "query": {
    "match": {
      "name": {
        "query": "华为手机",
        "operator": "and"
      }
    }
  }
}

此外我们还可以通过minimum_should_match控制最少需要匹配的词条数或比例。

GET /products/_search
{
  "query": {
    "match": {
      "description": {
        "query": "旗舰 降噪 拍照 续航",
        "minimum_should_match": "75%"
      }
    }
  }
}

上面的查询要求文档至少匹配4个词条中的75%,即至少匹配3个词条,数据记录才会出现在返回结果中。

Multi Match Query

Multi Match Query是Match Query的扩展版本,它允许同时在多个字段上执行全文检索,适合应用顶部那个“搜索框”类型的通用搜索场景。下面例子中,我们用Multi Match Query同时对产品的名字和描述字段做全文检索。

GET /products/_search
{
  "query": {
    "multi_match": {
      "query": "降噪耳机",
      "fields": ["name", "description"]
    }
  }
}

此外,我们还可以使用^符号对特定字段进行boost权重加成,让某个字段命中时得到更高的评分。下面例子中,我们认为产品的名字更重要,因此提升了它的权重。

GET /products/_search
{
  "query": {
    "multi_match": {
      "query": "降噪耳机",
      "fields": ["name^3", "description"]
    }
  }
}

name^3表示name字段的权重是description的3倍,命中商品名称的文档会排得更靠前,这在业务中非常实用,例如搜索关键词出现在标题里通常比出现在描述里更相关。

Multi Match Query还支持多种匹配模式设置,这可以通过type参数指定。

type 说明
best_fields(默认) 取得分最高字段的分数,适合字段之间互相竞争的场景
most_fields 匹配字段越多分越高
cross_fields 将多个字段视为一个整体,适合姓名、地址等被拆分到多个字段的场景
phrase 对每个字段做短语匹配

默认情况下,Multi Match Query使用best_fields模式,也就是说如果我们检索两个字段namedescription,那么score = max(name_score, description_score),这和后面介绍的Bool Query中用should做多次Match Query是不同的,后者的计算逻辑是score = name_score + description_score,这里要注意区分。一般来说,简单的多字段搜索用Multi Match Query就行了,它只匹配一次,因此性能也更好。

Term Query

Term Query是精确匹配查询,它不对查询词做任何分词处理,直接与字段值或倒排索引中的词条进行精确比对。

注意这和数据库SQL的LIKE查询是完全不同的,首先Term Query不是模糊匹配(ES中类似LIKE查询做子串模糊匹配对应的是Wildcard Query),如果用Term Query查询的是keyword那么它们的值必须精确的相等才会匹配;此外虽然Term Query不对查询词做分词,但被查询的可以是分词后的倒排索引,此时Term Query将试图精确的匹配某个分词值。不过从实际开发角度来说,用Term Query对分词后的text字段进行检索可能没有实际意义,因此Term Query一般都被用于keywordintegerboolean等不需要分词的字段。

GET /products/_search
{
  "query": {
    "term": {
      "brand": {
        "value": "华为"
      }
    }
  }
}

前面我们介绍过Query Context和Filter Context的区别,实际上我们仔细想想就会发现Term Query天然适合在Filter Context中使用,放入filter子句可以避免不必要的评分计算。

GET /products/_search
{
  "query": {
    "bool": {
      "filter": [
        { "term": { "brand": "华为" } }
      ]
    }
  }
}

Term Query如果需要同时精确匹配多个值,可以使用terms

GET /products/_search
{
  "query": {
    "terms": {
      "category": ["手机", "平板"]
    }
  }
}

Range Query

Range Query用于范围查询,支持数值、日期等类型的范围筛选。下面例子我们实现了按价格范围查询。

GET /products/_search
{
  "query": {
    "range": {
      "price": {
        "gte": 2000,
        "lte": 7000
      }
    }
  }
}

Range Query支持的参数有gt(大于)、gte(大于等于)、lt(小于)、lte(小于等于)。

当按日期范围查询时,注意日期格式需与Mapping中定义的format一致。

GET /products/_search
{
  "query": {
    "range": {
      "created_at": {
        "gte": "2024-02-01 00:00:00",
        "lte": "2024-03-31 23:59:59"
      }
    }
  }
}

Range Query同样适合放在filter子句中执行,避免计算评分并利用缓存加速。

Bool Query

Bool Query是QueryDSL中最核心也最强大的查询,它允许将多个查询条件组合起来,因此Bool Query也是构建复杂业务查询的基础。Bool Query包含以下4个子句,具体用法可以参考如下表格。

子句 说明 影响评分 上下文
must 必须匹配,等同于 AND Query Context
should 应该匹配,等同于 OR,可提升评分 Query Context
filter 必须匹配,等同于 AND Filter Context
must_not 必须不匹配,等同于 NOT Filter Context

下面例子中我们实现了一个典型的复杂业务查询场景:搜索关键词“旗舰手机”,品牌只看华为和苹果,价格在5000到10000之间,同时排除已删除的商品。

GET /products/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "multi_match": {
            "query": "旗舰手机",
            "fields": ["name^2", "description"]
          }
        }
      ],
      "filter": [
        {
          "terms": {
            "brand": ["华为", "苹果"]
          }
        },
        {
          "range": {
            "price": {
              "gte": 5000,
              "lte": 10000
            }
          }
        },
        {
          "term": {
            "is_deleted": false
          }
        }
      ]
    }
  }
}

这里的查询结构非常典型,全文检索放在must里计算相关性评分,而品牌过滤、价格区间、逻辑删除过滤这些固定条件都放在filter里。

此外,should子句在Bool Query中的行为需要特别说明:如果Bool Query中已经有mustfilter子句,should是可选匹配的,匹配到了可以提升评分,匹配不到也不影响文档是否出现在结果中;但如果Bool Query中只有should子句,则文档至少需要匹配其中一个条件才会被返回。下面例子中,查询手机时,带有“5G”标签的产品会优先展示,而非仅展示标签中有“5G”的数据。

GET /products/_search
{
  "query": {
    "bool": {
      "must": [
        { "term": { "category": "手机" } }
      ],
      "should": [
        { "term": { "tags": "5G" } }
      ],
      "filter": [
        { "term": { "is_deleted": false } }
      ]
    }
  }
}

Prefix Query

Prefix Query前缀查询用于匹配以指定字符串开头的词条,常用于keyword字段的前缀搜索场景,例如根据品牌前缀查找。

GET /products/_search
{
  "query": {
    "prefix": {
      "brand": {
        "value": "华"
      }
    }
  }
}

注意ES允许Prefix Query被用于text类型字段,但它没有实际意义,理由和Term Query相同。此外Prefix Query需要遍历索引中所有以该前缀开头的词条,在词条数量非常多的字段上性能较差,生产环境中如果高频使用前缀搜索,建议在Mapping中额外为该字段开启index_prefixes参数,ES会提前预构建前缀索引以加速查询。

Wildcard Query

Wildcard Query通配符查询类似数据库SQL语句的LIKE查询,它支持*(匹配任意多个字符)和?(匹配单个字符)两种通配符,适合模糊匹配场景,下面是一些例子。

GET /products/_search
{
  "query": {
    "wildcard": {
      "brand": {
        "value": "华*"
      }
    }
  }
}
GET /products/_search
{
  "query": {
    "wildcard": {
      "name.keyword": {
        "value": "*Pro*"
      }
    }
  }
}

*开头的通配符查询会触发全词条扫描,在数据量大的索引上可能非常慢,生产环境中应尽量避免以通配符开头的查询,或限制只允许后缀通配符,必要时还是应考虑改用match全文检索来替代。

Fuzzy Query

Fuzzy Query模糊查询允许查询词与实际词条存在一定的编辑距离(Edit Distance)。Fuzzy Query用于纠错容错场景,例如实现用户输入了错别字时仍然能搜到正确结果。

GET /products/_search
{
  "query": {
    "fuzzy": {
      "brand": {
        "value": "苹里",
        "fuzziness": 1
      }
    }
  }
}

fuzziness表示允许的最大编辑距离(插入、删除、替换、换位的操作次数),设置为 AUTO时ES会根据词条长度自动决定允许的编辑距离,也可以手动指定为012,数字越大越模糊。

模糊查询同样存在性能隐患,实际业务中应结合prefix_length参数限制不参与模糊匹配的前缀字符数。

GET /products/_search
{
  "query": {
    "fuzzy": {
      "brand": {
        "value": "苹里",
        "fuzziness": 1,
        "prefix_length": 1
      }
    }
  }
}

Regexp Query

Regexp Query正则查询支持使用正则表达式匹配keyword字段中的词条。

GET /products/_search
{
  "query": {
    "regexp": {
      "brand": {
        "value": "华.+"
      }
    }
  }
}

正则查询还是和通配符查询一样,需要遍历词条列表逐一匹配,性能很差,因此生产环境中通常需要严格限制使用场景或仅在数据量可控的小索引中使用。

Exists Query

Exists Query用于判断某个字段是否存在有效值,所谓存在有效值指的是字段存在且不为null,这里注意如果是空字符串仍算存在有效值,exists认为其“存在”,这里不要混淆。下面例子中,我们查询description不为空的数据。

GET /products/_search
{
  "query": {
    "exists": {
      "field": "description"
    }
  }
}

如果你想查询description为空的字段,可以配合must_not实现。

GET /products/_search
{
  "query": {
    "bool": {
      "must_not": [
        {
          "exists": {
            "field": "description"
          }
        }
      ]
    }
  }
}

排序

默认情况下ES按相关性评分_score降序排列,如果我们需要按特殊字段排序,也可以通过sort参数自定义排序规则,指定了自定义排序后_score将不再参与排序。排序字段可以指定多个,多个排序字段按数组顺序依次生效,前面的排序值相同时才会比较后面的字段。

GET /products/_search
{
  "query": {
    "bool": {
      "filter": [
        { "term": { "category": "手机" } },
        { "term": { "is_deleted": false } }
      ]
    }
  },
  "sort": [
    { "price": { "order": "asc" } },
    { "created_at": { "order": "desc" } }
  ]
}

如果需要同时兼顾相关性评分和业务字段排序,也可以将_score显式放入排序数组中。

"sort": [
  { "_score": { "order": "desc" } },
  { "price": { "order": "asc" } }
]

分页查询

ES提供了多种分页方式,这里我们分别介绍一下。

from + size分页

from + size分页是最基础的分页方式,它类似于MySQL的LIMIT offset, sizefrom指定起始位置(从第0条开始),size指定返回条数,下面是一个例子。

GET /products/_search
{
  "query": {
    "bool": {
      "filter": [
        { "term": { "is_deleted": false } }
      ]
    }
  },
  "from": 0,
  "size": 3
}

这种分页方式简单直观但有一个重要限制,from + size不能超过index.max_result_window,它的默认值为10000。如果查询深分页(例如跳转到第1000页)性能会急剧下降,这是因为ES需要在每个分片上取出前from + size条文档后再统一合并排序,代价极高。

search_after分页

search_after是ES中推荐的深分页方案,它基于上一页最后一条文档的排序值作为游标实现向后“翻页”,适合“下一页”这类线性翻页场景。

不过这里有一个乍看让人很费解的历史遗留问题,在ES早期版本中,search_after分页一般直接使用_id作为排序值向后翻页,但在新版本ES中这种做法被禁止了,其根本原因在于ES中的_id字段本身不是排序结构,对其排序需要先加载到内存构建FieldData,数据量巨大时有让整个集群卡死的风险。新版本ES中,一种解决方案是在存入数据时指定业务生成的唯一ID,但这种做法可能不太友好,ES原生给出替代_id排序的方案是结合使用PIT查询和_shard_doc

PIT:PIT是ES提供的“时间点视图”机制,它本质是对索引数据在某一时刻状态的轻量级快照,用于保证跨多次查询的结果一致性。创建PIT后,后续所有基于该PIT的查询都会看到完全相同的数据版本,不受索引增删改操作影响。PIT创建时需要指定超时时间,过期的PIT将被自动删除。

_shard_doc_shard_doc是PIT查询中自动生成的专用排序字段,它用于作为最终的排序决胜器(tie-breaker)。在同个PIT内,每个文档的_shard_doc值全局唯一且不会变化,因此可以在一个PIT内它可以替代_id作为排序字段。

具体查询时,首先我们得创建PIT,创建PIT需要指定超时时间,我们这里设置为5分钟。

POST /products/_pit?keep_alive=5m

上面执行后ES会返回PIT的ID,我们后续还需要它。下面我们指定PIT查询,PIT查询不必在URL中指定索引名,但需要在请求体中带上PIT的ID,此外我们查询时限制了返回结果为3条,以及_shard_doc作为排序字段之一。

GET /_search
{
  "pit": {
    "id": "ucyMBAEIcHJvZHVjdHMWOUZpVkVuQjZSR1NyLVM3MHN5MnZndwAWYjhudHIwUi1UTmFpcUNEQUhXTi12UQAAAAAAAAAg1BZRTXI2Vi11eVJ0V01MN0pMNlRDbFd3AAEWOUZpVkVuQjZSR1NyLVM3MHN5MnZndwAA", 
    "keep_alive": "5m"
  },
  "size": 3,
  "query": {
    "bool": {
      "filter": [
        { "term": { "is_deleted": false } }
      ]
    }
  },
  "sort": [
    { "price": "asc" },
    { "_shard_doc": "asc" }
  ]
}

下面我们执行“下一页”查询,这需要在拿到前面的结果后,取最后一条文档的sort值,放入下一次请求的search_after参数,这样就能拿到[4999, 3]之后的3条数据,即“下一页”数据。

GET /_search
{
  "pit": {
    "id": "ucyMBAEIcHJvZHVjdHMWOUZpVkVuQjZSR1NyLVM3MHN5MnZndwAWYjhudHIwUi1UTmFpcUNEQUhXTi12UQAAAAAAAAAg1BZRTXI2Vi11eVJ0V01MN0pMNlRDbFd3AAEWOUZpVkVuQjZSR1NyLVM3MHN5MnZndwAA", 
    "keep_alive": "5m"
  },
  "size": 3,
  "query": {
    "bool": {
      "filter": [
        { "term": { "is_deleted": false } }
      ]
    }
  },
  "sort": [
    { "price": "asc" },
    { "_shard_doc": "asc" }
  ],
  "search_after": [4999, 3]
}

高亮搜索

高亮搜索是个很实用的功能,它会在返回结果中将命中关键词用指定HTML标签包裹,常用于搜索结果页面展示。

GET /products/_search
{
  "query": {
    "multi_match": {
      "query": "旗舰手机",
      "fields": ["name", "description"]
    }
  },
  "highlight": {
    "pre_tags": ["<em>"],
    "post_tags": ["</em>"],
    "fields": {
      "name": {},
      "description": {
        "fragment_size": 100,
        "number_of_fragments": 1
      }
    }
  }
}

fragment_size用于控制每个高亮片段的字符数,number_of_fragments控制返回的片段数量。返回结果中会包含highlight字段,其中关键词会被<em>标签包裹,前端直接渲染即可。上面例子中,搜索结果中的“旗舰手机”、“旗舰”和“手机”都会被<em>包裹(假设你的IK分词器工作正常)。

打分规则详解

BM25算法简介

ES默认使用BM25算法计算相关性评分,它在经典的TF-IDF基础上做了改进,是目前全文检索领域的主流算法之一。BM25评分主要受以下几个因素影响:

词频(TF,Term Frequency):查询词在文档中出现次数越多评分越高,但BM25对词频有饱和处理,重复出现的收益是递减的,避免了关键词堆砌带来的评分虚高。

逆文档频率(IDF,Inverse Document Frequency):查询词在所有文档中出现得越少,这个词的区分度越高,IDF值越大,评分贡献越高。例如“手机”在商品索引中几乎每个文档都有,IDF值低;而“降噪”只出现在少数文档中,IDF值高,命中时得分更高。

字段长度归一化:文档字段越短,相同词频下评分越高,因为短字段中关键词占比更大、更相关。

实际开发中,我们可以通过explain参数查看一条文档的详细打分过程以辅助调试和进行查询优化。

GET /products/_search
{
  "explain": true,
  "query": {
    "match": {
      "name": "旗舰手机"
    }
  }
}

返回结果中每条文档会附带_explanation字段展示完整的评分树。

使用boost调整权重

boost参数可以手动调整某个查询子句的权重,boost大于1表示提升权重,小于1表示降低权重。下面例子中,我们设置查询中name字段的权重更高。

GET /products/_search
{
  "query": {
    "bool": {
      "should": [
        {
          "match": {
            "name": {
              "query": "旗舰手机",
              "boost": 3
            }
          }
        },
        {
          "match": {
            "description": {
              "query": "旗舰手机",
              "boost": 1
            }
          }
        }
      ],
      "filter": [
        { "term": { "is_deleted": false } }
      ]
    }
  }
}

boost还有个简化写法,其中{"match": {"name": {"query": "旗舰手机", "boost": 3}}}等价于{"match": {"name^3": "旗舰手机"}}

function_score实现业务排序与相关性融合

实际开发中,纯相关性排序有时还不够用,我们可能希望将业务指标(如销量、评分、库存、时效性)融入排序。function_score查询允许在原始评分基础上叠加自定义的评分函数。

GET /products/_search
{
  "query": {
    "function_score": {
      "query": {
        "bool": {
          "must": [
            {
              "multi_match": {
                "query": "手机",
                "fields": ["name^2", "description"]
              }
            }
          ],
          "filter": [
            { "term": { "is_deleted": false } }
          ]
        }
      },
      "functions": [
        {
          "field_value_factor": {
            "field": "stock",
            "factor": 0.1,
            "modifier": "log1p",
            "missing": 1
          }
        },
        {
          "gauss": {
            "created_at": {
              "origin": "now",
              "scale": "30d",
              "decay": 0.5
            }
          }
        }
      ],
      "score_mode": "sum",
      "boost_mode": "multiply"
    }
  }
}

上面例子中,field_value_factor根据库存量给文档加分,库存越多加分越多,但用额外用了log1p平滑避免差距过大;gauss是高斯衰减函数,越新的商品评分越高,30天前的商品评分会衰减到0.5。score_mode控制多个function之间如何合并,boost_mode控制function计算结果与原始查询评分如何结合。

作者:Gacfox
版权声明:本网站为非盈利性质,文章如非特殊说明均为原创,版权遵循知识共享协议CC BY-NC-ND 4.0进行授权,转载必须署名,禁止用于商业目的或演绎修改后转载。