☰
Elasticsearch韩文搜索实战:Nori分词器配置与性能调优指南
2026/10/20 18:57:24 网站建设 项目流程

做韩文内容搜索,最直接的问题就是分词。TongSearch 这个项目在早期版本里一直用标准分词器处理所有语种,英文、中文表现还行,唯独韩文搜索结果惨不忍睹。用户搜“검색엔진”,返回的结果和“검색”完全对不上,相关度排序基本靠运气。后来把 Elasticsearch 官方的韩文分词插件 analysis-nori 引入进来,才算是把这块补上了。这篇内容就是我基于 TongSearch 实际改造过程整理的实践笔记,适合正在做韩文站内搜索、日志分析、或者电商商品检索的同学参考,内容覆盖插件的安装、分词器配置、索引映射设计、自定义词典以及常见坑的排查。

1. 选型思考:为什么韩文检索必须走专用分词器

1.1 韩文语言特性对搜索的天然挑战

韩文属于黏着语,词与词之间的边界不像英文那样靠空格就能划清楚。一个句子里的实词后面通常会挂助词、连接词尾、时态词尾,比如“학교에 갈 거예요”(要去学校)这句话里,“학교”(学校)后面跟着助词“에”,“가다”(去)变成了“갈”,后面还有“거예요”。这些语法成分如果全被当成独立词项建索引,检索“학교”时会引入大量噪声,检索“가다”时却因为文档里存的是“갈”而召回不到。

中文分词器拿到韩文文本基本无能为力,标准分词器只会按空格和标点简单切分,遇到“검색엔진”这种复合词,它直接当成一个整体词项,搜索“검색”就匹配不上。英文分词器对韩文更不适用,它的 stemmer 规则基于拉丁语系词形变化,处理韩文字符只会产生一堆没意义的截断词项。

1.2 Nori 的设计思路:形态素分析路线

Nori 不是简单地按字典最大匹配切词,它底层是一个基于词典和词性标注的韩文形态素分析器。输入一段韩文后,它会先做音节分解,再结合词素词典把词切分成“词干 + 词尾”的形式,同时给每个词项打上词性标签,比如 NNG 表示普通名词,VV 表示动词词干,JKB 表示副词格助词。

这种思路和日文的 Kuromoji 很像,核心是先做语言学层面的分析,再输出 Lucene 可用的 token。好处是召回率明显提升:用户搜索“학교”时,索引里的“학교에”“학교에서”“학교만”都能被正确切出“학교”这个词干,实现形态素级别的匹配。

对我来说,选型最重要的理由是 Nori 是 Elasticsearch 官方插件,版本跟随 ES 主版本发布,不用像第三方插件那样担心兼容性。TongSearch 使用的 ES 版本升级时,Nori 插件同步升级即可,省去大量适配工作。

1.3 与 ngram 等其他方案的对比

另一个常见方案是 ngram 分词器,把韩文文本切成 2~3 个字符粒度的 ngram。这种方案召回率很高,但代价是索引体积膨胀严重,而且相关度很差。用户搜“검색엔진”,ngram 会匹配到大量无关文档,因为只要有“검”“색”“엔”“진”这几个片段就全算命中。

Nori 走的是语义切分路线,索引体积小,相关性也更好。当然 ngram 也并非一无是处,在做模糊匹配、拼写容错时仍有价值,但在 TongSearch 的主搜索链路上,Nori 毫无疑问是更合适的选择。

2. 安装与验证:三步装好 analysis-nori 并确认生效

2.1 在线安装与版本匹配

安装命令非常简单,进入 ES 所在节点的 bin 目录,执行:

bin/elasticsearch-plugin install analysis-nori

有一点要特别注意:插件版本必须与 Elasticsearch 主版本完全一致。这里说的“一致”不是主版本号一致就行,而是包括小版本号也要对得上,比如 8.11.2 的 ES 就装 8.11.2 的插件。装了不匹配的插件,节点启动时大概率会报错,最典型的是 “Incompatible elasticsearch version” 或者找不到主类。

