搜索框里敲下两个字符,下拉框弹不出东西;按回车之后,列表倒是出来了,可标题里那个关键字灰扑扑地藏在句子中间,用户得自己拿眼睛去捞。这两个体验问题,看着是一件事,落到代码里其实是两条完全不同的链路——SpringBoot 整合 ElasticSearch 的自动补全走的是 completion suggester 的前缀查找,搜索关键字高亮走的是 highlighter 的片段抽取。前者在内存里的 FST 上做毫秒级前缀匹配,后者在倒排表的偏移量上重排文本,两套机制、两套参数、两套坑。
这篇东西写给正在做站内搜索、且已经把 ES 跑起来但体验还差点意思的同学。我会按真实项目推进的顺序讲:先把两套机制的区别掰开,再对齐版本依赖,然后是 mapping 设计、数据灌入、联想接口、高亮接口,最后是几类故障的完整排查链路。代码以 Spring Boot 3.x + spring-data-elasticsearch 5.x + ES 8.x 为主线,涉及 7.x 差异的地方我会单独标出来,因为我被这个版本错位坑过不止一次。
1. 联想与高亮是两条互不相干的链路
很多人一开始会把这两件事塞进同一个查询里,想着一把查出来既返回文档又返回联想词,结果发现suggest只能返回候选词、拿不到_source,而高亮又必须挂在具体的命中文档上。搞清楚这个边界,后面所有设计都会顺很多。
1.1 completion suggester 在内存里维护的是一棵 FST
ES 的 completion 字段不是普通的倒排索引,它把每个文档写入的候选词,连同权重,一起构建成一个FST(有限状态转换器),常驻在堆内存里。查询时给一个前缀,它沿着状态机往下走,走到前缀末尾那个节点,然后把该节点下挂的所有候选词按 weight 倒序吐出来。整个过程的复杂度和前缀长度相关,和索引里有多少文档基本无关,这就是它敢在用户每敲一个字符就发一次请求的原因。
代价也很明确:这些东西全在内存里。我见过一个项目往 completion 字段里灌了四十多万条候选词,其中大部分是商品全称加规格描述,结果单节点堆内存直接被吃掉一大块,GC 频率肉眼可见地上升。所以补全字段里该放什么,必须提前想清楚——放用户真正会搜的短词、品牌名、类目名、热门查询词,而不是把标题整句扔进去。
另外一个常被忽略的点:completion 是文档级别的。一个文档的 suggest 字段里放多个 input,它们共享同一个 weight。想让"A 词权重 100、B 词权重 10",就得拆成两条文档,或者干脆另建一个只存候选词的轻量索引。这件事我在第三章会展开,因为它是补全结果排序不准的头号原因。
1.2 为什么 match_phrase_prefix 撑不起一个搜索框
刚接触这块的人常写的查询是match_phrase_prefix,测试的时候数据量小,感觉也能用。问题在于它的执行方式:每个分片要把前缀展开成大量 term,收集候选文档,再按打分排序取 top N,最后协调节点合并。前缀越短,展开的 term 越多,这个代价是随数据量线性增长的。用户敲第一个字符的时候,正是前缀最短、爆炸最厉害的时刻。
补全场景要的不是"相关性排序",而是"按热度加权的前缀枚举",这两件事的目标函数根本不同。completion suggester 支持skip_duplicates、size、context、fuzzy这些专门为下拉框设计的参数,match_phrase_prefix一个都没有,你得自己在应用层去重、自己控制返回条数,反而更麻烦。
我的判断标准很简单:只要这个查询是绑在"用户正在输入、还没按回车"这个动作上的,就用 completion;只要用户已经按了回车,就走正常的 multi_match + 高亮。两套接口,不要合并。
1.3 高亮不查 FST,它要的是偏移量
高亮的本质是:ES 已经知道哪些文档命中了,现在需要在这些文档的原始文本里,把命中的词标出来。做法是重新分析原文,拿到每个词的起止字符偏移量,再按片段规则切出一段文本,把命中位置的字符用pre_tags和post_tags包起来。所以它依赖的是分析器和偏移量,跟补全那套 FST 毫无关系。
这里就埋着中文场景最大的一个坑:高亮阶段会重新对原文分词,用的分析器如果和索引时不一致,切出来的词边界就对不上。索引时用ik_max_word切出"全文检索",搜索时用ik_smart只切出"全文"和"检索",高亮的时候再按ik_smart切,标出来的就只有"全文"两个字,用户体验上就是"我搜的词明明在,它只标了一半"。
解决办法是在高亮参数里显式指定analyzer,让它跟查询分析器保持一致。这个参数不在字段 mapping 里,在查询的 highlight 块里,很多人压根不知道有这个东西,只能靠调分词器粒度硬凑,越调越乱。
2. 版本对齐比写业务代码更先要命
我见过太多项目卡在启动阶段报NoSuchMethodError或者ClassNotFoundException,折腾一整天,最后发现是spring-data-elasticsearch和 ES 服务端版本对不上。这块没有捷径,必须先把矩阵抄在纸上。
2.1 Spring Boot 与 spring-data-elasticsearch 的对应关系
大致的关系是这样,实际以官方兼容性说明为准,我给的是我实测跑通过的组合:
| Spring Boot | spring-data-elasticsearch | 底层客户端 | 兼容的 ES 服务端 |
|---|---|---|---|
| 2.7.x | 4.4.x | RestHighLevelClient | 7.17.x |
| 3.0.x | 5.0.x | ElasticsearchClient(Java API Client) | 8.x |
| 3.1.x / 3.2.x | 5.1.x / 5.2.x | ElasticsearchClient | 8.x |
| 3.3.x | 5.3.x | ElasticsearchClient | 8.x |
最容易出事的组合是 Spring Boot 2.7 升 3.x 的时候,业务代码里到处是RestHighLevelClient的SearchRequest、SearchSourceBuilder,一升级全编译不过。我对这个迁移的建议是:不要试图在旧代码上改 API,把搜索层单独抽一个接口出来重写。原本散在 Service 里的查询构造,收拢到BookSearchRepository这样的接口后面,迁移的时候只改实现类。这一步花半天,能省后面两天的鸡飞狗跳。
还有一个隐性的坑:spring-data-elasticsearch 5.x 用的是新版 Java API Client,它和服务端的通信协议版本是绑定的。服务端如果是 7.10 这种偏老的版本,客户端 8.x 连上去很可能在握手阶段就报错,提示产品校验失败。别急着改代码,先看日志里的product check字样,那是版本不匹配的典型信号。
2.2 ElasticsearchClient 与 RestHighLevelClient 的迁移代价
RestHighLevelClient在 8.x 里已经被标记为废弃,新项目没有任何理由再用它。ElasticsearchClient是强类型的,SearchRequest.Builder一路链式构建,编译器帮你检查字段名,比过去拼SearchSourceBuilder的时候瞎猜参数名强太多。
代价是它的嵌套层级变深了。一个带 suggest、带高亮、带聚合的查询,Java 里写出来可能有七八层 lambda。我的处理方式是给每种查询写一个静态工厂方法,把构造逻辑封在SuggestQueries、HighlightQueries这样的工具类里,业务层只传参数。这样即使后面 API 又变了,改的也只是工具类那一处。
另外提醒一句:ElasticsearchClient的search方法返回的是SearchResponse<T>,取hits().hits(),每条Hit通过hit.highlight()拿高亮结果,是个Map<String, List<String>>。这个结构和 Spring Data 的SearchHit.getHighlightFields()返回的Map<String, List<String>>是一致的,但两者不能混用——你用 Spring Data 的ElasticsearchOperations查出来的实体,和用原生 client 查出来的 JSON,是两条路。
2.3 连接配置里几个必须显式写的参数
Spring Data 5.x 的配置类继承ElasticsearchConfiguration,重写clientConfiguration():
@Configuration public class EsConfig extends ElasticsearchConfiguration { @Value("${es.host}") private String host; @Value("${es.username}") private String username; @Value("${es.password}") private String password; @Override public ClientConfiguration clientConfiguration() { return ClientConfiguration.builder() .connectedTo(host) .usingSsl() .withBasicAuth(username, password) .withConnectTimeout(Duration.ofSeconds(5)) .withSocketTimeout(Duration.ofSeconds(30)) .build(); } }三个参数我必须显式写:连接超时、读超时、认证方式。默认的 socket 超时对带高亮的复杂查询来说偏短,数据量一上来,查询跑到一半连接被掐,日志里报的是SocketTimeoutException,你还会以为是 ES 挂了。读超时给到 30 秒,让慢查询有跑完的机会,同时在前端或者网关层做超时兜底,别让用户干等。
usingSsl()要不要加,取决于服务端有没有开 TLS。本地开发环境为了省事通常关掉安全功能,直接用 http;一旦你把本地验证通过的配置原样搬到开启了 TLS 的环境,第一次请求就会报Unrecognized SSL message。这个报错的字面意思和真实原因差得有点远,我踩过一次,排查了半小时才想起来是协议不对。
生产环境不要跳过服务端证书校验。本地为了跑通可以临时放宽,但那段代码绝对不能带进主干分支,我用
@Profile("local")单独隔离过,避免误提交。
3. mapping 决定后面 80% 的返工
我现在的习惯是:索引 mapping 手写 JSON,用脚本或者初始化器创建,不让 Spring Data 根据实体注解自动建索引。原因很实在,自动建索引只会根据@Field生成最基本的结构,analyzer、search_analyzer、term_vector、context这些关键配置它要么不支持要么写得不准,等你发现联想词不对、高亮不生效的时候,改实体注解再重启,索引不会自动重建,问题依旧。
3.1 一个图书索引的完整 mapping
假设我们做一个图书搜索,字段包括书名、作者、简介、类目、销量。mapping 大致长这样:
PUT /book_v1 { "settings": { "number_of_shards": 3, "number_of_replicas": 1, "analysis": { "analyzer": { "idx_analyzer": { "type": "custom", "tokenizer": "ik_max_word", "filter": ["lowercase"] }, "search_analyzer_custom": { "type": "custom", "tokenizer": "ik_smart", "filter": ["lowercase"] } } } }, "mappings": { "properties": { "title": { "type": "text", "analyzer": "idx_analyzer", "search_analyzer": "search_analyzer_custom", "term_vector": "with_positions_offsets" }, "author": { "type": "text", "analyzer": "idx_analyzer", "search_analyzer": "search_analyzer_custom" }, "category": { "type": "keyword" }, "sales": { "type": "integer" }, "suggest": { "type": "completion", "analyzer": "simple", "preserve_separators": true, "preserve_position_increments": true, "max_input_length": 50 } } } }几个字段值得单独说。title上加了term_vector: with_positions_offsets,这是给高亮留的加速通道——把每个词的偏移量预计算好存下来,高亮时直接读,不用重新分析一遍原文。索引体积会涨一些,大概百分之十几,换来的是高亮环节的耗时下降,我认为很值。
suggest字段的analyzer用simple,不是随便写的。completion 字段对输入文本会做一次分析,如果你给了中文分词器,它可能把一个完整词切成几段,导致前缀匹配的时候行为诡异。simple分析器只做小写化,保留原始字符,是补全场景最省心的选择。max_input_length限制单个候选词长度,防止有人把整段简介塞进 suggest 字段把内存撑爆。
3.2 completion 字段的三种写入方式与权重陷阱
写 suggest 字段有三种常见做法,效果差别很大。
第一种,直接把标题丢进去,靠 mapping 里type: completion自动处理。简单,但候选词就是标题全称,用户敲"三体"的时候,下拉框里出现的是一条完整书名,还行;敲"刘慈欣"的时候,什么都出不来,因为作者名不在 suggest 里。
第二种,用多字段copy_to把书名和作者都汇到 suggest 上。这样能覆盖更多输入,但问题一样——一个文档只有一个 weight,书名和作者的权重完全相同,没法让书名的候选排在作者前面。
第三种,也就是我现在用的:候选词独立索引。建一个book_suggest索引,一条文档只放一个候选词,字段结构是keyword(原文)、weight(整数)、suggest(completion)、category(keyword)。灌数据的时候,一本书拆成三到五条候选词文档,每条带自己的权重。
POST /book_suggest/_doc/1 { "keyword": "三体", "weight": 9800, "category": "科幻", "suggest": { "input": ["三体"], "weight": 9800 } }这里的关键是一次写入只放一个候选词。前文说过,completion 文档里的多个 input 共享 weight,如果你写{"input": ["三体", "三体全集", "刘慈欣"], "weight": 9800},这三个词的权重就完全一样了,热度排序等于没有。拆开写虽然文档数变多,但每条权重独立可控,联想结果的排序才有意义。
3.3 分词器选择:ik_max_word 索引、ik_smart 搜索
中文分词器的选择几乎是标准答案:索引用细粒度ik_max_word,搜索用粗粒度ik_smart。索引要尽可能多地切出词,保证召回;搜索要克制,避免查询串被切得太碎导致算分失真。
记住前面提到的:高亮阶段用的是分析器,不是 mapping 里那个search_analyzer就能覆盖的。高亮参数里要显式指定analyzer: ik_smart(或用你自己定义的那个搜索分析器名字),否则 ES 会按字段默认的分析器重切一遍,出来就是片段被标得七零八落。
分词器插件装好之后,用_analyze接口验证一下,别急着写代码:
curl -X POST "http://127.0.0.1:9200/_analyze" \ -H 'Content-Type: application/json' \ -d '{"analyzer":"ik_smart","text":"全文检索与自动补全"}'输出的 token 列表就是你后面高亮和算分的基础。自定义词典加进去之后,一定要重启节点或者调用热更新接口,否则词典不生效,你会怀疑是不是代码写错了。
4. 灌数据阶段最容易埋雷的地方
索引建好只是开始,真正让联想词像人做出来的,是数据阶段的权重设计。
4.1 bulk 批处理与 refresh 的取舍
灌数据统一走_bulk,单条 index 请求在网络往返上太浪费。批量大小我一般按单条文档大小反推,控制在 5MB 到 15MB 之间,太大容易在协调节点内存里排队,太小则网络开销占比高。
curl -X POST "http://127.0.0.1:9200/_bulk" \ -H 'Content-Type: application/x-ndjson' \ --data-binary @book_suggest.json刷新策略上,全量导入期间把refresh_interval设成-1,导完再改回1s并手动_refresh。这一步对导入速度的影响非常可观,几十万条数据的场景能从十几分钟降到两三分钟。但要注意,导入过程中查询是看不到新数据的,如果你的导入任务和线上查询共用同一个索引,必须靠别名切换,不能直接往线上写。
导入完成后再看_cat/indices确认文档数,顺便看_nodes/stats里 fielddata 和 suggest 占用的内存。suggest 的内存占用不在 fielddata 里,在segments.memory相关的统计中,粗略观察即可,重点是别让它无节制增长。
4.2 weight 怎么算才像人做出来的联想
权重这块没有标准公式,但有几个经验值可以参考。我一般组合三个因子:
- 基础热度:搜索日志里该词最近 30 天的查询次数,取对数。原始次数差异太大,线性放进权重里会让头部词永远霸榜,取对数后长尾词才有机会。
- 业务权重:付费推广词、人工运营词,给一个加成系数,比如乘以 1.5。
- 新鲜度衰减:新上架内容给一次性加成,配合每日衰减,避免老内容一直排在前面。
举个具体算法:weight = round(ln(1 + 搜索次数) * 100 + 业务加成),业务加成可以是运营配置的固定值。ln(1) = 0,ln(1000) ≈ 6.9,算出来是 690 左右的差距,这个量级在 completion 的整数权重里足够拉开层级,又不至于让所有词挤在一个数量级上。
数据来源上,我建议直接从搜索日志里挖:每天跑一次离线任务,统计过去 30 天的查询词,过滤掉长度小于 2 或大于 20 的、过滤掉纯符号的、过滤掉明显是调试用的,然后做一次归一化。这套流水线跑起来之后,补全的命中率会明显好于纯人工配置。
4.3 全量重建索引与别名切换
线上索引永远不要直接对着一个物理名写。建一个book_suggest_v1,再建一个别名book_suggest指向它,代码里只认别名。重建的时候建v2,灌完数据做一次对比验证,然后原子地把别名从 v1 切到 v2:
POST /_aliases { "actions": [ { "remove": { "index": "book_suggest_v1", "alias": "book_suggest" } }, { "add": { "index": "book_suggest_v2", "alias": "book_suggest" } } ] }这个动作是原子的,切完立刻生效,写代码的时候不需要考虑灰度。唯一要注意的是应用侧如果有本地缓存,切别名之后要主动清一次,否则用户看到的是旧数据,你会以为切换失败了。
5. 联想接口的实现细节
数据齐了,接下来是接口。这里有个绕不开的现实问题:Spring Data 的NativeQuery抽象不支持 suggest。NativeQueryBuilder没有withSuggest这种方法,你没法用它的链式 API 拼出 suggest 请求。
5.1 用原生 client 发 suggest 请求
我的做法是直接注入ElasticsearchClient,给联想接口单独写一套。这不丢人,反而清晰——联想本来就和文档查询不是一回事。
@Service public class SuggestService { private final ElasticsearchClient client; public SuggestService(ElasticsearchClient client) { this.client = client; } public List<String> suggest(String prefix, String category, int size) { String p = prefix == null ? "" : prefix.trim().toLowerCase(); if (p.length() < 1) { return Collections.emptyList(); } try { SearchResponse<SuggestDoc> resp = client.search(s -> { s.index("book_suggest") .size(0) .suggest(sg -> { sg.suggesters("book_sg", sb -> { sb.prefix(p) .completion(c -> { c.field("suggest") .size(size) .skipDuplicates(true); if (category != null && !category.isBlank()) { c.contexts(ctx -> ctx .name("category") .context(cat -> cat.category(category))); } if (p.length() >= 2) { c.fuzzy(f -> f.fuzziness("1").prefixLength(1)); } return c; }); return sb; }); return sg; }); return s; }, SuggestDoc.class); return resp.suggest().get("book_sg").stream() .flatMap(sg -> sg.completion().options().stream()) .map(o -> o.text()) .distinct() .limit(size) .collect(Collectors.toList()); } catch (IOException e) { log.warn("suggest query failed, prefix={}", p, e); return Collections.emptyList(); } } }几个点解释一下。size(0)是因为我们只要 suggest 结果,不要文档命中,把 hits 请求关掉能省不少序列化开销。.distinct()这一步不能省,skipDuplicates保证的是同一 shard 内不重复,多 shard 环境下同一个候选词仍可能出现多次,服务端合并后还是有重复项。
fuzzy只在前缀长度大于等于 2 的时候开。原因是单字符前缀开模糊匹配,编辑距离 1 会展开出海量候选,FST 遍历量暴涨,实测响应时间能从个位数毫秒跳到一两百毫秒。这个开关如果无脑打开,大概率是给自己挖坑。
用 context 做类目过滤之前,mapping 里得先声明:
"category": { "type": "completion", "contexts": [ { "name": "category", "type": "category", "path": "category" } ] }注意 context 的path指向文档里的字段,这里很容易写成category而实际文档字段叫categoryName,结果就是查询时 context 过滤不生效,但也不报错——静默失败最费时间。
5.2 skip_duplicates、fuzzy、size 的参数手感
size的取值我一般给 8 到 10,多给几条让前端有余量做二次过滤,但别给 50,那既浪费传输也拉长渲染。如果业务上确实需要更多候选,说明这个补全入口承载的东西太多,应该考虑加类目维度拆分。
fuzzy的坑前面说了。还有一个容易忽视的点:fuzziness用"1"而不是直接写数字,不同版本的枚举类型接受形式不一样,用字符串最保险。
skip_duplicates建议永远开。用户输入"三体",底下连着三条一模一样的"三体",这种体验是灾难。
5.3 拼音、首字母与多租户 context
中文用户经常用拼音首字母输入,st就是"三体"。这件事 completion suggester 本身不支持,但可以靠写入时把拼音和首字母也作为候选词塞进去:
keyword: "三体" suggest.input: ["三体", "santi", "st"]灌数据的时候用拼音转换库把原始词转成全拼和首字母,一起写进 input 数组。注意这些候选词共享同一个 weight,但在这个场景下是可接受的,因为它们本来就代表同一个词的不同输入方式。
多租户场景用 context 比用 filter 好。给每个文档打上tenant_id的 context,查询时带上当前租户的值,这样即在 FST 层面就做了隔离,又不需要在应用层过滤结果。这个设计比"查出来再过滤"要省得多,尤其是租户数量多、每个租户数据量小的时候。
5.4 接口层:防抖之外的防刷与缓存
前端做 200 到 300 毫秒的输入防抖,这是基本功。但只靠前端不够,接口层必须有自己的兜底:
- 前缀长度小于 1 直接返回空,不发请求。
- 单 IP 限流,比如 10 QPS,防止有人拿脚本刷补全接口当免费数据源。
- 热点前缀缓存,用本地缓存或分布式缓存对短前缀的结果做 30 到 60 秒缓存。这里要权衡,缓存时间太长会掩盖运营配置的更新,太短则命中率低。我的经验是运营词变更走主动失效,普通热度缓存给 60 秒。
还有一个细节:返回结果要带上keyword原文,不要只返回高亮后的字符串。补全下拉框的文本本身不需要高亮,但有些产品希望前缀部分加粗,那就把前缀和余下部分分开返回,让前端自己拼样式,服务端别掺和渲染逻辑。
6. 高亮接口从查询到前端渲染
这部分是 Spring Data 能帮上忙的地方,NativeQuery支持高亮,只是 API 在 5.x 里变了个样。
6.1 三种 highlighter 的取舍
ES 内置三种高亮器,选错了会直接体现在效果和性能上。
| 高亮器 | 原理 | 适用场景 | 注意事项 |
|---|---|---|---|
| unified | 优先读 term_vector,没有则从 postings 中解析 | 默认选择,大多数场景 | 需要准确偏移时建议配 term_vector |
| fvh(fast vector) | 完全依赖 term_vector | 大文本、多字段高亮 | 字段必须配 with_positions_offsets |
| plain | 重新分析原文 | 兼容性兜底 | 中文断句效果差,性能一般 |
我的默认选择是 unified,配合前文说的字段级term_vector: with_positions_offsets。这个组合在中文长文本上表现最均衡。fvh 的优势是快,但它要求字段必须存 term vector,如果索引里有一堆字段都要高亮,索引体积会膨胀得比较明显。plain 我基本不用,它在中文里经常把片段切在词中间,出现"检索"被拆成两半的情况。
6.2 fragment_size、number_of_fragments 与中文断句
fragment_size的单位是字符,不是词。英文一个词五六个字符,中文一个字就是一个字符,所以同样的 fragment_size 在中文里覆盖的内容要少得多。默认 100 在中文场景大概能出三四十个字,勉强够;如果你希望列表页展示两行摘要,给到 150 到 200 更合适。
Highlight highlight = new Highlight(); highlight.setPreTags("<em class=\"kw\">"); highlight.setPostTags("</em>"); highlight.setNumberOfFragments(1); highlight.setFragmentSize(150); highlight.setRequireFieldMatch(true); highlight.addField(new HighlightField("title")); highlight.addField(new HighlightField("summary")); NativeQuery query = NativeQuery.builder() .withQuery(q -> q.bool(b -> b .should(s -> s.match(m -> m.field("title").query(kw).boost(3.0f))) .should(s -> s.match(m -> m.field("summary").query(kw))) .minimumShouldMatch("1"))) .withPageable(PageRequest.of(page, size)) .withHighlightQuery(new HighlightQuery(highlight, Book.class)) .build(); SearchHits<Book> hits = operations.search(query, Book.class);上面这段是 5.x 的写法,Highlight和HighlightField是 spring-data-elasticsearch 自己封装的类,不是 ES 原生那张HighlightBuilder。7.x 时代用的是NativeSearchQueryBuilder.withHighlightFields(new HighlightBuilder.Field(...)),升级的时候这块必须改,否则编译不过。
中文断句还有一个参数值得调:boundary_scanner。unified 高亮器默认按句子边界扫描,中文的句子边界识别依赖标点,如果摘要里标点少,片段可能从一个奇怪的位置开始。设成word会让它按词边界回退,出来的片段更完整。这个参数在 Spring Data 的Highlight里不一定有对应方法,需要的话就退回原生 client 发请求。
6.3 highlight 结果的解析与安全转义
拿到的SearchHits里,每条SearchHit通过getHighlightFields()拿高亮结果,是个Map<String, List<String>>,key 是字段名,value 是片段列表。这里有几个必须处理的细节:
第一,高亮字段可能不存在。如果命中来自summary字段,title里没有关键词,那getHighlightFields()里就只有summary,取title会拿到 null。展示逻辑要写兜底,取不到就用原始字段值截断。
第二,<em>标签是服务端拼进字符串的,如果你的原文里本身含有 HTML 或者用户可控内容,直接塞进页面的innerHTML就是 XSS 漏洞。我的做法是:展示前先对原文做 HTML 转义,再让 ES 加标签;或者前端用文本节点拼接,把pre_tags换成自定义的、不可能在原文里出现的标记,前端按标记切分后用 DOM API 创建元素。这个环节我见过出问题的项目,搜索框输入被反射到页面上,后果很严重。
第三,分页和高亮要一起考虑。高亮是对返回的这一页文档做的,翻页之后同一条文档的片段可能不一样,这是正常现象,不要试图缓存片段。
List<BookVO> list = hits.stream().map(hit -> { BookVO vo = new BookVO(); Book src = hit.getContent(); vo.setId(src.getId()); Map<String, List<String>> hl = hit.getHighlightFields(); vo.setTitle(render(hl.get("title"), src.getTitle())); vo.setSummary(render(hl.get("summary"), truncate(src.getSummary(), 150))); return vo; }).collect(Collectors.toList());render方法里做两件事:有高亮片段就取第一个,用自定义标记替换标签;没有就返回原文的安全截断版本,并保证前端拿到的是可以安全渲染的结构。
7. 几类故障的完整排查链路
下面这几个是我实际遇到过并且印象最深的问题,把排查过程写出来,方便你对照。
7.1 联想一个词都不返回
先别改代码,按这个顺序看:
第一步,确认_search里带 suggest 能不能出东西。直接 curl 打服务端,排除应用层问题:
curl -X POST "http://127.0.0.1:9200/book_suggest/_search?pretty" \ -H 'Content-Type: application/json' \ -d '{"suggest":{"s":{"prefix":"san","completion":{"field":"suggest","size":10}}}}'第二步,如果 curl 也不出,看 mapping 里 suggest 字段是不是真的completion类型。用GET /book_suggest/_mapping确认,很多人建索引的时候字段名写成了suggestions,代码查的是suggest,ES 对不存在的 suggest 字段不会报错,直接返回空 options,静默失败。
第三步,确认写入的数据里 suggest 字段有值。用_search随便捞一条看看_source。如果灌数据时用了copy_to但 mapping 没配,字段就是空的。
第四步,查大小写。completion 字段用simple分析器会做小写化,如果你写入的是 "SpringBoot" 而查询传的是 "Spring",理论上没问题,但如果你换成了keyword分析器又没做小写化,就会匹配不上。我现在的做法是写入和查询两边统一toLowerCase(),别指望分析器。
第五步,也是我最容易忘的:前缀里带空格或者前导空白。用户输入" 三体",前缀带空格,FST 走不下去。.trim()一行代码的事,但漏了就查半天。
7.2 高亮标签没出来
高亮完全不生效,按这个顺序查:
先确认查询确实是命中了那个字段。如果你用multi_match搜title和summary,但命中实际来自title,你却在 highlight 里只配了summary,那requireFieldMatch开着的情况下就不会有任何高亮。这个逻辑是对的,但很容易被误判成 bug。
再确认字段类型。keyword类型的字段默认不支持高亮,因为它不分词,没有词元位置信息。有些人在 mapping 里把标题设成了keyword为了做精确匹配,然后又想在它上面高亮,这就矛盾了。正确做法是同一份内容放两个字段,一个text用于搜索和高亮,一个keyword用于精确筛选。
然后确认term_vector。如果你用了 fvh 高亮器而字段没配with_positions_offsets,请求会直接失败或者返回空片段。这是我遇到过最隐蔽的一次,日志里只有一条 warning,容易被忽略。
最后确认pre_tags有没有被 Spring Data 的默认值覆盖。Highlight对象如果没有显式设置标签,可能用的是默认的<em>,你在前端查的是自定义标签,自然看不到效果。我在HighlightQueries工具类里把标签做成常量,前后端共用一份,杜绝这类不一致。
7.3 返回耗时从 20ms 涨到 800ms
性能问题通常不是单一原因,我是这样分层的:
第一层,看请求本身。用"profile": true跑一次,看 time_in_nanos 花在哪个阶段。如果大部分时间在 suggest 上,检查是不是fuzzy在前缀很短时被打开了,或者候选词索引里塞了太多长文本。
第二层,看高亮。高亮的耗时和返回片段数量、字段长度成正比。如果number_of_fragments设成了 5,而前端只展示 1 条,那就是白算了四遍。改成 1,耗时立刻下来。
第三层,看分页深度。from + size超过一万,ES 的分片合并代价会飙升。搜索场景一般不会翻那么深,但如果有人写脚本遍历,一定要在网关层拦住。
第四层,看是否有字段爆炸。multi_match铺了十几个字段,每个字段都要算分,还要在高亮阶段逐个处理。这种设计往往是历史遗留,收敛到三到五个核心字段,耗时能降一半以上。
我还会在接口层加一个慢查询日志,记录超过 300ms 的前缀和查询串。跑一周之后回头看,通常能发现几个高频且低价值的前缀,比如用户误触产生的单字符查询,针对性地在入口处拦掉,整体响应时间会好看很多。
最后再分享一个小技巧:把联想接口和高亮接口的监控分开打点。这两个接口的 QPS 差一个数量级,混在一起看指标会互相干扰,出问题的时候分不清是谁拖慢的。分开之后,你会很快发现联想接口的 P99 其实很低,真正需要优化的是带高亮的那条检索链路。