☰
ElasticSearch 搜索提示实战:基于 Completion Suggest 的搜索框自动补全方案
2026/10/16 4:51:57 网站建设 项目流程

我最近在做一个创新实训项目,后台管理系统的搜索框里加了搜索提示功能。产品原型的描述很简单:“用户在搜索框输入关键词时,下拉列表实时给出候选词。”最开始我以为这不就是 SQLLIKE '%关键字%'查一下热词表吗,等真正跑到 ElasticSearch 这层才发现,搜索提示(Suggest)和搜索(Search)完全是两码事。最后我用 ElasticSearch 的 Completion Suggest 实现了这套提示逻辑,整个踩坑过程挺典型,写出来给后面做搜索模块的兄弟参考。

搜索提示和搜索框里的普通搜索看着像,但本质需求不一样。普通搜索要的是“包含关键字的结果文档”,搜索提示要的是“以已输入内容为前缀的候选词”。如果直接用搜索接口做提示,要么响应太慢,要么候选词质量差。我最终选型是 ElasticSearch 自带 Completion Suggest,原因后面细说,先看它的能力:用户输入“com”,下拉框立刻给出 completion、computer、community 这类以“com”开头的词;用户输入“电”,给出“电商”“电影”等词。配合 Web 端防抖,体验可以做到“输入一个字符就有反馈”。

这篇写法偏实战,围绕我实际实现搜索提示时遇到的核心问题展开:为什么选 Completion Suggest、FST 原理怎么理解、索引映射怎么设计、代码怎么写、有哪些坑。项目里用的 ElasticSearch 是 7.x 版本,Java 客户端是 RestHighLevelClient,其他版本差别不会太大。

1. 为什么是 Completion Suggest

1.1 搜索提示的需求拆解

在我这个项目里,搜索提示要承接两部分数据:一是商品和文章标题词,二是运营配置的热搜词。用户端表现是输入前缀、下拉出词、点击直达搜索结果。技术上要满足三个基本点:

  • 响应要快。提示候选词要在几十毫秒内返回,用户每敲一个字符都可能触发请求,慢一点都不行。
  • 要有权重控制。热词要排前面,不能只按字典序排。
  • 要支持中文。项目内容大量是中文标题,中文分词和拼音前缀都要考虑。

1.2 我把常见方案挨个试了一遍

最先想到的方案是match_phrase_prefix,它就是普通搜索的一种查询方式,用户输入“elastic”,能匹配到标题是“ElasticSearch 入门指南”的文档。问题是这个查询会走完整查询链路,要算分、聚合、取回文档,在小数据集上还行,一旦数据量上来或者并发高,响应时间根本扛不住搜索提示的场景。

第二个方案是wildcard通配符查询,title: elastic*这种写法。数据集小的时候测试效果确实直接,但通配符查询基本没办法利用倒排索引的加速能力,通配符越靠前越慢,而且中文场景下*开头的写法很容易把全表扫一遍。只能算“能跑”,算不上方案。

第三个方案是把标题分词成 edge_ngram,再配match查询。比如“ElasticSearch”被切成了 e, el, ela, elas,查询“el”就能命中。这个方案能做前缀搜索,但有两个绕不开的问题:一是索引体积明显变大,n-gram 本身就是空间换时间;二是 edge_ngram 会改变索引里的 token,做关键词提示还行,如果同一个字段还要做精确搜索或者聚合,就得另外再维护一个字段。

最后才是 Completion Suggest。它跟前面所有方案都不在一个思路上:它不靠倒排索引做查询,而是把用于提示的文本预加载到一个基于 FST 的内存式索引里,专门服务“前缀补全”这种场景。它快、有专门的权重参数、可以控制去重和模糊匹配,这几点完全长在我这个需求上。结论很明确:用 Completion Suggest 做搜索提示,普通搜索接口继续走 match 查询,各干各的,互不干扰。

1.3 选型确认后的架构判断

我还专门确认了一下 Completion Suggest 会不会给现有搜索链路造成负担。它的数据是独立字段、独立索引结构,查询走的是_search接口的suggest节点,不参与主查询评分。这就意味着我可以把现有搜索逻辑原封不动保留,只增加一个字面意义上的“附属模块”。

