首页 / 资讯中心 / 文章详情

Spring Boot 整合 Elasticsearch 自动补全与高亮实战

Spring Boot 整合 Elasticsearch 自动补全与高亮实战 ★ FEATURED ARTICLE
搜索框里敲下两个字符下拉框弹不出东西按回车之后列表倒是出来了可标题里那个关键字灰扑扑地藏在句子中间用户得自己拿眼睛去捞。这两个体验问题看着是一件事落到代码里其实是两条完全不同的链路——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 在内存里维护的是一棵 FSTES 的 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 Bootspring-data-elasticsearch底层客户端兼容的 ES 服务端2.7.x4.4.xRestHighLevelClient7.17.x3.0.x5.0.xElasticsearchClientJava API Client8.x3.1.x / 3.2.x5.1.x / 5.2.xElasticsearchClient8.x3.3.x5.3.xElasticsearchClient8.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方法返回的是SearchResponseT取hits().hits()每条Hit通过hit.highlight()拿高亮结果是个MapString, ListString。这个结构和 Spring Data 的SearchHit.getHighlightFields()返回的MapString, ListString是一致的但两者不能混用——你用 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整数、suggestcompletion、categorykeyword。灌数据的时候一本书拆成三到五条候选词文档每条带自己的权重。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) 0ln(1000) ≈ 6.9算出来是 690 左右的差距这个量级在 completion 的整数权重里足够拉开层级又不至于让所有词挤在一个数量级上。数据来源上我建议直接从搜索日志里挖每天跑一次离线任务统计过去 30 天的查询词过滤掉长度小于 2 或大于 20 的、过滤掉纯符号的、过滤掉明显是调试用的然后做一次归一化。这套流水线跑起来之后补全的命中率会明显好于纯人工配置。4.3 全量重建索引与别名切换线上索引永远不要直接对着一个物理名写。建一个book_suggest_v1再建一个别名book_suggest指向它代码里只认别名。重建的时候建v2灌完数据做一次对比验证然后原子地把别名从 v1 切到 v2POST /_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 ListString suggest(String prefix, String category, int size) { String p prefix null ? : prefix.trim().toLowerCase(); if (p.length() 1) { return Collections.emptyList(); } try { SearchResponseSuggestDoc 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_vectorfvhfast vector完全依赖 term_vector大文本、多字段高亮字段必须配 with_positions_offsetsplain重新分析原文兼容性兜底中文断句效果差性能一般我的默认选择是 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(); SearchHitsBook 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()拿高亮结果是个MapString, ListStringkey 是字段名value 是片段列表。这里有几个必须处理的细节第一高亮字段可能不存在。如果命中来自summary字段title里没有关键词那getHighlightFields()里就只有summary取title会拿到 null。展示逻辑要写兜底取不到就用原始字段值截断。第二em标签是服务端拼进字符串的如果你的原文里本身含有 HTML 或者用户可控内容直接塞进页面的innerHTML就是 XSS 漏洞。我的做法是展示前先对原文做 HTML 转义再让 ES 加标签或者前端用文本节点拼接把pre_tags换成自定义的、不可能在原文里出现的标记前端按标记切分后用 DOM API 创建元素。这个环节我见过出问题的项目搜索框输入被反射到页面上后果很严重。第三分页和高亮要一起考虑。高亮是对返回的这一页文档做的翻页之后同一条文档的片段可能不一样这是正常现象不要试图缓存片段。ListBookVO list hits.stream().map(hit - { BookVO vo new BookVO(); Book src hit.getContent(); vo.setId(src.getId()); MapString, ListString 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代码查的是suggestES 对不存在的 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 其实很低真正需要优化的是带高亮的那条检索链路。
阅读完成 · 觉得有帮助?
咨询建站