☰
ElasticSearch Suggest实战:搜索提示与纠错的三层方案
2026/10/16 5:06:41 网站建设 项目流程

做创新实训那段时间,我们小组在给校内课程检索平台加搜索提示,需求就一句话:用户在搜索框里敲出第一个字,下拉列表就要出现候选词,敲到一半就得猜到你想搜什么。比如输入"计",能弹出"计算机组成原理""计算机图形学""计算机网络";输入拼音"jsj",也能定位到"计算机"相关的课程。一开始我真没把这当回事,觉得要么like模糊查询,要么前端写个联动。等动手做项目才发现,搜索提示和普通搜索根本是两套逻辑——候选列表不强求给完整结果,但要求速度快、够聪明,能容忍用户拼错、输入拼音、甚至只记得半截课程名。这篇文章算是我在这轮实训里用ElasticSearch的Suggest接口实现搜索提示的一份完整记录,从方案对比、三种Suggester选型,到索引配置、查询代码,再到实测踩过的坑,给同样接手类似功能的人省点时间。

1. 为什么最终选了Suggest方案:先聊聊被否掉的三条路

1.1 MySQL的like查询:"能跑"和"好用"是两回事

刚拿到需求时,组里最快的方案就是改一句SQL:WHERE name LIKE '%计%'。demo确实能跑,输入"计"出来一堆带"计"的课程,但只要我们往真实场景推几步,这个方案就露馅了。

第一个痛点是持续输入的状态感知。用户在搜索框敲"计"和敲"计算机",对数据库来说是两次完全独立的查询,数据库不知道"人在持续搜索"这件事。前端只能每次按键都向后端发请求,后端每次都全表扫一遍。数据量小的时候无所谓,我们后来把全校一万两千多门课程灌进去,再加上并发,like查询的响应时间立刻变得不可控。更关键的是,LIKE '%计%'这种写法会把索引直接废掉,写成LIKE '计%'又只能照顾前缀命中的情况,用户输入"组成原理"这种中间片段时照样没结果。

第二个痛点是排序能力太弱。我们希望热门课程排在候选列表前面,但数据库里只有课程名称、教师、院系这些固定字段。想按搜索热度排序,就得额外维护一张统计表,每次查询先聚合热度再join回来,代码写起来绕,性能还更差。

第三个痛点是完全没法纠错。用户把"计算机原理"敲成"计算机原里",like查出来是空结果集。搜索提示如果只能在你输对时给出选项,就失去了它一半的价值。基于这几点,我们很快把纯数据库方案否掉了。

1.2 自己维护前缀树或词典:工作量比想象中大得多

也考虑过在应用层维护一棵前缀树,把热门课程名提前load进内存,前缀匹配用Trie做,复杂度只有O(长度),看起来很美好。

真正推演实施细节时,问题一个接一个冒出来。首先是数据同步:课程库每天都在变,新增课程、课程改名、教师调整,都要同步刷进内存里的Trie,缓存失效和重建的时机怎么定都是麻烦。其次是拼音和多音字:用户输入"jsj"期望的是"计算机",这意味着不仅要存汉字,还要存全拼和首字母缩写,而"重庆"这种词到底按"chongqing"还是"zhongqing"给出提示,需要单独维护多音字表。再次是错别字纠正:前缀树默认只能做精确前缀匹配,要做到编辑距离小于等于1的容错,得自己实现Levenshtein相关的逻辑。最后,我们发现自己其实是在做一个迷你搜索引擎,而项目里已经有ElasticSearch了。

这里有一个很朴素的判断:凡是"从数据里找出用户想要的东西"的活,都应该交给搜索引擎,而不是应用代码。应用层做Trie,只适合数据极小、规则极固定的场景。我们课程名有别名、拼音、热度、分类这些维度,明显不是Trie能优雅处理的。

1.3 决策点:项目里已有ElasticSearch,Suggest是现成能力

