搜索的命中质量,瓶颈往往不在查询语句写得有多花哨,而在写入那一刻文本是怎么被切碎、清洗、再归档的。Elasticsearch默认提供的standard分析器确实“能跑”,但业务一复杂,很快就会发现它不够用:英文里R&D搜不到research and development,网页正文塞满了HTML标签噪声,用户搜running却匹配不上run。这些问题的根源都指向同一个环节——分析器。第9篇,我把自定义分析器这件事从头拆到尾,包括它的三个内部组件、完整配置步骤、索引期和检索期的差异,以及我实际操作中踩过的几个坑。
这篇东西适合两类人:一类是刚开始接触ES、对mapping里的analyzer参数似懂非懂的新手;另一类是已经在用ES做搜索、但总感觉匹配结果不受控的同学。看完之后,你能自己动手组装一个合身的分词流水线,也能在线上搜索不准的时候,知道先去查哪一环。
1. 先弄明白分析器在索引和检索链路里干了什么
1.1 一次查询请求在Lucene里的完整流水线
很多人会把“分词”想得太简单,以为就是把一句话按空格切开。真实的分析器比这复杂得多。Elasticsearch底层是Lucene,文本从原始字符串变成倒排索引里的词项,中间是一条固定流水线:字符过滤器、分词器、词项过滤器,三段依次作用。
写入时,字段值先经过字符过滤器,再交给分词器切分,最后逐个经过词项过滤器加工,得到的终态词项被写入倒排索引。查询时逻辑上也是一样:查询字符串要经过相同的或兼容的处理,变成词项,才能去倒排索引里做匹配。
这一步很多人容易忽略,导致后面看搜索结果时一头雾水:为什么“run”能搜到“running”?因为写入端用了词干提取,把“running”还原成了“run”;为什么搜“R&D”什么都搜不到?因为默认分析器把“R&D”切成了“r”和“d”两个单字符token,最多只能匹配到单字母词项。所有看似奇怪的现象,几乎都能从分析器流水线上找到答案。
1.2 为什么要自己造轮子:内置分析器的三个盲区
内置分析器不是不好,而是“通用”就意味着“对任何具体业务都不够贴合”。我归纳了三个最常见的盲区。
第一个盲区是文本格式清洗。网页正文、富文本编辑器输出的内容,经常夹带HTML标签、特殊符号和HTML实体。standard分析器根本不管这些,标签里的属性值会被当成正文内容参与索引,搜索结果里出现大量莫名其妙的词条。
第二个盲区是语言形态处理。英文有单复数、时态、所有格,用户搜“shoes”时,理想情况下应该匹配“shoe”。standard分析器做不到这一点,它只是机械切分。中文场景也是一样,标准分析器按字切分或者按空格切分,遇到中文词库才会暴露乏力感。
第三个盲区是同义与业务黑话。同一个事物在业务里有多种叫法,比如“手机”和“移动电话”,“bug”和“defect”。内置分析器不认识这些等价关系,只能让你在查询端写一堆should子句硬凑。
自定义分析器解决的就是这三类问题。它不是魔法,而是允许你在流水线上任意替换和拼装组件,让文本处理适配你的语料、语言和检索预期。
2. 自定义分析器的三大积木和串联数据流
2.1 字符过滤器:在分词前先做文本清洗
字符过滤器作用在字符流上,输入是一整段原始文本,输出是清洗后的文本。它不负责切词,只负责删改字符。内置三种各有用途。
html_strip会剥掉所有HTML标签,只保留可见文本。比如<p>hello <b>world</b></p>经过它会变成hello world,非常适合富文本正文。
mapping支持你写一批字符映射规则,把指定字符或短语在分词之前替换成其他写法。典型用法是把&替换成and,把|替换成or,这样搜索“R&D”时,底层实际索引的是“r and d”这几个词项,查询逻辑自然能被覆盖。
pattern_replace则用正则表达式做替换,能力强但性能开销也大。它能做到把“abc123”中的数字全部去掉,或者把日期格式统一成一种写法。用正则之前先想清楚:这个字符过滤是否真的解决了一个明确的业务问题?如果只是想清空格,trim filter就能搞定,没必要上正则。
2.2 分词器:切词规则的定制
分词器负责把清洗后的字符流切成词项。这是自定义分析器里最影响检索行为的一环,因为同样的文本,不同分词器切出来的词项集合完全不同。
如果你面对的是英文技术文档、代码片段或日志,pattern分词器很好用。它按正则定义的分隔符来切分,默认分隔符是\W+,即所有非单词字符。更常用的是我后面实例里推荐的Unicode写法:[^\p{L}\p{N}]+,意思是“除字母和数字外的一切连续字符都作为分隔符”,这样能保留“c++”里的字母?不,[^\p{L}\p{N}]会把+当成分隔符,所以“c++”会被切成“c”。如果你需要保留“c++”,就得改用mapping字符过滤器先把“++”替换成“cpp”,或者用word_delimiter_graph过滤器。
还有一些内置分词器值得记住。whitespace分词器只管按空格切分,适合日志行;keyword分词器不切分,整个输入变成唯一词项,用于精确匹配场景;uax_url_email分词器在standard基础上特殊处理了URL和邮箱,保证https://example.com/a?b=c不会被拦腰切断;ngram和edge_ngram分词器则用于前缀匹配、自动补全,但代价是索引体积膨胀。
自建分词器时,最常见的坑是正则表达式在JSON里的转义。比如你想写[^\p{L}\p{N}]+,在JSON配置里必须写成"[^\\p{L}\\p{N}]+",少一个斜杠,启动配置就会报错。
2.3 词项过滤器:对token的二次加工
词项过滤器是流水线的最后一段,作用在每一个切好的token上,可以做增、删、改。这是自定义分析器里可选择组件最多的地方。
lowercase是基础中的基础,把大写字母转小写,通常放在停用词过滤之前。stemmer用于英文词干提取,能够把“running”还原成“run”,“shoes”还原成“shoe”。stop用来过滤停用词,可以填内置语言列表,比如_english_,也可以自己维护一份停用词表。synonym用来做同义词扩展,适合处理业务黑话和近义词。
还有几个容易被忽略但很实用的过滤器:keyword_marker可以标记某些词不做词干提取,品牌名、人名这类不该被还原的词汇特别需要它;length按token长度过滤,能丢弃过长或过短的噪声词项;shingle把相邻token合并成词组,适合做短语召回;unique能把重复token合并,减少索引体积。
词项过滤器的顺序很有讲究。lowercase必须放在stop之前,否则“The”永远不会被停用。stemmer一般放在stop之后、如果先做词干再停用,停用词表里的词往往已经变形了,匹配不准。keyword_marker则应放在stemmer之前,提前标记保护词。这个顺序问题,我后面会专门用实例讲。
2.4 三者串联的完整数据流
用一个具体句子看整条链路。假设原始文本是:
The Running&Walking Guys <b>Quickly</b>自定义分析器定义:mapping字符过滤器把&换成and,html_strip剥掉标签,pattern分词器按非字母数字切分,再依次过lowercase、stop、stemmer。
处理过程:
- 字符过滤阶段:去掉
<b>和</b>,&替换为and,得到The Running and Walking Guys Quickly。 - 分词阶段:按字母数字边界切出
The、Running、and、Walking、Guys、Quickly。 - 词项过滤阶段:小写化后是
the、running、and、walking、guys、quickly;停用词过滤删除the和and;词干提取把running变run,walking变walk,guys变guy,quickly变quick。
最终进入倒排索引的词项是:run、walk、guy、quick。用户搜“running”时,查询端被处理成run,就能匹配上。这就是自定义分析器提升召回率的核心逻辑。
3. 手把手配置一个带词干还原的英文分析器
3.1 需求设定与组件选型
我现在以一个英文博客站的正文搜索为例。业务方提出三点需求:第一,正文来自富文本编辑器,经常带HTML标签,需要清洗;第二,用户搜索时不希望受大小写影响;第三,搜单数能匹配复数,搜原形能匹配过去式,不能只做等于匹配。
围绕需求做选型:
- 字符过滤器:html_strip负责去标签,mapping负责把
&统一成and。 - 分词器:pattern分词器,分隔符用
[^\p{L}\p{N}]+,这样标点、空格、引号都成切分点,同时保留字母数字组合。 - 词项过滤器:lowercase、en_stop、keyword_marker、en_stemmer按顺序放。其中keyword_marker保护一批品牌词,避免被词干提取破坏。
组件选型的原则是:能用内置的就不自己造,正则能解决的优先用简单字符过滤替代,避免堆砌。
3.2 完整的settings配置
创建索引时,在settings.analysis里一次性定义好。直接给一份可复用的配置:
PUT /blog_index { "settings": { "analysis": { "char_filter": { "amp_to_and": { "type": "mapping", "mappings": ["& => and", "| => or"] } }, "tokenizer": { "letter_digit": { "type": "pattern", "pattern": "[^\\p{L}\\p{N}]+" } }, "filter": { "en_stop": { "type": "stop", "stopwords": "_english_" }, "brand_protect": { "type": "keyword_marker", "keywords": ["elastic", "kibana", "logstash"] }, "en_stemmer": { "type": "stemmer", "language": "light_english" } }, "analyzer": { "my_en_analyzer": { "type": "custom", "char_filter": ["html_strip", "amp_to_and"], "tokenizer": "letter_digit", "filter": ["lowercase", "en_stop", "brand_protect", "en_stemmer"] } } } }, "mappings": { "properties": { "title": { "type": "text", "analyzer": "my_en_analyzer" }, "content": { "type": "text", "analyzer": "my_en_analyzer" } } } }几点说明。keyword_marker里的keywords数组,作用是让“elastic”这类词在词干提取阶段保持不变,否则“elastic”可能被还原成奇怪的短形式,影响品牌词匹配。stopwords用_english_是内置英文停用词列表,省去自己维护词表的工作。light_english是轻量词干器,比标准english词干器更克制,降低误伤概率。
如果你维护了一套自己的停用词文件,可以放到ES配置目录的analysis下,然后用stopwords_path参数引用,旧版本还需要节点重启,新版本通常支持热加载。这个细节在不同版本里行为有差异,部署前要自己确认。
3.3 用_analyze API验证每一级输出
配置完之后不要急着写业务代码,先在Kibana Dev Tools里验证。最基础的是直接测试完整分析器:
POST /blog_index/_analyze { "analyzer": "my_en_analyzer", "text": "The Running&Walking Guys <b>Quickly</b> Kibana Elastic" }响应里会列出每个token及位置信息,预期应该是:running被还原成run,walking变成walk,guys变成guy,quickly变成quick,标签消失,The和&被正确处理,Kibana和Elastic因为keyword_marker保护保持原样。
如果某个环节不符合预期,就把流水线逐段拆开来测。单独测分词器:
POST /blog_index/_analyze { "tokenizer": "letter_digit", "text": "The Running&Walking Guys <b>Quickly</b>" }再单独测字符过滤器:
POST /blog_index/_analyze { "char_filter": ["html_strip", "amp_to_and"], "tokenizer": "letter_digit", "text": "The Running&Walking Guys <b>Quickly</b>" }这样一级一级拆,能准确定位是分词规则问题、过滤规则问题,还是顺序问题。这个方法非常重要,我后面排查问题时会反复用到。
3.4 绑定到字段mapping并验证检索效果
分析器定义好之后,还要绑定到text字段的analyzer参数上,否则写了等于白写。绑定方式就在mapping里指定:
PUT /blog_index/_mapping { "properties": { "content": { "type": "text", "analyzer": "my_en_analyzer" } } }注意,mapping只能对尚未建立或尚未有该字段的索引执行。已经写入数据的字段,analyzer不能直接改。要改的话,只能新建索引、reindex数据、再切换别名,或者删掉索引重建。如果是同一索引里新增一个字段,那直接PUT mapping就能挂上新分析器,不影响旧数据。
验证检索效果,最常用的方式还是_analyze加match查询对比:
POST /blog_index/_search { "query": { "match": { "content": "quickly" } } }正常情况下,这条查询能召回包含“quick”的文档,因为写入端把quickly还原成了quick,查询端也把quickly还原成了quick。
4. 索引期和检索期分析器为什么要分开管
4.1 index analyzer与search analyzer的作用边界
分析器在Elasticsearch里其实有两个使用时机:写入期和查询期。写入期负责把文档字段切词入库,查询期负责把query string处理成可匹配的词项。默认情况下,两者是同一个分析器,由字段的analyzer参数决定。
你没有单独设置search_analyzer时,查询会复用analyzer的配置。这种默认行为在大多数场景下没问题,但也隐藏了一个坑:有些分析器只适合入库,不适合查询。
典型的例子是自动补全场景。假设你为了支持前缀搜索,在写入端配置了edge_ngram过滤器,把“elasticsearch”切成了“el”、“ela”、“elas”等前缀词项。这些词项是写入端的产物。如果查询端还用同一套逻辑,用户输入“ela”时,查询端也会把“ela”切成多个前缀,最终匹配时行为非常奇怪。更合理的做法是写入端用edge_ngram、查询端用standard,这样用户输入什么,就按完整词查询。
配置方式就是显式指定search_analyzer:
"content": { "type": "text", "analyzer": "autocomplete_analyzer", "search_analyzer": "standard" }4.2 什么时候必须分开配置
不只是自动补全需要分开,我实际用过几个场景都是强制分开的。
第一个是日志系统。日志消息里带时间戳、IP、状态码,写入端想尽量切细,方便各种前缀搜索。查询端却希望用户输入“ERROR 404”时能按完整短语匹配,而不是被切得零碎。
第二个是商品搜索。商品名写入端按拼音、简称做了同义词扩展,写入很多冗余token;查询端反而希望用精准词做匹配,避免扩展词干扰排序。
第三个是搜索建议。写入端用shingle生成连词词组,查询端如果也用shingle,用户输入两个词时会被额外合并成词组,影响match查询的语义。
分开配置时最怕一件事:写入端和查询端处理结果不一致,导致原本应该匹配的文档miss掉。比如写入端把“running”还原成“run”,查询端却因为配置了不同的stemmer,把“running”还原成“runn”,两边对不上,结果就是零召回。所以分开配置前,一定要用_analyze API同时验证两端的输出。
4.3 配置不一致导致的经典翻车现场
我见过一个线上事故,印象深刻。某团队给标题字段配置了同义词扩展,写入端把“手机”扩展成“手机”、“移动电话”、“手持装置”,查询端用的是标准分析器,没有同义词。看起来两边应该没问题,因为查询“手机”总能匹配到“手机”。实际上排序乱套了:包含“移动电话”的文档,在查“手机”时得分很低,因为查询端没有把“手机”扩展成“移动电话”,匹配只能发生在写入侧的冗余词项上,评分的计算方式跟预期完全不一样。
这说明一个问题:同义词、拼写纠错这类分析器组件,如果只在写入端生效,会造成索引脏数据的嫌疑——索引里存在大量用户永远不会直接搜索的词项,还占据了磁盘空间。分开配置分析器的正确姿势是:写入端做基础清洗和切分,查询端做扩展、纠错、紧致处理。
当然,同义词过滤器的选择还要看版本。新版推荐用synonym_graph并开启updateable,这样同义词文件更新后,查询端能自动感知,不需要重建索引。
5. 自定义分析器上线前后的问题排查与避坑
5.1 配置完之后报analyzer not found
这是新人最常撞的墙:明明在settings里定义了分析器,但_analyze API一调用就报找不到。排查思路按三步走。
第一步,确认analyzer定义所在的索引就是当前请求的索引。_analyze请求打到的是某个具体索引,索引名写错了自然找不到。第二步,确认配置确实写进了这个索引。用GET /blog_index/_settings查看analysis段是否存在。如果不存在,回去检查PUT创建索引时的JSON格式,尤其要关注大小写:分析器名称是严格区分大小写的。第三步,确认你引用的是analyzer层级的名字,而不是filter或tokenizer层级的名字。在_analyze请求里,tokenizer参数、filter参数都是独立字段,如果你不小心把filter名填到了analyzer里,也会报类似错误。
这里有一个隐藏要点:对一个已存在的索引,你通过PUT _settings去追加新的analysis配置,在部分版本是允许新增的,但对已经存在的数据不影响,而且无法修改已投入使用的分析器。最稳妥的是像第3节那样,在创建索引时一次性把所有分析器定义好。
5.2 停用词和同义词怎么都不生效
停用词不生效,十有八九是顺序问题。lowercase必须排在stop之前,因为停用词表是apple这类小写形式,如果文本是先过了stop再lowercase,写入的token还是大写“The”,跟停用词表里的“the”对不上。
同义词不生效,常见原因有三个。第一,同义词过滤器没有放在正确位置,synonym通常需要放在lowercase之后,如果大小写不统一,同义词表里的“iPhone”就匹配不上“iphone”。第二,同义词文件路径不对,尤其用synonyms_path时,文件路径解析失败但配置不报错,结果静默失效。第三,同义词配置写的是简单等价形式还是词组扩展形式,在match查询里的行为差异极大,如果只做了简单映射,查询端做词项归一同步,表现符合预期;但如果你期望查到“移动电话”也能召回“手机”,需要在同义词过滤器里明确双向映射,并考虑使用graph类型的同义词过滤器,否则部分查询语法下扩展不会生效。
最常见的错误是只验证了写入端却没验证查询端。同义词一定要分别用_analyze的analyzer和search_analyzer两种视角各测一遍,确保查询词扩出来的词项,能对上索引里的词项。
5.3 修改自定义分析器后旧数据不生效
分析器和tokenizer、filter在索引创建后属于“不可变”配置的一部分。你改了分析器定义,旧文档不会自动用新逻辑重新分词。这是一个很容易忽略的坑,因为ES不报错,查询行为却变得诡异。
正确做法分四步:新建一个索引,配置好新的分析器;用reindex API把旧索引数据迁移到新索引;如果业务上不允许感知索引变化,用别名切换,让应用连新索引;停掉旧索引,确认没有查询再打到旧索引上。
操作示例如下:
POST /_reindex { "source": { "index": "blog_index" }, "dest": { "index": "blog_index_v2" } }然后做别名切换:
POST /_aliases { "actions": [ { "remove": { "index": "blog_index", "alias": "blog_alias" } }, { "add": { "index": "blog_index_v2", "alias": "blog_alias" } } ] }reindex的梗在于:reindex不会重新执行分析器?不,reindex会把源文档的_source重新写回目标索引,目标索引的mapping和analyzer会重新生效。所以放心迁。不过要注意,reindex期间源索引如果有新写入,数据会不一致,最好先在写入侧做双写或停写。
5.4 filter顺序引起的“看似正确实则错误”
filter顺序是自定义分析器里最隐蔽的坑。同一个filter表,只是顺序颠倒,结果天差地别。举三个我用过的真实例子。
第一个例子:lowercase和keyword_marker的顺序。keyword_marker的匹配是大小写敏感的。先marker再lowercase的话,你写进keywords里的“Elastic”能匹配到原词“Elastic”,但如果这词本身在文本里是小写“elastic”,marker又没带上小写变体,就会漏保护。先lowercase再marker,keywords里统一写小写,匹配就稳定了。
第二个例子:stop和stemmer的顺序。如果先stemmer再stop,停用词表里的“running”不会出现在停用词表里,因为词表默认维护的是原形。所以标准顺序是lowercase、stop、stemmer。
第三个例子:length和ngram的顺序。如果你先做edge_ngram再length,长度过滤会对每个ngram片段生效,可能把本来够长的原始词的全部前缀删掉。先length再ngram,才能保证只过滤原始词。
处理这类问题的核心方法,还是分段测试。每加一个filter就重新跑一遍_analyze,用输出的token变化判断该filter行为是否符合预期。
6. 最后说几个我用下来觉得重要的细节
6.1 保存词根和原词的取舍
词干提取提高了召回率,但也降低了精确度。用户搜“run”,可能连“runners”的文档一起召回,这在某些场景是好事,在品牌词、专有名词场景则可能是灾难。我习惯用keyword_marker把品牌词、人名、产品型号单独保护起来,同时把词干提取的激进级别往低调,比如用light_english而不是english。
另外,term级别的精确查询不会经过分析器,只有match、match_phrase这类query会。如果你在业务里遇到“我配置了分析器,但term查询还是搜不到”,这不是bug,是设计如此。term查询是直接拿原始字符串去匹配倒排索引里的词项,如果你写入时做了词干提取,term查询自然匹配不上变形后的词。
6.2 分析器数量、性能与日志
自定义分析器本身性能开销不大,但如果每个索引都堆了一堆重型的char_filter和stemmer,写入吞吐会明显下滑。es的写入链路是:每条文档的每个text字段都要跑一遍完整分析器,新建索引时测试性能,不要只测一个字段,要测真实文档量级。
分析器相关的错误日志通常不会太显眼。如果发现某条文档写入失败,错误信息里偶尔会有“failed to parse”加一段文本片段,那就是分析器处理异常了。我建议在排查时先关掉索引的refresh,把问题精确到单条文档,再用_analyze手动复现那段文本,会比直接翻日志高效得多。
6.3 用_analyze拆链路是我最推荐的调试习惯
说了这么多,如果只能保留一个习惯,我强烈建议你养成“拿到任何分析器问题,先拆着测”的肌肉记忆。不要整体测完后只看最终输出,最终输出正常不代表每个组件都正常。我把完整链路拆开,分别测char_filter、tokenizer、filter。任何一个环节输出不符合预期,就能立刻顺着链路找到原因。这样改配置时也更有底气,不会出现改了A却导致B行为变化的情况。
再分享一个小技巧:在_analyze请求里,除了指定“analyzer”参数外,响应中默认会返回token的start_offset和end_offset。如果发现某个token的偏移量是错位的,通常说明字符过滤器做了替换,但映射关系没处理好,这也会间接影响短语查询和高亮显示。做高亮显示场景,阶段性的字符替换一定要检查偏移量,否则高亮片段会切在奇怪的位置。
自定义分析器的能力边界,取决于你对这三段流水线的理解程度。把这套机制吃透之后,再回头看ES的搜索问题,很多答案会变得清晰很多。