ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Elasticsearch Completion Suggest 搜索提示实战与优化

Elasticsearch Completion Suggest 搜索提示实战与优化

说个我最近在实训里被问得最多的问题:你们那个搜索框的联想词到底是怎么做的?很多人第一反应是“前端拿到用户输入,再往后端查一遍包含这个关键词的数据不就行了”,真这么干过的人都知道,等你把数据量撑到几十万条、几百万人次同时在线的时候,输入框每敲一个字后端就崩一次的感觉有多酸爽。后来我在Elasticsearch里用Completion Suggest实现搜索提示,才把这条链路真正跑通:索引设计、查询优化、前端防抖三条线各司其职,整体响应稳定在个位数毫秒。这篇就把整个方案的踩坑过程、核心配置、代码实现和优化思路一次性说清楚,适合正在做实训项目、或者工作中要给搜索框做“边输入边提示”功能的同学参考。

1. 搜索提示到底在解决什么问题:它和普通查询根本不是一回事

1.1 联想提示的三个核心价值

先说一个容易被忽略的事实:搜索提示不是一个“锦上添花”的交互彩蛋,它对搜索系统的整体效果有实打实的正向影响。

第一是帮用户省字。商品名、书名、旅游线路这类词条往往很长,用户根本记不住完整名字,但他能记住开头的两三个字。输入“北京”两个字就能带出“北京五日游”“北京周边自驾”这些完整词条,用户少打一半字,搜索完成率自然就上去了。

第二是规范搜索词。真实用户的输入千奇百怪,有人打错字,有人用口语缩写,有人带了多余的空格和符号。联想词相当于把系统里真正存在的词条提前摆到用户面前,用户点击之后,落到检索层的就是一个干净准确的词,而不是一堆脏数据。这在实训项目里尤其重要,因为日志分析时会发现,一大半无效搜索都源于输入不规范。

第三是缩短决策路径。用户在不确定自己要什么的时候,下拉列表里的关键词本身就是在帮他梳理需求。我们看到很多电商站点,用户输入“手机”之后,联想词会给出“手机壳”“手机支架”“手机散热器”这些细分方向,用户顺着联想点下去,比他自己在结果页里翻找要快得多。

1.2 直接拿前缀查询硬干的后果

我实训初期也偷过懒,心想不就是一个前缀匹配吗,用prefix查询、match_phrase甚至wildcard不都能凑合?实测下来,词条量刚到几十万级别,这几个方案就崩了。

prefix查询在Elasticsearch里的实现是倒排索引上的范围扫描,它会找出所有以给定前缀开头的词项,再回表拉取文档。数据量一上来,这个扫描范围就大得离谱,一个前缀可能命中几万个词项,响应时间轻松突破几百毫秒。wildcard更夸张,它禁止前缀通配符,一个*keyword*要全量遍历整个词项字典,CPU直接被打满。我把老接口压测了一轮,每秒几百个请求就能让集群的查询线程池爆掉。

这里可以打个比方:倒排索引就像一本书最后的“关键词索引页”,它能告诉你去哪一页可以查到某个词,但你想知道“有哪些词是以abc开头的”,只能把索引页从头到尾扫一遍。而Completion Suggest用的FST(有限状态转换器)更像一本按字典序排列的通讯录,你翻到“a”这个首字母,就能顺着路径一路往下走,天然就是为前缀匹配设计的。

1.3 什么时候该用Completion Suggest

不是所有场景都需要上Completion Suggest,我在实训中总结了一条比较实用的判断标准:

  • 词条规模大,几万条以上;
  • 对响应时间敏感,要求毫秒级反馈;
  • 词条本身比较短小、相对固定,比如商品名、书名、城市名、科室名、热搜词;
  • 是“输入前缀给候选词”的形态,而不是“在一段长文本里搜关键词”。

反过来,如果你需要的是全文模糊检索、带上下文的短语匹配、或者需要对大段正文做相关性排序,那还是老老实实用match、match_phrase和multi_match。Completion Suggest本质上是“提示词典”的专用通道,不是通用搜索的替代品。

2. Mapping和索引设计:Completion Suggest的“地基”踩坑实录

2.1 一份可以直接抄的mapping

