☰
Elasticsearch复合查询全解析:bool、function_score与性能调优实战
2026/10/20 9:43:59 网站建设 项目流程

1. 复合查询的定位与核心应用场景

先说个我实际遇到的场景:某电商平台的搜索团队找我排查问题,说他们用 ElasticSearch 做了“笔记本电脑”的搜索,结果返回的商品里有大量“鼠标垫”和“键盘膜”,原因很简单——他们从前到后只用了match查询。这就是教科书级别的“单查询撑不起复杂业务需求”的典型例子。你在一个字段上做关键词匹配,没有上下文、没有权重控制、没有过滤条件,搜索引擎自然把相关性算得一团糟。

复合查询的核心价值,就是帮你在一次请求里把多个简单查询组合起来,并且精细控制它们之间的逻辑关系和权重分配。它是 DSL 查询里最接近“业务逻辑表达”的部分——你要找什么、必须满足什么、最好满足什么、哪些结果可以加分、哪些结果必须被排除,这些通通可以用复合查询在检索阶段一次搞定。很多初学者会用多个bool嵌套来硬凑,结果发现性能不行、算分不受控,这其实是因为没有理解复合查询家族里每个成员的设计意图。

在 ElasticSearch 中,复合查询家族主要有这么几号成员:bool、boosting、constant_score、dis_max,以及功能最复杂也最强大的function_score。它们各有各的脾气。bool负责组合多个条件并保留相关性算分;constant_score适合“只过滤不参与算分”的场景,把所有匹配文档统一为固定分值;boosting可以对某些不想要但又不该直接排除的文档做降权处理;dis_max解决的是“多个字段任其一个匹配”的最佳匹配场景;function_score则允许你用业务指标(比如销量、时间、库存状态)直接干预算分。这篇文章我会把这些查询逐一拆开,配合实际可运行的请求体,把 查询串起来讲透。

适合读这篇文章的,是已经掌握match、term、range等叶子查询基础语法,但一碰到组合查询就不知道怎么设计算分逻辑的开发者。如果你对bool和filter的算分差异还有点模糊,或者想知道function_score的多种打分函数到底该怎么选,那这篇文章就是为你准备的。

2. Bool 查询:复合查询的基石

2.1 四个子句的职责划分

bool查询是复合查询里出场率最高的,几乎可以说只要涉及“多个条件”,你的第一反应就应该是它。bool支持四个子句,分别承担不同的逻辑职责,这一点很像 SQL 里的AND、OR、NOT,但算分机制完全不同。

  • must:文档必须满足这些条件,并且参与相关性算分。对应 SQL 里的AND,正常查“价格大于100”的笔记本你会把它放这里。
  • must_not:文档必须不满足这些条件,不参与算分。比如排除“已下架”的商品。
  • should:文档可以满足也可以不满足,但满足的会增加算分。通常用来做“软加分项”,比如指出商品标题描述里出现了某个词就额外加分。
  • filter:文档必须满足,但不参与算分,使用缓存机制来提升性能。适用于“价格区间”“状态筛选”这类对评分无关的条件。

这里最容易被忽视也最重要的是:must和filter在逻辑上都是“必须满足”,区别只在于是否参与算分。很多开发者查自己想筛选的条件通通放到must里,结果相关性打分被一堆筛选条件稀释,最终排序结果很难看,而且性能也受到影响。我见过有团队搜索“固态硬盘 1TB”,本来应该按销量排,结果搜索的结果里夹着大量贴着“1TB 内存”描述的旧款商品,用户一眼就看出搜索功能不对劲。

2.2 算分逻辑与实操示例

先看一个基础场景:在一个图书索引里搜“分布式系统”,要求必须是已上架状态,并且书评数大于20。如果只要求“必须满足”,用filter就好:

GET /books/_search { "query": { "bool": { "must": [ { "match": { "title": "分布式系统" } } ], "filter": [ { "term": { "status": "published" } }, { "range": { "review_count": { "gte": 20 } } } ] } } }

这样match子句负责产出相关性分数,filter里的条件则纯粹是硬性门槛,即使它们命中也不会干扰_score。这里有个重要的细节:filter里的子查询会被 ElasticSearch 缓存起来,所以同一组过滤条件反复使用时,查询性能会比塞进must里的版本好不少。我自己实测,在一个千万级文档的索引里,同样是查询过滤条件,filter的走缓存路径的响应时间能稳定优化 20% 以上,随着过滤条件复杂度上升这个差别还会拉大。