我们实训项目的底层就是ElasticSearch,课程全文搜索早就在用。查资料时发现它原生提供了Suggest API,专门用来做搜索提示,不需要额外部署服务,也不需要给同一份课程数据再造一套存储。

当时打动我的有三点。第一,Suggest API和普通搜索吃的是同一个索引,数据一致性天然解决;第二,它内置了三种机制分别处理补全、纠错和短语修正,覆盖了搜索提示的绝大多数场景;第三,接入成本低,改动集中在mapping的字段声明和查询的suggest块,不用动业务主流程。

可以这样理解普通搜索和搜索提示的关系:普通搜索像让搜索引擎找出所有包含关键词的页面,而搜索提示像图书馆门口的老馆员,听你说出半个书名就能猜到是哪一本,还会在你念错书名时小声纠正你。Suggest就是这位馆员的工作机制。

2. 三种Suggester的分工:Completion、Term、Phrase

2.1 Completion Suggester:前缀补全的主力

Completion是三种机制里最贴近"边输入边提示"的一种。它的特点是只做前缀匹配:索引里存好一批候选字符串,用户在输入框里给出"计",它就把所有以"计"开头的候选列出来。实现上,ElasticSearch会为completion字段构建FST(有限状态转换器),把候选字符串压缩成一张紧凑的前缀查找表,查询时按输入逐字符走状态机,所以速度特别快,这也是它适合做即时提示的原因。

Mapping声明如下:

PUT /course_index { "mappings": { "properties": { "name": { "type": "text" }, "name_suggest": { "type": "completion", "analyzer": "simple", "preserve_separators": true, "preserve_position_increments": true, "max_input_length": 50 } } } }

analyzer这里用simple做预处理,作用是把输入统一转小写、按非字母字符切分;preserve_separators决定是否保留分隔符;max_input_length限制单条候选的最长长度,防止过长的字符串进入FST浪费内存。

查询时在suggest块里写:

POST /course_index/_search { "suggest": { "course-suggest": { "prefix": "计", "completion": { "field": "name_suggest", "size": 10, "skip_duplicates": true } } } }

响应的options数组里就是按权重排好的候选词,每条候选还带着_source,前端可以直接拿到课程ID、分类这些扩展信息。completion还支持fuzzy参数,比如"fuzzy": {"fuzziness": 1},允许用户输入有一个字符的出入,这个后面调优时我还会展开说。

2.2 Term Suggester:词级别的拼写纠正

Term Suggester做的是词粒度纠错。它把用户输入的整个查询串拆成若干个词,然后对每个词在索引里找相近的词项,依据是编辑距离(Levenshtein距离)。比如用户输入"计算机原里",索引里有"计算机""原理",Term Suggester会发现"原里"和"原理"之间编辑距离是1,于是给出修正建议"原理"。

基本查询长这样:

POST /course_index/_search { "suggest": { "course-term": { "text": "计算机原里", "term": { "field": "name", "max_edits": 2, "prefix_length": 1, "mode": "always" } } } }

max_edits表示允许的最大编辑距离,一般设1就够,设2会让候选噪点明显变多;prefix_length表示词首多少个字符不允许被修改,这个参数很实用,加了它之后"计算机原里"的前几个字不会被乱改,只针对尾部纠错;mode有三个取值,默认是missing,意思是索引里没搜到该词时才给建议,互动提示场景一般改成always,这样即使用户输对了也能拿到候选,方便前端做"你是不是要找XXX"的提示条。

用Term Suggester时要注意,它给的候选是词而不是完整短语,所以前端通常要把各词的建议拼起来展示。"原里"修正为"原理"后,最终展示的就是"计算机原理"。

2.3 Phrase Suggester:把整个短语一起修

如果说Term Suggester是逐个词纠错,Phrase Suggester就是把整个短语放在一起考虑。它引入了n-gram语言模型的概念,会计算"计算机原理"作为一个整体出现的概率,比"计算机原里"更合理,从而给出更流畅的修正结果。它对英文场景效果尤其明显,对中文也有帮助,尤其能处理多个词同时出错的情况。