既然决定用Completion Suggest,第一步就是设计索引映射。这里有一个高频坑:很多人照着老博客建了一个completion类型的字段,结果中文输入一个“北”字,什么都联想不出来——因为completion类型的默认分词器是simple,它对中文文本的处理就是整段当成一个词条,根本没有按词切分。

我最终用的映射长这样:

PUT /suggest_index { "settings": { "number_of_shards": 1, "number_of_replicas": 0 }, "mappings": { "properties": { "id": { "type": "keyword" }, "title": { "type": "text", "analyzer": "ik_max_word" }, "title_suggest": { "type": "completion", "analyzer": "ik_max_word", "search_analyzer": "ik_max_word", "preserve_separators": true, "max_input_length": 50, "preserve_position_increments": false } } } }

逐个说下关键参数:

  • analyzer:用于索引阶段把输入词条切分成可匹配的词项。中文场景建议用ik_max_word,它对“北京旅游”会切出“北京/北京旅游/旅游”等多个词项,这样用户输入“北京”时就能命中“北京旅游”。不指定分词器,中文联想基本废掉。
  • search_analyzer:查询阶段分析用户输入用的分词器,最好和analyzer保持一致。如果索引用IK、查询用默认,很容易出现“词条能查到但前缀对不上”的诡异问题。
  • preserve_separators:默认true,保留分隔符。像“北京-上海”这种带连字符的词条,是否按分隔符切分会影响匹配结果,如果希望用户输入“北京”就能命中,建议保持默认并按IK分词来兜底。
  • max_input_length:默认50,单个输入超过50个字符会被直接截断,不再索引进FST。中文环境下一条商品标题基本够用,但如果是长文本描述,务必调大或者另建专用词条。
  • preserve_position_increments:这个参数控制分词后的位置增量是否保留。我把它设成false,避免某些分词器在去除停用词后产生位置空洞,导致前缀匹配判断错位。

2.2 input、output、weight三个字段的“小心思”

mapping建好之后,怎么写文档也是门学问。completion字段可以接收一个对象,包含input、output、weight三个子字段。

{ "id": "book_001", "title": "Elasticsearch从入门到实践", "title_suggest": { "input": ["elasticsearch", "es", "从入门到实践"], "weight": 30, "output": "Elasticsearch从入门到实践" } }
  • input:真正参与前缀匹配的词条,可以放多个别名。比如英文名、缩写、中文名都放进去,用户不管输入哪个前缀都能命中同一条文档。
  • output:返回给前端展示的文案。如果不设置,默认返回input里命中的那个值。我建议单独设置,因为input里可能有es这种缩写,直接展示给用户不妥。
  • weight:权重,正整数,越大排序越靠前。权重在FST构造阶段就直接参与排序,所以它是控制联想词顺序的最有力手段。后面讲优化时再展开。

这里一个容易误解的点是:weight只影响联想结果的排序,不影响匹配命中本身。不是权重高的优先被选中,而是被选中的按权重排序。搜索提示的结果集由前缀决定,顺序由权重决定,两者责任分明。

2.3 我踩过的mapping坑

第一个坑是中文不指定分词器,前面已经提过。第二个坑是给completion字段塞了太多不相干的数据。曾有人把整条文本文档内容都塞进input,结果FST体积暴增,内存告警,查询速度反而不如普通前缀查询。completion字段的内存在节点启动时就要装载,它不是一个“存数据”的地方,而是“存提示词条”的地方,能短则短。

第三个坑和大小写有关。simple分词器默认会做小写化,英文场景不敏感;但换成IK分词器后,大小写不会被自动统一。如果业务里存在英文输入,建议在业务侧写入时统一转小写,查询时也做同样处理,否则“Elasticsearch”和“elasticsearch”会被当成两个完全不同的词条。

第四个坑是字段更新代价。completion字段的FST在索引刷新时需要重建,频繁更新文档会造成明显的CPU毛刺。这个问题放在后面“索引维护”里重点说,这里先留个印象。

3. 后端查询实现:把搜索提示做成一个“正经接口”

3.1 先看查询DSL长什么样

mapping就绪后,查询侧的DSL其实很简洁:

POST /suggest_index/_search { "suggest": { "hot_suggest": { "prefix": "beijing", "completion": { "field": "title_suggest", "size": 8, "skip_duplicates": true, "fuzzy": { "fuzziness": 2 } } } } }

