1. 多条件查询的真实痛点:为什么单条件够用,多条件就崩
1.1 从一次线上查询变慢说起
多条件查询是 Elasticsearch 使用中绕不过去的一道坎。但真正让我重视这件事的,是几年前一次线上接口响应时间从 80ms 涨到 2.3 秒的经历。当时业务方要在订单检索页加两个筛选项:一个是状态多选,一个是时间范围。开发同学图省事,直接在一堆must里又塞了两个terms和range,还顺手加了个wildcard做模糊匹配。测试环境数据量小,跑得飞快,上线之后查询耗时直接飙了两个数量级。
这件事的根源不在于 Elasticsearch 慢,而在于查询条件的组合方式和执行顺序决定了引擎能不能有效利用索引结构。Elasticsearch 的查询本质上是把一个大查询拆成若干子查询,每个子查询独立计算命中文档集合,最后做集合的交、并、补运算。条件越多,集合运算越多,参与打分(score)计算的文档也越多。如果你不区分哪些条件是用来"缩小范围"的、哪些是用来"影响排序"的,引擎就只能老老实实把所有条件都当成打分因子算一遍。
所以这篇内容想解决的问题很明确:在布尔查询体系下,怎样把多个条件组合得既准确又快。适合谁看?写过基础match查询但一遇到复杂筛选就靠"全塞 must"硬顶的同学;被慢查询告警折腾过、想搞清楚 query 和 filter 区别的同学;以及需要把业务侧的复杂筛选表单翻译成 DSL 的同学。
1.2 先把结论摆出来:多条件查询的三层结构
我用一个类比说明白这件事。假设你要在一个巨大的图书馆里找书,管理员有三个动作可以做:
第一种,先按书架位置把不相关的区域锁死——这对应 filter 上下文,不计算相关性得分,结果可以被缓存,速度极快。 第二种,在剩下的书架里按内容相关度排序——这对应 query 上下文,会走打分流程,成本高但结果有排序。 第三种,把几个结果集做交并运算——这对应 bool 查询的 must、should、must_not、filter 四个子句的组合逻辑。
绝大多数多条件查询的问题,都可以归结为把第一类动作错放到了第二类里。你在must里写term做精确过滤,引擎会认真地为每个命中文档算一个_score,而实际上你根本不关心这个得分——你只关心它是不是等于某个值。这一份算力就白白烧掉了。
我习惯把多条件查询拆成三层来设计,这套结构在后续所有实操里都会用到:
| 层级 | 承担职责 | 对应 DSL 位置 | 是否打分 | 是否可缓存 |
|---|---|---|---|---|
| 范围收敛层 | 把候选集从千万级压到万级 | bool.filter | 否 | 是 |
| 精确判定层 | 处理枚举、状态、标签等硬性条件 | bool.filter+term/terms | 否 | 是 |
| 相关性排序层 | 关键词匹配、模糊匹配、权重调整 | bool.must/bool.should | 是 | 否 |
这个分层不是教条,但它能帮你避开 90% 的性能陷阱。后面每一节我都会反复回到这张表,因为很多看起来"莫名其妙的变慢",一对照就发现是层级错位。
2. bool 查询的四个子句到底怎么选
2.1 must、filter、should、must_not 的语义差异
单独看文档,四个子句的说明都很清楚:must必须匹配且参与打分,filter必须匹配但不打分,should满足其一即可(受minimum_should_match控制),must_not必须不匹配。但真正写代码的时候,很多人会把must当成"必须匹配"的英文直译来用,实际上它承担了额外的语义负担——打分。
这里有个细节值得展开。filter子句的结果会被 Lucene 记录成位图(bitset),同一个 filter 在多次查询中出现时可以直接复用缓存。而must的子查询结果需要参与 score 计算,无法走这套缓存机制。所以两者的性能差距不是"一点点",在数据量大、查询重复度高的场景下,差距可能是数量级的。
我做过一组对照测试,在一个千万级文档的索引上做同样的条件组合,唯一变量是term放在must还是filter:
| 条件放置位置 | 首次查询耗时 | 重复查询耗时 | 说明 |
|---|---|---|---|
must+term | 320ms | 310ms | 每次都要重新算分 |
filter+term | 340ms | 12ms | 首次稍慢,后续命中缓存 |
must+term+constant_score | 330ms | 15ms | 效果接近 filter |
表格里第三行的constant_score是个实用技巧:它把子查询的得分固定为 1,同时享受 filter 的缓存能力。适合那些"我想让它出现在 must 里保持结构统一,但不需要它影响排序"的场景。
再说should。它最常见的误用是把"可选筛选项"直接扔进should,然后指望不传就不影响结果。问题在于should有minimum_should_match这个参数,默认行为是:如果 bool 里没有must或filter,至少匹配 1 个 should;如果有must或filter,should 就变成纯粹的加分项,可以不匹配。这个默认规则坑过不少人——同一个查询模板,加了个 filter 之后 should 的过滤能力就悄悄消失了。
所以我的建议是:凡是参与筛选的可选项,不要用 should 表达,用 filter 内部的条件拼接。在代码里判断参数是否为空,为空就不往 DSL 里加这个子句。这比依赖 should 的语义要可控得多。
2.2 嵌套 bool 的展开策略与陷阱
多条件查询写复杂了必然要嵌套 bool。嵌套本身没问题,问题在于嵌套的层数和叶子节点的排布方式。
先看一个典型的错误结构,假设要做"标题含关键词 且(状态为已发布 或 状态为审核中)且 创建时间在近 30 天":
{ "query": { "bool": { "should": [ { "bool": { "must": [ { "match": { "title": "关键词" } }, { "term": { "status": "published" } }, { "range": { "created_at": { "gte": "now-30d" } } } ] } }, { "bool": { "must": [ { "match": { "title": "关键词" } }, { "term": { "status": "reviewing" } }, { "range": { "created_at": { "gte": "now-30d" } } } ] } } ] } } }这个写法把公共条件复制了两遍。虽然 ES 有查询重写机制不至于完全重复执行,但 DSL 体积翻倍,维护起来非常痛苦,改一个条件要改两处。正确做法是把公共条件提到外层:
{ "query": { "bool": { "filter": [ { "match": { "title": "关键词" } }, { "range": { "created_at": { "gte": "now-30d" } } }, { "terms": { "status": ["published", "reviewing"] } } ] } } }你要表达的是"状态属于某个集合",terms一个子句就够了,根本不需要 should 嵌套。当你发现自己在用 should 枚举一个字段的多个取值时,先想想是不是应该换成 terms。
至于嵌套深度,我的经验上限是三层。超过三层基本都是结构设计问题,要么是字段建模不合理(把本该打平的维度做成了嵌套对象),要么是把业务逻辑硬塞进了查询层。深嵌套除了让 DSL 难以维护,还会影响查询重写器的优化空间。
有一类嵌套是不得不用的,就是nested类型字段的查询。如果你的索引里有nestedmapping,那么对它的查询必须包在nested查询里,而且要配合nested的 filter 才能让条件作用在同一个嵌套对象上。这个稍后在数组对象场景里详细说。
3. 组合查询的性能分层设计
3.1 用 filter 承担筛选,把打分留给真正的相关性
回到第 1 节那张三层表。我现在的习惯是:只要是用户从下拉框、日期选择器、多选框里选出来的条件,一律进 filter。只有搜索框里输入的文本才进 must。
这个划分标准的依据是用户意图。用户从下拉框选"状态=已发布",他要的是"必须符合",不是"符合度更高"。用户在多选框里勾三个标签,他要的是"包含这三个中的任意一个",也不涉及排序。而搜索框输入"季度报告",用户是希望匹配度高的排前面,这才是相关性打分的用武之地。
这套划分带来的直接收益是缓存命中率。同一个筛选组合在短时间内被多个用户请求,filter 位图可以复用。而打分查询每次都不同,缓存帮不上忙。
实际写法上,我会把查询结构固定成这个骨架:
{ "query": { "bool": { "must": [ { "multi_match": { "query": "用户输入的关键词", "fields": ["title^3", "content", "tags^1.5"], "type": "best_fields" } } ], "filter": [ { "terms": { "status": ["published"] } }, { "range": { "created_at": { "gte": "2024-01-01", "lte": "2024-12-31" } } }, { "term": { "tenant_id": "t_10086" } } ], "must_not": [ { "term": { "is_deleted": true } } ] } } }注意must里只有一个multi_match,其余全在 filter。这个骨架可以覆盖绝大部分"搜索框 + 筛选条件"的页面需求。字段权重title^3表示标题命中比正文命中重要三倍,这个系数不是拍脑袋的,是根据业务反馈调的——用户搜一个词,标题命中的结果点击率明显高于只在正文出现的。
3.2 查询条件的排列顺序对执行计划的影响
Elasticsearch 在执行 bool 查询时,并不严格按你写的顺序执行子句,但成本低的子句优先执行确实能带来收益,因为它能更快地把候选集缩小,后续子句需要处理的文档数就少了。
哪些子句成本低?大致排序是:term/terms(走倒排索引的精确查找)<range(走 BKD 树的范围扫描)<match(要分词、要算分)<wildcard/regexp(前缀扫描,成本高)<script(逐文档执行脚本,成本极高)。
我在写 DSL 时会有意识地把tenant_id、status这类高选择性的 term 条件放前面。不过要提醒一句:现代 ES 版本对 filter 子句有自动重排优化(基于成本估算),手工排序的收益没有早期版本那么明显。但如果你用的是wildcard或script,把它们往后放仍然有意义,因为这类查询不参与自动优化。
真正需要你手工控制顺序的场景是wildcard的滥用。我见过这样的查询:wildcard: {"title": "*报告*"},前后都带通配符。这是性能杀手,因为它必须扫描整个词项字典。如果业务确实需要"包含"语义,正确的做法是在 mapping 里配置ngram分词器,写入时就把词切成多个子串,查询时用match走索引。这个改动是写时多花一点存储,读时快一两个数量级。
3.3 minimum_should_match 与可选条件的处理
前面提过 should 的默认行为,这里展开说说minimum_should_match的实际用法。它支持几种写法,每种适用场景不同:
| 写法 | 含义 | 适用场景 |
|---|---|---|
2 | 固定匹配 2 个子句 | 子句数量固定的模板 |
75% | 匹配 75% 的子句 | 子句数量随用户选择变化 |
2<75% | 超过 2 个子句时按 75% 算 | 复杂组合场景 |
-1 | 减去 1 个,即至少匹配 n-1 | 允许一个条件不满足 |
75%这种百分比写法在"用户勾选多个标签,至少要命中大部分"的场景很好用。比如用户选了 4 个标签,75%就是至少要命中 3 个。这样能避免"随便命中一个就返回"的噪音结果。
但我要强调一个我踩过的坑:minimum_should_match只在 bool 查询的should子句层面生效,而且如果外层有 filter 或 must,should 的默认过滤语义就失效了,只作为加分项。这意味着你的"至少命中 3 个标签"逻辑,可能因为这个默认规则完全没起作用。
规避办法是显式把 should 包一层,或者干脆在应用层做条件拼接。我现在的做法是:需要"至少命中 N 个"的语义,就用一个独立的 bool 查询只装 should,不要和外层的 filter 混在一起。这样语义清晰,调试也方便。
4. 典型场景的完整 DSL 实现
4.1 场景一:列表页的搜索 + 多维筛选
这是最常见的场景。一个内容列表页,顶部有搜索框,右侧有分类、状态、时间范围三个筛选器,还要求分页和指定排序。
先看 mapping 的关键部分,因为查询能不能快,mapping 里就定了一半:
{ "mappings": { "properties": { "tenant_id": { "type": "keyword" }, "status": { "type": "keyword" }, "category_id": { "type": "keyword" }, "created_at": { "type": "date" }, "is_deleted": { "type": "boolean" }, "title": { "type": "text", "analyzer": "ik_max_word", "fields": { "raw": { "type": "keyword" } } }, "content": { "type": "text", "analyzer": "ik_max_word" } } } }tenant_id、status、category_id都必须是keyword,不能是text。用text会导致term查询查不到——因为 text 被分词了,倒排索引里存的是一个个词,不是原值。这个坑我在小白同学身上见过太多次。
完整查询:
{ "query": { "bool": { "must": [ { "multi_match": { "query": "年度总结", "fields": ["title^3", "content"], "operator": "and" } } ], "filter": [ { "term": { "tenant_id": "t_10086" } }, { "term": { "is_deleted": false } }, { "terms": { "status": ["published", "reviewing"] } }, { "terms": { "category_id": ["c_01", "c_03", "c_07"] } }, { "range": { "created_at": { "gte": "now-90d/d", "lte": "now/d" } } } ] } }, "sort": [ { "_score": { "order": "desc" } }, { "created_at": { "order": "desc" } } ], "from": 0, "size": 20, "track_total_hits": true }几个细节说明。operator: "and"表示关键词的所有分词都要命中,这是为了减少噪音——默认是or,输入"年度总结"时只命中"年度"的文档也会返回,结果相关性很差。now-90d/d末尾的/d是取整到天,让同一天内的多次请求使用相同的日期值,这样 filter 缓存才能命中。如果不取整,每次请求的时间戳微秒级不同,缓存永远失效。
track_total_hits: true是为了让total返回精确值。默认情况下 ES 只统计到 10000 条就返回"relation": "gte",如果你的前端要显示"共 X 条",就必须打开这个开关。但要注意它是有成本的,数据量特别大时可以考虑关掉,改成"超过 10000 条显示 10000+"。
sort里我把_score放在第一位、created_at放第二位。这样既保证了相关性高的靠前,同分时按时间倒序,避免分页时结果顺序不稳定。排序字段的顺序稳定性对分页非常重要,如果排序值有大量重复而你又没有稳定的次级排序键,翻页时可能出现重复或遗漏。
4.2 场景二:数组字段的"同一元素内"多条件匹配
这个场景不容易被识别,但真遇到了会卡很久。需求是:文档里有一个skus数组,每个元素有color和size两个属性,要查"红色 且 尺码为 L"的商品。
如果 mapping 是这样的普通对象数组:
{ "skus": { "properties": { "color": { "type": "keyword" }, "size": { "type": "keyword" } } } }那么下面这个查询会出问题:
{ "query": { "bool": { "filter": [ { "term": { "skus.color": "red" } }, { "term": { "skus.size": "L" } } ] } } }它会命中"红色 S 码 + 蓝色 L 码"这样的商品,因为普通数组在内部被扁平化了,color和size的关联关系丢失。这就是所谓的跨对象匹配问题。
正确解法是把skus声明为nested类型:
{ "skus": { "type": "nested", "properties": { "color": { "type": "keyword" }, "size": { "type": "keyword" } } } }查询改成:
{ "query": { "bool": { "filter": [ { "nested": { "path": "skus", "query": { "bool": { "filter": [ { "term": { "skus.color": "red" } }, { "term": { "skus.size": "L" } } ] } } } } ] } } }nested查询保证两个 term 作用在同一个嵌套文档上。如果要同时匹配多个"颜色+尺码"组合(比如红色L或蓝色M),就用多个nested查询包在 should 里,每个 nested 内部是 and 关系。这个组合方式在下拉多选的商品筛选里非常常用。
代价是nested查询的性能不如扁平查询,因为它要先定位父文档再查子文档。如果数组元素很多,还受index.mapping.nested_objects.limit(默认 10000)限制。所以能用扁平结构表达的需求,尽量别上 nested。
4.3 场景三:带权重的多字段相关性查询
有时候多条件不只是"筛选",还包括"多个字段共同决定排序"。比如一个知识库搜索,希望标题命中权重最高、标签次之、正文最低,同时希望"最近更新"的内容有加成。
function_score是处理这类需求的标准工具:
{ "query": { "function_score": { "query": { "bool": { "must": [ { "multi_match": { "query": "接口文档", "fields": ["title^3", "tags^2", "content"], "type": "best_fields", "tie_breaker": 0.3 } } ], "filter": [ { "term": { "tenant_id": "t_10086" } }, { "term": { "is_deleted": false } } ] } }, "functions": [ { "filter": { "range": { "updated_at": { "gte": "now-7d/d" } } }, "weight": 2 }, { "field_value_factor": { "field": "view_count", "factor": 0.1, "modifier": "log1p", "missing": 0 } } ], "score_mode": "sum", "boost_mode": "multiply" } } }几个参数值得说清楚。tie_breaker: 0.3的作用是:当多个字段都命中时,除了最佳字段的分数,其他字段的分数乘以 0.3 也计入总分。不加这个参数,best_fields只看最高分字段,其他字段的命中信息就浪费了。
modifier: "log1p"是对浏览量取对数,避免热门内容的分数把相关性完全压死。浏览量 10 和 100 的差距是 10 倍,但取对数后只有 2 倍,这样既保留了热度加成,又不至于让新内容永远排不上来。missing: 0处理字段为空的文档,防止分数变成 NaN。
score_mode: "sum"表示多个 function 的分数相加,boost_mode: "multiply"表示这个总分与查询原始分数相乘。这两个参数的组合决定了最终的排序效果,选错了会出现"完全不相关的文档排第一"的诡异结果。我建议先在测试索引上用"explain": true看几个文档的分数构成,确认逻辑符合预期再上线。
4.4 场景四:动态筛选条件的参数化拼装
实际业务里筛选条件是动态的,用户可能只选了一两个,也可能全选。DSL 需要在代码里拼装。
Python 客户端下的写法:
def build_query(keyword=None, status=None, categories=None, start_time=None, end_time=None, tenant_id=None): must, filters = [], [] if keyword: must.append({ "multi_match": { "query": keyword, "fields": ["title^3", "content"], "operator": "and" } }) if tenant_id: filters.append({"term": {"tenant_id": tenant_id}}) filters.append({"term": {"is_deleted": False}}) if status: filters.append({"terms": {"status": status}}) if categories: filters.append({"terms": {"category_id": categories}}) if start_time or end_time: rng = {} if start_time: rng["gte"] = start_time if end_time: rng["lte"] = end_time filters.append({"range": {"created_at": rng}}) body = {"query": {"bool": {"filter": filters}}} if must: body["query"]["bool"]["must"] = must return body这个函数的几个设计取舍。is_deleted: false是无条件加的,因为业务上不允许查到已删除数据,这种"安全底裤"条件不该由调用方决定是否添加。must只有在关键词存在时才加,避免空must数组导致查询报错。range用字典累加的方式拼,支持只传开始时间或只传结束时间。
注意:不要用字符串拼接的方式构造 JSON DSL。除了注入风险,更常见的问题是日期、数字类型被拼成字符串,导致
range查询行为异常。用字典构造、最后统一序列化。
另一个要提醒的点:动态拼装时容易忽略空数组和空字符串的区别。categories=[]和categories=None在业务上可能都表示"不限",但代码里if categories:对空列表也是 False,行为一致。可如果有人传了categories=[""],就会生成terms: {"category_id": [""]},查不到任何数据还以为索引有问题。在入口做一次参数清洗比在查询层打补丁靠谱。
5. 排查实录:多条件查询的典型故障
5.1 查不到数据但单条件能查到
这是被问得最多的一类问题。现象:单独加状态条件能查到,单独加分类条件能查到,两个一起加就零结果。
排查顺序我是固定的。第一步,把 DSL 拿到_validate/query?explain=true上跑一遍,看 ES 自己怎么解释这个查询,有没有子句被重写掉。第二步,逐个拆解,每次只保留一个 filter,确认每个子句单独都能命中。如果每个都能命中但组合后不行,那基本就是逻辑关系错了——比如你以为是 or 关系,实际写成了 and。
还有一种情况是跨字段的 and 被误当成同元素 and,就是我前面说的 nested 问题。普通数组字段上做两个 term 的 and,语义是"数组里存在红色元素 且 数组里存在 L 元素",不是"存在一个元素既是红色又是 L"。这个语义差异在小数据量测试时经常测不出来,因为测试数据往往很简单。
第三个隐蔽原因是analyzer不一致。写入时用了一个分词器,查询时term查的是原始词项,两者对不上就查不到。判断方法是用_analyze接口对比一下:GET index/_analyze {"field": "title", "text": "年度总结"},看输出是不是你期望的词项。term查询不经过分析器,它查的就是倒排索引里的原样词项,这一点务必记牢。
5.2 结果顺序忽上忽下,分页出现重复
多条件查询里如果带了 should 加分项,分数会随查询词变化,排序自然不稳定。但更常见的元凶是排序字段值重复且没有稳定的 tiebreaker。
设想按created_at排序,同一天创建了 50 条数据,这 50 条的排序值完全相同。ES 在分片间归并时,对于排序值相同的文档,返回顺序是不保证的。你请求第 1 页拿到 A、B,请求第 2 页可能又看到 B。
解决办法是加一个唯一性字段作为最后一级排序,比如_id或者业务主键:
{ "sort": [ { "created_at": { "order": "desc" } }, { "doc_id": { "order": "asc" } } ] }如果不想改排序,也可以在查询里加一个_doc排序兜底。但要注意_doc的顺序在不同分片间没有全局一致性,只适合作为最后的稳定器,不适合作为主排序。
还有一种情况是用了from + size做深分页,翻到后面几十页时结果错乱。这是因为from + size的上限受index.max_result_window(默认 10000)限制,超了会报错;而在接近上限时,各分片返回的候选集可能不足以覆盖全局正确排序。深分页的正确做法是search_after,用上一页最后一条的排序值作为下一页的起点。
5.3 慢查询定位的实操路径
遇到慢查询,我的定位流程是这样的:
先开慢查询日志,把阈值调到能捕获问题的水平:
PUT /my_index/_settings { "index.search.slowlog.threshold.query.warn": "500ms", "index.search.slowlog.threshold.query.info": "200ms", "index.search.slowlog.fetch.warn": "200ms" }日志里能看到完整的 DSL 和耗时。注意 query 阶段和 fetch 阶段要分开看:query 阶段慢说明筛选或打分有问题,fetch 阶段慢往往是返回字段太多或者_source太大。
然后对可疑查询做 profile:
{ "profile": true, "query": { ... } }profile 输出会显示每个子查询的耗时和命中文档数。我一般重点看两个指标:哪个子查询的time_in_nanos最高,以及advanced和build_scorer的耗时分布。如果某个 term 查询的 build_scorer 特别慢,可能是索引段太多,需要考虑 force merge;如果是 wildcard 的耗时高,那就回到前面说的,改分词方案。
常见的慢查询成因我整理成了速查表:
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| filter 子句慢 | 字段类型是 text 而非 keyword | 改 mapping,加 keyword 子字段 |
| 打分阶段慢 | must 里塞了大量精确过滤条件 | 下沉到 filter |
| 前缀通配慢 | wildcard: "*xx*"扫描全词项 | 改用 ngram 分词 + match |
| 深分页慢 | 大 from 值 | 改 search_after |
| 首次查询慢、后续快 | 未预热或缓存未命中 | 正常现象,可做查询预热 |
| 聚合慢 | 高基数字段做 terms 聚合 | 加size限制,或用 composite 聚合 |
还有一类容易被误判的"慢":查询本身不慢,但客户端超时。这种情况通常是返回体太大,或者客户端设置的超时时间太短。把size调小、用_source过滤只返回需要的字段,往往能立刻缓解。
5.4 filter 缓存失效的几种隐蔽原因
filter 缓存是个好东西,但它失效的原因很隐蔽。我遇到过三次印象深刻的。
第一次,日期范围用了now而不是now/d。每次请求now的值都不同(精确到毫秒),ES 认为这是不同的查询,缓存无法复用。改成now/d之后,同一天内所有请求共享同一个缓存。
第二次,查询里带了随机数或时间戳作为"防缓存"参数。团队里有人从别的系统照搬了这个习惯,导致 ES 侧的缓存全部失效。ES 的 filter 缓存是按查询结构自动判断的,不需要手工加随机参数。
第三次,terms里的值顺序不同。理论上 ES 会对 terms 的值做规范化排序,但如果值集合本身每次都变(比如前端传的 ID 列表顺序和内容都不固定),缓存命中率自然下降。这个只能从业务侧收敛,比如前端固定排序后再传。
还有一种情况是索引段合并导致缓存被清空。这个属于正常行为,不用太纠结,关注长期命中率即可。可以用GET index/_stats/request_cache观察hit_count和miss_count的比例,如果命中率长期低于 50%,就值得排查一下查询结构了。
6. 我踩过的坑和几条实操建议
6.1 关于关键词查询的一个小技巧
multi_match的type参数默认是best_fields,意思是取各字段中的最高分。但如果你希望"标题和正文都命中"的文档排更靠前,可以用most_fields,它会把所有字段的分数加起来。我做过对比,在内容检索场景下,most_fields配合operator: "and"的效果通常比默认的best_fields更符合用户预期,因为"多处命中"往往意味着"更相关"。
但most_fields有个已知问题:同一个词在多个字段出现会被重复计分,长文档容易占便宜。所以如果你的文档长度差异很大,记得在字段上配置norms(默认是开的),或者在function_score里加一个长度归一化的因子。
另一个小技巧:中文分词器选择上,ik_max_word切得细,召回高但噪音多;ik_smart切得粗,精度高但可能漏。我的做法是索引时用ik_max_word,搜索时用ik_smart,通过search_analyzer指定。这样写入的粒度和查询的粒度分离,召回和精度的平衡更好。
6.2 多条件组合时的字段建模优先
我越来越觉得,多条件查询写得好不好,八成取决于 mapping 设计得对不对。有几个建模原则值得反复强调:
枚举类字段一律用keyword。状态、类型、分类、租户 ID 这类字段,即使你确定业务上只有一个值,也不要图省事用 text。用 text 的后果是term查不到、terms查不准、聚合结果是一堆切碎的词。
需要同时支持精确匹配和模糊搜索的字段,用text加fields.keyword的多字段方案。比如title主字段是 text 用于全文检索,title.raw是 keyword 用于精确匹配和排序。排序必须用 keyword 或者数值类型,用 text 排序会报错。
高基数、高频过滤的字段考虑加index_options: "docs"或者调整norms。前者让倒排表只记录文档号不记录词频,省空间也加快过滤;后者关闭长度归一化因子,对于不需要长度加权的 filter 字段可以减少计算。不过这些是微调,收益没有改对类型那么大,建议在确认基础类型正确后再考虑。
还有一点,如果某个筛选维度是固定的几个大类(比如业务线、地区、终端类型),可以用routing把同一类数据路由到同一分片,查询时带上routing参数只查一个分片。这对多条件查询的加速效果非常直接,因为它把"每个分片都查一遍再归并"变成了"只查一个分片"。代价是写入时的分片分布不均,以及跨类查询需要全分片扫描。适合数据天然按租户或区域隔离的场景。
6.3 一个容易被忽略的调试习惯
我现在写复杂 DSL 有一个固定动作:先在 Kibana 或 Dev Tools 里手写跑通,确认结果正确,再翻译成代码。反过来的顺序(先写代码再调)会让人陷入两难——分不清是 DSL 逻辑错了还是代码拼装错了。
跑通之后,我会把 DSL 和预期结果记在一个小本子里,作为后续改动的回归基准。多条件查询最怕的是"改一个条件影响了另一个条件",有基准就能快速判断。这个习惯看起来笨,但省下来的排查时间远超记账的时间。
另外,涉及function_score或者权重调整的查询,上线前一定开"explain": true看几个典型文档的分数构成。分数是乘法链,一个因子设错,整体排序就完全变了。而且分数问题很难从结果层面反推——你看到"文档 A 排第一",但不知道为什么,explain 能直接告诉你每一层的贡献值。
最后提一句关于查询模板的想法。如果同一个业务有多处地方需要类似的多条件查询,考虑用 search template 或者把查询封装成后端的领域方法,而不是每处都手写一遍 DSL。重复手写迟早会漂移,一处改了 filter 顺序另一处没改,排查起来又是一场噩梦。
这套东西我用了几年,核心就三句话:范围过滤进 filter 不要进 must,字段类型建对不要事后补救,DSL 先在工具里跑通再落代码。多条件查询本身没什么高深技术,难的是每次都忍住"全塞 must"的冲动,多想一步这条条件的用户意图到底是筛选还是排序。想清楚这一点,DSL 写出来自然就干净了。