使用Phrase Suggester前需要配置带shingle过滤器的分析器。shingle的作用是把相邻词组合成带空格的词组token,比如"计算机 原理"会额外生成"计算机_原理"这样的bigram,这样短语模型才能看到词与词之间的关联。

配置示例:

PUT /course_index { "settings": { "analysis": { "filter": { "phrase_shingle": { "type": "shingle", "min_shingle_size": 2, "max_shingle_size": 3 } }, "analyzer": { "phrase_analyzer": { "type": "custom", "tokenizer": "standard", "filter": ["lowercase", "phrase_shingle"] } } } }, "mappings": { "properties": { "name": { "type": "text" }, "name_phrase": { "type": "text", "analyzer": "phrase_analyzer" } } } }

这里我单独建了一个name_phrase字段,专门给Phrase Suggester用,避免shingle过滤器污染正常搜索用的name字段。查询时:

POST /course_index/_search { "suggest": { "course-phrase": { "text": "计算机原里", "phrase": { "field": "name_phrase", "size": 5, "confidence": 0.8 } } } }

confidence是接受建议的最低置信阈值,值越大,给出的建议越保守。实战中我建议从小到大试,先0.5,观察体验再往上调。

三种Suggester的关系,我习惯用这张表来记:

Suggester核心原理典型场景配置要点性能特点
Completion前缀匹配 + FST输入框下拉提示completion类型字段、input数组、weight快,但占用堆内存
Term词级编辑距离拼写纠错、单字错max_edits、prefix_length、mode中等,依赖词典规模
Phrasen-gram语言模型整句纠错、多词错shingle过滤器、confidence较重,索引分析成本高

3. 一份能跑起来的完整实现:从索引到查询

3.1 索引设计:搜索字段和提示字段分开

我们的课程索引里有这些字段:课程名name用于普通全文搜索,teacher存教师名,category存课程分类,popularity存搜索热度,另外单独声明了一个name_suggest的completion字段专门给下拉提示用。

一个细节是,不要把course name直接塞进completion字段就完事。completion字段做的是整段前缀匹配,不擅长分词,所以我们在写入文档时就把各种候选形式都算好,通过completion字段的input数组塞进去:

POST /course_index/_doc { "name": "计算机组成原理", "teacher": "某任课老师", "category": "计算机", "popularity": 256, "name_suggest": { "input": ["计算机组成原理", "组成原理", "计组", "jsjzcyl", "JSJZCYL"], "weight": 256 } }

input数组里每一项都是一个候选入口,用户输入"计"、"组成"、"jsjzcyl"都能命中这条课程。"组成"能命中是因为我们把"组成原理"单独作为input项存进去了,这样补全就不局限于课程名第一个字,用户敲中间片段也能出结果。weight字段用于排序,我们直接把搜索热度传进去,热门课程自然排在前面。

如果项目里已经有成熟的开源拼音分析插件,也可以在mapping里配置一个带pinyin过滤器的字段来生成拼音候选。我们当时为了减少插件依赖、方便排查问题,选择在写入端把全拼和首字母提前算好,逻辑透明,效果也一样。

3.2 数据灌入:从MySQL到ES的转换逻辑

课程数据源是MySQL,我们用一个定时任务每天同步增量。同步逻辑不复杂,就是把课程记录读出来,组装成上面的文档格式,走bulk接口批量写入。真正花时间的是input数组的生成规则:

  • 课程全名,比如"计算机组成原理";
  • 课程常用简称,比如"计组";
  • 教师姓名和课程名的组合,比如"张三的计算机组成原理";
  • 全拼和首字母缩写,比如"jsjzcyl"和"jsjzc";
  • 英文课程名或常见缩写,如果有的话。

