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

ElasticSearch Suggest实战:搜索提示与纠错的三层方案

ElasticSearch Suggest实战:搜索提示与纠错的三层方案 ★ FEATURED ARTICLE
做创新实训那段时间我们小组在给校内课程检索平台加搜索提示需求就一句话用户在搜索框里敲出第一个字下拉列表就要出现候选词敲到一半就得猜到你想搜什么。比如输入计能弹出计算机组成原理计算机图形学计算机网络输入拼音jsj也能定位到计算机相关的课程。一开始我真没把这当回事觉得要么like模糊查询要么前端写个联动。等动手做项目才发现搜索提示和普通搜索根本是两套逻辑——候选列表不强求给完整结果但要求速度快、够聪明能容忍用户拼错、输入拼音、甚至只记得半截课程名。这篇文章算是我在这轮实训里用ElasticSearch的Suggest接口实现搜索提示的一份完整记录从方案对比、三种Suggester选型到索引配置、查询代码再到实测踩过的坑给同样接手类似功能的人省点时间。1. 为什么最终选了Suggest方案先聊聊被否掉的三条路1.1 MySQL的like查询能跑和好用是两回事刚拿到需求时组里最快的方案就是改一句SQLWHERE name LIKE %计%。demo确实能跑输入计出来一堆带计的课程但只要我们往真实场景推几步这个方案就露馅了。第一个痛点是持续输入的状态感知。用户在搜索框敲计和敲计算机对数据库来说是两次完全独立的查询数据库不知道人在持续搜索这件事。前端只能每次按键都向后端发请求后端每次都全表扫一遍。数据量小的时候无所谓我们后来把全校一万两千多门课程灌进去再加上并发like查询的响应时间立刻变得不可控。更关键的是LIKE %计%这种写法会把索引直接废掉写成LIKE 计%又只能照顾前缀命中的情况用户输入组成原理这种中间片段时照样没结果。第二个痛点是排序能力太弱。我们希望热门课程排在候选列表前面但数据库里只有课程名称、教师、院系这些固定字段。想按搜索热度排序就得额外维护一张统计表每次查询先聚合热度再join回来代码写起来绕性能还更差。第三个痛点是完全没法纠错。用户把计算机原理敲成计算机原里like查出来是空结果集。搜索提示如果只能在你输对时给出选项就失去了它一半的价值。基于这几点我们很快把纯数据库方案否掉了。1.2 自己维护前缀树或词典工作量比想象中大得多也考虑过在应用层维护一棵前缀树把热门课程名提前load进内存前缀匹配用Trie做复杂度只有O(长度)看起来很美好。真正推演实施细节时问题一个接一个冒出来。首先是数据同步课程库每天都在变新增课程、课程改名、教师调整都要同步刷进内存里的Trie缓存失效和重建的时机怎么定都是麻烦。其次是拼音和多音字用户输入jsj期望的是计算机这意味着不仅要存汉字还要存全拼和首字母缩写而重庆这种词到底按chongqing还是zhongqing给出提示需要单独维护多音字表。再次是错别字纠正前缀树默认只能做精确前缀匹配要做到编辑距离小于等于1的容错得自己实现Levenshtein相关的逻辑。最后我们发现自己其实是在做一个迷你搜索引擎而项目里已经有ElasticSearch了。这里有一个很朴素的判断凡是从数据里找出用户想要的东西的活都应该交给搜索引擎而不是应用代码。应用层做Trie只适合数据极小、规则极固定的场景。我们课程名有别名、拼音、热度、分类这些维度明显不是Trie能优雅处理的。1.3 决策点项目里已有ElasticSearchSuggest是现成能力我们实训项目的底层就是ElasticSearch课程全文搜索早就在用。查资料时发现它原生提供了Suggest API专门用来做搜索提示不需要额外部署服务也不需要给同一份课程数据再造一套存储。当时打动我的有三点。第一Suggest API和普通搜索吃的是同一个索引数据一致性天然解决第二它内置了三种机制分别处理补全、纠错和短语修正覆盖了搜索提示的绝大多数场景第三接入成本低改动集中在mapping的字段声明和查询的suggest块不用动业务主流程。可以这样理解普通搜索和搜索提示的关系普通搜索像让搜索引擎找出所有包含关键词的页面而搜索提示像图书馆门口的老馆员听你说出半个书名就能猜到是哪一本还会在你念错书名时小声纠正你。Suggest就是这位馆员的工作机制。2. 三种Suggester的分工Completion、Term、Phrase2.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问题才解决。这里有两个补充细节。第一个是全半角用户可能在中文输入法状态下输入了全角字符比如如果不做转换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和配参数整个实训项目的进度会顺利得多。希望这篇文章能帮你在自己的搜索提示功能上少走几段弯路。
阅读完成 · 觉得有帮助?
咨询建站