ElasticsearchNotes

第 10 章:复合与嵌套查询

zjc 于 2026-01-10 发布

这是《Elasticsearch 零基础实战指南》的独立章节版。本章从概念、实操和生产排查三个视角展开,代码块保留了原书可直接运行的版本。 真实业务查询很少只有一个条件。本章讲解 bool 的进阶用法、constant_score、function_score、nested、父子关系、脚本查询和查询组织原则。

10.1 bool 查询再深入

GET /orders/_search
{
  "query": {
    "bool": {
      "must": [
        { "multi_match": { "query": "笔记本", "fields": ["item_names", "title"] } }
      ],
      "filter": [
        { "range": { "created_at": { "gte": "now-30d/d" } } },
        { "terms": { "status": ["PAID", "SHIPPED", "COMPLETED"] } }
      ],
      "must_not": [
        { "term": { "is_deleted": true } }
      ],
      "should": [
        { "term": { "vip_level": "HIGH" } },
        { "term": { "channel": "APP" } }
      ],
      "minimum_should_match": 0
    }
  }
}

查询语义:

必须匹配商品词
且最近 30 天
且状态有效
且未删除
VIP 或 APP 命中可以加分

10.2 嵌套 bool

{
  "query": {
    "bool": {
      "filter": [
        { "range": { "created_at": { "gte": "now-7d/d" } } },
        {
          "bool": {
            "should": [
              { "term": { "brand": "NOVA" } },
              { "term": { "brand": "MARS" } }
            ],
            "minimum_should_match": 1
          }
        }
      ]
    }
  }
}

常见错误是把“品牌 A 且价格大于 100”和“品牌 B 且价格小于 50”写成扁平 bool:

{
  "bool": {
    "must": [
      { "terms": { "brand": ["A", "B"] } },
      {
        "bool": {
          "should": [
            { "range": { "price": { "gt": 100 } } },
            { "range": { "price": { "lt": 50 } } }
          ]
        }
      }
    ]
  }
}

这个查询会错误匹配“品牌 A 价格 30”。正确方式是外层 should,内层 must:

{
  "bool": {
    "should": [
      {
        "bool": {
          "must": [
            { "term": { "brand": "A" } },
            { "range": { "price": { "gt": 100 } } }
          ]
        }
      },
      {
        "bool": {
          "must": [
            { "term": { "brand": "B" } },
            { "range": { "price": { "lt": 50 } } }
          ]
        }
      }
    ],
    "minimum_should_match": 1
  }
}

10.3 constant_score

{
  "query": {
    "bool": {
      "must": [
        { "match": { "title": "笔记本" } }
      ],
      "should": [
        {
          "constant_score": {
            "filter": { "term": { "in_stock": true } },
            "boost": 1.5
          }
        }
      ]
    }
  }
}

适合把过滤条件转换成固定加分,而不是让条件参与 BM25 文本打分。

10.4 function_score

function_score 用于把文本相关性与业务指标混合。

GET /products/_search
{
  "query": {
    "function_score": {
      "query": {
        "multi_match": {
          "query": "笔记本",
          "fields": ["title^3", "brand", "description"]
        }
      },
      "functions": [
        {
          "filter": { "term": { "status": "ON_SALE" } },
          "weight": 1.5
        },
        {
          "field_value_factor": {
            "field": "sales",
            "factor": 1.2,
            "modifier": "log1p",
            "missing": 1
          }
        },
        {
          "gauss": {
            "created_at": {
              "origin": "now",
              "scale": "30d",
              "decay": 0.5
            }
          }
        }
      ],
      "score_mode": "sum",
      "boost_mode": "multiply",
      "min_score": 1
    }
  }
}

参数说明:

参数 说明
score_mode 多个 function 得分如何合并
boost_mode 查询得分与 function 得分如何合并
min_score 过滤最低分
weight 固定权重
field_value_factor 使用字段值参与打分
gauss / exp / linear 衰减函数
random_score 随机打散

典型业务:

  1. 销量加成;
  2. 库存商品优先;
  3. 新品加权;
  4. 距离衰减;
  5. 赞助商品固定加权;
  6. 随机推荐。

注意事项:

  1. 权重需要持续评估;
  2. 过多函数会增加查询成本;
  3. field_value_factor 应使用对数修饰避免大值淹没文本分;
  4. 赞助内容应可识别、可审计。

10.5 script_score

当内置 function 不够时,可以使用 Painless:

GET /products/_search
{
  "query": {
    "script_score": {
      "query": {
        "match": { "title": "笔记本" }
      },
      "script": {
        "source": """
          double score = _score;
          if (doc['sales'].size() > 0) {
            score += Math.log1p(doc['sales'].value) * params.salesWeight;
          }
          if (doc['in_stock'].size() > 0 && doc['in_stock'].value) {
            score += params.stockWeight;
          }
          return score;
        """,
        "params": {
          "salesWeight": 2.0,
          "stockWeight": 1.0
        }
      }
    }
  }
}