再看需要软加分的场景。你想搜“Java 并发编程”,标题里精确包含“并发”这个词的文档要排前面,但内容里提到“并发”也应返回,只是分值低一些:

GET /books/_search { "query": { "bool": { "must": [ { "match": { "title": "Java" } } ], "should": [ { "match": { "title": "并发" } }, { "match": { "description": "并发" } } ], "minimum_should_match": 1 } } }

注意这里我加了minimum_should_match。在没有must或filter的情况下,should是“至少满足一个”的关系,默认值就是 1;但一旦bool查询里同时存在must,should就变成纯粹的加分项,默认不再要求必须命中。这个行为差异让很多新手上头——明明加了should希望它起过滤作用,结果它只是默默加分。要强制至少命中一个should,就必须显式设置minimum_should_match。

2.3 多层嵌套与性能陷阱

bool查询可以无限嵌套,这是它灵活性的来源,也是性能失控的根源。我接手过一个慢查询案例,一段bool查询嵌套了五六层,加上各种should和filter,单次查询耗时从 50ms 飙升到 1800ms。排查下来发现,内层should里放了大量高基数的term查询——比如给每个商品标签建一个term条件,一次查询几百个标签。这种写法的开销来自每个term对倒排索引的独立遍历和合并,嵌套结构会把合并成本指数级放大。

实际项目中我会有几条原则:能不嵌套就不嵌套,能用filter就绝不放must,should里的条件数量控制在 3 个以内,超过就考虑用function_score来做加权。还有一个技巧:如果多个条件本质上属于同一个字段的范围过滤,尽量用一个range合并,而不是拆成多个子句。这不只影响性能,也会让查询的可读性提高不少。

3. Boosting、Constant Score 与 Dis Max 的差异化用法

3.1 Boosting 查询:降权比排除更优雅

业务里经常有这种需求:某些文档不能排除,因为用户可能会需要;但它们确实不是最佳匹配。举个例子,商品搜索“iphone”,结果里有“iphone 保护壳”也有“iphone 15 手机”,直接排除保护壳可能伤召回率,不处理又影响排序。boosting查询就是为这种场景设计的。

boosting的语法结构有三部分:positive是正常参与算分的查询,negative是命中了就降权的查询,negative_boost控制降权幅度。看一个例子:

GET /products/_search { "query": { "boosting": { "positive": { "match": { "title": "iphone" } }, "negative": { "match": { "category": "accessory" } }, "negative_boost": 0.2 } } }

这段查询里,所有标题含“iphone”的商品都能拿到基础分,但如果商品分类是配件,总分就乘以 0.2。这个“降权乘法”和funcion_score里的加权思路不同:boosting直接把negative命中的文档分数压到很低,但又不会归零,适合“备胎型结果”的排序策略——排到后面但不至于彻底消失。

还有个容易忽略的点:boosting查询的positive和negative都是完整的查询子句,不是简单关键词。所以negative也可以是复合查询,比如“分类是配件并且评分低于 4 分”才触发降权。这种写法比单纯匹配一个字段更能反映真实业务规则。

3.2 Constant Score:把相关性计算彻底关掉

如果查询条件完全不关心相关度排序,只想要一个固定分值的结果集,constant_score就是最直接的工具。它的用法非常简单,内部只有一个filter和一个可选的boost:

GET /orders/_search { "query": { "constant_score": { "filter": { "term": { "status": "completed" } }, "boost": 1.5 } } }

所有命中filter的文档,_score统一变成 1.5。这有什么用?最常见的一种场景是“这个查询作为更大复合查询的一个分支”。比如在一个bool的should里放两个条件,一个是普通match,另一个是constant_score包装的过滤条件,那后者就变成一个“命中则固定加分”的开关,而不引入任何算分噪音。这比用match+boost控制起来更干净,因为match会引入词频、文档长度等一堆变量,你很难精确控制它加的分值是多少。

constant_score的另一个隐形好处是:因为不需要计算相关性,它可以完整利用filter的缓存机制。在日志检索、状态统计这类场景里,能用constant_score就不用裸term查询,性能差异在数据量上来后才明显。

3.3 Dis Max:多字段匹配的“取最大值”

先回忆一个经典需求:搜索“elasticsearch”,文档的title字段和body字段都命中了这段文本,但一个标题只有 5 个词,一个正文有 5000 词。按默认的bool+should组合算分,长文档的分数会被极大的字段长度拉低,导致标题匹配的短文档排到了正文匹配的长文档后面——但用户明显更希望标题命中的排前面。