如果你用 Docker 部署 ES,我建议不要直接 docker exec 进去装插件,因为容器重建后插件就丢了。更稳妥的做法是把插件安装写入自定义 Dockerfile:

FROM docker.elastic.co/elasticsearch/elasticsearch:8.11.2 RUN bin/elasticsearch-plugin install --batch analysis-nori

这样每次从镜像拉起容器,插件都在。

2.2 重启节点并验证插件状态

插件安装完成后,必须重启 Elasticsearch 节点才能生效。如果是多节点集群,建议逐个重启,避免同时重启导致集群状态红掉。重启完成后,先看插件是否被正确加载:

GET /_cat/plugins?v

输出里能看到analysis-nori记录,就说明插件已经注册到节点上了。

2.3 用分词接口做冒烟测试

插件注册成功不代表分词器可用,我习惯直接用 _analyze 接口做一次冒烟测试。先测默认的 nori_tokenizer:

POST /_analyze { "tokenizer": "nori_tokenizer", "text": "학교에 갈 거예요" }

如果返回的 token 里包含학교、에、갈、거、예요,且每个 token 都带position、type信息,说明分词器核心链路是通的。

第一次调用时节点可能会稍微卡顿一下,因为 Nori 需要把词典加载到 JVM 内存里。这不是故障,是词典加载的固有开销。后面再调用就快了。

3. 配置分析器:一份可以直接复用的映射模板

3.1 三个关键参数:decompound_mode、user_dictionary、词性过滤

Nori 分词器在项目里最值得调的就是decompound_mode。它控制复合词的拆分方式,有三个可选值:

模式行为适用场景
none不拆分复合词,输出完整词品牌词、专有名词多的场景
discard只保留拆分后的子词召回率优先,索引体积小
mixed同时保留原词和子词相关度和召回率兼顾

举例说明,“새벽녘”(黎明时分)这个复合词,三种模式下的输出分别是:

  • none 模式:새벽녘
  • discard 模式:새벽、녘
  • mixed 模式:새벽녘、새벽、녘

TongSearch 用的就是 mixed 模式。理由很直接:用户搜“새벽”时能召回包含“새벽녘”的文档,搜“새벽녘”时也能精准命中原文。虽然索引体积大了些,但换来了更高的召回边界。

3.2 组合 nori_part_of_speech 过滤器

形态素分析输出的 token 里包含大量助词、词尾等语法成分,这些对搜索没有实际价值。nori_part_of_speech 过滤器可以根据词性标签把这些噪声滤掉。

官方给了建议的 stoptags 列表,一般在生产环境我会保留这些:

{ "type": "nori_part_of_speech", "stoptags": [ "E", "IC", "J", "MAG", "MAJ", "MM", "NA", "NNB", "NNBC", "NP", "NR", "SC", "SE", "SF", "SH", "SN", "SP", "SSC", "SSO", "SY", "UNA", "UNKNOWN", "VA", "VCN", "VCP", "VV", "VX", "XPN", "XSA", "XSN", "XSV" ] }

这个配置会把助词、感叹词、动词、形容词、语尾等全部过滤掉,只保留名词等实词类。用这个做基础过滤器后,搜索结果明显干净了很多,用户高频搜索词“카페”(咖啡馆)不会被“카페에서”(在咖啡馆)这样的助词污染排序。

3.3 一个完整的、经过验证的索引模板

下面这个配置是 TongSearch 的韩文内容字段在用的映射模板,可以直接抄:

PUT /tongsearch_docs { "settings": { "analysis": { "tokenizer": { "nori_complex": { "type": "nori_tokenizer", "decompound_mode": "mixed" } }, "filter": { "nori_pos_filter": { "type": "nori_part_of_speech", "stoptags": [ "E", "IC", "J", "MAG", "MAJ", "MM", "NA", "NNB", "NNBC", "NP", "NR", "SC", "SE", "SF", "SH", "SN", "SP", "SSC", "SSO", "SY", "UNA", "UNKNOWN", "VA", "VCN", "VCP", "VV", "VX", "XPN", "XSA", "XSN", "XSV" ] }, "nori_reading": { "type": "nori_readingform" }, "lowercase_filter": { "type": "lowercase" } }, "analyzer": { "nori_analyzer": { "type": "custom", "tokenizer": "nori_complex", "filter": [ "nori_pos_filter", "nori_reading", "lowercase_filter" ] } } } }, "mappings": { "properties": { "title": { "type": "text", "analyzer": "nori_analyzer", "search_analyzer": "nori_analyzer" }, "content": { "type": "text", "analyzer": "nori_analyzer", "search_analyzer": "nori_analyzer" } } } }

这里我在索引时和分析时都指定了同一个nori_analyzer,保证查询词和文档词项被同样方式切分,这是搜索能匹配上的前提。nori_readingform过滤器主要用来处理文档里的汉字词,把汉字形式的韩文读音统一成韩文字符,避免同一个词因为书写形式不同导致检索失败。

3.4 理解 index analyzer 和 search analyzer 的差异

如果你有特殊需求,比如索引时用更细的切分、查询时用更粗的切分,可以把search_analyzer单独配置成另一个分析器。但日常项目里我强烈建议两者保持一致,否则会出现很隐蔽的召回问题。曾经有个同事把 search_analyzer 漏配了,结果 ES 默认用 standard 分词器处理查询词,搜索“검색엔진”时把它当整个词项去倒排索引里找,自然什么都匹配不到。这个排查过程当时花了大半天,后面会再展开。

4. 自定义词典:让 Nori 认识你不知道的词

4.1 为什么要配自定义词典

Nori 自带的词典覆盖了韩文常用词、固有词、汉字词和外来词,但新造词、网络流行语、社区黑话、品牌词它并不认识。比如“갓생”这个词,意思是“过得很认真自律的生活”,属于近年流行的新词,Nori 会把它切得七零八落。搜索系统面对的是真实用户,用户不会按你的词典去说话,所以自定义词典几乎是必配项。

4.2 两种自定义方式

Nori 支持两种词典配置方式。第一种是user_dictionary_rules,直接在 settings 里写词条,适合少量或临时测试:

{ "tokenizer": { "nori_custom": { "type": "nori_tokenizer", "decompound_mode": "mixed", "user_dictionary_rules": [ "갓생 NNG 1", "노잼 NNG 1" ] } } }

词条格式是词形 词性 定义,字段间用空格或 tab 分隔,定义字段在这里主要起注释作用。第二种是user_dictionary,指向 ES config 目录下的词典文件,适合维护大量词条的场景:

{ "tokenizer": { "nori_custom": { "type": "nori_tokenizer", "decompound_mode": "mixed", "user_dictionary": "userdict_ko.txt" } } }

词典文件放在$ES_PATH_CONF/userdict_ko.txt,每行一个词条,格式和 rules 一致。文件务必保存为 UTF-8 编码,且不要带 BOM,BOM 会导致 ES 解析第一个词条时读入不可见字符,词典无法生效。

4.3 新词如何进词典

TongSearch 的做法是:先通过搜索日志挖出高频 Nori 分词异常的查询词,再去词典里测试,确认存在问题后由内容运营维护一份词表,定期同步给开发更新词典文件,然后逐个节点重启。这里有个经验:如果词条只需要对搜索生效,而文档索引已经建好,最简单的方式是重新索引相关文档;自定义词典的改动不会自动回溯到已索引的文档。

如果你用的是user_dictionary_rules且只想对查询生效,也可以把搜索分析器单独指向带自定义规则的 tokenizer,让索引继续用旧分析器。但这种方式维护成本高,长期运营我不推荐。

4.4 同义词过滤器与 Nori 的配合