2. Completion Suggest 的核心原理

2.1 一句话理解 FST

Completion Suggest 底层的数据结构叫 FST,全称 Finite State Transducer,中文一般译作有限状态转换器。它本质上是一个有向图,路径上的边对应字符,节点对应状态。把任意一个词拆成一个个字符,沿着图的边从起点走到终点,这条路就代表了这个词。多个词共享相同的前缀路径,所以 FST 的存储效率比普通 Set 高很多。比如 “com” 和 “community” 会共享 c-o-m 这一段路,后者的后面部分才单独分叉。

我一开始也不太理解这东西,后来想了个类比:它有点像 Trie 树,但 Trie 树每条边只存一个字符,FST 可以一条边上存一串字符,还能在边上带权重信息。ES 就是利用这个结构,把一个字段的所有 suggest 文本压缩成一份紧凑的映射,查询时顺着字符路径走就行,前缀匹配天然就快。

2.2 内存加载和查询路径

Completion Suggest 和普通倒排索引不一样的地方是,它的数据在加载后会常驻内存。ES 节点启动后,凡是包含 completion 类型字段的分片,都会把 FST 加载到堆外内存(mmap 或 direct memory 的机制),查询时直接操作内存里的 FST,绕开磁盘 IO。

这就是为什么 Completion Suggest 能稳定地在几毫秒内返回结果。普通查询再快也要过一遍倒排链、合并文档列表,Completion Suggest 从根节点开始沿着输入的前缀走就行了。输入“com”,FST 就把从 “com” 这个节点出发的所有后续路径都枚举出来,取前 N 个作为候选。

2.3 为什么它不支持子串匹配

必须说清楚:Completion Suggest 只做前缀匹配,不做包含匹配。用户输入“astic”是提不出 “ElasticSearch” 的,因为 “astic” 不是 “ElasticSearch” 的前缀。这也决定了数据的组织方式:要么你确保原始词本身以用户输入开头,要么你提前把“别名”“拼音”“分词结果”这些可能性都塞进 input 数组里。

这个约束其实帮我们简化了工程模型。搜索提示本来就是引导用户补全,不是兜底搜索,所以不需要担心“搜索不到”的问题——那是普通搜索接口的任务。

3. 索引映射与数据处理

3.1 设计 suggest 字段

我先在索引里增加了一个专门用于提示的字段。最简映射长这样:

{ "mappings": { "properties": { "title": { "type": "text", "analyzer": "ik_max_word" }, "suggest": { "type": "completion", "analyzer": "simple", "search_analyzer": "simple", "preserve_separators": true, "preserve_position_increments": true, "max_input_length": 50 } } } }

几个参数解释一下:

  • analyzer和search_analyzer是给 completion 字段用的分词器。我这里用的是simple,它会把“ElasticSearch-入门”拆成 “elasticsearch” 和 “入门” 两个 token。如果你有更细的中文分词需求,可以换成项目里已有的 ik 分词器,但注意 completion 的分词器对内存占用影响比较大,词拆得越碎 FST 越大。
  • preserve_separators控制分隔符是否保留。默认 true 表示用分隔符隔开的多个词会分别建立提示路径,比如“ElasticSearch 入门”会拆成 “ElasticSearch” 和 “入门”;如果设成 false,会把整串带分隔符的内容作为一个路径。
  • preserve_position_increments和分词位置有关,一般保持默认。
  • max_input_length限制单个 input 的最大长度,超过部分不参与构建 FST。设成 50 足够覆盖搜索词长度,同时避免超长文本浪费内存。

3.2 中文提示词的数据预处理

中文项目和英文项目在 completion 数据处理上差别很大。英文天然按空格分词,用户输入几个字母就能命中。中文用户习惯输入汉字前缀,也可能输入拼音首字母。我项目里的处理方式是:

  • 对每个标题,生成原始标题词。
  • 额外生成拼音全拼。
  • 额外生成拼音首字母。
  • 再补充运营配置的热词。

数据落到索引里的 document 大致是这样:

{ "title": "ElasticSearch 搜索引擎", "suggest": { "input": ["ElasticSearch 搜索引擎", "elasticsearch", "elastic", "es", "es搜索"], "weight": 100 } }

这里把 “es” 也塞进 input 数组,用户输入 “es” 就能命中。注意:input 数组里每一项会单独建立前缀路径,所以不会出现“只匹配到 ‘ElasticSearch’ 却带不出‘搜索引擎’四个字”的情况。

3.3 中文索引的实际坑:分词器选择

有同学会惯性思维,给 completion 字段也配 ik_max_word。结果是:一个“搜索引擎”会被拆成 “搜索引擎”“搜索”“引擎”“索引”等 token,每个 token 都成为 FST 里的一条路径。表面看好像提示覆盖更广,但问题是候选词会被大量无意义组合污染,FST 体积也膨胀得厉害。

我最终的实践是:基础词还是用 simple 或 whitespace 分词器把完整词条作为提示单元,需要覆盖的场景(拼音、简称、别名)统一由数据清洗环节加进 input 数组,而不是依赖分词器自动切碎。这个取舍很重要,一开始不控制好,后面数据量上来就是灾难。

3.4 weight 权重设计

weight控制候选词排序。ES 返回的 suggest 结果默认按 score 排序,score 主要由 weight 决定。同一个前缀下有多个候选词时,weight 大的排前面。

我的经验是不要只有一个静态权重,最好按业务热度动态算:

  • 运营热搜词:权重 1000 起步。
  • 标题完全匹配原词:权重 500。
  • 拼音或别名:权重 100。

实际实现时可以从数据中心拿到热度值,写入文档前做归一化,乘一个基数。比如热度值在 0-10000 范围,直接取 log 再乘 100,避免长尾数据权重差距过大。

3.5 数据更新与同步

Completion Suggest 的数据是跟着原始文档走的。如果标题变了、别名变了,直接更新文档的 suggest 字段,ES 会帮你重建对应 FST 片段。但如果运营配置了一个独立的热词表,这些词不一定有对应的业务文档,我就单独建了一个suggest_words索引,只存 suggest 数据。

业务文档和热词索引分开的好处是职责清楚:搜索业务文档命中真实内容,热词索引纯做提示。更新热词只需要调 upsert 接口,不用触碰业务文档。坏处是多一份需要同步的数据,热词表变更时要有 MQ 或定时任务做同步,保持两侧一致。如果数据量小也可以不做热词表,直接建一个字段放在文档里,看项目实际情况。

4. 核心代码实现

4.1 查询 DSL

Completion Suggest 的查询语法不长:

POST /product_index/_search { "suggest": { "product_suggest": { "prefix": "el", "completion": { "field": "suggest", "size": 5, "skip_duplicates": true, "fuzzy": { "fuzziness": 1 } } } } }

说明一下参数:

  • prefix是用户当前输入的关键字。
  • field指向映射里的 completion 字段。
  • size控制每个 suggest 返回的候选数量。建议控制在 5-10,前端下拉框展示太多反而没用。
  • skip_duplicates为 true 时会跳过完全重复的文档,同一标题在多个文档里出现时,只返回一次。
  • fuzzy是可选配置,开启后允许输入中有少量拼写错误也能匹配,比如输入“elastc”也能提示“elastic”。这块我是在中后期加的,因为用户拼音输入常见错位。

4.2 Java 端调用封装

项目用的是 Spring Boot,Java 客户端是 RestHighLevelClient 7.x。核心代码如下:

public List<String> suggest(String prefix) { CompletionSuggestionBuilder completionSuggestionBuilder = SuggestBuilders.completionSuggestion("suggest") .prefix(prefix) .size(8) .skipDuplicates(true) .fuzzy(new FuzzyOptionsBuilder().setFuzziness(Fuzziness.ONE)); SuggestBuilder suggestBuilder = new SuggestBuilder(); suggestBuilder.addSuggestion("product_suggest", completionSuggestionBuilder); SearchSourceBuilder sourceBuilder = new SearchSourceBuilder(); sourceBuilder.suggest(suggestBuilder); SearchRequest searchRequest = new SearchRequest("product_index"); searchRequest.source(sourceBuilder); try { SearchResponse response = restHighLevelClient.search(searchRequest, RequestOptions.DEFAULT); Suggest suggest = response.getSuggest(); if (suggest == null) { return Collections.emptyList(); } CompletionSuggestion completionSuggestion = suggest.getSuggestion("product_suggest"); if (completionSuggestion == null) { return Collections.emptyList(); } return completionSuggestion.getOptions().stream() .map(option -> option.getText().string()) .collect(Collectors.toList()); } catch (IOException e) { log.error("search suggest error, prefix: {}", prefix, e); return Collections.emptyList(); } }

