TongSearch 这个项目做到韩文检索这一步的时候,我和团队里几个同学是真的头疼了一阵子。韩文不像英文按空格切词就行,也不像中文有现成的分词习惯可以参考——它的词形变化、助词粘着和复合词拆分,处处都是坑。当时对比了好几个方案,最后落在 Elasticsearch 官方的 analysis-nori 韩文分词插件上。这篇就把我们从头到尾的接入过程、配置思路、踩过的坑和最终的线上表现完整记录下来,给打算在搜索系统里接入韩文分词的开发者做个参照。
1. 为什么韩文分词绕不开 Nori,以及 Nori 到底做了什么
先说结论:Elasticsearch 生态里做韩文分词,Nori 基本是目前最靠谱的默认答案。它不是唯一的选择,也有基于机器学习的分词方案,但 Nori 的优势在于它是官方插件、基于大规模词典、词性标注完整、并且和 ES 的版本严格绑定。对于大多数搜索业务来说,选 Nori 等于选了一条维护成本最低的路。
1.1 韩文的语言特性对分词器提出了什么特殊要求
韩文是谚文(Hangul)音节字,但实际语义单元是"词素 + 助词"的组合。一个典型的韩文句子里,助词、语气词、连接词尾都附着在实词后面,直接按空格切分会导致检索词全部匹配失败。比如"학교에서"(在学校)这个词,如果用户搜索"학교"(学校),常规分词器会把"학교에서"当作一个整体 token,查询时完全无法命中。这正是所谓"词形变化"问题。
韩文还存在大量复合词和派生词。同一个汉字词根的多种变形,比如"연구하다"(研究)、"연구소"(研究所)、"연구원"(研究员),在词典层面就有关联关系。Nori 内置的 mecab-ko-dic 词典对这类词条做了细致的词性标注和还原处理,能够把"학교에서"拆成"학교"(名词)+ "에서"(助词),查询时自然就能命中。
1.2 Nori 的核心机制:词典 + 词性标注 + 形态还原
Nori 的底层实现借鉴了 MeCab-ko 的形态分析思路,整体由三部分组成:
- 词典:默认携带 mecab-ko-dic,包含大约 90 万条词条,覆盖现代韩文常用词汇、拟声拟态词、外来语、人名地名等。
- 词性标注:每个 token 都带有细粒度的词性标签,例如 NNG(一般名词)、JKB(副词格助词)、VV(动词)等,方便后续做查询词权重调整或过滤。
- 形态还原:把动词、形容词的各种活用形还原到词典原形,例如"먹었다"还原为"먹다","빨라서"还原为"빠르다"。
因为这个机制存在,Nori 不仅能解决"匹配不到",还能在词频统计、相关性打分层面获得更可靠的词项。我们当初测试时做过一个对比:用标准分词器(standard analyzer)建索引,搜"학교"的召回率低得可怜;换成 Nori 之后,含"학교에서""학교에""학교의"的文档全部可以被命中。这一个测试直接推动我们确定了技术选型。
1.3 版本兼容是第一个容易被忽略的坑
ES 插件的版本必须和集群版本完全一致。我们当时集群是 7.10 版本,如果用 7.16 的 Nori 插件包强行安装,会直接报错。插件安装命令本身很简单,但版本匹配问题一旦忽略,后面排查起来非常浪费时间。建议在选型阶段就把目标 ES 版本确认清楚,然后到官方发布页找对应的插件 zip 包。
2. 安装与验证:从下载插件到跑通第一个分词结果
这一节记录的是我们落地 Nori 的完整操作过程。环境是 Linux 服务器上的一套单节点 ES 7.10,纯粹为了功能验证。生产集群的部署差异我会在后面的章节单独讲。
2.1 插件安装与常用排查命令
安装插件的方式有两种:在线安装和离线 zip 安装。在线安装最简单,但国内网络环境下经常因为下载超时中断,所以我更推荐先把 zip 包下载到本地再装。
# 在线安装(可能需要配置镜像加速) bin/elasticsearch-plugin install analysis-nori # 离线安装(推荐,尤其在内网环境) bin/elasticsearch-plugin install file:///opt/plugins/analysis-nori-7.10.0.zip安装完成后必须重启 ES 节点,插件才会生效。重启完成后先确认插件状态:
bin/elasticsearch-plugin list输出里能看到analysis-nori,代表安装成功。之后建议先调用一次索引分词接口做冒烟测试。因为插件未加载时接口会直接报错,而插件加载成功时即使没有索引也能正常返回结果。
2.2 第一个分词测试:nori_tokenizer 与 nori_analyzer 的差异
Nori 提供了两种直接可用的分词器,区分很重要:
nori_tokenizer:只做形态分析和词性标注,不附加任何过滤器。适合需要完全自定义过滤链的场景。nori_analyzer:预置了nori_tokenizer加上nori_part_of_speech(默认过滤部分无意义词性)和nori_readingform(汉字词注音)两个过滤器。适合开箱即用的场景。
冒烟测试可以直接用_analyzeAPI 指定内置分析器:
POST _analyze { "tokenizer": "nori_tokenizer", "text": "학교에서 친구를 만났다" }返回结果中,학교会被识别为名词(NNG),에서是助词(JKB),친구是名词,를是宾格助词(JKO),만났다被还原为만나다(VV+EP+EC 的组合,最终输出原形)。这个结果意味着后续无论用户搜"학교"还是"만났다",都能在倒排索引中找到对应词元。
2.3 中文汉字词的处理:nori_readingform 过滤器
韩文词汇里有大量汉字词,Nori 会为这些词附加读音标注。例如"학교"(学校)的汉字词读音是"hakgyo"。nori_readingform过滤器会把 token 的 reading 形式输出到索引中,便于处理韩文汉字词和谚文混用的搜索场景。
这个功能在 TongSearch 里实际用到了:我们的产品标题里经常出现"교육"(教育)和"敎育"这种谚文与汉字混写的情况,开启 readingform 之后,索引里同时保留了谚文原形和汉字读音形式,查询命中率提升了不少。
提示:
nori_readingform只对带有汉字词条的 token 生效,纯谚文词汇不受影响。开启后索引体积会有少量增加,因为每个汉字词多了一个读音 token。
3. 配置一个生产可用的自定义分析器:从 demo 到实战的距离
直接用内置nori_analyzer能跑通 demo,但离生产还有差距。TongSearch 的实际需求是搜索教育类内容,涉及大量课程名称、机构名称和用户自由输入的查询词。我们最终没有直接用内置分析器,而是基于nori_tokenizer自己组装了一条过滤链。
3.1 自定义分析器的骨架设计与参数含义
下面是我们在 TongSearch 中使用的核心配置。索引 settings 里定义一个名为korean_search的分析器:
{ "settings": { "analysis": { "analyzer": { "korean_search": { "type": "custom", "tokenizer": "nori_tokenizer", "filter": [ "nori_readingform", "nori_part_of_speech", "lowercase", "trim" ] } } } } }nori_tokenizer作为分词器,nori_readingform保留汉字词读音,nori_part_of_speech负责过滤停用词性。这里有一个容易忽略的细节:内置nori_analyzer已经包含了 part_of_speech 过滤,如果自定义分析器里不加这个 filter,大量助词、语气词会被索引为 token,导致索引体积膨胀。我们一开始就吃过这个亏,索引建完体积比预期大了将近 30%。
3.2 词性过滤的真正用途:为什么助词必须从索引里拿掉
韩文句子中助词出现的频率极高,但在搜索场景里基本没有信息量。用户搜"학교"的时候,不会期望因为"학교에서"里的"에서"被单独索引而导致相关度计算被干扰。nori_part_of_speech过滤器支持按词性标签做白名单/黑名单配置,默认会过滤掉助词、标点、语气词等。
如果业务上有特殊需要,比如想保留某些词性做特殊处理,可以显式指定标签。官方文档里的stoptags参数用法如下:
{ "filter": { "nori_pos_filter": { "type": "nori_part_of_speech", "stoptags": [ "JKS", "JKB", "JKV", "JX", "EC", "EP", "EF" ] } } }上面的配置只过滤了常见的助词和连接词尾,保留了名词、动词、形容词等核心词性。如果对词性标签不熟悉,我建议先用默认过滤器跑一轮,然后看词频统计结果再决定是否需要微调。
3.3 宽度归一化与字符规整:容易被忽略的细节
韩文文本中经常混入全角英文字符、半角韩文标点等不规范写法。倒排索引是精确匹配的,同一个词如果存在全角和半角两种写法,就会变成两个不同的 token,导致召回率下降。Nori 本身并不处理这个问题,需要额外挂一个cjk_width过滤器,把全角字符统一转成半角。
我们最终的配置里把cjk_width加在了最前面:
{ "analyzer": { "korean_search": { "type": "custom", "tokenizer": "nori_tokenizer", "char_filter": ["cjk_width"], "filter": [ "nori_readingform", "nori_part_of_speech", "lowercase", "trim" ] } } }加完之后,一个显著变化是:用户输入全角英文"ES"和半角"ES"都能匹配到同一份文档。韩文数字、日期信息也有类似问题,cjk_width一并处理,省了不少后患。
3.4 索引分析器和搜索分析器分离设计
TongSearch 最终采用了索引端和查询端使用两套分析器的方案。索引端使用更保守的配置,确保合并词元、子词都能被正确拆出;查询端则额外增加了同义词扩展和更粗粒度的过滤,让查询词能被更灵活地命中。
简单说,索引端分析器负责"尽可能完整、规范地把文本拆成词项",查询端分析器负责"把用户的输入拆成能命中索引词项的查询词项"。两者可以相同,但当我们发现同义词、自动补全等需求出现后,查询端分析器的职责就和索引端明显分化了。
4. 用倒排结构验证分词参数,并打通搜索查询链路
配置写完后,光看_analyze返回的 token 列表还不够,最好用一个小的 demo 索引把整条链路跑通。这样才能确认 mapping、分析器、查询语法三者之间没有矛盾。
4.1 创建一个带韩文分析器的测试索引
创建一个 demo 索引,mapping 里指定一个 text 字段使用刚定义的korean_search分析器:
PUT /nori_demo { "settings": { "analysis": { "analyzer": { "korean_search": { "type": "custom", "tokenizer": "nori_tokenizer", "char_filter": ["cjk_width"], "filter": [ "nori_readingform", "nori_part_of_speech", "lowercase", "trim" ] } } } }, "mappings": { "properties": { "title": { "type": "text", "analyzer": "korean_search", "search_analyzer": "korean_search" } } } }这里有一个关键细节:搜索分析器(search_analyzer)必须显式指定。如果只设置了 analyzer,那么搜索时 ES 会默认使用索引分析器,这在很多情况下没问题,但对于那些额外挂了同义词或不同过滤链的场景,不显式指定 search_analyzer 会带来行为不一致。
4.2 分词效果验证:索引结构里的词元长什么样
向测试索引写入几条数据之后,可以用_termvectors接口查看某个文档的倒排结构。这一步远比_analyze更有说服力,因为它展示的是实际进入倒排索引的词项。
GET /nori_demo/_termvectors/1?fields=title举个例子,我们写入了一条"온라인 교육 플랫폼에서 강의를 수강하다"(在在线教育平台上课),_termvectors返回的词项中包含온라인、교육、플랫폼、강의、수강하다等核心词项,而에서、를这些助词已经被过滤掉了。这就是生产可用的分词结构——索引干净、词项完整。
4.3 查询侧的实际效果:match 查询下的召回率变化
在验证查询效果时,我们重点测试了匹配模式的选择。match查询默认是 OR 语义,多个词项之间只要有一个命中就能召回,相关度排序时用词频和 IDF 加权。以"온라인 강의"为例,Nori 会把它拆成 "온라인" 和 "강의" 两个词项,文档中只要包含任意一个词项就会被召回,同时包含两个的文档排在前面。
这里我们发现一个比较关键的问题:如果只拆词不加控制,部分无关文档也会因为只命中了"온라인"而被排到前面。这种情况建议在业务上使用 match_phrase 或增加 minimum_should_match 参数来提精度。TongSearch 里最终是让用户在精确搜索和模糊搜索之间做了切换,分别对应不同的查询语法。
4.4 自动补全场景下的 Nori 配置取舍
TongSearch 的搜索框需要做韩文输入联想,试过几种方案后,我们最终没有直接对 Nori 的 output 做前缀匹配,而是额外建立了一个自动补全字段,用edge_ngram过滤器来处理前缀。因为 Nori 是词素级切分,直接把用户输入"온라인"传给_search做前缀查询,会出现词元不完整的问题。
自动补全字段的配置与主分析器分开:
{ "properties": { "title_suggest": { "type": "text", "analyzer": "suggest_analyzer" } } }suggest_analyzer使用nori_tokenizer但跳过nori_part_of_speech过滤,或者直接使用edge_ngram对原始文本做切分。这样输入联想时,用户每敲一个音节,都能从 edge_ngram 词典里快速匹配到候选词。这一步不走主查询链路,专门服务搜索框下拉提示。
5. 扩展自定义词典与同义词:覆盖 Nori 词典覆盖不到的业务词
Nori 自带的 mecab-ko-dic 覆盖面已经很广,但是遇到教育领域的专业术语、机构简称、外来语转写时,仍然会出现无法识别的情况。TongSearch 就碰到过"코딩"(编程)和"에듀테크"(教育科技)这类词在默认词典中缺失或切分不稳定的问题。解决方法是在 Nori 上挂自定义词典。
5.1 自定义词典文件格式与配置
Nori 支持通过user_dictionary参数指定一个外部词典文件。文件路径相对于 ES 的config目录,格式是每行一个词条,每个字段用逗号分隔,遵循 mecab-ko-dic 的 CSV 格式。Nori 要求的字段顺序是:
단어, 태그, 발음, 의미比如我们添加了一个自定义词条:
에듀테크, NNG, *, *, *, *, *, *, *, *实际格式比这个稍繁琐,官方文档和 mecab-ko 词典源码里都有明确说明。如果只是添加一个名词,通常需要补齐 10 个左右的字段。配置文件增加一行后,需要把自定义词典路径加到 tokenizer 的参数里:
{ "tokenizer": { "nori_user_dict": { "type": "nori_tokenizer", "user_dictionary": "user_dict_ko.txt" } } }然后调用_reload_search_analyzer或重启节点,让词典变更生效。有一点要注意:修改词典后并不需要重建索引,因为词典影响的只是分析过程,旧索引中的词元已经存在,只有新写入/新查询的文档会用到新词典。如果业务上要求历史文档匹配新词,才需要做 reindex。
5.2 自定义词典在集群中的同步问题
生产环境如果是多节点集群,每个节点的 config 目录下都必须存在相同的词典文件。手动同步容易漏节点,我们后来是把词典文件放到共享存储或配置管理服务里分发,确保每个节点的文件一致。否则同一份文档在不同节点上可能因为词典不同而分析结果不一致,会出现搜索结果不稳定的诡异现象。
5.3 同义词配置:搜索端召回扩展
同义词扩展在韩文搜索里特别有用,因为韩文存在大量汉韩混写、缩写和外来语变体。比如用户可能搜"온라인 학습"(在线学习),而文档里写的是"인터넷 강의"(网络课程),从语义上相关但词面完全不同。Nori 分词后,两个词项互不关联,这时需要查询端的同义词过滤器来打通。
配置方式是在查询分析器里加一个synonym_graph过滤器,同义词文件按行写映射关系:
온라인 학습, 인터넷 강의 => 온라인 학습, 인터넷 강의关键在于:同义词过滤器应该放在搜索分析器里,不要放在索引分析器里。把同义词扩展写入索引会导致索引体积膨胀,而且难以灵活调整。查询端扩展虽然每次查询略微增加开销,但换来的是随时调整同义词规则的能力,这才是搜索系统该有的灵活性。
6. 上线之后的性能与稳定性:词典加载、内存占用与常见故障排查
前面的工作做完,功能基本可用了,但上线前还有几个性能层面的问题必须面对。Nori 在运行时会加载词典到内存,词典本身约 60MB,但 JVM 堆内实际占用可能达到数百 MB,具体取决于配置和词条数量。在多节点集群里,这个内存开销不容忽视。
6.1 内存参数与节点资源配置建议
如果节点堆内存设置在 4GB 以下,Nori 词典加载可能会变得很紧张,尤其当业务索引量大、查询 QPS 高时,GC 压力会明显上升。我们在 TongSearch 里为所有跑韩文分析的节点设置了至少 6GB 的堆内存。这里说的堆内存是指 ES 的ES_JAVA_OPTS配置项,不是操作系统内存。建议预留节点物理内存的 50% 给堆外开销。
6.2 常见的启动失败与运行时报错
插件安装后启动报错通常有三种情况:
- 插件版本与 ES 版本不匹配:安装时报
plugin [analysis-nori] is incompatible with version [7.x]。解决方法是找同版本插件包重新安装。 - 词典文件路径错误:自定义词典路径写错或文件编码不对,ES 启动时会报
FileNotFoundException或IllegalArgumentException。韩文词典文件必须使用 UTF-8 编码,并且路径不能包含中文或特殊符号。 - 字典加载时内存不足:日志里能看到
OutOfMemoryError或GC overhead limit exceeded。解决方法是调大堆内存,或缩减自定义词典规模。
这些错误在测试环境里往往不会暴露,但一上线多节点同时启动时就会集中爆发。我们的建议是:上线前在预发布环境跑一遍完整的索引创建+查询压测。
6.3 Nori 与查询性能的取舍:什么时候该关掉 readingform
nori_readingform虽然能提升汉字词召回,但它会让索引中的 token 数量增加。如果业务场景对索引体积和查询延迟有严格要求,而内容里汉字词占比不高,可以考虑关闭这个过滤器。我们在压测中的实测数据是:开启 readingform 后,索引体积增加约 10%,p99 查询延迟增加约 5ms。对于大部分教育内容业务来说,这个代价换召回率提升是划算的,但如果是高并发、低延迟的搜索服务,需要权衡取舍。
6.4 一个容易忽略的坑:查询词拼音/读音的召回差异
Nori 的 readingform 只对词典中标注了汉字读音的词生效。如果用户输入的是谚文形式的查询词,而文档中只有汉字词形式,某些情况下 readingform 可能无法建立关联。比如"수학"(数学)和"數學"(汉字形式),如果两个形式在词典中的读音标注一致,可以打通;如果没有标注,则无法匹配。这种情况只能通过同义词或自定义词典来补。
7. 在 TongSearch 落地 Nori 之后,我的一些实际体会
最后说几点个人体会,不是泛泛的经验,都是实际项目里踩出来的。
词典加载时间和节点重启窗口比预期长。我们的词典加上自定义词条后,单个节点加载大约需要 30 秒到 1 分钟,集群滚动重启时要预留这个时间窗口。如果业务不能接受短暂查询超时,建议用多节点滚动重启的方式,一个一个来,不要同时重启所有节点。
同义词扩展是一把双刃剑。查询端同义词加得太多,会导致召回量大增但相关度下降。我们在教育场景里对同义词做了一层精简,只保留了强相关的等价词,而不是所有近义词。这一步需要业务方参与,不能纯靠技术团队拍脑袋。
Nori 的参数调优其实没有银弹。不同的业务内容,最佳配置可能完全不同。以我们的经验,先跑通默认配置,然后用线上真实查询日志做评估,再针对性调整过滤器和同义词,是效率最高的路径。不要一开始就追求完美配置,先让系统跑起来,再逐步迭代。
我们的生产环境目前已经稳定运行了几个月,最直观的变化是韩文搜索的召回率从不到 50% 提升到了 90% 以上,用户搜索"학교에서"能顺利找到"학교"相关内容,搜索"에듀테크"也能命中包含"교육 기술"的文档。如果你的项目也需要接入韩文分词,Nori 是一个值得认真投入的方案。