简介:这是一款用 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 性能优化的三个抓手
性能调优主要靠三样。第一是缓存,默认容量 10000,LRU 淘汰,避免高频词反复走规则匹配;第二是规则预编译,启动时把枚举规则展开成 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ğı"输出格式为「原词 词干」,一行一个。主要参数如下:
| 参数 | 默认值 | 说明 |
|---|---|---|
| --aggressiveness | 0 | 派生层是否启用,0 到 2,越大切得越猛 |
| --cache-size | 10000 | LRU 缓存容量,调大适合批处理 |
| --dict-path | 内置 | 加载外部词典文件,覆盖内置词表 |
| --list-rules | false | 打印启用的规则表,用于调试 |
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 或 1,2 几乎永远不碰。
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 里。项目自己就带着这类测试用例,拿到源码后先跑一遍,能直观看到词典回验在哪些词上做了保守回滚。在那以后我每次改规则表,都强制走一遍这条测试流,确保低攻击性设计不被一次误切的修正打破。希望帮到你。
本文还有配套的精品资源,点击获取