【Spring Boot】集成 Elasticsearch:中文分词与关键字高亮对接文档

Spring Boot 集成 Elasticsearch:中文分词与关键字高亮对接文档

一、整体方案

复制代码
┌──────────────┐  ①查询请求    ┌──────────────────────┐   ②ES 查询    ┌─────────────┐
│  前端/调用方  │ ───────────▶ │  Spring Boot 服务     │ ───────────▶ │ Elasticsearch│
│              │              │  · QueryBuilder 组装  │              │  · IK 分词器  │
│              │ ◀─────────── │  · 高亮结果解析        │ ◀─────────── │  · 倒排索引   │
└──────────────┘  ④带高亮HTML  └──────────────────────┘   ③source+     └─────────────┘
                                             highlight 片段

三个关键点:

  1. 分词 :中文必须用分词器(本文用 IK),否则「供应链金融」会被当成一个字一个字或整串匹配,召回率极差。分词器是 ES 插件,需要单独安装(见第三章)。
  2. 高亮 :由 ES 在返回结果时附带命中片段(highlight),服务端把片段存成 xxx_HTML 字段返回给前端渲染,不需要自己写字符串匹配。
  3. 查询条件组装 :用「接口 + 多种 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)。

  1. 从 IK 官方 release 下载与 ES 版本号一致的 zip 包(文件名形如 elasticsearch-analysis-ik-<ES版本>.zip)。
  2. 在每个 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
  1. 验证:
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 账号密码来自配置中心/环境变量,未硬编码进代码或提交到仓库。
  • 高频接口的分词结果(若走主动分词)已加缓存,且有分词失败降级为原始关键字的兜底逻辑。
相关推荐
jonyleek1 小时前
JVS私有化文档系统运维避坑指南:5大断点检测与加固实践(K8S+MinIO+ES)
elasticsearch·minio·clamav·jvs·k8s运维·文档系统·权限同步
骑着蜗牛撵大象3271 小时前
CentOS 7 单机拉起 Elasticsearch 7.17.29:从内核报错到公网可达的全链路记录
elasticsearch·centos·jenkins·restful·内核调优·公网部署
m0_587383001 小时前
GogoAI自助健身门店解决方案核心技术实现与落地部署全流程实操指南
运维·spring boot·信息可视化·架构·需求分析
打工仔折腾 AI2 小时前
CentOS 7 单机部署 Elasticsearch 7.17.29:启动报错排查与 CRUD 验证
人工智能·后端·python·elasticsearch·性能优化·centos·jenkins
爱编程的小白L2 小时前
基于 SpringBoot 的校园停车场收费管 理系统的设计与实现
spring boot
十年Java程序媛2 小时前
Redis 分布式锁 + GZIP 压缩缓存踩坑|高并发缓存真实线上复盘
java·开发语言·spring boot·redis·分布式·缓存·面试
凤山老林2 小时前
Spring Boot 集成 Redis ZSet 实现实时排行榜、动态排名、同分加权与多维度榜单实战
spring boot·redis·后端
IT研究室3 小时前
最新计算机毕业设计选题推荐-基于spring boot的教育学习平台-网站-文档指导-Java-springboot
java·spring boot·课程设计
SelectDB技术团队3 小时前
VARIANT 列接入网关 JSON 日志:建表、导入、子列查询与物化的完整步骤
大数据·数据库·人工智能·elasticsearch·搜索引擎·json·apache doris