RTS土耳其语词干分析器:低攻击性剥离与词典回验的实现
简介这是一款用 Java 编写的土耳其语词干分析器面向自然语言处理研究者与 Java 开发者可在信息检索、文本挖掘、舆情分析等场景中替代传统激进的词干提取方案。其核心基于超过 110 万词干对生成的词典借助 HashMap 实现快速查询并采用单例、线程安全、惰性初始化的设计能兼顾处理速度与内存占用运行时约 300 MB。压缩包共 11 个文件内含 5 个 Java 源码、2 个词典数据、1 个 XML 配置、项目说明文档与许可证包体仅 3.57 MB源码按标准 Maven 工程组织主代码与测试代码分离便于直接导入 IDE 阅读和二次开发。目前已有 193 人学习适合需要高准确率、低攻击性土耳其语预处理能力的开发者参考既可直接调用也可基于源码调整词干词典与提取策略。1. 一个被过度切词干的场景土耳其语检索为什么需要低攻击性词干分析器做土耳其语全文搜索时我第一次用经典雪球式词干分析器处理「kitabım」我的书结果被切成了「ki」。词干分析器把名词和动词的屈折、派生后缀全部剥掉短词根优先最后返回一个「ki」——这在词典里根本不是一个合法土耳其语词干。搜索「kitap」时索引里存的是「kitabım」切出来的「ki」匹配不上排在前面的全是噪音结果。RTS 词干分析器解决的就是这个问题它是 Java 实现的土耳其语词干分析器处理单个 Token 在微秒级而且攻击性低——只剥离屈折后缀派生后缀默认不碰每次剥离后用内置词典回验输出的一定是真实词干。适合做土耳其语检索、情感分类、文本去重的开发者直接拿来当预处理组件。2. 架构与数据流从 UTF-8 字符到词典回验的完整链路2.1 土耳其语为什么难元音和谐、辅音交替与后缀海洋土耳其语是典型的黏着语词根后面可以挂一长串后缀。「ev」房子加上复数后缀「-ler」变成「evler」再接位格后缀「-de」变成「evlerde」在房子里们实际口语更常用单数形式再接人称后缀还能更复杂。英语词干分析器处理 -s、-ed、-ing 这类二十几条规则就够了土耳其语的后缀组合有几百种而且两条音变规则会直接影响剥离结果。第一条是元音和谐后缀里的元音必须与词根最后一个元音同属一个音类。土耳其语元音分前元音e、i、ö、ü和后元音a、ı、o、u同一后缀通常有多个元音变体规则表里必须写好变体列表。第二条是辅音交替词根末尾的 k 或 t 在接以元音开头的后缀时会软化比如「ayak」加后缀变成「ayağı」k 变成 ğ。如果词干分析器不处理这种音变剥离后缀后得到的中间态根本不在词典里词典回验直接失败。RTS 把音变规则、后缀规则、词典验证拆成三个独立组件而不是把几百条硬编码在 if-else 里这一点比许多简化实现清晰得多。2.2 模块划分与目录结构解压源码包后src/main/java 下面只有五个包整个工程没有任何第三方运行时依赖纯 JDK 就能构建。结构如下src/main/java/ ├── trstemmer/ │ ├── Stemmer.java # 门面类对外唯一入口 │ ├── config/StemmerConfig.java # 攻击性、缓存大小等配置 │ ├── rule/SuffixRule.java # 后缀规则抽象 │ ├── rule/InflectionRules.java # 屈折后缀规则集默认启用 │ ├── rule/DerivationRules.java # 派生后缀规则集默认关闭 │ ├── dict/TurkishDictionary.java # 词典加载与回验 │ ├── phonetics/VowelHarmony.java # 元音和谐检查 │ └── util/Normalizer.java # Unicode 规范化五个模块分工明确Normalizer 负责把 UTF-8 文本统一形态处理 i 与 ı、İ 与 I 这类容易出乱码的字符VowelHarmony 做元音和谐与辅音软化检查SuffixRule 定义一次剥离尝试InflectionRules 和 DerivationRules 分别存放不同攻击性级别的规则集TurkishDictionary 用 HashSet 存约两万条土耳其语基础词干。这里有个务实的取舍为什么用 HashSet 而不是 Trie两万词的规模下 HashSet 查询是 O(1)Trie 需要逐字符遍历常数反而更大项目注释里明确写了这一点。2.3 低攻击性到底是怎么实现的「低攻击性」不是说切得少就笨而是「宁可不切也不要切错」。RTS 把后缀规则分成两层屈折层只处理复数、格、领属、动词时态这类语法后缀派生层处理能把名词变动词、动词变名词的派生后缀。默认配置只跑屈折层派生层由 aggressiveness 参数控制这直接决定了词干分析器在检索场景里的行为差异。每剥离一个后缀必须拿中间态去词典回验。假设输入「kalplerde」心们里屈折层先匹配「-lerde」得到「kalp」词典里有接受如果某条规则把「kalp」再剥成「ka」中间态不在词典里整条路径回滚。这样虽然牺牲了一部分召回率但保证输出一定是合法土耳其语词干。对搜索场景来说错误的过度切分比保守更伤查询相关性这一点我在避坑章节还会详细展开。3. 核心算法拆解五步管线里的元音和谐、规则表与回验细节3.1 主流程一次 stem() 调用做了什么Stemmer 的门面方法只有 stem(String)完整链路是规范化、长度预检、缓存查询、规则剥离、词典回验五步。核心代码压缩如下public String stem(String raw) { String word normalizer.normalize(raw); // 1. NFD 规范化统一 i/ı if (word.length() MIN_STEM_LENGTH) { // 2. 长度 2 直接返回 return word; } String cached cache.get(word); if (cached ! null) return cached; // 3. 命中缓存直接返回 String current word; TONE: for (SuffixRule rule : inflectionRules.all()) { // 4. 先跑屈折规则 String stripped rule.strip(current); if (stripped ! null dictionary.contains(stripped) vowelHarmony.check(stripped)) { current stripped; break TONE; // 每轮只剥一层防止连锁误切 } } if (config.aggressiveness() 0) { // 5. 可选派生层默认不执行 for (SuffixRule rule : derivationRules.all()) { } } cache.put(word, current); return current; }这里有两个细节值得注意。第一每轮匹配到合法候选后立刻 break而不是贪婪地继续往下剥。注释里写的「防止连锁误切」是血泪经验如果一个词形既能匹配复数后缀又能匹配格后缀多剥一层就可能越过词根边界。第二缓存放在规则剥离之前存的是「原始词 → 最终词干」映射同一批文本里重复出现的词直接跳过全部规则匹配。3.2 元音和谐检查与辅音软化回验VowelHarmony 的实现很克制它不做完整的音变重构只做约束校验与软化回验。剥离后的词干如果以 k 或 t 结尾而原词的后续部分提示这里发生过辅音交替就把 k→ğ、t→d 映射后再查词典这样能正确回验「ayağı」这类词。核心逻辑如下public String restoreStemEnding(String candidate, String original) { if (!candidate.endsWith(k) !candidate.endsWith(t)) { return candidate; } char lastVowel lastVowel(candidate); if (isFrontVowel(lastVowel)) { return candidate.endsWith(k) ? candidate ğ : candidate d; } return candidate; }参数说明candidate 是剥离后缀后的中间态original 是原始完整词。方法先判断词干结尾是否触发过辅音交替再根据最后一个元音的前后属性决定软化映射。前元音后接 k 时比如「ayak」→「ayağ」这里的「k→ğ」在土耳其语书写里实际发生在音节的末尾属于标准拼写规则。真正的元音和谐检查不写在主流程里而是放在规则表构建阶段。每一条 SuffixRule 创建时就把元音变体列表绑定进去比如复数后缀绑定「ler/lar」两个变体运行时只做哈希查找不再逐字符扫描。这是性能设计的核心把计算从请求时挪到初始化时。3.3 后缀规则表长度降序与优先级后缀按长度降序排列是词干分析器的通用经验。「-lerden」比「-den」先尝试因为长后缀匹配更精确避免「evlerden」先剥掉「-den」剩「evler」再剥掉「-ler」才得到「ev」整整多了一轮且中途可能被词典拒绝。规则表用枚举加变体数组定义public enum SuffixRule { ABLATIVE_DEN(den, dan, ten, tan), // 离格从... LOCATIVE_DE(de, da, te, ta), // 位格在... POSS_1S(ım, im, um, üm), // 第一人称单数领属 PLURAL_LER(ler, lar), // 复数 // 其余规则省略按长度降序声明 }每个枚举常量就是一个规则第一列是变体原型。匹配时从最长变体开始尝试。特别提醒如果把系动词「-dır/dir」混进复数规则会在「evlerdir」它们是房子上产生歧义正确做法是把系动词拆成独立规则放到派生层。后续项目里已经这么调整源码包里是干净版。3.4 性能优化的三个抓手性能调优主要靠三样。第一是缓存默认容量 10000LRU 淘汰避免高频词反复走规则匹配第二是规则预编译启动时把枚举规则展开成 HashMap变体, 规则匹配复杂度 O(1)第三是字符串常量池Normalizer 里对高频后缀做 String.intern() 去重减少堆占用。实测跑十万条随机土耳其语新闻标题平均单 Token 处理时间在个位数微秒级具体数字取决于词长和是否命中缓存这个量级足够支撑线上搜索索引的全量重建。4. 跑起来Maven 构建、CLI 单测与 Java API 接入示范4.1 Maven 构建与 jar 生成工程是标准 Maven 布局pom.xml 里没有引入任何第三方运行时库唯一插件是 maven-shade-plugin用来打可执行 fat jar。构建命令如下mvn clean package -DskipTests构建完成后 target/ 下会出现 tr-stemmer-1.0.jar。我习惯先做两件验证用 jar tf 确认词干词典资源文件被打进包内再跑一次 CLI 冒烟测试。词典文件如果没打包运行时会报找不到土耳其语词典的异常这是打包场景最常见的坑。4.2 CLI 用法与参数说明CLI 支持三种输入方式直接传参、管道输入、交互模式。直接传参适合快速验证管道输入适合接在分词脚本后面交互模式适合调试时反复试词。示例echo kütüphanelerde | java -jar tr-stemmer-1.0.jar --stdin java -jar tr-stemmer-1.0.jar kitabım kalpler ayağı输出格式为「原词 词干」一行一个。主要参数如下参数默认值说明--aggressiveness0派生层是否启用0 到 2越大切得越猛--cache-size10000LRU 缓存容量调大适合批处理--dict-path内置加载外部词典文件覆盖内置词表--list-rulesfalse打印启用的规则表用于调试4.3 Java API 集成示例Java API 的用法比 CLI 更常用尤其是在 Spring Boot 服务里当工具类注入。三行代码就够StemmerConfig config StemmerConfig.builder() .aggressiveness(0) .cacheSize(50_000) .build(); Stemmer stemmer new Stemmer(config); String stem stemmer.stem(kitaplarımız); System.out.println(stem); // 期望输出 kitaplar参数说明aggressiveness 设为 0表示只做屈折层剥离cacheSize 设为 50000比默认大一倍适合大量文本批处理的场景。Stemmer 是线程安全的类内部缓存用了并发容器可以在服务里声明为单例不要每次请求 new 一个实例否则缓存失效性能直接掉回冷启动水平。4.4 集成到搜索索引的 Lucene 示例如果项目用 Lucene 做检索直接把词干分析器包进 Analyzer 的 TokenFilter 即可。这里给一个精简示例public class TurkishStemFilter extends TokenFilter { private final Stemmer stemmer new Stemmer(StemmerConfig.builder().build()); public TurkishStemFilter(TokenStream input) { super(input); } Override public boolean incrementToken() throws IOException { if (input.incrementToken()) { CharTermAttribute term getAttribute(CharTermAttribute.class); term.setLength(0); term.append(stemmer.stem(term.toString())); return true; } return false; } }接入时注意 Lucene 的 TokenFilter 实现里不能持有重状态而 Stemmer 的缓存是共享的正好满足要求。做索引写入和查询解析时用同一个 Filter查询词和文档词干才能对齐否则会出现「索引里有 kitaplar查询词kitaplar查不到」的诡异现象。5. 避坑记录五个把词干切错的现场与修复5.1 小写 i 被转成无点 ı词典回验全挂现象输入「kitap」能正常输出「kitap」但输入「kitaplar」时返回原词规则一个都没命中输出结果和输入一模一样等于没切。原因Java 默认的 toLowerCase() 处理土耳其语时会把大写的 İ 转成「i̇」带点的小写 i而小写的 ı 是无点的。词典里存的是无点 ı 的词规则匹配时做的是带点 i 与无点 ı 的字符串比较永远不一致。解决所有字符规范化操作必须显式指定 Locale.TR并且资源文件统一 UTF-8。我后来在 Normalizer 里加了一个强制方法所有进入 stem() 的字符串先过一遍 NFC 标准化从源头保证 i 与 ı 不会混。5.2 外来词被元音和谐规则误伤现象「radyo」收音机的派生词「radyolar」是正常复数但规则回验「radyolar」时词干「radyo」以 o 结尾元音和谐检查判定后元音 o 与复数变体「lar」匹配逻辑上没错。真正出问题的是「kral」国王这类以辅音结尾的外来词复数「krallar」被切了「-lar」剩「kral」词典里有没问题但「telefon」的某些派生形态会被和谐检查误判成非法组合。原因土耳其语外来词不严格遵循元音和谐后缀元音可能保留来源语言的元音。RTS 默认对全部词严查和谐检查导致一批外来词永远切不动。解决给规则表和词典加外来词豁免标记。具体做法是在 TurkishDictionary 里维护一个外来词例外集命中例外集的词跳过和谐检查只做后缀剥离和词典回验。5.3 派生层全开词根被切到不能看现象把 aggressiveness 调到 2 后输入「öğretmenlik」教师职业输出变成了「öğ」完全不可读。检索相关性反而下降。原因派生层规则集里包含「-lik/-lık」这类把名词变名词的后缀「öğretmenlik」剥掉「-lik」得「öğretmen」如果再往下剥「-men」就剩下不存在的「öğret」词典回验没有拦住——因为「öğret」在动词形态里是合法词干。解决派生层默认关闭只有明确做词族归并的场景才打开。我把 aggressiveness 当作一个三档枚举0 只做屈折1 做常见派生2 才允许递归派生。实际业务里我只会用 0 或 12 几乎永远不碰。5.4 并发环境下缓存死循环现象线上服务在高峰期偶发请求卡死线程 dump 显示大量线程阻塞在 HashMap 的 resize 方法上。原因Stemmer 的单例缓存用的是普通 HashMap多线程写入时触发并发 resize内部链表形成环查询就死循环了。这是 Java 并发里最经典的翻车现场。解决把缓存容器改成 ConcurrentHashMap并给 StemmerConfig 增加 cacheSize 上限配合 LRU 淘汰逻辑避免无界增长。从那以后我再写缓存组件第一件事就是问自己这个类会不会被多线程共享。5.5 词典更新后没清缓存新词永远切不动现象外部词典追加了新词后重新构建服务但老词缓存还在新词又无法命中规则回验产线反馈切词结果和发布前完全不同且很难复现。原因Stemmer 的缓存是进程内黑匣子词典文件更新后缓存没有失效机制旧词干映射一直存活。解决在 StemmerConfig 里加一个 warmup 开关构建时如果加载了外部词典先清除缓存再重建。从那以后我每次发布涉及词表变更都强制走一遍「清缓存 预热」流程验证脚本里也固定加上这一步。6. 二次开发调整攻击性参数、扩展词典并接入搜索链路6.1 攻击性参数的正确调法aggressiveness 不是越大越好。建议先跑一遍业务语料的 10 万条高频词分别用 0、1、2 三档输出词干抽样对比三类差异切分后词干是否合法、同一词族的词干是否统一、低频词是否被误判。搜索场景建议用 0 档起步优先保证词干正确率。如果发现召回不足再调 1 档观察误差率。6.2 扩展领域词典的接入点词典扩展只需要在 resources 目录下放一个自定义词典文件一行一个词干构建时覆盖内置词表。定制领域时把行业术语作为词根加入词典比改规则表安全得多。路径参数StemmerConfig config StemmerConfig.builder() .dictPath(Paths.get(src/main/resources/custom.turkish.txt)) .build();6.3 二次开发常用验证方法我习惯写一条针对回归的单测流准备二十个典型词覆盖元音和谐、辅音交替、屈折、派生、外来词五种形态集成到 CI 里。项目自己就带着这类测试用例拿到源码后先跑一遍能直观看到词典回验在哪些词上做了保守回滚。在那以后我每次改规则表都强制走一遍这条测试流确保低攻击性设计不被一次误切的修正打破。希望帮到你。本文还有配套的精品资源点击获取