适合人群:已经创建
blog_articlesMapping,想把“用户关键词 + 业务筛选 + 排序展示”实现为可解释、可测试 Query DSL 的 Java 开发者。
本文使用 Elasticsearch 8.x request body search。查询字段必须与 Mapping 对应,不能脱离text、keyword和 analyzer 直接复制 JSON。
前两篇完成了环境和字段基础:第 02 篇:Docker、Kibana 与 CRUD 调试、第 03 篇:Mapping、分词与中文搜索设计。本篇将召回、过滤、评分、高亮和诊断组织成一个搜索接口。
一、request body search 的结果结构
搜索通常写为 GET /blog_articles/_search,也可以使用 POST 以兼容不支持 GET body 的 HTTP 客户端:
1 | GET /blog_articles/_search |
响应中的 hits.total 是命中数,hits.hits 是当前页,_source 是业务文档,_score 是当前查询下的相关性分数。_score 会随查询、分析器、索引统计和分片变化,不能作为跨查询可比较的业务分数。
1 | { |
大结果集的 total.relation 可能是 gte,只表示达到某个下限。列表页不必总是精确计数;需要精确总数时显式设置 track_total_hits: true,同时了解统计成本。
二、全文查询:match、multi_match、match_phrase
2.1 match 让查询文本经过分析
match 用字段的 search_analyzer 处理自然语言,再到倒排索引召回:
1 | GET /blog_articles/_search |
默认 operator 是 or,任一词项命中即可;and 要求全部词项命中,召回更窄。中长中文输入可以用 minimum_should_match,而不是一律要求全部词:
1 | "match": { |
不要对 text 字段直接用 term 期待“用户搜索”。如果结果异常,先用 _analyze 对比索引期和查询期 token,再决定是否调整 analyzer 或条件。
2.2 multi_match 同时搜索标题和正文
1 | GET /blog_articles/_search |
title^3 表示标题信号更重要。常用类型有默认 best_fields(取一个字段的最佳匹配)、most_fields(累加多个字段信号)、cross_fields(把多个字段当组合字段)和 phrase(多字段短语)。字段 boost 是相对权重,不保证每次结果的分数按整数倍变化。
2.3 match_phrase 处理词项顺序和距离
1 | GET /blog_articles/_search |
slop 是 token 位置距离,不是字符编辑距离。中文分词后一个业务词可能对应多个 token,调大前先观察 positions。短语常作为加分而不是唯一召回条件:
1 | "bool": { |
三、精确条件:term、terms、range、exists
term 不分析查询值,适合 keyword、数值、日期和布尔字段:
1 | "term": { "category": "Java" } |
terms 表达一组完整值的 OR:
1 | "terms": { "category": ["Java", "Elasticsearch", "Spring Boot"] } |
用户输入不能不加限制地传入 terms;Java 层应校验允许值和列表长度。若 category 是 text 或 normalizer 改变了大小写,先确认实际索引词项。
日期和数值范围使用 range:
1 | "range": { |
Java 接口应把用户日期和时区转换为明确的 Instant 区间,避免“当天 23:59:59”遗漏毫秒。exists 只判断是否有可索引值,不等同于业务上已经发布:
1 | "exists": { "field": "publishedAt" } |
业务状态应使用明确的 status 字段,不要把字段存在性当作领域规则。
四、bool:让每个条件各司其职
文章搜索常见目标是:关键词匹配标题或正文;分类、时间和租户必须通过;短语或标签命中加分;草稿排除。用 bool 把意图分开:
1 | GET /blog_articles/_search |
- must:必须匹配并参与评分,通常承载全文召回。
- filter:必须通过但不计分,适合状态、分类、租户、时间和权限范围。
- should:命中后加分;若 bool 没有 must/filter,默认至少命中一个,复杂条件请显式设置
minimum_should_match。 - must_not:排除黑名单、草稿、软删除数据,不产生正向分数。
把精确条件全部塞进 must 会让过滤也参与评分;把租户 filter 完全交给前端则是越权风险。服务端必须强制注入不可绕过的权限条件。
五、相关性、boost 与 function_score
默认全文评分通常基于 BM25。直觉上,稀有词、词频、字段长度和各个子查询都会影响分数。它解决“当前搜索谁更像”,并不自动代表热门度、新鲜度或业务优先级。
字段和子查询可用 boost:
1 | "multi_match": { |
先修复召回、Mapping、分析器和 filter,再调 boost。调参要覆盖真实 query 日志中的短词、长句、同义词、零结果和跨分类样本,评价首条命中、点击和零结果率,而不是只看 _score 数字。function_score 可把业务数值温和地合进相关性:
1 | GET /blog_articles/_search |
field_value_factor 适合浏览量等数值;gauss 等衰减函数可以表达发布时间或距离越近分越高。max_boost 防止热门度压过文本意图。热门信号会产生马太效应,应结合新鲜度、探索流量和业务指标评估。
六、排序、_source 与分页
默认按 _score 降序。需要稳定页面时声明完整 sort 并加 tie-breaker:
1 | GET /blog_articles/_search |
text 不能直接排序,使用 title.raw 等 keyword multi-field。_source 过滤只缩小响应,不是权限脱敏;敏感数据仍要由 Mapping、权限和 Java DTO 共同控制。
小页码可用 from/size,深分页应使用 search_after:
1 | GET /blog_articles/_search |
将上一页最后一个 hit 的 sort 数组作为下一页游标。数据持续写入时边界仍可能变化;需要一致快照时使用 Point in Time(PIT)并管理其 keep_alive。
七、高亮:在正确位置展示命中
1 | GET /blog_articles/_search |
标题用完整片段,正文返回有限片段,避免列表页携带全文。highlight 是包含标签的片段;前端必须采用白名单方式安全渲染,不能把它当可信 HTML。高亮不自然时检查分析器、词项、字段位置和 query,再配置 require_field_match、boundary_scanner 或 matched_fields。
八、_explain 与 Profile:让结果可诊断
_explain 分析特定文档为什么匹配、各分数从哪里来:
1 | GET /blog_articles/_explain/101 |
它适合确认标题 boost、should 与 BM25 是否生效,但不能在生产接口中对每一条 hit 自动调用。对慢查询使用 profile: true:
1 | GET /blog_articles/_search |
Profile 的 query、rewrite_time 等帮助定位哪个阶段耗时,但 Profile 自身会改变性能,且不包含网络、排队和所有磁盘等待。结合慢日志、节点指标、分片数量和真实流量建立基线再优化。
九、Java 服务如何组织查询
不要让前端传入任意 field name 或原始 DSL。服务端将参数转成白名单字段、限定分类列表和时间范围,再用官方 Java API Client 或 Spring Data 的 NativeQuery 构造 bool。核心约束是:
- 文本为空时定义清楚是
match_all、仅 filter,还是拒绝请求。 - 租户、权限和软删除 filter 由服务端强制加入。
- 排序字段、方向、分页大小都使用允许列表和上限。
- 最终请求 JSON、Mapping、典型命中与高亮响应应由集成测试覆盖。
一个可维护的调试顺序是:先用单match验证召回;加multi_match;再加 filter;最后加 should、sort、_source、highlight。每一步保留请求与结果,使用 explain 回答“为什么排前”,Profile 回答“哪一段慢”。
十、总结与练习
Query DSL 的骨架是:match、multi_match、match_phrase 处理全文意图;term、terms、range、exists 表达结构化条件;bool 用 must、filter、should、must_not 清楚分工。相关性可通过 boost 与 function_score 融合业务信号,但它必须由真实数据验证。
稳定搜索还需要正确的排序与分页、受控 _source、安全高亮以及 explain/Profile 诊断。Java 应用应以 Mapping 为契约、以白名单构造查询、以集成测试保存关键请求和预期结果。
基础检索链路至此完整:第 01 篇:全文检索与核心概念 → 第 02 篇:Docker、Kibana 与 CRUD 调试 → 第 03 篇:Mapping、分词与中文搜索设计 → 本篇。接下来进入搜索页工程化:第 05 篇:聚合、筛选面板与深分页方案。
思考与练习
- 为
blog_articles写“标题权重高、分类和时间过滤、短语加分、草稿排除”的查询,并用 explain 比较两篇文档。- 用
search_after替换深分页,为日期相同的文档加入稳定 ID 排序,验证翻页不重复。- 增加
_sourceincludes 和正文高亮,设计前端安全渲染 highlight 片段的响应 DTO。- 使用 Profile 比较分类进入 must 与 filter 的表现;结合慢日志写出一次可复现的优化结论。