如果团队里用的是 Spring Data Elasticsearch,也可以不用 RestHighLevelClient,直接构造CompletionSuggestQuery,但封装的自由度低一些。需要动态调模糊参数、权重加解密逻辑时,还是原生客户端更顺手。

4.3 候选词去重问题

如果业务索引里有大量重复标题,建议在数据写入前就做归一化,避免每个重复文档都贡献一份 suggest 路径。skip_duplicates只能对完全相同的 text 去重,类似 “ElasticSearch 搜索引擎” 和 “ElasticSearch搜索引擎教程” 这种不完全相同但前缀路径重叠的,还是会都返回。

去重要落到业务层面:在索引建议词之前,先按标题做一轮聚合或 distinct,确保写入 suggest 字段的候选词本身就唯一。热词表也是同理,配置重复了直接覆盖。

4.4 fuzzy 模糊匹配的代价

开启 fuzzy 后,FST 查询会做有限编辑距离的扩展,比如输入“elastc”会尝试 e-l-a-s-t-c 的附近状态。这个功能好用,但不能把 fuzziness 开太大。我试过设成 2,结果是一个前缀会延伸出几十条候选路径,返回时间从 3ms 涨到 80ms,虽然体感还行,但完全没有必要。正常键盘错位和拼音误输入,fuzziness = 1足够。

5. 前端交互与性能优化

5.1 防抖:搜索提示的命门

搜索提示虽然单次查询很快,但用户连续输入时如果不做防抖,后端每秒可能被请求几十次。我前半个月试探式地跑一个“每输入一个字符都发一次请求”的版本,结果搜索接口负载直接上去了,日志里全是 suggest 请求。

后面改为 lodash 风格的 debounce,300ms 内不再触发请求。用户连续打字时只发最后一次请求。另外做了最小输入长度限制:低于 1 个字符不请求,因为单字符候选词太多,前端渲染反而是负担。真正需要单字提示的热词会由后端权重拉上来,不需要额外请求。

5.2 客户端缓存

搜索提示的候选词有很强的局部性和时间性。同一时段、同一热词榜下的结果基本一致,我不希望每个用户都去打一遍 ES。简单方案是前端做一个 Map 缓存,以输入前缀为 key,缓存 5 分钟。后端也可以用本地缓存或 Redis,但要注意缓存一致性——运营修改热词后不能立即生效的问题,后面再谈。

我采用的是一种比较土的方案:后端基于前缀做本地 Caffeine 缓存,key 是 prefix+是否模糊,过期时间 60 秒。运营热词更新后最多等一分钟,可接受。

5.3 高亮显示

Completion Suggest 返回的是完整候选词,不是匹配片段。前端需要自己把用户输入的部分高亮,例如:“ElasticSearch”里的 “Elastic” 加粗。原生高亮语法也很简单:

function highlightKeyword(text, input) { const index = text.toLowerCase().indexOf(input.toLowerCase()); if (index === -1) return text; return text.substring(0, index) + '<em>' + text.substring(index, index + input.length) + '</em>' + text.substring(index + input.length); }

后端不参与高亮逻辑,减少一次字符串处理。注意要做 Unicode 安全处理,别把中文拆出半个字符来,不过 JavaScript 的 substring 对 UTF-16 基本安全,除非碰上冷僻字体代理对。

5.4 降级方案