几个参数解释一下:

  • prefix:用户当前输入的词,这是Completion Suggest推荐的传参方式。早期版本习惯在suggest外层传text,新项目直接写prefix最省心,不用踩版本兼容的坑。
  • size:返回联想词条数,通常8到10个就够,给太多前端展示反而杂。
  • skip_duplicates:默认true。当一个文档有多个input同时命中时,不开启的话会返回重复项。比如上面那个例子,用户输入“es”,input里的es和elasticsearch都可能命中,重复的结果非常影响体验。
  • fuzzy:开启模糊匹配,fuzziness设为2表示允许用户输入与真实词条有最多两处字符差异。搜索提示场景里错个字很常见,这个参数非常实用。还可以配合fuzzy.prefix_length,比如设为2,表示前两个字符必须精确匹配,后续字符才允许模糊,可以显著降低模糊匹配的CPU开销。

3.2 Java和Python两种落地写法

实训项目后端用Java比较多,我用的是7.x的RestHighLevelClient写法:

@GetMapping("/api/suggest") public Map<String, Object> suggest( @RequestParam String prefix, @RequestParam(defaultValue = "8") Integer size) throws IOException { SearchRequest searchRequest = new SearchRequest("suggest_index"); SearchSourceBuilder sourceBuilder = new SearchSourceBuilder(); CompletionSuggestionBuilder suggestionBuilder = SuggestBuilders.completionSuggestion("title_suggest") .prefix(prefix) .size(size) .skipDuplicates(true) .fuzzy(new FuzzyConfig().setFuzziness(FuzzyOptions.INSTANCE).setPrefixLength(2)); sourceBuilder.suggest(new SuggestBuilder().addSuggestion("hot_suggest", suggestionBuilder)); // 只返回需要的字段,减少网络传输 sourceBuilder.fetchSource(new String[]{"id", "title"}, null); searchRequest.source(sourceBuilder); SearchResponse response = highLevelClient.search(searchRequest, RequestOptions.DEFAULT); return parseSuggestion(response); } private Map<String, Object> parseSuggestion(SearchResponse response) { List<String> results = new ArrayList<>(); Suggestions suggestions = response.getSuggest(); if (suggestions == null) { return Collections.singletonMap("results", results); } CompletionSuggestion completionSuggestion = suggestions.getSuggestion("hot_suggest"); for (CompletionSuggestion.Entry entry : completionSuggestion.getEntries()) { for (CompletionSuggestion.Entry.Option option : entry.getOptions()) { SearchHit hit = option.getHit(); if (hit != null) { results.add((String) hit.getSourceAsMap().get("title")); } else { results.add(option.getText().string()); } } } return Collections.singletonMap("results", results); }

如果后端是Python,配合elasticsearch客户端,代码更短:

from elasticsearch import Elasticsearch es = Elasticsearch("http://localhost:9200") def suggest_v1(prefix): body = { "suggest": { "hot_suggest": { "prefix": prefix, "completion": { "field": "title_suggest", "size": 8, "skip_duplicates": True } } } } resp = es.search(index="suggest_index", body=body) options = resp["suggest"]["hot_suggest"][0]["options"] return [ option.get("_source", {}).get("title", option.get("text")) for option in options ]

Python返回结构里,resp["suggest"]["hot_suggest"]是一个列表,每个元素对应一次查询输入,通常只有一个元素,直接取[0]即可。options里的每个对象带了text和可选的_source,解析起来比Java的API直观很多。

我实训时用Java写接口,请求落到/api/suggest?prefix=bj,内网环境压测单节点百万级词条索引,平均响应稳定在2到3毫秒,比之前用wildcard扫描快了两个数量级。这个差距在真实线上环境只会更明显,因为提示接口的查询并发量远比普通搜索高。

3.3 返回结果解析与字段透传

查询返回结构大致如下:

{ "suggest": { "hot_suggest": [ { "text": "beijing", "offset": 0, "length": 7, "options": [ { "text": "北京五日游", "_score": 30.0, "_source": { "id": "trip_101", "title": "北京五日游" } } ] } ] } }

这里的options就是最终要展示的联想结果数组,默认按_score降序排列。_score很大程度上由weight决定,所以调整权重就能控制排序。需要特别注意的是:如果你没有单独设置output,options里的text就是input命中的原始词条;设置了output,这里的text才是给前端展示的文案。

另外,option里带上_source非常关键。因为前端点击联想词后,往往需要直接跳转或者发起二次搜索,这时把文档ID、标题、跳转链接一并返回,就省去了前端再拿text去查一遍的额外请求。我的习惯是配合fetchSource只返回必要字段,避免整条大文档塞给前端。

3.4 一个容易忽略的细节:独立词条索引的取舍

Completion Suggest的FST需要常驻内存,而且每次索引更新都要重建对应部分,所以我不建议把主业务索引里放一个超大号的completion字段就完事。更稳的方案是单独建一个“提示索引”,文档结构极简单:词条ID、业务ID、词条文本、权重、类型、更新时间。业务数据变更后,通过定时任务或消息队列把提示数据同步过去。这样主索引负责检索,提示索引负责联想,互不干扰,后续做热词运营、敏感词下线也都有一个独立的地方操作。

4. 前端联调里的四个“体验细节”:联想好用和难用就差这几行代码

4.1 防抖和最小输入长度:别让搜索接口被键盘打爆

后端再快,也经不住用户在输入框里每敲一个字符就发一次请求。用户打出“beijing”这个过程,如果不做任何限制,会触发7次请求,其中前几次查询的结果毫无意义。

我的处理方式是两点:最小输入长度设为2,防抖时间设为150到200毫秒。这样用户只有在停止输入一小段时间后,才会真正发出一次请求。

let timer = null; input.addEventListener("input", function () { const kw = input.value.trim(); clearTimeout(timer); if (kw.length < 2) { renderSuggest([]); return; } timer = setTimeout(() => fetchSuggest(kw), 180); });

防抖代码看起来简单,但它直接决定了搜索提示接口的峰值QPS,省下来的是实打实的服务器资源和用户等待时间。

4.2 高亮与选中自动补全

联想词展示时,命中部分高亮是一个“低成本高感知”的加分项。前端不需要依赖后端做高亮,自己拼一下就行:

function highlight(text, keyword) { const lowerText = text.toLowerCase(); const lowerKeyword = keyword.toLowerCase(); const idx = lowerText.indexOf(lowerKeyword); if (idx === -1) return text; return text.slice(0, idx) + "<b>" + text.slice(idx, idx + keyword.length) + "</b>" + text.slice(idx + keyword.length); }

配合键盘上下键移动选中项、回车把当前项塞回输入框并直接提交搜索,这个交互闭环才算完整。实训里很多同学只做了鼠标点击,没有键盘操作,被用户在测评里吐槽得厉害。键盘选中的实现不复杂,主要是监听ArrowDown、ArrowUp、Enter三个键,维护一个activeIndex变量即可。

4.3 缓存和请求取消

前端联调还有一个很容易踩的坑:网络请求返回顺序不一定是发送顺序。用户先输入“beij”,再快速补全到“beijing”,如果不做取消处理,前一个慢请求可能后返回,把你展示的下拉列表覆盖成旧结果。

用AbortController配合一个简单的Map缓存,能把这个问题顺便解决掉:

let controller = null; const suggestCache = new Map(); async function fetchSuggest(kw) { if (controller) controller.abort(); controller = new AbortController(); if (suggestCache.has(kw)) { renderSuggest(suggestCache.get(kw)); return; } try { const resp = await fetch( "/api/suggest?prefix=" + encodeURIComponent(kw), { signal: controller.signal } ); const data = await resp.json(); suggestCache.set(kw, data.results); renderSuggest(data.results); } catch (e) { if (e.name !== "AbortError") { console.error(e); } } }

缓存的粒度按关键词来,同一个关键词在本次页面生命周期内不会重复请求。AbortController负责在上一次请求未返回时直接取消,保证最终展示的一定是最新输入的结果。

4.4 无结果时的兜底:让用户永远有路可走

联想词列表为空的时候,下拉框不要直接消失。最稳妥的做法是保底展示一行“搜索 xxx”,用户点击后用完整的原始输入去执行检索。我在实训里遇到过这样的情况:库存里没有以“机械键盘青轴”开头的商品,但用户明显是想要这个方向,联想词给不出候选项,如果直接空白,用户就会以为系统坏了。

兜底实现就是在渲染函数里判断results.length === 0时,追加一个把当前输入原样返回的选项。这个细节不起眼,但对搜索系统整体的可用性评价影响非常大。

5. 从“能用”到“好用”:权重、词条来源与索引维护

5.1 权重怎么定:让热搜词排到最前面

如果所有词条的weight都是默认1,联想结果的排序几乎是字典序和写入顺序的混合体,用户真正想点的词不一定排得靠前。搜索提示要做到“好用”,权重系统是核心。

实训里我见过最简单有效的权重方案就是基于搜索日志统计:统计近7天每个搜索词的出现次数,按次数排名映射到1到100的权重区间。

  • 出现10万次以上的词,权重给90到100;
  • 出现1万次以上,权重给70到89;
  • 出现1000次以上,权重给50到69;
  • 长尾词统一给10到50。

另外还要给运营留一个人工置顶的口子,比如节假日活动词条可以直接把权重拉到最高。具体实现就是在词条文档里多维护一个weight字段,定时任务每天根据日志更新一次。

5.2 中文、拼音、英文缩写如何一碗水端平

纯中文场景下,给completion字段配IK分词器就够了,用户输入“北”能命中“北京”,输入“北京”能命中“北京旅游”。但很多业务场景还需要支持拼音缩写,比如用户输入“bj”就想看到“北京”。

我当时的做法是单独增加一个pinyin_suggest字段,类型同样是completion,索引时用拼音插件把词条的全拼和首字母都生成出来,写入到这个字段。查询时同时查中文建议字段和拼音建议字段,前端合出去重。

这套方案的代价是FST内存翻倍,因为每个词条在内存里多存了一份拼音路径。所以是否要上拼音建议,得看业务用户实际输入习惯。如果日志里大量出现拼音缩写,就值得加;如果都是中文输入为主,优先保证中文词条质量。

5.3 词条从哪里来:不要直接整表搬

提示词条不是把业务表里所有字段都塞进去,而是要回答一个问题:用户会怎么搜这个系统。

常见的词条来源有四类:

  1. 业务字段中的核心词,比如商品标题、导演名、书名、城市名;
  2. 历史搜索日志中的高频搜索词;
  3. 运营手工维护的热词表;
  4. 用户在下拉列表里的点击反馈,点击多的词说明匹配得好,可以反向加权。

我建议把提示词条单独建模,字段设计为:id、biz_id、text、weight、type(来源类型)、update_time。不同来源最终都归拢到这个表里,做统一管理和清洗。上线一定时间后,把搜索日志和点击日志一对比,就能发现哪些词条被频繁展示但没有点击,可以考虑降权或下线。

5.4 词条数据更新与同步,以及我的血泪教训

最后聊聊维护。completion字段本身更新的代价很高,因为它依赖的FST结构在每次文档更新时都可能需要局部重建。如果直接把提示词条放在主索引里,业务数据频繁更新,搜索集群的CPU毛刺会非常明显。我实训中就踩过一次坑:为了图省事,把商品标题既当text又当completion,结果每天晚上的同步任务一跑,整个主索引都跟着抖动,用户前台搜索直接出现查询毛刺。

后来我把方案改成独立提示索引,思路是这样的:

  • 全量重建:每天晚上用业务索引的数据批量写入提示索引,词条经过清洗、聚合、权重计算后再刷进去;
  • 增量更新:业务侧变更时发一条消息,消费端只更新对应的提示词条文档;
  • 删除处理:下线词条直接用delete_by_query过滤后删除,操作都在独立索引内完成,不会影响主检索链路。

这样主索引和提示索引彻底隔离,提示数据的更新无论多频繁,都不会干扰核心搜索质量。如果项目早期数据量小,你用同一个索引也能跑,但我建议一开始就按独立索引来设计,后期迁移的成本远超一开始多写两个同步方法的成本。

最后一件事

搜索提示做到最后,我发现体验瓶颈往往不在Elasticsearch的查询语法上,而在于词条质量。weight再调,fuzzy参数再精细,词表本身是脏的、乱的、缺的,联想出来依然是一堆用户不想点的东西。实训里真正让我受益的做法,是先花时间把热搜词表、同义词表、停用词表整理干净,再回来调ES的参数,比什么都管用。如果你也在做类似的功能,建议先把词条来源理清,再开始写代码,这个顺序别搞反。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进