dis_max(disjunction max query)就是来解决这个问题的,不多做复杂的分值融合,直接取多个子查询里分数最高的那个作为文档总分。配合tie_breaker参数,可以把其他字段的匹配分数按一定比例叠加进来:

GET /articles/_search { "query": { "dis_max": { "queries": [ { "match": { "title": "elasticsearch" } }, { "match": { "body": "elasticsearch" } } ], "tie_breaker": 0.3 } } }

tie_breaker的取值范围是 0 到 1,0 表示完全不叠加,就是纯取最高分;0.3 表示除了最高分,再额外加上其他查询分值的 30%。这样既可突出“标题命中”的主导性,又保留正文命中的额外贡献。这个参数非常微妙,调得太高又会回到bool查询的融合逻辑,调得太低会忽略掉正文命中的价值。我一般建议从 0.2 或 0.3 起步,再通过评测结果微调。

dis_max适合的场景是“多个字段表达同一个维度”——比如文章的标题、摘要、正文、关键词标签,各不相同但又都描述主题。如果几个字段的表达内容差异很大(比如价格字段和颜色字段),那用dis_max就没有意义,还是老老实实回到bool的组合逻辑。

4. Function Score:用业务数据干预排序权重

4.1 先理解评分干预的思维转变

function_score是整个复合查询家族里最值得花时间研究的成员,因为它允许你基于业务指标——销量、评分、发布时间、库存状态——对文档进行细粒度的动态加权。前面的bool、constant_score解决的是“匹配逻辑”问题,function_score解决的是“价值排序”问题。

不过刚开始接触的时候,我确实觉得function_score的算分逻辑比较复杂。它本质上是一个可以包裹任意查询的容器,内部的打分函数有weight、random_score、field_value_factor、script_score,以及复杂的gauss、linear、exp三种衰减函数。你还可以指定boost_mode和score_mode来控制函数产出分值与原始查询分值的合并方式。听起来繁重,但理解一个核心原则后就不慌了:所有这些都是把“业务规则”翻译成分值,然后跟原有的相关性得分合并。

放在电商场景里举例:一个搜“咖啡机”的查询,match已经为每款咖啡机算出了相关性分。现在你希望销量高的排在前面一点,于是用field_value_factor读取sales_count字段,把销量值映射成一个加分项。但是直接拿销量原始值参与乘法会有问题——一款销量 10 万的产品会把相关性分压到完全不存在,所以必须引入log2p之类的平滑函数,把大数值压缩到可控范围。

4.2 Field Value Factor 的实际配置过程

来看一个直接能跑的例子。商品搜索“蓝牙耳机”,希望在相关性基础上按用户评分加权:

GET /products/_search { "query": { "function_score": { "query": { "match": { "title": "蓝牙耳机" } }, "field_value_factor": { "field": "rating", "factor": 2.5, "modifier": "log2p", "missing": 0.1 }, "boost_mode": "multiply", "score_mode": "multiply" } } }

field指定字段;factor是额外放大系数;modifier负责对字段值做数学变换,常用值有none(不处理)、log2p(计算 log(1+x))、ln1p(计算 ln(1+x))、sqrt;missing处理字段缺失的情况,避免拿空值算分导致报错或结果异常。boost_mode决定最终文档分值是函数分乘上查询分(multiply),还是两分相加(sum),又或者是直接用函数分替换查询分(replace)。

这里我特别强调modifier的选择:不加任何变换的时候,一个在 0 到 5 之间的rating值直接参与乘法,会让分数区间拉得非常大,评 4.9 和评 4.8 的差距都会被放大成显著的排序差异,这在很多场景中并不合理。用log2p这类平滑变换后,分值的边际差异变得缓和,排序结果更自然。具体选哪种 modifier,取决于字段分布和业务上对“差异敏感度”的预期,需要多试几组对比。

4.3 Weight 与 Script Score 的补充作用

weight是function_score里最简单的打分函数,其实就是一个常量分值。作用跟constant_score的boost类似,但可以叠加多个 weight 函数形成组合加权,比bool里堆should更直观:

GET /recruit/_search { "query": { "function_score": { "query": { "match": { "job_title": "Java 开发" } }, "functions": [ { "filter": { "term": { "city": "北京" } }, "weight": 5 }, { "filter": { "range": { "salary": { "gte": 20000 } } }, "weight": 3 } ], "score_mode": "sum", "boost_mode": "sum" } } }

这个例子是招聘网站的职位搜索。工作地点在北京加 5 分,薪资两万以上加 3 分。functions数组里的每个元素都可以带一个filter,只有命中了对应过滤条件的文档才应用该权重。这在语义上非常清晰:业务加分项集中在一个数组里,想调整某个权重只改数字就行,不需要重新拆解嵌套的bool。