script_score 灵活但昂贵。更复杂的精排通常在应用层或独立排序服务中完成。

10.6 nested 查询

Mapping:

PUT /products
{
  "mappings": {
    "properties": {
      "skus": {
        "type": "nested",
        "properties": {
          "color": { "type": "keyword" },
          "size": { "type": "keyword" },
          "price": { "type": "scaled_float", "scaling_factor": 100 },
          "stock": { "type": "integer" }
        }
      }
    }
  }
}

组合条件:

GET /products/_search
{
  "query": {
    "nested": {
      "path": "skus",
      "query": {
        "bool": {
          "must": [
            { "term": { "skus.color": "black" } },
            { "term": { "skus.size": "16G" } },
            { "range": { "skus.price": { "lte": 7000 } } }
          ]
        }
      },
      "score_mode": "max"
    }
  }
}

score_mode 决定多个匹配 nested 对象的得分合并方式,常用 maxsumavg

10.7 nested 聚合

统计 SKU 颜色:

GET /products/_search
{
  "size": 0,
  "aggs": {
    "skus": {
      "nested": { "path": "skus" },
      "aggs": {
        "colors": {
          "terms": { "field": "skus.color" }
        }
      }
    }
  }
}

从 nested 结果回到父文档:

{
  "aggs": {
    "skus": {
      "nested": { "path": "skus" },
      "aggs": {
        "colors": {
          "terms": { "field": "skus.color" },
          "aggs": {
            "back_to_product": {
              "reverse_nested": {},
              "aggs": {
                "avg_price": { "avg": { "field": "price" } }
              }
            }
          }
        }
      }
    }
  }
}

10.8 父子查询

has_child 查询父文档:

GET /company/_search
{
  "query": {
    "has_child": {
      "type": "employee",
      "query": {
        "term": { "city": "Shanghai" }
      },
      "score_mode": "sum"
    }
  }
}

has_parent 查询子文档:

GET /company/_search
{
  "query": {
    "has_parent": {
      "parent_type": "department",
      "query": {
        "term": { "name.keyword": "Search Platform" }
      }
    }
  }
}

父子查询的约束:

  1. 父子必须在同一分片;
  2. 查询成本高于普通文档;
  3. 更新父或子都会增加索引压力;
  4. 大量层级不适合使用 join。

多数商品搜索场景更推荐把 SKU、类目、品牌在同步层冗余到商品文档,或为 SKU 建独立索引。

10.9 脚本查询

script query:

GET /orders/_search
{
  "query": {
    "bool": {
      "filter": {
        "script": {
          "script": {
            "source": "doc['amount'].value > doc['paid_amount'].value",
            "lang": "painless"
          }
        }
      }
    }
  }
}

脚本会绕过部分索引优化,成本高。替代方式:

  1. 写入时计算差额字段;
  2. 使用 runtime field;
  3. 使用 range 或 term 表达;
  4. 在数据库中先过滤。

10.10 查询改写实践

慢查询:

{
  "query": {
    "bool": {
      "must": [
        { "wildcard": { "order_no": "*0001" } },
        { "match_all": {} }
      ]
    }
  }
}

优化思路:

  1. order_no 是否应有后缀字段;
  2. 是否能使用 term 精确查;
  3. 是否能限制时间范围;
  4. 是否应该放数据库;
  5. 是否要建补全索引。

优化后:

{
  "query": {
    "bool": {
      "filter": [
        { "term": { "order_no_suffix": "0001" } },
        { "range": { "created_at": { "gte": "now-1d/d" } } }
      ]
    }
  }
}

10.11 查询代码封装建议

public SearchRequest buildProductSearch(ProductSearchQuery query) {
    List<Query> filters = new ArrayList<>();
    filters.add(termQuery("status", "ON_SALE"));

    if (query.brandIds() != null) {
        filters.add(termsQuery("brand_id", query.brandIds()));
    }
    if (query.minPrice() != null || query.maxPrice() != null) {
        filters.add(rangeQuery("price", query.minPrice(), query.maxPrice()));
    }

    Query textQuery = query.keyword().isBlank()
            ? matchAll()
            : multiMatch(query.keyword(), "title^3", "brand", "description");

    return SearchRequest.of(r -> r
            .index("products")
            .from(query.from())
            .size(query.size())
            .query(q -> q.bool(b -> b.must(textQuery).filter(filters))));
}

原则:

  1. 查询构造与业务参数校验分离;
  2. 不允许用户直接传任意 DSL;
  3. 限制字段和 size;
  4. 输出结构化慢日志;
  5. 为每个查询类型建立监控。

10.12 本章小结

10.13 思考题

  1. 如何表达“(品牌 A 且价格>100) 或 (品牌 B 且价格<50)”?
  2. function_score 的 boost_mode 和 score_mode 有什么区别?
  3. 普通 object 为什么无法正确查询 SKU 属性组合?
  4. nested 聚合中 reverse_nested 的作用是什么?
  5. 哪些场景应该避免 script query?