Spring Boot 集成 Elasticsearch:中文分词与关键字高亮对接文档
一、整体方案
┌──────────────┐ ①查询请求 ┌──────────────────────┐ ②ES 查询 ┌─────────────┐
│ 前端/调用方 │ ───────────▶ │ Spring Boot 服务 │ ───────────▶ │ Elasticsearch│
│ │ │ · QueryBuilder 组装 │ │ · IK 分词器 │
│ │ ◀─────────── │ · 高亮结果解析 │ ◀─────────── │ · 倒排索引 │
└──────────────┘ ④带高亮HTML └──────────────────────┘ ③source+ └─────────────┘
highlight 片段
三个关键点:
- 分词 :中文必须用分词器(本文用 IK),否则「供应链金融」会被当成一个字一个字或整串匹配,召回率极差。分词器是 ES 插件,需要单独安装(见第三章)。
- 高亮 :由 ES 在返回结果时附带命中片段(
highlight),服务端把片段存成xxx_HTML字段返回给前端渲染,不需要自己写字符串匹配。 - 查询条件组装 :用「接口 + 多种 bool 上下文实现 + 工厂」的方式组装条件,业务代码里只描述"要查什么",不关心
must/filter/should的细节(见第五章)。
参考实现的版本体系:Spring Boot 2.3.x + ES Java Client 7.6.x(RestHighLevelClient)+ Spring Data Elasticsearch 4.0.x。
二、依赖与客户端
2.1 Maven 依赖
xml
<!-- ES 集成(自带 spring-data-elasticsearch 与 ES 官方 Java Client) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>
说明:
- 该 starter 会带入
spring-data-elasticsearch与elasticsearch-rest-high-level-client,本文用的是后者(Low Level 之上的RestHighLevelClient),它既能结构化拼查询,也能拿到原始 JSON,兼顾可读性与灵活度。 - 不要 再手动引入
org.elasticsearch.client:elasticsearch-rest-client的其他版本,容易与 starter 管理的版本冲突导致NoSuchMethodError。 - Spring Boot 3.x / ES 8.x 的差异提醒:ES 8 已移除
RestHighLevelClient,需要改用新的elasticsearch-java客户端(co.elastic.clients.elasticsearch.*,构建方式为 Lambda 风格的 Builder)。本文的思路完全一致,仅 API 写法不同。
2.2 配置项(application.yml)
yaml
elasticsearch:
# 多个节点用逗号分隔,如 host1:9200,host2:9200
uris: ${ES_URIS:localhost:9200}
username: ${ES_USERNAME:}
password: ${ES_PASSWORD:}
# 索引别名(查询、写入都走别名,便于索引重建时不改代码)
alias: ${ES_ALIAS:example_index}
生产环境请把账号密码放到配置中心 / 环境变量 / 加密配置里,不要明文入库。
2.3 客户端初始化
RestHighLevelClient 是 线程安全 的,全局单例即可,务必配置连接池与超时:
java
@Slf4j
@Configuration
public class ElasticsearchConfig {
@Value("${elasticsearch.uris}")
private String uris;
@Value("${elasticsearch.username:}")
private String username;
@Value("${elasticsearch.password:}")
private String password;
@Bean(destroyMethod = "close")
public RestHighLevelClient restHighLevelClient() {
String[] uriArr = uris.split(",");
HttpHost[] hosts = new HttpHost[uriArr.length];
for (int i = 0; i < uriArr.length; i++) {
String[] hostAndPort = uriArr[i].trim().split(":");
hosts[i] = new HttpHost(hostAndPort[0], Integer.parseInt(hostAndPort[1]), "http");
}
RestClientBuilder builder = RestClient.builder(hosts);
// 账号密码(未配置则跳过,方便本地无鉴权环境直接跑)
if (StringUtils.hasText(username)) {
CredentialsProvider provider = new BasicCredentialsProvider();
provider.setCredentials(AuthScope.ANY,
new UsernamePasswordCredentials(username, password));
builder.setHttpClientConfigCallback(http -> http
.setDefaultCredentialsProvider(provider)
.setMaxConnTotal(100) // 连接池总上限
.setMaxConnPerRoute(15) // 每个节点上限
.setKeepAliveStrategy((resp, ctx) -> Duration.ofMinutes(5).toMillis()));
}
// 超时:默认值偏小,长文本聚合类查询容易超时,按业务调整
builder.setRequestConfigCallback(cfg -> cfg
.setConnectTimeout(5000)
.setSocketTimeout(60000));
return new RestHighLevelClient(builder);
}
}
要点:
destroyMethod = "close":容器关闭时释放连接,避免优雅下线时报连接泄漏。- 连接数与超时必须显式设置:
connectTimeout默认 1 秒,在网络抖动或节点切换时容易直接失败。 - 用别名(alias)做查询入口:后续重建索引(换分词器、改 mapping)时只要把别名指向新索引,业务代码零改动。
三、中文分词(IK)
3.1 结论先行:IK 是需要安装的插件
ik_smart、ik_max_word 这两个分析器名 不是 ES 自带的 ,它们由插件 elasticsearch-analysis-ik 提供。如果 ES 上没装 IK,会出现以下报错:
- 建索引时:
analyzer [ik_smart] not found for field [xxx] - 查询时:
failed to create query ... analyzer [ik_smart] not found - 调分词接口时:返回
"error": "analyzer [ik_smart] not found"
3.2 安装步骤
版本必须与 ES 完全一致 (7.6.2 的 ES 只能用 7.6.2 的 IK 包,不能用 7.6.0 或 7.10.x)。
- 从 IK 官方 release 下载与 ES 版本号一致的 zip 包(文件名形如
elasticsearch-analysis-ik-<ES版本>.zip)。 - 在每个 ES 节点上安装并重启该节点:
bash
# 在线安装(节点可访问外网时)
bin/elasticsearch-plugin install https://<release地址>/elasticsearch-analysis-ik-<ES版本>.zip
# 离线安装(内网环境:把 zip 分发到每个节点后执行)
bin/elasticsearch-plugin install file:///opt/plugins/elasticsearch-analysis-ik-<ES版本>.zip
# 集群需滚动重启各节点
systemctl restart elasticsearch
- 验证:
bash
# 应能看到 analysis-ik
GET _cat/plugins?v
# 分词效果自检
POST _analyze
{
"analyzer": "ik_smart",
"text": "供应链金融服务平台"
}
3.3 ik_smart 与 ik_max_word 怎么选
| 分析器 | 切分粒度 | 产物示例("供应链金融服务平台") | 适用位置 |
|---|---|---|---|
ik_max_word |
最细粒度,穷举所有可能词 | 供应链 / 供应 / 金融 / 金融服务 / 服务 / 服务平台 / 平台 ... | 索引时(mapping 的 analyzer) |
ik_smart |
粗粒度,按语义切最粗的词 | 供应链 / 金融服务 / 平台 | 查询时,或需要独立调分词接口时 |
实践建议:索引用 ik_max_word(提高召回),查询用 ik_smart(提高精度),即 mapping 里写:
json
"content": {
"type": "text",
"analyzer": "ik_max_word",
"search_analyzer": "ik_smart"
}
另外,如果字段既要做全文检索、又要做精确匹配/排序/聚合,就建成多字段(multi-field):
json
"title": {
"type": "text",
"analyzer": "ik_max_word",
"search_analyzer": "ik_smart",
"fields": {
"keyword": { "type": "keyword" }
}
}
3.4 自定义分析器(如姓名、机构名专用分词)
参考实现里除了 ik_smart,还出现过 rm_ik_smart、qymc_ik_smart 这类名字------它们不是插件提供的 ,而是在索引的 settings 里基于 IK 分词器 + 过滤器自定义的分析器,例如:
json
{
"settings": {
"analysis": {
"analyzer": {
"my_name_analyzer": {
"type": "custom",
"tokenizer": "ik_smart",
"filter": ["lowercase"]
}
}
}
}
}
所以:自定义分析器必须建索引时一起定义,业务代码里只引用名字;换名字或换 tokenizer 属于"改 mapping",需要重建索引(配合别名切换,对业务无感)。
补充:IK 支持自定义词库(
config/IKAnalyzer.cfg.xml里配置本地词典或远程词典 URL),把行业专有名词(如产品名、机构简称)加进去,能显著提升召回。词典改动后需要重新加载(远程词典可配置自动热更新)。
3.5 代码里两种使用方式
方式 A(推荐):把分词交给 ES ,直接写 match / match_phrase 查询,ES 会用字段配置的分析器自动分词:
java
QueryBuilders.matchQuery("content", keyword); // 分词后 OR 匹配,命中即可,
QueryBuilders.matchPhraseQuery("content", keyword); // 分词后要求"连续且顺序一致",更精确
方式 B:先主动分词拿到词表,再自行拼条件。适合需要控制分词结果(比如给不同词设不同权重、或先剔除无意义后缀词)的场景:
java
public List<String> analyze(String text, String analyzer) {
// 查别名对应的真实索引(分词器需指定索引,或用索引 settings 里的默认分析器)
String index = resolveIndexByAlias(alias);
AnalyzeRequest request = AnalyzeRequest.withIndexAnalyzer(index, analyzer, text);
try {
AnalyzeResponse response = restHighLevelClient.indices()
.analyze(request, RequestOptions.DEFAULT);
return response.getTokens().stream()
.map(AnalyzeResponse.AnalyzeToken::getTerm)
.filter(StringUtils::hasText)
.collect(Collectors.toList());
} catch (IOException e) {
// 分词失败时退化为原始关键字,避免整个查询失败
log.warn("分词失败,降级使用原始关键字, analyzer={}", analyzer, e);
return Collections.singletonList(text);
}
}
拿到词表后常见的两种拼法:
java
// 1) 全部词都必须命中(AND),相关性更严
IESQueryBuilders must = ESQueryBuilderFactory.creatESQueryBuilder(ESQueryMode.MUST);
for (String token : tokens) {
must.matchPhraseQuery("content", token);
}
// 2) 原词 + 分词词一起作为 OR 条件,并按权重加权
IESQueryBuilders should = ESQueryBuilderFactory.creatESQueryBuilder(ESQueryMode.SHOULD);
should.matchQuery("content", keyword).getBoolQueryBuilder().boost(50f);
for (String token : tokens) {
should.matchQuery("content", token);
}
注意:方式 B 多了一次 ES 往返(分词请求),QPS 高的接口建议对分词结果做短时缓存 (同一关键字的分词结果稳定,可用本地缓存 + 5~10 分钟过期)。另外,ES 的
_analyze请求要指定索引(或analyzer全局存在),否则节点上找不到自定义分析器。
四、关键字高亮
4.1 配置高亮规则
高亮由 HighlightBuilder 描述,挂在 SearchSourceBuilder 上。只有会走分析器的查询(match / match_phrase)才会产生高亮 ,term / wildcard 这类精确查询不会高亮。
java
HighlightBuilder highlight = new HighlightBuilder();
highlight.preTags("<span class='hit'>"); // 命中内容前缀
highlight.postTags("</span>"); // 命中内容后缀
highlight.fragmentSize(200); // 片段长度(字符)
highlight.numOfFragments(1); // 每个字段最多返回几个片段
highlight.requireFieldMatch(false); // 不要求高亮字段与查询字段完全一致
// 需要高亮的字段(多字段逐个声明,可单独设置片段长度)
highlight.field(new HighlightBuilder.Field("title").fragmentSize(50).numOfFragments(1));
highlight.field(new HighlightBuilder.Field("content").fragmentSize(200).numOfFragments(1));
各参数说明:
| 参数 | 作用 | 常见取值 |
|---|---|---|
preTags / postTags |
包裹命中词的标签,最终由前端按 HTML 渲染 | <span class='hit'> / </span>(也可用 <em>) |
fragmentSize |
单个片段长度,过长会截断上下文 | 标题 30~60,正文 150~300 |
numOfFragments |
返回片段数量,0 表示按整字段返回 | 列表页取 1,详情页取 3~5 |
requireFieldMatch(false) |
允许高亮非查询字段上的相同内容 | 一般设 false,避免"查 A 字段但想高亮 B 字段"时拿不到高亮 |
noMatchSize |
没命中时也返回前 N 个字符 | 列表页做摘要时有用 |
preTags里可以是任意 HTML。若想直接带样式,也可以写内联样式(如<span style="color:#E67700">),省掉前端样式定义。前端渲染时务必确认是内部可信内容(或做白名单过滤),避免 XSS。
4.2 嵌套字段(nested)的高亮
当要检索的是数组对象内部 的字段(例如一个文档下有多个子项,每个子项有 name),且希望"只高亮命中的那个子项"时,必须用 nested 查询 + inner_hits,并把高亮挂到 inner hit 上:
java
HighlightBuilder highlight = new HighlightBuilder()
.preTags("<span class='hit'>").postTags("</span>")
.requireFieldMatch(false);
// 子项内精确命中(term 命中 + keyword 子字段)
NestedQueryBuilder exact = QueryBuilders.nestedQuery("items",
QueryBuilders.termQuery("items.name.keyword", keyword), ScoreMode.Max)
.boost(10f);
exact.innerHit(new InnerHitBuilder("items_exact")
.setIgnoreUnmapped(false)
.setHighlightBuilder(highlight));
// 子项内模糊命中(分词短语匹配)
NestedQueryBuilder fuzzy = QueryBuilders.nestedQuery("items",
QueryBuilders.matchPhraseQuery("items.name", keyword), ScoreMode.Max)
.boost(5f);
fuzzy.innerHit(new InnerHitBuilder("items_fuzzy")
.setIgnoreUnmapped(false)
.setHighlightBuilder(highlight));
BoolQueryBuilder keywordQuery = QueryBuilders.boolQuery()
.should(exact)
.should(fuzzy); // 精确与模糊都要,权重不同
- inner hit 需要命名 :名字用于回读结果(
searchHit.getInnerHits().get("items_exact")),不同匹配策略用不同名字,避免互相覆盖。 - inner hit 有自己的
from/size,默认只返回前 3 条子项,子项多时需要显式设置。 - 若列表接口既要"整体文档字段"的高亮,也要"子项字段"的高亮,就把
highlight.field(...)与innerHit.setHighlightBuilder(...)分别配置。
4.3 执行查询与解析高亮结果
java
public PageResult search(SearchRequest request, String[] highlightFields) throws IOException {
// 分页
sourceBuilder.from((pageNo - 1) * pageSize);
sourceBuilder.size(pageSize);
sourceBuilder.trackTotalHits(true); // 关键:默认只精确统计到 10000 条
sourceBuilder.highlighter(highlightBuilder);
request.source(sourceBuilder);
SearchResponse response = restHighLevelClient.search(request, RequestOptions.DEFAULT);
long total = response.getHits().getTotalHits().value;
List<Map<String, Object>> rows = new ArrayList<>();
for (SearchHit hit : response.getHits().getHits()) {
Map<String, Object> row = hit.getSourceAsMap();
row.put("id", hit.getId());
// 普通字段高亮:拼接片段,写入「字段名_HTML」
for (String field : highlightFields) {
HighlightField highlightField = hit.getHighlightFields().get(field);
if (highlightField == null || highlightField.getFragments().length == 0) {
continue;
}
String fragment = Arrays.stream(highlightField.getFragments())
.map(Text::string)
.collect(Collectors.joining(""));
row.put(field + "_HTML", fragment);
}
// 嵌套字段高亮:从 inner hits 里取子项,并把高亮写到子项的「子字段_HTML」
SearchHits innerHits = hit.getInnerHits() != null ? hit.getInnerHits().get("items_fuzzy") : null;
if (innerHits != null && innerHits.getTotalHits().value > 0) {
List<Map<String, Object>> items = new ArrayList<>();
for (SearchHit inner : innerHits.getHits()) {
Map<String, Object> item = inner.getSourceAsMap();
HighlightField innerHighlight = inner.getHighlightFields().get("items.name");
if (innerHighlight != null && innerHighlight.getFragments().length > 0) {
item.put("name_HTML", Arrays.stream(innerHighlight.getFragments())
.map(Text::string).collect(Collectors.joining("")));
}
items.add(item);
}
row.put("items", items);
}
rows.add(row);
}
return new PageResult(total, rows);
}
返回给前端的数据约定(建议固定下来):
xxx_HTML:该字段命中后的高亮片段 (含preTags/postTags);前端有_HTML就渲染 HTML,没有再渲染原始字段。xxx:原始字段值,始终返回,便于前端复制/导出/排序。
五、查询条件构建器(IESQueryBuilders)
5.1 为什么需要这层抽象
ES 的 BoolQueryBuilder 有 4 个语义完全不同的子句容器:
| 子句 | 语义 | 是否参与打分 | 典型用途 |
|---|---|---|---|
must |
AND,必须满足 | 参与 | 关键字匹配 |
filter |
AND,必须满足 | 不参与(可缓存,性能好) | 状态、地区、ID 等精确过滤 |
should |
OR,满足若干即可 | 参与 | 多字段匹配、加权 |
mustNot |
NOT,必须不满足 | 不参与 | 排除删除态、黑名单 |
业务代码里到处 if (xxx) boolQueryBuilder.must(...) 会非常冗长,且容易把"过滤条件"错写成"打分条件"(拖慢查询)。因此抽一层:
ESQueryMode(MUST / FILTER / SHOULD / MUST_NOT)
│
ESQueryBuilderFactory.creatESQueryBuilder(mode)
│
┌────────────┼────────────┬──────────────┐
ESMust ESFilter ESShould ESMustNot ← 都实现 IESQueryBuilders
(must) (filter) (should) (mustNot)
│
链式拼条件 → getBoolQueryBuilder() → 交给 BoolQueryBuilder
即:同一个接口方法名(如 termQuery),在不同实现里被放进不同的 bool 子句容器 。业务侧只表达"我要一个 term 条件,语义是过滤",由实现决定落到 filter 还是 must。
5.2 接口方法逐个说明
| 方法 | 生成的条件 | 说明与注意点 |
|---|---|---|
termQuery(name, value) |
term 查询 |
不分词 的精确匹配。对 text 字段要写带 .keyword 的子字段(如 status.keyword),否则值被分词后往往匹配不到;对 keyword/数值/日期字段直接用字段名。适合状态、枚举、编码类条件。 |
termsQuery(name, values...) |
terms 查询(IN) |
一次匹配多个值,等价 SQL 的 IN。ES 默认 max_terms_count = 65536,超长数组会被拒绝,需分批或改用其他方案。 |
matchPhraseQuery(name, value) |
match_phrase 查询 |
会分词 ,要求词项"连续且顺序一致",相当于短语搜索。比 match 精确、比 term 宽松,是最常用的"关键字主体匹配",也是能产生高亮的查询之一。 |
wildcardQuery(name, values...) |
wildcard 查询,自动为每个值前后加 * |
模糊包含匹配,等价 SQL 的 LIKE '%value%'。⚠️ 前导通配符无法走索引 ,数据量大时非常慢,慎用;若必须用,只对 keyword 子字段用,并尽量限制其他过滤条件。 |
rangeQuery(name, from, to) |
range 查询,闭区间 (includeUpper(true) + includeLower(true)) |
区间过滤,适合金额、注册资本、时间等。该方法已标记 @Deprecated,建议改用下面重载。 |
rangeQuery(name, compare, val) |
单边 range 查询(gte / gt / lte / lt) |
替代上面的重载:用 ESCompareEnum.GTE / GT / LTE / LT 表达"大于等于/大于/小于等于/小于",可分别构建下界与上界(前端常见的"最小值/最大值"输入框正好对应两次调用)。 |
getBoolQueryBuilder() |
返回内部累积的 BoolQueryBuilder |
取值出口:既可以作为子查询嵌入上层 bool(实现嵌套组合),也可以直接用于 SearchSourceBuilder.query(...)。另外可用它调 boost(...) 给整组条件加权,或用 hasClauses() 判断是否真的拼了条件(避免把空 bool 塞进查询导致 match_all 意外生效)。 |
四个实现(ESMust / ESFilter / ESShould / ESMustNot)的方法签名与行为完全一致,唯一差异是落到 must / filter / should / mustNot。
实现细节与使用注意:
ESMustNot语义是"必须不满足",但接口里仍然只有termQuery这类正向 方法名------即"把 term 条件放进 mustNot",表达的是"该 term 命中则排除",不要误读成"term 取反"。另外这四个实现类内部各持有一个BoolQueryBuilder实例,不是线程安全的 ,所以它们都是"每次查询现用现建"(参考实现用工厂 + 反射newInstance创建,因此也要求实现类必须有无参构造),不要写成 Spring 单例成员变量复用。
5.3 使用示例
java
// 一次查询:关键字(打分)+ 城市过滤(不打分)+ 金额区间 + 排除下架
BoolQueryBuilder root = QueryBuilders.boolQuery();
// 1) 关键字:多字段 OR,企业名权重更高
IESQueryBuilders keywordQuery = ESQueryBuilderFactory.creatESQueryBuilder(ESQueryMode.SHOULD);
keywordQuery.matchPhraseQuery("title", keyword);
keywordQuery.getBoolQueryBuilder().should(
QueryBuilders.matchQuery("content", keyword).boost(1.5f));
if (keywordQuery.getBoolQueryBuilder().hasClauses()) {
root.must(keywordQuery.getBoolQueryBuilder());
}
// 2) 城市:精确过滤,放 filter 不参与打分(更快,且可命中缓存)
IESQueryBuilders filterQuery = ESQueryBuilderFactory.creatESQueryBuilder(ESQueryMode.FILTER);
if (StringUtils.hasText(city)) {
filterQuery.termQuery("city.keyword", city);
filterQuery.rangeQuery("amount", ESCompareEnum.GTE, minAmount);
filterQuery.rangeQuery("amount", ESCompareEnum.LTE, maxAmount);
}
if (filterQuery.getBoolQueryBuilder().hasClauses()) {
root.filter(filterQuery.getBoolQueryBuilder());
}
// 3) 排除已下架
IESQueryBuilders mustNotQuery = ESQueryBuilderFactory.creatESQueryBuilder(ESQueryMode.MUST_NOT);
mustNotQuery.termQuery("status.keyword", "OFFLINE");
root.mustNot(mustNotQuery.getBoolQueryBuilder());
空条件要判断
hasClauses()再拼:一个空的bool子句在部分版本下等价于match_all,会造成"条件没生效"的错觉。
六、最小可用示例(新项目可直接抄)
下面把前面章节串起来,只保留 3 个查询条件 (关键字 / 城市 / 金额区间),并包含分词与高亮。字段约定:title(text + keyword 子字段)、content(text)、city(keyword)、amount(double)。
索引 mapping(建索引时执行一次)
json
PUT /example_index
{
"settings": {
"number_of_shards": 1,
"number_of_replicas": 1,
"analysis": {
"analyzer": {
"my_name_analyzer": {
"type": "custom",
"tokenizer": "ik_smart"
}
}
}
},
"mappings": {
"properties": {
"title": {
"type": "text",
"analyzer": "ik_max_word",
"search_analyzer": "ik_smart",
"fields": { "keyword": { "type": "keyword" } }
},
"content": { "type": "text", "analyzer": "ik_max_word", "search_analyzer": "ik_smart" },
"city": { "type": "keyword" },
"amount": { "type": "double" }
}
}
}
查询实体
java
@Data
public class SearchRequestDTO {
/** 关键字(走分词 + 高亮) */
private String keyword;
/** 城市(精确过滤) */
private String city;
/** 金额区间(闭区间) */
private BigDecimal minAmount;
private BigDecimal maxAmount;
private Integer pageNo = 1;
private Integer pageSize = 10;
}
Service
java
@Slf4j
@Service
@RequiredArgsConstructor
public class ExampleSearchService {
private static final String INDEX_ALIAS = "example_index";
private final RestHighLevelClient client;
public PageResult<Map<String, Object>> search(SearchRequestDTO dto) {
BoolQueryBuilder root = QueryBuilders.boolQuery();
// ---------- 1. 高亮配置:关键字命中的字段 ----------
HighlightBuilder highlight = new HighlightBuilder()
.preTags("<span class='hit'>")
.postTags("</span>")
.fragmentSize(200)
.numOfFragments(1)
.requireFieldMatch(false);
highlight.field(new HighlightBuilder.Field("title").fragmentSize(60).numOfFragments(1));
highlight.field(new HighlightBuilder.Field("content").fragmentSize(200).numOfFragments(1));
// ---------- 2. 关键字:分词 + 多字段 OR(打分) ----------
if (StringUtils.hasText(dto.getKeyword())) {
IESQueryBuilders keywordQuery = ESQueryBuilderFactory.creatESQueryBuilder(ESQueryMode.SHOULD);
keywordQuery.matchPhraseQuery("title", dto.getKeyword());
keywordQuery.getBoolQueryBuilder()
.should(QueryBuilders.matchQuery("content", dto.getKeyword()).boost(1.2f));
root.must(keywordQuery.getBoolQueryBuilder());
}
// ---------- 3. 城市 + 金额:精确过滤(不打分,更快) ----------
IESQueryBuilders filterQuery = ESQueryBuilderFactory.creatESQueryBuilder(ESQueryMode.FILTER);
if (StringUtils.hasText(dto.getCity())) {
filterQuery.termQuery("city", dto.getCity());
}
if (dto.getMinAmount() != null) {
filterQuery.rangeQuery("amount", ESCompareEnum.GTE, dto.getMinAmount());
}
if (dto.getMaxAmount() != null) {
filterQuery.rangeQuery("amount", ESCompareEnum.LTE, dto.getMaxAmount());
}
if (filterQuery.getBoolQueryBuilder().hasClauses()) {
root.filter(filterQuery.getBoolQueryBuilder());
}
// ---------- 4. 组装请求 ----------
int pageNo = Math.max(1, dto.getPageNo());
int pageSize = Math.min(100, Math.max(1, dto.getPageSize()));
SearchSourceBuilder source = new SearchSourceBuilder()
.query(root)
.from((pageNo - 1) * pageSize)
.size(pageSize)
.highlighter(highlight)
.trackTotalHits(true);
// 排序:有关键字时按相关性,否则按金额倒序
source.sort(StringUtils.hasText(dto.getKeyword())
? SortBuilders.scoreSort()
: SortBuilders.fieldSort("amount").order(SortOrder.DESC));
SearchRequest request = new SearchRequest(INDEX_ALIAS).source(source);
log.info("ES 查询语句: {}", source);
try {
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
return new PageResult<>(response.getHits().getTotalHits().value,
toRows(response, new String[]{"title", "content"}));
} catch (IOException e) {
log.error("ES 查询失败, dto={}", dto, e);
throw new RuntimeException("检索服务暂时不可用,请稍后重试");
}
}
/** source + 高亮片段(`字段_HTML`)合并输出 */
private List<Map<String, Object>> toRows(SearchResponse response, String[] highlightFields) {
List<Map<String, Object>> rows = new ArrayList<>();
for (SearchHit hit : response.getHits().getHits()) {
Map<String, Object> row = hit.getSourceAsMap();
row.put("id", hit.getId());
for (String field : highlightFields) {
HighlightField hl = hit.getHighlightFields().get(field);
if (hl != null && hl.getFragments().length > 0) {
row.put(field + "_HTML", Arrays.stream(hl.getFragments())
.map(Text::string).collect(Collectors.joining("")));
}
}
rows.add(row);
}
return rows;
}
}
前端约定:列表渲染时 优先用 title_HTML / content_HTML (原样插入 HTML),没有该字段时退回渲染 title / content 原文。
七、常见问题(FAQ)
1. 高亮返回了,但片段里没有标签?
- 检查该字段的查询是否是
match/match_phrase(term、wildcard、range不会高亮)。 - 检查
highlight.field("xxx")的字段名与 mapping 是否一致(nested 字段要写全路径items.name)。 - 检查是否用了
fetchSource(...)把该字段排除了:高亮依赖_source,字段不返回时也拿不到高亮片段。
2. 高亮整段返回(没截断)?
numOfFragments 设成了 0(表示按整个字段返回)。列表页建议设 1 并配合 fragmentSize;需要摘要又担心无命中时为空,可加 noMatchSize。
3. 报 analyzer [ik_smart] not found?
IK 插件没装、版本不匹配、或该分析器是自定义分析器但当前索引的 settings 里没定义。先 GET _cat/plugins?v 确认插件,再 GET <索引>/_settings 确认分析器。
4. term 查询查不到数据?
text 字段被分词了,term 是精确匹配。要么改查 match,要么把条件字段改成 xxx.keyword。
5. total 只到 10000 就不再增长?
ES 7.x 默认 track_total_hits 只精确统计到 10000。需要精确总数时显式 sourceBuilder.trackTotalHits(true)(大索引上会略增开销,列表页如不需要精确值可保持默认)。
6. nested 子项的高亮拿不到?
nested 字段的高亮必须在 innerHit(...) 上设置;同时高亮字段名写全路径(如 items.name),并从 hit.getInnerHits().get("<innerHit名称>") 里回读片段。
7. wildcard "*xxx*" 很慢?
前导通配符无法使用索引,会退化为扫描。优先用 match / match_phrase 或 ngram 分析器方案替代;非用不可时,尽量叠加 filter 条件缩小数据范围。
8. Spring Boot 3 / ES 8 项目怎么改?
换客户端:引入 co.elastic.clients:elasticsearch-java,查询用 Query.of(q -> q.bool(b -> b.must(...))),RestHighLevelClient 相关代码全部替换;本章的分词、高亮、xxx_HTML 约定不变。
八、上线自查清单
- ES 各节点已安装 与 ES 版本严格一致 的 IK 插件,并重启验证(
_cat/plugins可见analysis-ik)。 - 索引 mapping 已为中文检索字段配置
analyzer/search_analyzer,需要精确匹配/排序的字段有.keyword子字段。 - 自定义分析器(如有)已随索引 settings 一起创建,业务代码引用的名字与之一致。
- 查询入口统一走别名,索引重建可平滑切换。
-
RestHighLevelClient为单例 Bean,已配置连接池、连接/读取超时与close()销毁。 - 高亮字段与查询字段、mapping 字段三者一致;
preTags/postTags与前端样式约定一致。 - 返回值固定包含「原始字段 +
字段_HTML」,前端渲染策略明确(有_HTML用高亮,否则用原文)。 - 需要精确
total的地方已trackTotalHits(true)。 - 空条件已用
hasClauses()判空,避免空bool造成match_all。 - ES 账号密码来自配置中心/环境变量,未硬编码进代码或提交到仓库。
- 高频接口的分词结果(若走主动分词)已加缓存,且有分词失败降级为原始关键字的兜底逻辑。