script_score提供的是无限制的自由度,可以用 Painless 脚本访问文档字段做任意运算。举个例子,库存量越少排名越靠前,用来刺激稀缺性:

GET /products/_search { "query": { "function_score": { "query": { "match_all": {} }, "script_score": { "script": { "source": "doc['stock'].value > 0 ? (1 / doc['stock'].value) : 0" } } } } }

这里有个明显的坑:script_score是在每个文档上执行脚本,无法利用倒排索引和缓存。如果索引里有千万级文档,这么搞会把查询拖到不可接受的性能。所以我的原则是:能用field_value_factor解决,就别上script_score;必须用脚本时,尽量限制查询条件先把候选文档过滤到很小的集合内,再用function_score包裹。

4.4 衰减函数:距离与时间的优雅建模

还有一类业务加权,普通函数很难精确表达,比如“离我越近的店越靠前”“发布越新越靠前”。这类需求用gauss、linear、exp衰减函数来建模最合适。以地理位置为例:

GET /stores/_search { "query": { "function_score": { "query": { "match": { "category": "咖啡店" } }, "functions": [ { "gauss": { "location": { "origin": { "lat": 39.9, "lon": 116.4 }, "offset": "2km", "scale": "5km" } } } ], "boost_mode": "sum" } } }

origin是基准点,offset是“不受衰减影响的距离范围”,scale是衰减速度的参考尺度。在offset内,函数分值保持在高位;超出offset后,分值按高斯曲线迅速下降。理解成“地理位置的温柔惩罚机制”就很容易把握。所谓衰减,就是在偏离目标点的过程中,分数缓缓地掉,但不会一下子消失。gauss适用于缓和的过渡,linear线性下降更直接,exp下降最快,选择标准完全看业务对“距离敏感度”的要求。

衰减函数里最容易踩的坑是字段类型不匹配。gauss要求目标字段是带geo_point映射的类型,传普通字符串经纬度会直接报错。另外origin、offset、scale的单位必须与字段定义一致,geo_point字段用km,date字段用时间单位d、h、m之类的,混用必然解析失败。

5. 复合查询的性能调优与排查实录

5.1 慢查询排查的思路

先列一个真实的排查流程,希望能给你一些启发。有个同事反馈某个聚合统计接口从 200ms 慢慢涨到了 3s,数据量并没有暴涨。本地复现后我先把原查询拆开,发现它的骨架是一个bool查询嵌套了三层:外层filter放时间范围,中层should放了三组match,内层又包了一个function_score,里面有超过 20 个weight函数。问题根源不在某一条查询上,而是function_score内部的 20 个函数让每个候选文档都要跑一遍完整的加权计算。

我的排查思路第一永远是“从候选集规模入手”。每个filter子句都在缩小候选集,但function_score和script_score必须在候选集上逐文档执行。如果你想复杂评分只作用于 100 个文档,那就应该先用bool+filter把检索范围收敛到几百,再套function_score;反过来的话,20 个函数乘以 100 万候选集,性能不出问题才奇怪。复合查询的常见性能问题,一大半都是因为执行顺序和候选集收缩策略没有设计好。

另一个特定的排查方法是启用 Profile API。ElasticSearch 自带_profile接口,能输出每个子查询的耗时、命中数和分布式开销。我看到不少开发者不知道有这工具,还在靠肉眼猜哪条查询慢。开启方式非常简单,在原来的查询请求上增加"profile": true即可,结果里会按阶段拆解耗时明细。一般来说,build_scorer阶段耗时高说明评分逻辑复杂,advance阶段耗时高说明候选集大。

5.2 算分问题的调试手册

复合查询的另一个疑难点是算分结果不符合预期。我总结了三类常见问题:

第一类是“为什么明明加了should条件,排序没有任何变化”。这通常是因为bool里存在must子句,should降级成了可选加分,而minimum_should_match又没有显式设置。解决方式是按需设置minimum_should_match,或者把应该强制满足的条件从must移到filter,让should真正参与评分占比。

第二类是“某个字段值很大,直接压过了其他所有字段”。比如function_score里不加modifier就用原始销量做乘法,一个爆款商品的销量值可以让所有普通商品常年站不上搜索结果第一页。处理方式前面提过,选一个合适的modifier,或者改用boost_mode: "sum"让函数分不直接跟查询分相乘,避免极端值主导排序。