同一门课可能会生成很多条input,这里必须去重,不然一条课程会在候选列表里占好几个位置,前端看着像重复数据。另外,input数组里每项长度不能超过max_input_length,超长的要截断或舍弃。

这个规则看起来琐碎,但它直接决定了提示质量的底线。用户不管从哪个角度接近这门课,只要能对上input里的任意一项,下拉框就能给出正确建议。后面每次有用户反馈"我输XX搜不到",我们的排查入口都是"input数组里缺了哪种写法"。

3.3 查询端:suggest块怎么写才对,响应怎么解析

后端用的是Java客户端的RestHighLevelClient,核心代码片段:

SuggestBuilder suggestBuilder = new SuggestBuilder(); CompletionSuggestionBuilder csb = new CompletionSuggestionBuilder("name_suggest"); csb.prefix(keyword).size(10).skipDuplicates(true); suggestBuilder.addSuggestion("course-suggest", csb); SearchRequest searchRequest = new SearchRequest("course_index"); searchRequest.source().suggest(suggestBuilder); SearchResponse response = client.search(searchRequest, RequestOptions.DEFAULT);

响应解析也很直观:

Suggest suggest = response.getSuggest(); CompletionSuggestion suggestion = suggest.getSuggestion("course-suggest"); for (CompletionSuggestion.Entry entry : suggestion.getEntries()) { for (CompletionSuggestion.Entry.Option option : entry.getOptions()) { String source = option.getHit().getSourceAsString(); // 把source里的课程名、分类、教师返回给前端 } }

这里要注意,completion的候选结果默认也能带上_source,所以前端不仅能拿到提示文本,还能拿到课程ID、分类这些信息,用来做跳转或者展示副标题都很方便。

前端联调时建议加两个常规优化:一是防抖,用户停止输入100到200毫秒后再发请求,避免每次按键都打一次ES;二是丢弃过期响应,标记这次请求的序号,只展示最新一次的结果,防止网络抖动导致旧响应把新响应覆盖掉。这个小问题实战中出现频率极高,尤其用户快速删除再输入时,下拉框经常闪一下旧数据。

4. 实测中踩过的三个坑

4.1 坑一:completion只做前缀匹配,"中间片段"命中不了

我们第一版上线后收到的反馈是:输入"组成"居然搜不到"计算机组成原理"。开发同学第一反应是分词器有问题,排查了半天才发现,这根本不是bug,是completion字段的设计使然——它只匹配用户输入与候选input项的前缀关系。课程全名"计算机组成原理"里,"组成"在字符串中间,前缀匹配自然命中不了。

解法有两个层面。一层是数据侧,把"组成原理""原理"这些中间片段显式加进input数组,这个我们上文已经做了;另一层是逻辑侧,如果产品上希望输入任意中文字段都能补全,就得放弃completion,改用edge n-gram配合普通text字段的方案。两种方案我都实验过:数据规模不大时,前者简单可控;数据量大、补全要求覆盖所有子串时,后者更通用。我们最终选择的是前者,因为教学场景下提示词以课程名开头和简称居多,覆盖中间片段反而会把候选列表搞得很杂。

4.2 坑二:拼音、全半角、大小写的入口不一致

拼音输入是搜索提示的隐形需求。用户不一定会打汉字,尤其课程名称又长又拗口时,他们更愿意打"jsjzc"。但我们第一版只往input数组里塞了课程全名,结果用户输入"jsjzc"直接什么都出不来。后来把全拼、首字母全部预先算好写进input,问题才解决。

这里有两个补充细节。第一个是全半角:用户可能在中文输入法状态下输入了全角字符,比如"jsj",如果不做转换,ES匹配不到。我们在查询入口统一做了一个全角转半角的清洗。第二个是大小写:我在completion字段上配置了simple分析器,它会统一转小写,但为了保险,写入input和查询keyword时都做一次toLowerCase(),彻底杜绝大小写不一致的隐患。

还有一个值得注意的点是多音字。我们课程名里有"大"读dai的专有名词,第一次用拼音插件时给"重庆"生成了"zhongqing",用户输入"chongqing"却匹配不到。这也是最终选择在写入端手动维护拼音候选数组的原因之一——多音字规则可以针对性地写在业务代码里,而不是依赖通用插件一刀切。

4.3 坑三:套路化的结果排序,看起来"很笨"

另一个典型反馈是:输入"计",排第一位的不是最热门的"计算机组成原理",而是一些点击率很低的冷门课程。原因是我们最初写文档时没给weight赋值,所有候选权重一致,ES就按内部顺序返回,表现就是结果顺序不稳定、不贴近用户预期。

解决方式很简单,把搜索日志聚合出来的热度写进weight字段。但热度数据在项目初期是零,冷启动阶段怎么排序?我们当时先用课程名的"被选课人数"做近似热度,把这个值归一化后作为weight,效果虽然不如真实搜索热度精准,但已经比随机顺序好很多。等运行一段时间收集到真实的搜索点击日志后,再定期更新权重。

另外,如果课程数据有分类属性,可以研究一下completion field的contexts功能。它允许在查询时指定上下文范围,比如只搜索"计算机"分类下的提示词,可以把一个大索引拆成多个逻辑分区,既能加快查询,又能避免跨分类的候选干扰。我们后面把"院系"作为context传入,候选人列表的精准度又上了一个台阶。

5. 如果重做一次,我会在哪些地方投入更多

5.1 提前设计"候选词表"的维护机制

实训项目做到后半段,我才意识到一个更合适的架构思路:不应该让ES的completion字段承载所有课程名的全量联想,而应该维护一张独立的候选词表,只把用户真正会去搜的热词、简称、别名放进去。冷门课程和长尾查询交给普通搜索兜底。

这样做的好处是,completion字段的FST体积大幅缩小,内存占用低,查询速度快,候选列表质量也高。坏处是要多维护一张表和一套同步任务,但这部分工作完全可以用消费搜索日志的定时任务自动化。如果你们项目的搜索框交互量大、候选词多,强烈建议提前考虑这个拆法。

5.2 明确三种Suggester的分工时机

三种Suggester不是都要用在同一个交互环节里。我们最终落地的方案是:

  • 用户正在输入时,下拉框只走Completion,要求的就是快和准;
  • 用户输完敲回车,结果为空或者数据量很少,这时候前端额外发一次Term或Phrase的suggest请求,展示"你是不是要找:XXX",引导用户修正输入;
  • 纠错建议只有当置信度高时才展示,Term可以配合prefix_length控制敏感度,Phrase用confidence卡阈值,避免出现"用户明明输对了却被纠正"的傻提示。

这个分层结构实测很稳。用户不会在下拉框阶段看到一堆纠错噪点,但走错路时又能被温柔拉回来。

5.3 日志和指标要早做

搜索提示功能上线后,除了看响应时间和错误率,更值得埋点的是:下拉候选展示了多少条、用户最终选了哪一条、候选在第几位被选中。这三个数字能直接指导权重优化——如果大量点击集中在第三条以后,说明排序逻辑有问题;如果用户输入完全匹配但没点任何候选,说明候选本身不符合预期。

我们当时把点击日志简单聚合到Redis,每天跑一次任务更新weight写回ES。这个机制不复杂,但让提示质量进入了一个正向循环,后面基本不用人工干预。

最后再分享一个我自己的体会。刚接触Suggest时,我总想找到一种"万能配置"一步到位,实际上它更考验的是对交互场景的理解。下拉提示、拼写纠错、无结果引导,看起来都是"给用户几行字",但数据准备、字段设计、查询策略完全不同。先把使用场景拆清楚,再去选Suggester和配参数,整个实训项目的进度会顺利得多。希望这篇文章能帮你在自己的搜索提示功能上少走几段弯路。

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

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

立即咨询