
简介这是基于Python版jieba分词封装的Java实现项目面向需要在Java服务端或桌面应用中进行中文分词的开发者。项目包含完整源码、词典文件与Eclipse工程配置解压后可直接导入运行。包内共30个文件以java源码、class编译文件及txt词典为主另含xml与project配置整体4.17MB结构清晰。目前已有2210人学习使用。通过run包下test入口可快速验证分词效果支持精确模式、全模式、搜索引擎模式并能通过修改词典或扩展代码实现关键词提取、词性标注等功能适合信息检索、文本挖掘、舆情分析等场景快速集成。1. 从 Python 生态搬到 JVM这份 jieba 分词 Java 版到底能干什么只要在 Java 服务里碰过中文搜索、评论关键词提取或者客服工单分类大概率遇到过同一个尴尬Python 里一句jieba.cut就能拿到漂亮的分词结果JVM 生态却总在几个半死不活的库之间反复横跳。这份 jieba 分词 Java 版项目把 Python 版那套「词典切分 未登录词识别」的完整方案搬到了 Java 世界对外接口收敛成一个JiebaSegmenter类几行代码就能接进 Spring Boot 服务或者 ES 索引前置处理。它做得最扎实的是算法链路完整移植主词典加载成 Trie 前缀树句子用 DAG 描述所有可能的切分方式动态规划选出最大概率路径最后靠 HMM 隐马尔可夫模型兜底词典里根本没有的人名和新词。对想给系统加中文分词能力的开发者来说这份资源最大的价值不是「能分词」这个结果而是能直接打开源码看明白每一步在干什么出了问题有地方查、想改有地方下手。适合这么用业务字段的精确切分、搜索索引的前置分词、离线批量文本处理。新手照着 Demo 能跑通熟手能顺着 DAG 和 Viterbi 两条线做二次开发。下面按「架构 → 核心代码 → 运行 → 避坑 → 改造」的顺序把它拆开讲。2. 架构与模块拆解Trie 词典、DAG 切图、HMM 兜底的三层设计整份代码拆开来看其实就是一条清晰的处理流水线一个句子进来先按标点断成短句每个短句先查词典画出一张有向无环图再在这张图上用动态规划找最优切分路径上那些词典查不到、被拆成单个字的「孤儿」统一交给 HMM 猜词最后根据你选的模式决定要不要把长词再切细。三层各管一段边界非常清楚这也是它比很多「调用即黑匣子」的分词方案更适合落在自己项目里的原因。2.1 目录结构与核心类职责常见的移植版目录长这样你手上的源码如果类名稍有出入以实际为准jieba-analysis/ ├── pom.xml # Maven 工程描述坐标与编译配置 ├── src/main/java/ │ ├── JiebaSegmenter.java # 分词门面对外唯一主要入口 │ ├── WordDictionary.java # 词典单例加载 dict.txt 并维护 Trie │ ├── CharacterUtil.java # 字符类型判定中/英/数字/分隔符 │ ├── SegToken.java # 分词结果对象word startOffset endOffset │ ├── Viterbi.java # HMM 解码负责未登录词识别 │ ├── Node.java # Trie 节点children 用 HashMap 存 │ ├── Pair.java # 动态规划用的值对象 │ └── demo/ │ └── Demo.java # 可直接运行的示例入口 └── src/main/resources/ ├── dict.txt # 主词典每行「词 词频 词性」 └── prob_emit.txt # HMM 发射概率表有的版本编译进 Viterbi 类核心类的职责可以用一张表说清类名职责关键入口JiebaSegmenter分词门面切句、建 DAG、跑 DP、调 HMMprocess(paragraph, SegMode)WordDictionary词典单例加载 dict.txt维护 Trie 和词频表getInstance()、addWord()ViterbiHMM 解码输出 BMES 状态序列viterbi(sentence)CharacterUtil字符归类决定哪些片段交给谁处理identifyCharType(char)SegToken单个分词结果含原文偏移量word / startOffset / endOffsetprocess()是总控它拿到整段文本后先按中英文标点、换行把文本切成短句逐个短句调用sentenceProcess()。sentenceProcess()内部才是完整的算法链路。注意SegToken里保存的是相对整段文本的 offset不是相对短句的偏移这一点在之后对接业务字段抽取时很重要取子串直接用 token 的 offset 就能定位回原文。2.2 两种分词模式与业务场景对照Java 移植版一般保留两种SegMode对应 Python 原版的精确模式和搜索引擎模式。用「自动化研究所」这句话试一下差别INDEX 模式自动化 / 研究所 SEARCH 模式自动 / 自动化 / 研究 / 研究所INDEX 模式输出粗粒度的词典词适合业务字段抽取、标签提取这种「要准确不要冗余」的场景SEARCH 模式在粗粒度基础上把长度超过两个字的词再按词典切细同时保留原词适合搜索引擎倒排索引——召回率优先宁可多切也不漏切。两种模式的差异对照如下模式输出粒度典型用途INDEX词典词整词输出精确切分、字段提取、意图识别SEARCH整词 长词二次切分搜索索引、召回阶段的分词需要提醒的是Python 版还有一个「全模式」输出所有可能的词组合Java 移植版一般没提供这个枚举。真要全模式可以自己把 DAG 里所有边全部展开输出后面第六节会讲怎么改。2.3 技术选型为什么是 Trie DAG 动态规划 HMM四个组件缺一个都不行。Trie 前缀树解决的第一个问题是匹配效率三十万级词条如果每次切词都全量扫一遍词典性能根本扛不住。把词按字符逐层挂到树里之后一个位置能否成词只跟它后面几个字符有关查找复杂度从词典规模降到了词长级别。DAG 解决的是「一句中文有无数种切法」的问题。拿「研究生命起源」来说「研究 / 生命」和「研究生 / 命」都合法计算机需要一种结构把所有这些可能性表达出来DAG 就是干这个的每个位置 i 指向所有可能的词尾 j一条从 0 走到 n-1 的路径就是一种切分。路径太多就得靠动态规划筛选——从后往前累计对数概率保证选出来的是全局最优而不是局部最优。相比之下简单的正向最大匹配经常被「局部大词」带偏缺乏全局视角。HMM 是最后一道保险。词典再大也覆盖不了人名、地名、网络新词这部分如果硬切会被拆成单字。Viterbi 算法把连续单字串当作一个待解码序列用 B(词首)/M(词中)/E(词尾)/S(单字) 四个隐状态去猜哪些字应该并成一个词。整套设计本质上就是「确定的部分靠词典保证不确定的部分靠概率模型兜底」分离得干净二次开发时想替换哪一层都容易下手。3. 核心代码逐段解析一个句子从 getDAG 到 Viterbi 的完整旅程这一章直接进源码。代码是按常见移植版的实现逻辑整理的个别变量名、方法名和你手上的版本可能不一样但算法骨架是一致的照着看不会迷路。3.1 词典加载WordDictionary 的 Trie 构建与词频统计所有分词的基础是词典。dict.txt 每一行是「词 词频 词性」三段词性在切分时用不到所以加载时只取前两列public synchronized void loadDict() { try (BufferedReader reader new BufferedReader(new InputStreamReader( getClass().getResourceAsStream(MAIN_DICT), StandardCharsets.UTF_8))) { String line; while ((line reader.readLine()) ! null) { String[] parts line.trim().split(\\s); if (parts.length 2) continue; String word parts[0]; double freq Double.parseDouble(parts[1]); insertToTrie(word, freq); // 把词按字符逐层挂进前缀树 freqs.put(word, freq); // 词频表DP 时直接查 totalFrequency freq; // 累加总词频用于归一化 } } catch (IOException e) { throw new IllegalStateException(dict.txt 加载失败, e); } minLogProb Math.log(1.0 / totalFrequency); }这段代码干了三件事建 Trie、存词频、算总词频。insertToTrie()遍历词的每个字符在Node的childrenMap 里逐层putIfAbsent最后一个字符所在的节点标记为isWordtrue并挂上词频。totalFrequency是所有词频之和计算概率时用freq / totalFrequency得到这个词的先验概率实际计算都在对数空间进行所以minLogProb log(1/totalFrequency)是「词典外单字」的概率下限后面 HMM 和 DP 兜底都要用到它。加载是懒触发的多数版本在WordDictionary.getInstance()时一次性完成。几十万词条同时写进两个数据结构启动占用几百兆内存是常态这个特性在后面避坑章节会重点说。3.2 构建 DAGgetDAG 逐字前缀匹配词典就绪后对一个短句要做的第一件事是画出它的 DAG。核心思路从句子每个位置 i 出发在 Trie 里从根节点开始逐字往下走每走到一个isWordtrue的节点就记下这个位置 j表示「i 到 j 能组成一个词典词」private MapInteger, ListInteger getDAG(String sentence) { MapInteger, ListInteger dag new HashMap(); Node root wordDict.getTrieRoot(); int n sentence.length(); for (int i 0; i n; i) { ListInteger hits new ArrayList(); Node cur root; // 从 i 开始逐字沿着 Trie 向下匹配 for (int j i; j n; j) { cur cur.getChild(sentence.charAt(j)); if (cur null) break; // 前缀断了后面不可能再成词 if (cur.isWord()) hits.add(j); // i..j 是一个合法词典词 } if (hits.isEmpty()) hits.add(i); // 一个词都没命中退化成单字 dag.put(i, hits); } return dag; }getDAG返回的结构是MapInteger, ListIntegerkey 是起始位置value 是所有可能的结束位置。比如「研究生命起源」中位置 0研可能匹配到 1研究也可能匹配到 2研究生如果词典有这个词那dag[0]就是[1, 2]。任何一个位置即使词典全不命中也会有一条指向自己的单字边保证图一定是连通的能走到句子末尾。这一步的复杂度最坏是 O(n × 最大词长)但因为 Trie 的前缀匹配一旦断开就 break实际循环次数远小于 n²。有些版本还会限制最大匹配长度比如超过 8 个字符就不再往下走防止个别超长词典词拖慢速度。3.3 最大概率路径calc 动态规划求 route有了 DAG问题变成「从 0 出发走到 n走哪条路径概率最大」。这里用的对数概率把乘法变成加法避免概率连乘下溢成 0。动态规划从句子末尾开始往前推private MapInteger, PairInteger, Double calc(String sentence, MapInteger, ListInteger dag) { int n sentence.length(); MapInteger, PairInteger, Double route new HashMap(); route.put(n, new Pair(0, 0.0)); // 虚拟终点累计概率为 0 for (int i n - 1; i 0; i--) { double bestProb Double.NEGATIVE_INFINITY; int bestEnd i; for (int j : dag.get(i)) { String word sentence.substring(i, j 1); double prob wordDict.logFreq(word) route.get(j 1).freq; if (prob bestProb) { bestProb prob; bestEnd j; } } route.put(i, new Pair(bestEnd, bestProb)); } return route; }route.get(i)是一个Pairkey 是「位置 i 的最佳词尾」freq 是「从 i 切到句子末尾的累计最大对数概率」。核心转移方程就一行从 i 到 j 切出一个词它的概率等于这个词的对数概率logFreq(word)加上从 j1 到末尾的最佳概率route.get(j1).freq取所有候选 j 里的最大值。wordDict.logFreq(word)内部就是Math.log(freqs.get(word) / totalFrequency)。注意如果word不在词典里freqs.get会返回 null所以实现里通常有containsKey判断或者getOrDefault(word, 1.0)保证单字边也有一个基于minLogProb的保底概率。路线回溯也很简单从route.get(0).key拿到第一段的词尾再从route.get(词尾1).key拿第二段一路走到 n。3.4 未登录词识别sentenceProcess 主循环与 ViterbiDP 算完之后主循环沿最优路径把词一个个吐出来。遇到「i 的最优词尾就是它自己」这种单字节点先不急着输出攒进缓冲区攒到一批连续单字后整批交给 HMM 猜词private void sentenceProcess(String sentence, SegMode mode, ListSegToken tokens) { MapInteger, ListInteger dag getDAG(sentence); MapInteger, PairInteger, Double route calc(sentence, dag); int x 0; ListInteger singleBuf new ArrayList(); // 攒连续单字 while (x sentence.length()) { int y route.get(x).key; if (y x) { singleBuf.add(x); // 词典未命中先缓存 } else { if (!singleBuf.isEmpty()) { // 遇到词典词先处理前面的单字串 emitHmmTokens(singleBuf, sentence, tokens); singleBuf.clear(); } tokens.add(new SegToken( sentence.substring(x, y 1), x, y 1, word)); } x y 1; } if (!singleBuf.isEmpty()) { emitHmmTokens(singleBuf, sentence, tokens); // 句尾剩下的单字串别漏 } if (mode SegMode.SEARCH) { splitLongWords(tokens); // 长度2的词再按词典切细 } }emitHmmTokens从缓冲区里取出连续单字拼成一个字符串调用 Viterbi 算出每个字的状态再按状态把串切成词一个B...E连续段合并成一个词单独的S状态就是单字词。这解释了为什么「他来到了某知名高校的实验室」这类句子能把“知名”这种词典词切对——词典里没有就靠 HMM 猜。Viterbi 的实现核心是四状态 DPpublic ListCharacter viterbi(String sentence) { int n sentence.length(); double[][] dp new double[4][n]; // 0B 1M 2E 3S int[][] back new int[4][n]; // 记录前驱状态回溯用 // 首字符P(状态s) * P(该字符 | 状态s)对数空间用加法 for (int s 0; s 4; s) { dp[s][0] startProb[s] emit(s, sentence.charAt(0)); } for (int t 1; t n; t) { for (int s 0; s 4; s) { double maxVal Double.NEGATIVE_INFINITY; for (int prev 0; prev 4; prev) { double v dp[prev][t - 1] transProb[prev][s]; if (v maxVal) { maxVal v; back[s][t] prev; } } dp[s][t] maxVal emit(s, sentence.charAt(t)); } } // 合法句尾只能是 E词尾或 S单字取概率大的往回回溯 int last dp[2][n - 1] dp[3][n - 1] ? 2 : 3; ListCharacter states new ArrayList(); for (int t n - 1; t 0; t--) { states.add(STATES.charAt(last)); last back[last][t]; } Collections.reverse(states); return states; } private double emit(int state, char c) { double[] probs emitProb.get(c); return probs null ? minLogProb : probs[state]; // 生僻字给下限别抛NPE }startProb和transProb是 HMM 的初始概率和状态转移概率emitProb是发射概率prob_emit.txt加载后就是这张表。整个递推就是在算「当前字符属于状态 s 的最大对数概率 前一字符的最佳状态概率 转移概率 发射概率」。最后强制句尾落在 E 或 S保证状态序列一定合法不会出现 M 开头或者 B 结尾这种非法词形。4. 跑起来Maven 引入、Demo 运行与 SegMode 参数对照源码到手先别急着改词表按部就班把工程跑通再谈二次开发。4.1 从源码工程到本地依赖如果这份资源是完整 Maven 工程第一步打开根目录的pom.xml确认三件事groupId、artifactId、version这三个坐标值以及properties里的编译级别常见移植版一般按 JDK 8 配置。确认后在工程根目录执行mvn clean install -DskipTests这条命令把项目编译、打包并装进本地 Maven 仓库。之后在你自己的服务工程里把 pom 里的坐标原样抄进dependencies就能像用普通依赖一样引用它。如果坐标在中央仓库本来就发布过直接在依赖里写坐标拉取即可如果拉不到本地install是永远有效的后悔药。工程本身可以直接用 IDEA 打开选择根目录的 pom.xml 作为项目文件等 Maven 把依赖拉完src/main/java/demo/下的 Demo 类就能右键运行不需要额外配置。个别版本可能依赖junit之类测试库-DskipTests就是为了跳过这些不必要的环节。4.2 最小可运行 Demo写一个最简单的入口验证分词效果import com.xxx.analysis.jieba.JiebaSegmenter; import com.xxx.analysis.jieba.SegToken; public class Demo { public static void main(String[] args) { JiebaSegmenter segmenter new JiebaSegmenter(); String text 我们研究生命起源的最新进展; System.out.println( INDEX ); for (SegToken t : segmenter.process(text, JiebaSegmenter.SegMode.INDEX)) { System.out.println(t.word [ t.startOffset , t.endOffset )); } System.out.println( SEARCH ); for (SegToken t : segmenter.process(text, JiebaSegmenter.SegMode.SEARCH)) { System.out.println(t.word [ t.startOffset , t.endOffset )); } } }包名com.xxx.analysis.jieba是占位写法以你源码里package声明为准。跑起来会看到类似输出 INDEX 我们 [0, 2) 研究 [2, 4) 生命 [4, 6) 起源 [6, 8) 的 [8, 9) 最新 [9, 11) 进展 [11, 13)SegToken的三个字段很直白word是切出来的词startOffset是词在原始文本里的起始下标endOffset是结束下标左闭右开。你不需要拿着词去原文里再 indexOf 一遍直接用 offset 截取即可这就是为什么切分后做高亮、做实体对齐都方便。4.3 SegMode 参数与业务场景对照两处最容易改的参数都在process()的第二个参数上。SegMode.INDEX和SegMode.SEARCH的选择直接决定输出粒度选型参照第四节那张对照表即可。我一般按这个规则定做搜索召回就 SEARCH做数据清洗和字段抽取就 INDEX。除了模式还有一个常被忽略的预处理点英文数字混排。分词器对连续英文字母和数字的处理能力有限常见做法是在调process()之前先用CharacterUtil.identifyCharType()把文本扫一遍把连续的 LETTER 和 NUM 片段直接整体输出剩下的中文片段才交给分词器。这个预处理能省掉后面很多「产品型号被切碎」的投诉。5. 避坑与排查五个在真实业务里踩过的典型问题以下五条都是实际接项目时遇到过的坑按「现象 → 原因 → 解决」整理每条都对应源码里一个具体位置。5.1 启动慢、内存占用高现象第一次new JiebaSegmenter()卡住好几秒监控里堆内存涨了两三百兆小规格容器直接 OOM。原因WordDictionary是静态单例加载时把几十万条词频同时塞进 Trie 和 HashMap还要逐行解析、累加总词频。这些对象建完就常驻老年代不会被回收。解决给服务至少留 512MB 堆把初始化放在应用启动阶段别等第一个请求进来才触发懒加载。如果业务只用少量词不要全量加载大词典可以自己定制一个小词库文件替换MAIN_DICT路径。5.2 自定义词加了却切不出来现象调用了addWord(某项目代号, 100)结果句子还是被切成单字。原因词频给得太小log(freq / totalFrequency)是很大的负数动态规划比较时赢不过「拆成两个单字」的路径概率。另外要确认addWord的实现是否同步更新了 Trie——有的版本只写了 HashMap没写 Trie等于没加。解决给词频一个压倒性的值比如直接把词频设成词典的totalFrequency这时对数概率接近 0DP 一定会选这条路径。加完词后建议跑一遍包含该词的测试句子确认真的切出来了再继续。5.3 INDEX 和 SEARCH 输出完全一样现象同一句话用两种模式跑结果一字不差怀疑 SEARCH 没实现。原因SEARCH 模式只对长度大于 2 的词做二次切分如果测试句子全是短词、或者整句本来就没有长词典词两种模式自然没有差别。这不是 bug是测试样本选得不好。解决用「自动化研究所」这种长度超过两个字的复合词典词测试。如果对长词仍然切不出细粒度结果检查代码里splitLongWords是否真的被调到了以及它的阈值是不是写死成了别的值。5.4 英文数字混排被切得稀碎现象iPhone15 Pro 被切成 i/Phone/15/Pro索引里搜产品型号完全召回不了。原因词典里没有英文词条HMM 的发射概率表也几乎只针对汉字对字母数字序列的 BMES 状态估计基本靠猜切出来的结果自然稀碎。解决在调process()之前先用CharacterUtil.identifyCharType()识别连续 LETTER/NUM 片段直接整段输出或者把固定型号、品牌名加入自定义词典并用大词频压住让 DP 路径主动选中它。5.5 生僻字触发 NullPointerException现象文本里出现一个词典和模型都不认识的生僻字Viterbi 直接抛空指针。原因emitProb按语料里出现过的字符建索引遇到没见过的字符get(c)返回 null代码如果没做默认值处理求最大值那一步就炸了。解决把发射概率取法改成getOrDefault(c, minLogProb)或者像第 3.4 节写的那样判空后返回下限保证生僻字虽然概率极低但不会中断程序。同样检查startProb和transProb的取数逻辑统一兜底。这条改完建议顺手写进单测防止后续加词典时回归。6. 二次开发把默认分词器改造成业务自己的词表与规则6.1 注册业务词与停用词过滤接入真实业务后第一件事永远是加自定义词。常见的做法是在分词器初始化完成后批量把业务词注册进去WordDictionary dict WordDictionary.getInstance(); // 词频给到 totalFrequencyDP 一定会选中这条路径 dict.addWord(某项目代号, dict.getTotalFrequency()); dict.addWord(另一业务词, dict.getTotalFrequency()); SetString stopWords new HashSet(Arrays.asList(的, 了, 是, 在)); ListSegToken tokens segmenter.process(text, JiebaSegmenter.SegMode.INDEX); tokens.removeIf(t - stopWords.contains(t.word) || t.word.length() 1);注意过滤顺序先process再过滤停用词不要反过来。SegToken里的 offset 是相对原文的过滤后如果你还要做高亮需要保留被过滤 token 的 offset 集合否则高亮位置会错位。6.2 改 HMM 参数与验证方法如果业务领域新词密集默认 BMES 模型效果不理想可以替换prob_emit.txt为领域语料训练出的发射概率表。训练方法不复杂拿一批领域文本用当前分词器切好人工修正后统计每个汉字在 B/M/E/S 四种状态下出现的对数频率重新生成 emit 表即可。start 和 trans 不需要动除非你的语料里大量出现单字词或超长词。改完任何一行代码先用一张黄金测试集验证。我从一个翻车项目里学到的习惯是准备 20 到 50 句覆盖各类情况的样本人工标注正确切分结果存成文件每次改完代码跑一遍自动 diff而不是肉眼抽查。HMM 参数、词频、过滤规则任何一处改动都可能影响全局路径靠眼睛看十句漏三句几乎必然回归。从那以后我每次接分词需求都强制先搭黄金测试集再动手跑不通就回到上一版绝不带着怀疑上线。希望帮到你。本文还有配套的精品资源点击获取