如果 ES 集群发生异常、或者 suggest 服务临时不可用,用户在下拉框里看到的应该是空列表,而不是报错。前端要判空,后端要捕获异常返回空 list,绝不能让搜索提示的故障影响主搜索链路。我用一个简单的熔断开关:连续 N 次 suggest 超时后,直接放空返回,不再请求 ES,等一段时间后再恢复探测。

6. 我踩过的几个坑

6.1 completion 字段参与 _all 或 copy_to 的坑

早期我把 suggest 字段意外配置到了copy_to的全局字段里,导致每次更新文档时,FST 重建的触发条件变得很诡异,部分文档的提示词一直没刷新。排查了很久才发现是 copy_to 链路里多了一层字段,建议 completion 字段不要跟其他字段一起 copy,单独使用会更直观。

6.2 更新权重不能原地改

ES 的 FST 一旦构建完成后,某个词项的 weight 变了,单纯 update 文档是不行的,至少我遇到的情况是这样:修改文档其他字段不影响 suggest 的 FST,单独更新 suggest 里某个 input 的 weight 经常不生效。稳妥做法是删除文档后重新写入,或者直接 reindex。热词场景建议用“整词重建”的思路,把热词表整体刷一遍,而不是单条更新。

6.3 中文标点与分隔符

中文标题里经常会带冒号、括号等符号。输入“elastic 入门”时,由于中间有空格,而 completion 默认对空格是保留分隔符的,可能匹配不到“ElasticSearch入门”这种词。我的处理是数据写入前做一次标点清洗,生成无标点版本塞进 input。用一组小正则把常见全角标点替换成空字符串。这样用户无论输不带标点还是带标点的词,都能命中。

6.4 大小写问题

Completion Suggest 是区分大小写的。用户输入小写 “elastic”,只有当 FST 里存了小写形式才算命中。所以我写入 suggest 字段时,input 里会同时存原词和 toLowerCase 的小写版本,前端搜索时也可以统一先转小写再请求。最省事的是全局统一小写。

6.5 冷启动和无数据状态

新环境部署后,ES 里没有任何 suggest 数据,前端下拉框会空空如也。需要准备一份种子热词脚本,初始化时跑一批通用词。我在项目里直接读配置中心的热词 JSON,启动时批量 upsert 到 suggest_words 索引。

6.6 max_input_length 设置太小

我有一次为了省内存把max_input_length设成 20,结果超过 20 个字符的长标题只截取前 20 个字符索引。前端点击下拉词跳转后,搜索结果是基于截断词去搜的,当然搜不到。后来把参数调到 50,数据才正常。这个参数要根据实际业务最长关键词设置,不能贪内存。

7. 效果观察与一点体会

功能上线后,我重点是观察 suggest 请求在整体搜索流量中的占比和响应时间。实际看下来,Completion Suggest 平均响应在 3ms 到 15ms 之间,相比普通搜索接口动辄几十毫秒,完全是两个量级。还没做防抖前,suggest 的 QPS 接近搜索的 60%,做了防抖之后降到 20% 左右,对主集群基本没有压力。

FST 内存占用这块,我也观测了一下:业务索引里 10 万个标题词,每个标题平均补充 4 个 input 项,suggest 字段在节点上占用的内存大约 200MB 左右。这个量级在我们集群可接受范围内。如果数据量到百万级,建议把 suggest 单独拆到一个独立节点或者独立索引,避免挤占搜索链路内存。

还有一个小经验:上线初期我建议给 suggest 索引单独配置refresh_interval。因为提示词不需要近乎实时的更新,把 refresh 间隔调大到 30s 甚至更久,可以明显降低因频繁 refresh 产生的 IO 压力。但如果业务要求热词几秒内生效,还是要看具体情况。我在热词场景是 30s 刷新,普通业务文档场景保持默认 1s,折中处理。

最后再说一个实操细节:每次改动 suggest 的 mapping 或 analyzer 后,都要重新 reindex,因为 FST 是建在 Lucene 段里的,旧的映射不会自动重建。我吃过一次亏,改完 mapping 后忘了 reindex,线上查出来还是老路径,排查了半天才发现是文档段没刷新。所以这个流程一定要写进发布检查单里:改 suggest 配置 -> reindex -> 验证两个前缀用例 -> 发布。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询