第三类是“结果集合里的_score全是 1.0”。这多半是在constant_score或filter上下文中执行了查询,文档分反正不会计算。如果你希望看到梯度明显的排序差异,就别把所有条件都塞进filter,让一部分匹配条件走must或should。

5.3 一个贴近实际的综合案例

我最后整理了一个比较综合的案例,串起前文说过的所有知识点。某跨境商城要做“数据线”搜索,需求条件是:标题必须命中“数据线”;上架状态必须为在售;品牌是自有品牌的加权重;销量高的适当提前;近 30 天有出过单的优先;但“已下架”的不能展示。

对应查询可以这样组织:

GET /products/_search { "query": { "function_score": { "query": { "bool": { "must": [ { "match": { "title": "数据线" } }, { "term": { "status": "on_sale" } } ], "should": [ { "term": { "brand": "自有品牌" } } ], "must_not": [ { "term": { "status": "off_shelf" } } ] } }, "functions": [ { "filter": { "term": { "brand": "自有品牌" } }, "weight": 8 }, { "filter": { "range": { "sales_count": { "gte": 1000 } } }, "field_value_factor": { "field": "sales_count", "modifier": "log2p", "factor": 0.5 } }, { "gauss": { "last_order_date": { "origin": "2024-11-01", "offset": "7d", "scale": "30d" } } } ], "boost_mode": "sum", "score_mode": "sum" } } }

这里的设计思路是:先由bool保证基本相关性,三个function各自负责一层业务加权——品牌是固定加分,销量是平滑加分,最近出单时间是衰减加分。score_mode设为sum,把三个函数分直接相加,boost_mode再设为sum,让总函数分叠加到查询分上,而不是乘到查询分上。这样设计的理由在于:这个场景业务权重不应该互相放大,销量极高的产品不应把品牌权重挤到可有可无,大家是各加各的分。

我实际跑过类似查询,跟最初的版本比排序合理性明显提升,搜索“数据线”时自有品牌且销量稳定的商品排到了前面,30 天无出单的长尾商品虽然还在结果集合里,但已经不会抢占首页位置。这就是复合查询的价值——它给你的不是“一整套标准答案”,而是一套可以随业务规则灵活调整的积木。

注意:这个案例里last_order_date字段需要是date类型,并且索引映射里要保证能用gauss衰减。如果字段缺失或有大量文档没有该字段值,要给gauss函数设置missing值或考虑调整origin的逻辑,不然没出过单的商品直接就得不到这层函数分。

5.4 复合查询的缓存机制利用

最后专门聊一下查询缓存。bool里的filter和constant_score内的filter,默认都会走 ElasticSearch 的查询缓存。但很多人不知道的是:must子句里的term条件并不会进入缓存,哪怕它跟filter里的term完全一样。原因是must查询的返回结果要参与算分,而filter查询的返回结果只用开关判断,缓存命中率更高。

在实际调优里,我发现很多慢查询可以通过“把固定的筛选条件从must挪到filter”获得立竿见影的提速。比如状态字段、类目字段、用户标签这类变化很少的过滤逻辑,本来就不该影响相关性,完全没必要参与算分。配套的还有索引级别的index.sort功能,能让filter命中的文档按指定字段排序存储,不过这是另一个话题了,这里只是提示如果你经常做同样的时间范围过滤,可以研究下。

6. 我的实操验收建议

做复合查询有一个绕不开的现实问题:你不能只看一眼返回结果就判断查询写得对不对,因为排序差异往往只在几十个文档的排位上。我的习惯是给每个用例准备一组“标注数据”——比如针对“数据线”这个关键词,人工标注了 20 个商品,哪些应该排前三、哪些应该在第二页,然后拿不同查询策略去跑,看排序命中率。这个过程听起来笨,但对复合查询的调优是最高效的。

另一个习惯是全套查询先准备好测试用例索引,用kibana或者直接脚本方式批量跑查询,把_score和业务得分抽样对比。因为function_score的调参空间很大,weight、factor、modifier、boost_mode四个旋钮拧起来效果又互相耦合,没有数据集支撑就纯靠感觉调参,基本等于盲人摸象。我甚至有段时间把每个查询的分值计算过程打印出来做对比,虽然麻烦,但对理解每个参数的真实影响特别有帮助。

这套东西学习曲线有一点陡,但经过几个真的业务场景检验,你会慢慢形成直觉。我现在接到一个搜索需求,通常先问三个问题:必须满足什么?可以加分的候选条件有哪些?有没有业务指标必须影响排序?这三个问题想清楚,查询骨架基本就搭完了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询