第04篇:Query DSL 全景与高级检索实战——从入门查询到复杂业务场景

🔍 引言:Query DSL 是 ES 的"SQL"

如果说 Mapping 是 ES 的 Schema 设计,那 Query DSL 就是 ES 的查询语言------是你与 ES 数据打交道的日常工具。

但 Query DSL 和 SQL 有一个本质区别:SQL 的查询结果是"满足条件的数据",而 ES 的查询结果是按相关性排序的数据。理解这个区别,才能真正用好 ES 的各种查询类型。

本篇用一个真实的电商搜索场景贯穿全程:用户在搜索框输入关键词,同时可以按品牌、价格区间筛选,按销量/评分排序,看聚合统计结果(品牌分布、价格分布),最后还要支持深翻页。这些需求,我们逐一实现。


一、Query Context vs Filter Context------影响性能的关键理解

在写任何查询之前,必须先搞清楚这个核心概念,因为它直接影响查询性能和结果排序。

Query Context(查询上下文) :回答"文档有多匹配?",会计算相关性分数(_score,结果按分数排序。

Filter Context(过滤上下文) :回答"文档是否匹配?",不计算分数,结果可以缓存,性能比 Query Context 高得多。

json 复制代码
{
  "query": {
    "bool": {
      "must": [
        // ↑ must 里的条件在 Query Context,会计算 _score
        { "match": { "name": "iPhone" } }
      ],
      "filter": [
        // ↑ filter 里的条件在 Filter Context,不计算 _score,走缓存
        { "term": { "brand": "Apple" } },
        { "range": { "price": { "gte": 5000 } } }
      ]
    }
  }
}

实践原则

  • 全文检索 (用户输入的关键词)→ 放 must,因为需要按相关性排序
  • 精确过滤 (品牌、分类、价格区间、是否上架)→ 放 filter,性能更好

这一条规则能让查询性能提升 30-50%。


二、基础查询类型详解

2.1 match------全文检索的主力

java 复制代码
// 单字段全文检索
esClient.search(s -> s
    .query(q -> q
        .match(m -> m
            .field("name")
            .query("苹果手机")
            // operator: AND 表示所有词必须都出现,默认 OR
            // 默认 OR 下,"苹果 手机"会匹配到只含"苹果"的文档
            // AND 更严格,适合精确度要求高的场景
            .operator(Operator.And)
            // minimum_should_match: 至少匹配的词数/比例
            // "2" 表示至少匹配2个词,"75%" 表示至少匹配75%的词
            .minimumShouldMatch("2")
        )
    ),
    Product.class
);

⚠️ 踩坑match 的默认 operatorOR,这意味着"苹果手机"会匹配到任何含有"苹果"或"手机"的文档,包括"苹果汁"、"翻盖手机"。如果你的搜索结果里出现了明显不相关的文档,很可能就是这个原因。在精确度要求高的 B 端搜索场景,建议改成 AND 或使用 minimum_should_match

2.2 match_phrase------短语检索

java 复制代码
// 短语检索:词序必须一致,且词之间距离不超过 slop
esClient.search(s -> s
    .query(q -> q
        .matchPhrase(m -> m
            .field("description")
            .query("高清摄像头")
            // slop: 允许词之间插入的其他词数量
            // slop=0(默认):必须连续出现"高清摄像头"
            // slop=1:允许"高清 xxx 摄像头"这样的形式
            .slop(1)
        )
    ),
    Product.class
);

2.3 multi_match------多字段检索

java 复制代码
// 同时在多个字段检索,是最常用的搜索入口查询
esClient.search(s -> s
    .query(q -> q
        .multiMatch(m -> m
            .query("iPhone 15")
            // 字段权重:^数字 表示权重倍数
            // name 权重最高(商品名),brand 次之,description 最低
            .fields("name^3", "brand^2", "description", "tags^1.5")
            // type: best_fields(默认):取最高分字段的分数
            // type: most_fields:多字段分数相加
            // type: cross_fields:跨字段匹配,适合人名/地址等需要跨字段组合的场景
            .type(TextQueryType.BestFields)
            // tie_breaker: best_fields 模式下,其他字段的得分乘以这个系数加到总分上
            // 0.3 是经验值,让在多个字段都匹配的文档得分更高
            .tieBreaker(0.3)
        )
    ),
    Product.class
);

2.4 term/terms------精确匹配

java 复制代码
// term:单值精确匹配(只能用于 keyword、number、boolean、date 类型)
// 注意:term 查询不做分析,"Apple" 和 "apple" 是不同的值
// 如果字段是 text 类型,term 通常查不到,因为 text 已经被小写化了
esClient.search(s -> s
    .query(q -> q
        .term(t -> t
            .field("brand")     // 必须是 keyword 类型
            .value("Apple")
        )
    ),
    Product.class
);

// terms:多值匹配(相当于 SQL 的 IN)
esClient.search(s -> s
    .query(q -> q
        .terms(t -> t
            .field("category")
            .terms(tv -> tv.value(
                List.of(
                    FieldValue.of("手机"),
                    FieldValue.of("平板"),
                    FieldValue.of("笔记本")
                )
            ))
        )
    ),
    Product.class
);

2.5 range------范围查询

java 复制代码
// 价格区间 + 时间范围组合
esClient.search(s -> s
    .query(q -> q
        .bool(b -> b
            .filter(
                // 价格 1000-5000
                Query.of(f -> f
                    .range(r -> r
                        .field("price")
                        .gte(JsonData.of(1000))
                        .lte(JsonData.of(5000))
                    )
                ),
                // 最近30天上架
                Query.of(f -> f
                    .range(r -> r
                        .field("created_at")
                        .gte(JsonData.of("now-30d/d"))   // ES 的日期表达式
                        .lte(JsonData.of("now/d"))
                    )
                )
            )
        )
    ),
    Product.class
);

三、bool 查询------组合查询的基石

bool 查询是 ES 中最重要的复合查询,几乎所有复杂业务查询都是 bool 的组合。

复制代码
bool 查询的四个子句:
├── must     → 必须匹配,影响 _score(AND + 计分)
├── should   → 可选匹配,至少匹配 minimum_should_match 个(OR + 计分)
├── must_not → 必须不匹配,不影响 _score(NOT + 不计分)
└── filter   → 必须匹配,不影响 _score,可缓存(AND + 不计分)

mustfilter 都是"必须匹配",区别只在于是否计分。能用 filter 的地方不要用 must。

来看一个完整的电商搜索场景:

用户需求:搜索"高清摄像头",品牌只看"苹果"或"华为",价格 3000-8000,要有库存,排除二手商品,评分高的优先。

java 复制代码
SearchResponse<Product> response = esClient.search(s -> s
    .index("products")
    .from(0).size(20)
    .query(q -> q
        .bool(b -> b
            // ── must:关键词全文检索,影响相关性评分 ──────────────
            .must(m -> m
                .multiMatch(mm -> mm
                    .query("高清摄像头")
                    .fields("name^3", "description", "tags^2")
                )
            )
            // ── filter:精确过滤,不影响评分,走缓存 ───────────────
            // 品牌过滤(多值)
            .filter(f -> f
                .terms(t -> t
                    .field("brand")
                    .terms(tv -> tv.value(
                        List.of(FieldValue.of("苹果"), FieldValue.of("华为"))
                    ))
                )
            )
            // 价格范围
            .filter(f -> f
                .range(r -> r
                    .field("price")
                    .gte(JsonData.of(3000))
                    .lte(JsonData.of(8000))
                )
            )
            // 有库存
            .filter(f -> f
                .range(r -> r.field("stock").gt(JsonData.of(0)))
            )
            // 已上架
            .filter(f -> f
                .term(t -> t.field("is_active").value(true))
            )
            // ── must_not:排除条件 ────────────────────────────────
            .mustNot(mn -> mn
                .term(t -> t.field("is_second_hand").value(true))
            )
            // ── should:非必须,但匹配则加分 ─────────────────────
            // 有官方授权标识的商品加分
            .should(sh -> sh
                .term(t -> t.field("is_official_certified").value(true))
            )
            // 近期新品加分
            .should(sh -> sh
                .range(r -> r
                    .field("created_at")
                    .gte(JsonData.of("now-7d"))
                )
            )
            // minimumShouldMatch: should 子句至少满足几个(这里是 0,全部可选)
            // 如果 bool 里只有 should(没有 must/filter),默认至少满足 1 个
            // 如果同时有 must/filter,should 默认 0 个(纯加分)
        )
    )
    // 排序:先按相关性,再按销量
    .sort(
        SortOptions.of(so -> so.field(f -> f.field("_score").order(SortOrder.Desc))),
        SortOptions.of(so -> so.field(f -> f.field("sales_count").order(SortOrder.Desc)))
    ),
    Product.class
);

四、聚合(Aggregation)------ES 的统计分析能力

聚合是 ES 除搜索之外最强大的功能,可以在返回搜索结果的同时,计算统计数据------品牌分布、价格区间分布、按时间的趋势等。

4.1 Terms 聚合:品牌分布统计

java 复制代码
// 同一个请求:既搜索商品,又统计品牌分布(像 SQL 的 GROUP BY)
SearchResponse<Product> response = esClient.search(s -> s
    .index("products")
    .size(20)    // 搜索结果返回20条
    .query(q -> q
        .match(m -> m.field("name").query("手机"))
    )
    .aggregations("brand_distribution", a -> a
        // terms 聚合:按 brand 字段分组,统计每组文档数
        .terms(t -> t
            .field("brand")
            .size(10)                          // 只返回 Top 10 品牌
            .order(NamedValue.of("_count", SortOrder.Desc))  // 按数量降序
        )
        // 子聚合:在每个品牌分组里,再计算平均价格
        .aggregations("avg_price", aa -> aa
            .avg(avg -> avg.field("price"))
        )
    )
    // 再加一个价格区间聚合
    .aggregations("price_ranges", a -> a
        .range(r -> r
            .field("price")
            .ranges(
                AggregationRange.of(ar -> ar.key("0-1000").to(1000.0)),
                AggregationRange.of(ar -> ar.key("1000-3000").from(1000.0).to(3000.0)),
                AggregationRange.of(ar -> ar.key("3000-5000").from(3000.0).to(5000.0)),
                AggregationRange.of(ar -> ar.key("5000+").from(5000.0))
            )
        )
    ),
    Product.class
);

// 解析聚合结果
StringTermsAggregate brandAgg = response.aggregations()
        .get("brand_distribution")
        .sterms();

List<BrandStats> brandStats = brandAgg.buckets().array().stream()
        .map(bucket -> {
            String brandName = bucket.key().stringValue();
            long count = bucket.docCount();
            double avgPrice = bucket.aggregations()
                    .get("avg_price")
                    .avg()
                    .value();
            return new BrandStats(brandName, count, avgPrice);
        })
        .collect(Collectors.toList());

log.info("品牌分布: {}", brandStats);

⚠️ 踩坑 :Terms 聚合在分布式环境下,每个分片独立计算 Top N,然后汇总。这意味着结果可能不精确------某个品牌在整体上是 Top 5,但在每个分片上都排第 6,最终就会被遗漏。数据量越大、分片数越多,这个误差越明显。_count 字段旁边通常有 doc_count_error_upper_boundsum_other_doc_count 字段,用于描述这个误差范围。对于精确统计要求高的场景,设置 shard_size(每个分片返回更多候选)来减小误差,代价是汇总计算量增加。

4.2 Histogram 聚合:价格直方图

java 复制代码
// 按价格每 500 元一个区间,生成直方图数据(用于前端展示价格分布图)
esClient.search(s -> s
    .index("products")
    .size(0)    // 只要聚合结果,不要搜索结果(size=0 性能更好)
    .aggregations("price_histogram", a -> a
        .histogram(h -> h
            .field("price")
            .interval(500.0)      // 每个区间的步长
            .minDocCount(1)       // 只返回文档数 >= 1 的区间(过滤空桶)
            .extendedBounds(eb -> eb.min(0.0).max(20000.0))  // 固定边界
        )
    ),
    Void.class    // 没有 source,用 Void
);

4.3 Nested 聚合:统计规格分布

记得第03篇的 specs 字段是 nested 类型?聚合 nested 字段需要套一层 nested 聚合:

java 复制代码
// 统计"颜色"规格的分布
esClient.search(s -> s
    .index("products")
    .size(0)
    .aggregations("color_distribution", a -> a
        // 第一层:进入 nested 文档空间
        .nested(n -> n.path("specs"))
        .aggregations("by_spec_name", aa -> aa
            // 过滤:只看 spec_name = "颜色" 的规格
            .filter(f -> f.term(t -> t.field("specs.spec_name").value("颜色")))
            .aggregations("color_values", aaa -> aaa
                .terms(t -> t.field("specs.spec_value").size(20))
            )
        )
    ),
    Void.class
);

五、相关性打分干预------function_score

默认情况下,ES 按 BM25 算法打分:关键词在文档中出现频率越高、文档越短,得分越高。但在实际业务中,我们往往需要把业务因素 (销量、评分、上架时间、是否促销)融入排序。这就是 function_score 的用武之地。

5.1 基础用法:线性衰减

java 复制代码
// 新品优先:上架时间越近,得分越高(时间衰减函数)
esClient.search(s -> s
    .query(q -> q
        .functionScore(fs -> fs
            // 原始查询(基础相关性)
            .query(inner -> inner
                .match(m -> m.field("name").query("手机"))
            )
            // 衰减函数:距今越远,分数乘以越小的系数
            .functions(f -> f
                .gauss(g -> g
                    .field("created_at")
                    .placement(p -> p
                        .origin("now")        // 以当前时间为中心
                        .scale("7d")          // 7天内分数衰减到 0.5
                        .decay(0.5)           // 衰减系数
                        .offset("1d")         // 1天内不衰减
                    )
                )
            )
            // boost_mode: 如何把 function 的结果与原始 _score 结合
            // multiply: 相乘(默认),sum: 相加,replace: 直接用 function 得分
            .boostMode(FunctionBoostMode.Multiply)
        )
    ),
    Product.class
);

5.2 多函数组合:综合排序

这是实际电商搜索中最常用的模式:把多个因素综合起来影响排序。

java 复制代码
// 综合排序:相关性 × 销量权重 × 评分权重 × 新品加成
esClient.search(s -> s
    .query(q -> q
        .functionScore(fs -> fs
            .query(inner -> inner
                .multiMatch(m -> m
                    .query("手机")
                    .fields("name^3", "description")
                )
            )
            .functions(
                // 函数1:按销量加权(sales_count 字段值作为权重因子)
                FunctionScore.of(f -> f
                    .fieldValueFactor(fvf -> fvf
                        .field("sales_count")
                        .factor(0.001)          // 缩放系数,防止销量数值太大压制相关性
                        .modifier(FieldValueFactorModifier.Log1p)  // log(1+x),平滑大值
                        .missing(1.0)           // 字段缺失时的默认值
                    )
                ),
                // 函数2:按评分加权
                FunctionScore.of(f -> f
                    .fieldValueFactor(fvf -> fvf
                        .field("rating")
                        .factor(2.0)
                        .modifier(FieldValueFactorModifier.None)
                        .missing(3.0)
                    )
                ),
                // 函数3:促销商品额外加 boost
                FunctionScore.of(f -> f
                    .filter(filter -> filter
                        .term(t -> t.field("is_promotion").value(true))
                    )
                    .weight(1.5)    // 促销商品得分 × 1.5
                )
            )
            // score_mode: 多个 function 的结果如何合并
            // multiply: 相乘,sum: 相加,avg: 平均,first: 取第一个匹配的
            .scoreMode(FunctionScoreMode.Multiply)
            .boostMode(FunctionBoostMode.Sum)     // function 结果与 _score 相加
            .maxBoost(3.0)   // 限制最大 boost 倍数,防止某个因素把结果完全主导
        )
    ),
    Product.class
);

⚠️ 踩坑script_score 是 function_score 的一种,允许用 Painless 脚本写任意打分逻辑,非常灵活。但每次查询都要对每个命中文档执行脚本,当结果集很大时(几万+命中),性能会急剧下降。建议优先用 field_value_factor 和内置衰减函数,只在不得不用时才用 script_score,并且要配合 pre_filter_shard_size 减少脚本执行次数。


六、高亮(Highlight)实现

高亮是搜索结果中关键词飘红的效果,ES 原生支持,但有几个细节需要注意:

java 复制代码
SearchResponse<Product> response = esClient.search(s -> s
    .index("products")
    .query(q -> q.match(m -> m.field("name").query("iPhone 摄像头")))
    .highlight(h -> h
        // 全局高亮配置
        .preTags("<em class='es-highlight'>")
        .postTags("</em>")
        // 每个字段可以单独覆盖全局配置
        .fields("name", hf -> hf
            // numberOfFragments=0:返回整个字段内容(不截断)
            // 适合标题类短字段
            .numberOfFragments(0)
        )
        .fields("description", hf -> hf
            // 长文本截取3个片段,每段150字
            .numberOfFragments(3)
            .fragmentSize(150)
            // order: score 表示按高亮关键词数量排序片段(最相关的在前)
            .order(HighlighterOrder.Score)
        )
    ),
    Product.class
);

// 从响应中提取高亮内容
response.hits().hits().forEach(hit -> {
    Product product = hit.source();
    Map<String, List<String>> highlights = hit.highlight();

    // 如果有高亮,用高亮内容替换原始内容(前端直接渲染)
    String displayName = highlights.containsKey("name")
            ? highlights.get("name").get(0)
            : product.getName();

    List<String> descFragments = highlights.getOrDefault("description", List.of());
    log.info("商品: {}, 描述摘要: {}", displayName, descFragments);
});

⚠️ 踩坑 :高亮使用的分析器必须和查询时一致,否则高亮会失效(找不到 token 的位置)。如果你在查询时用了 ik_smart,高亮配置里也要保持一致。另外,对于 nested 类型的字段,高亮需要额外配置 nested_path,否则高亮内容是空的。


七、分页方案对比与选择

分页是搜索功能的必选项,但 ES 的三种分页方案各有适用场景,选错了会踩大坑。

7.1 from/size------传统分页

java 复制代码
// 最常见的分页,类似 SQL 的 OFFSET LIMIT
esClient.search(s -> s
    .from(page * size)  // 跳过前 N 条
    .size(size)         // 返回 N 条
    // ...
);

限制from + size 不能超过 index.max_result_window(默认 10000)。超过时报错 Result window is too large

为什么有这个限制?因为 ES 的分布式查询是 scatter-gather 模式:每个分片返回 from + size 条结果,Coordinating Node 汇总后取前 size 条。如果 from=9000, size=10,每个分片要返回 9010 条数据到 Coordinating Node,3 个分片就是 27030 条,内存和网络开销极大。

⚠️ 踩坑 :很多人遇到 max_result_window 限制后,第一反应是把这个值改大(比如改到 100000)。这是饮鸩止渴------数据量越大,深翻页越慢,最终会导致查询超时甚至 OOM。正确方案是用 search_after

7.2 search_after------深翻页的正确姿势

search_after 是游标分页,每次请求返回当前页最后一条数据的排序值,下次请求从这个值之后开始取:

java 复制代码
// 第一页:正常查询,获取第一页数据
SearchResponse<Product> firstPage = esClient.search(s -> s
    .index("products")
    .size(20)
    .query(q -> q.match(m -> m.field("name").query("手机")))
    .sort(
        // 必须有稳定的排序,且最后一个排序字段必须唯一(通常用 _id)
        SortOptions.of(so -> so.field(f -> f.field("_score").order(SortOrder.Desc))),
        SortOptions.of(so -> so.field(f -> f.field("sales_count").order(SortOrder.Desc))),
        SortOptions.of(so -> so.field(f -> f.field("_id").order(SortOrder.Asc)))
    ),
    Product.class
);

// 获取最后一条的排序值,用于下一页查询
List<FieldValue> lastSortValues = firstPage.hits().hits()
        .get(firstPage.hits().hits().size() - 1)
        .sort();

// 第二页:使用 searchAfter
SearchResponse<Product> secondPage = esClient.search(s -> s
    .index("products")
    .size(20)
    .query(q -> q.match(m -> m.field("name").query("手机")))
    .sort(/* 同第一页的排序 */)
    .searchAfter(lastSortValues),    // 关键:传入上一页最后一条的排序值
    Product.class
);

search_after 的优点是无论翻到第几页,性能都是稳定的(每次只取 size 条)。缺点是不支持跳页,只能前后翻,适合"加载更多"或移动端的上拉刷新场景。

7.3 Point In Time(PIT)+ search_after------兼顾一致性

search_after 有一个隐患:翻页过程中,如果有新文档写入或旧文档删除,可能导致数据错位(某条数据被跳过或重复出现)。PIT(时间点快照)解决了这个问题:

java 复制代码
// 1. 创建 PIT 快照(有效期10分钟)
OpenPointInTimeResponse pitResponse = esClient.openPointInTime(p -> p
    .index("products")
    .keepAlive(Time.of(t -> t.time("10m")))
);
String pitId = pitResponse.id();

// 2. 使用 PIT 分页(数据集固定在创建 PIT 时的快照)
SearchResponse<Product> response = esClient.search(s -> s
    .pit(p -> p.id(pitId).keepAlive(Time.of(t -> t.time("10m"))))
    .size(20)
    .sort(SortOptions.of(so -> so.field(f -> f.field("_shard_doc").order(SortOrder.Asc))))
    .searchAfter(lastSortValues),
    Product.class
);

// 3. 用完后关闭 PIT,释放服务端资源
esClient.closePointInTime(c -> c.id(pitId));

7.4 三种方案对比

方案 跳页 一致性 性能 适用场景
from/size 低(无快照) 深翻页性能差 数据量小,精确跳页
search_after 中(无快照) 稳定 O(size) 加载更多、移动端
PIT + search_after 高(快照) 稳定 O(size) 高一致性分页需求

八、Java 完整搜索服务封装

把前面所有知识点整合成一个生产可用的搜索服务:

java 复制代码
// ProductSearchService.java(完整版)
@Service
@RequiredArgsConstructor
@Slf4j
public class ProductSearchService {

    private final ElasticsearchClient esClient;

    /**
     * 电商商品搜索:综合了全文检索、多维过滤、聚合统计、高亮、分页
     *
     * 设计原则:
     * 1. 关键词检索放 must,影响相关性排序
     * 2. 所有过滤条件放 filter,走缓存提升性能
     * 3. function_score 融入业务权重(销量、评分)
     * 4. 聚合与搜索同请求完成,减少网络往返
     */
    public ProductSearchResponse search(ProductSearchRequest req) {
        try {
            SearchResponse<Product> esResponse = esClient.search(s -> s
                .index(req.getTenantIndexName())  // SaaS:按租户路由到不同索引
                .from(req.getPage() * req.getSize())
                .size(req.getSize())
                .query(buildQuery(req))
                .highlight(buildHighlight())
                .aggregations("brands", a -> a.terms(t -> t.field("brand").size(20)))
                .aggregations("price_ranges", a -> a.range(r -> r
                    .field("price")
                    .ranges(
                        AggregationRange.of(ar -> ar.key("0-999").to(1000.0)),
                        AggregationRange.of(ar -> ar.key("1000-2999").from(1000.0).to(3000.0)),
                        AggregationRange.of(ar -> ar.key("3000+").from(3000.0))
                    )
                ))
                .sort(buildSort(req)),
                Product.class
            );

            return ProductSearchResponse.builder()
                    .total(esResponse.hits().total().value())
                    .products(parseHits(esResponse))
                    .brandFacets(parseBrandFacets(esResponse))
                    .priceRangeFacets(parsePriceRangeFacets(esResponse))
                    .build();

        } catch (IOException e) {
            log.error("ES 查询异常", e);
            throw new SearchException("搜索服务暂时不可用", e);
        }
    }

    private Query buildQuery(ProductSearchRequest req) {
        return Query.of(q -> q
            .functionScore(fs -> fs
                .query(inner -> inner
                    .bool(b -> {
                        // 全文检索
                        if (StringUtils.hasText(req.getKeyword())) {
                            b.must(m -> m.multiMatch(mm -> mm
                                .query(req.getKeyword())
                                .fields("name^3", "brand^2", "description", "tags^1.5")
                            ));
                        } else {
                            b.must(m -> m.matchAll(ma -> ma));
                        }
                        // SaaS 租户隔离(必须带这个过滤)
                        b.filter(f -> f.term(t -> t.field("tenant_id").value(req.getTenantId())));
                        // 品牌过滤
                        if (!CollectionUtils.isEmpty(req.getBrands())) {
                            b.filter(f -> f.terms(t -> t.field("brand")
                                .terms(tv -> tv.value(req.getBrands().stream()
                                    .map(FieldValue::of)
                                    .collect(Collectors.toList())))));
                        }
                        // 价格范围
                        if (req.getMinPrice() != null || req.getMaxPrice() != null) {
                            b.filter(f -> f.range(r -> {
                                r.field("price");
                                if (req.getMinPrice() != null) r.gte(JsonData.of(req.getMinPrice()));
                                if (req.getMaxPrice() != null) r.lte(JsonData.of(req.getMaxPrice()));
                                return r;
                            }));
                        }
                        b.filter(f -> f.term(t -> t.field("is_active").value(true)));
                        return b;
                    })
                )
                // 销量和评分加权
                .functions(
                    FunctionScore.of(f -> f.fieldValueFactor(fvf -> fvf
                        .field("sales_count").factor(0.0001).modifier(FieldValueFactorModifier.Log1p).missing(1.0)
                    )),
                    FunctionScore.of(f -> f.fieldValueFactor(fvf -> fvf
                        .field("rating").factor(1.0).modifier(FieldValueFactorModifier.None).missing(3.0)
                    ))
                )
                .scoreMode(FunctionScoreMode.Sum)
                .boostMode(FunctionBoostMode.Multiply)
            )
        );
    }

    private HighlightBase buildHighlight() {
        return HighlightBase.of(h -> h
            .preTags("<em>").postTags("</em>")
            .fields("name", f -> f.numberOfFragments(0))
            .fields("description", f -> f.numberOfFragments(2).fragmentSize(120))
        );
    }

    private List<SortOptions> buildSort(ProductSearchRequest req) {
        // 按用户选择的排序方式
        return switch (req.getSortBy()) {
            case "price_asc" -> List.of(
                SortOptions.of(so -> so.field(f -> f.field("price").order(SortOrder.Asc)))
            );
            case "price_desc" -> List.of(
                SortOptions.of(so -> so.field(f -> f.field("price").order(SortOrder.Desc)))
            );
            case "sales" -> List.of(
                SortOptions.of(so -> so.field(f -> f.field("sales_count").order(SortOrder.Desc)))
            );
            default -> List.of(
                SortOptions.of(so -> so.field(f -> f.field("_score").order(SortOrder.Desc))),
                SortOptions.of(so -> so.field(f -> f.field("sales_count").order(SortOrder.Desc)))
            );
        };
    }
}

❓ 高频面试 & AI 问答

Q: ES 的 query 和 filter 有什么区别?

A: query 在 Query Context 下执行,会计算相关性分数(_score),结果按分数排序,不走缓存;filter 在 Filter Context 下执行,只判断文档是否满足条件,不计算分数,结果可以被缓存,性能更好。实践中,全文检索放 query,精确过滤条件放 filter。

Q: bool 查询的 must 和 filter 都是"必须匹配",区别是什么?

A: must 会参与相关性分数计算(影响 _score),filter 不参与计分且结果可缓存。对于精确过滤条件(品牌、分类、价格区间),放 filter 性能更好;对于全文检索(需要按相关性排序),放 must。

Q: ES 深翻页为什么性能差?如何解决?

A: ES 用 scatter-gather 模式分页,每个分片都需要返回 from+size 条数据,from 越大,每个分片返回的数据越多,网络和内存开销呈线性增长。解决方案是使用 search_after 游标分页,或结合 Point In Time 保证翻页一致性。

Q: function_score 和直接对结果排序(sort)有什么区别?

A: sort 完全忽略相关性分数,按指定字段绝对排序;function_score 是在相关性分数基础上做权重干预,保留了"关键词相关性"的影响,同时融入业务因素。搜索场景通常用 function_score,纯列表浏览场景用 sort


🔗 上一篇

第03篇深入 Mapping 与数据类型设计------ES Schema 设计避坑指南

🔗 下一篇

第05篇分词器深度定制 + 中文搜索优化------让搜索真正"懂"中文

为什么"华为"搜不到"华为 Mate 60"?为什么搜"shouji"找不到"手机"?中文分词器的坑,比英文多得多。下篇从 IK 分词器安装开始,到自定义词典、拼音搜索、同义词扩展,再到搜索建议(自动补全 + 拼写纠错),全部实战演示。

相关推荐
做个文艺程序员3 小时前
第09篇:ES 数据同步方案——Canal + Logstash + Flink 全方案对比与实战
大数据·elasticsearch·mysql同步es·es数据同步·flink实时同步·es增量同步
杰克尼11 小时前
天机学堂复习总结(day03-day04)
java·开发语言·redis·elasticsearch·spring cloud
一勺菠萝丶21 小时前
Git Tag 使用教程:如何打 Tag、切换 Tag、推送 Tag 和删除 Tag
大数据·git·elasticsearch
Elastic 中国社区官方博客1 天前
Kibana 中的 AI Chat 现在可以原生渲染仪表板
大数据·数据库·人工智能·elasticsearch·搜索引擎·云原生
Elastic 中国社区官方博客1 天前
Elastic 的 ML 与 AI Assistant 如何将 NOC 中 802.1x 故障排查时间从 20 分钟缩短到数秒
大数据·运维·人工智能·elasticsearch·搜索引擎·全文检索·可用性测试
Gavin-Wang1 天前
swift 项目 commit 规范
大数据·elasticsearch·搜索引擎
Wils0nEdwards2 天前
Windows本地 git 版本管理
windows·git·elasticsearch
不吃鱼的羊2 天前
提交代码添加Change-Id
大数据·elasticsearch·搜索引擎
逸Y 仙X2 天前
文章四:Elasticsearch 的扩容与集群升级
java·大数据·elasticsearch·搜索引擎·全文检索