韩文里外来词和固有词经常混用,比如“컴퓨터”(电脑)和“전자계산기”意思相近,但形式完全不同。这时候可以用 synonym 过滤器做查询扩展。要注意的是,synonym 过滤器必须挂在 Nori 分词之后,并且同义词条最好用 Nori 分词后的形态,否则又会引入词形不一致的问题。

{ "filter": { "ko_synonym": { "type": "synonym", "synonyms": [ "컴퓨터 => 컴퓨터, 전자계산기" ] } } }

这个过滤器要放在 nori_pos_filter 之后,这样“컴퓨터”已经被正确提取为名词,再做同义词扩展就不会把助词一起卷进去。

5. 性能调优:Nori 跑得快不快,取决于你怎么喂它

5.1 JVM 堆内存与词典加载

Nori 的词典以 FST 结构加载到 JVM 堆内,词典越大,堆占用越高。TongSearch 的集群节点分配了 16 GB 堆内存,Nori 词典和 Lucene 索引缓存一共占用了大约 20%~25% 的堆空间,剩余空间留给 query 执行和 segment merge。

堆内存不是越大越好,超过 32 GB 后 JVM 会启用压缩指针失效,反而增加内存占用。如果集群内存充裕,我建议单节点堆内存在 8 GB 到 24 GB 之间,并配置ES_JAVA_OPTS保持稳定。同时要对老年代 GC 做监控,Nori 加载词典时如果触发频繁的 Full GC,说明堆分配偏小了,优先排查是不是同义词表、停用词表、自定义词典这几项合计占用过高。

5.2 超过 10 GB 的词典部署经验

如果自定义词典膨胀到几 GB 量级,每个节点加载完整词典会产生巨大的内存压力。这种情况可以考虑分词结果降级方案:把热词放自定义词典,冷词走默认词典,让 Nori 自己切分。搜索场景里 80% 的查询集中在 20% 的热词上,自定义词典只需要覆盖这部分就够用了。

5.3 慢查询排查:profile API 的用法

遇到搜索变慢,我第一步不是看服务器负载,而是打开 profile API 看阶段耗时:

POST /tongsearch_docs/_search { "profile": true, "query": { "match": { "content": "검색엔진 최적화" } } }

返回结果里会列出每个分片在build_scorer、advance、score等环节的耗时。如果build_scorer时间明显偏长,多半是查询命中的 term 太多,检查是不是过滤助词没生效;如果score耗时高,多半是 HNSW 或 BM25 相关参数不适合你的数据分布。这个工具对定位 Nori 分词后的搜索性能问题非常有效。

5.4 用批量写入降低索引压力

Nori 分词本身是 CPU 密集操作,大批量灌数据时节点 CPU 很容易打满。TongSearch 的实践是把 bulk 请求大小控制在 5 MB 到 15 MB 之间,同时关闭 refresh:

PUT /tongsearch_docs/_settings { "index": { "refresh_interval": "-1" } }

灌完数据后再恢复成原来的刷新间隔或执行一次强制合并。这样可以明显减少 segment 数量,也降低写入时分词与刷新竞争 CPU 的概率。真实测试下来,索引吞吐量比默认配置提升了 30% 左右,代价是写入后的数据不能立刻被搜到,但对于定时任务类的批量更新场景完全可接受。

6. 常见问题与排查技巧实录

6.1 插件已装,但配置 tokenizer 报错

现象:_cat/plugins里能看到 analysis-nori,但建索引时提示tokenizer [nori_tokenizer] not found。

原因:节点在插件安装后没有重启,或者你连接的是集群里另一台没装插件的节点。ES 的插件是节点级别的,一个集群里如果有多台节点,必须每台都装。

排查步骤:确认你请求的节点是否已经完成重启,并再次查看_cat/plugins?v。如果是在 Kibana 控制台操作的,注意你连接的是协调节点还是数据节点,建议直接请求数据节点地址验证。

6.2 自定义词典不生效

词典不生效有几种常见原因:

  • 文件编码不是 UTF-8,或者带了 BOM。
  • 词条格式不对。Nori 词典格式是词形 词性 定义,词性必须是 Nori 支持的标签,比如 NNG、NNP、VV,写错不会报错但会被静默忽略。
  • 修改词典文件后没有重启节点。文件型词典只在节点启动时加载,改完不重启不可能生效。
  • 索引是用旧分析器建的,新词典只对新索引文档生效,老数据需要 reindex。

6.3 搜不到或召回率低

排除了配置问题后,先检查查询词在分析器下的输出。比如用户输入“학교”而你搜不到任何内容,用 _analyze 分别测试索引文档和查询词:

POST /tongsearch_docs/_analyze { "analyzer": "nori_analyzer", "text": "학교에 다니는 학생입니다" }

如果索引侧输出包含학교、학생,查询侧输出也能命中,再看搜索请求。常见错误是查询时用了term而不是match,term 查询不会分词,直接拿原始字符串去倒排索引里查,而索引里的词项是 Nori 切过的“학교”这种形态,自然匹配不上。全文字段检索一定要用match或match_phrase。

6.4 韩文和英文、数字混排的处理

Nori 处理韩文没问题,但文本里夹着英文品牌名、型号数字时,只靠 Nori 是不行的。比如“아이폰 15 pro”里的“pro”会被 Nori 当成普通 token 保留,大小写不同的“Pro”就匹配不上。建议在 filter 链里加 lowercase,如果有英文转写需求再加 asciifolding。数字部分如果涉及价格、数量范围查询,不要放在 text 字段里,单独用 integer 或 float 字段。

6.5 助词过滤导致部分查询反而变差

nori_part_of_speech 过滤掉动词和形容词后,一些用户习惯用口语短句搜索的情况会出问题。TongSearch 就遇到用户直接搜“이쁜 카페”(漂亮的咖啡馆),“이쁜”是形容词,被过滤器干掉了,只能匹配到“카페”这个词。这种情况下可以调整策略:把 verb 相关的 stoptags 从过滤器里去掉,或者对搜索请求同时跑两个分析器,一个严格名词,一个保留动词,再用 dis_max 合并结果。这也是很典型的召回与精度平衡问题,没有绝对正确答案,取决于你的用户习惯。

6.6 典型问题速查表

现象可能原因解决办法
tokenizer not found节点未重启 / 插件没装到该节点重启节点,检查所有节点插件列表
自定义词典没效果编码 / 格式 / 未重启 / 老索引UTF-8 无 BOM,检查词性标签,重启节点,reindex
搜索跑不出结果term 查询 / 分析器不一致改用 match,统一 index 和 search analyzer
召回太高全是噪声助词没过滤配置 nori_part_of_speech stoptags
词典加载后 OOM堆内存不足 / 词典过大调整堆容量,精简自定义词典

7. 实操过程中的两个额外心得

7.1 新字段上线前,先做一轮词项对比

TongSearch 上线韩文检索前,我做过一个对比验证:抽了 1000 条真实文档和 200 个高频搜索词,分别用标准分词器、keyword 和 Nori 跑一遍,统计词项重叠度。不重叠的那些词里,有大量是助词黏着导致的,这正是 Nori 能解决的问题。这套验证方法很简单,但对说服团队做技术选型非常有用,数据摆在面前比口头解释有说服力得多。

7.2 分词器测试要放真实语料里看

用单个单词测试 Nori 分词效果,只能验证它“能分”,验证不了它“分得对”。比如“정부가”(政府+主格助词)在单词场景下能出정부和가,看起来没问题;但放回长句里,Nori 有时会根据上下文给出不同切法。所以验收时一定要用真实文档片段,跑完再看词项列表,而不是只看单个词的分词结果。

Nori 这套东西,配置本身不难,难的是理解韩文形态素分析的行为,以及它在你自己的数据分布下会暴露什么问题。如果你正准备给搜索系统加上韩文支持,希望这篇基于 TongSearch 实战整理的指南能帮你绕开那些我已经踩过的坑。

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

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

立即咨询