分词这块,很多人一开始都会图省事,找个现成库一把梭。之前做某个业务系统的时候,需要从用户评价文本里提取关键词并过滤噪声词,我图apdplat轻量、文档全,就直接引了WordSegmenter,配上自有词库,打算分词和过滤一步到位。结果跑下来,效果确实差强人意——该滤掉的“的、了、啊”还在,不该滤掉的业务词反倒被干掉了好几个。折腾了半天,最后把自带过滤逻辑整个绕开,用JDK自带的正则和集合框架搭了一套过滤方案,几分钟就捋顺了。
这类问题其实特别典型:不是分词器不行,而是你把它自带的“业务过滤”能力当成了业务本身。这篇就把我在这个项目里的完整思路和踩坑过程写出来,包括WordSegmenter的用法、自带过滤为什么不好使、以及最后用JDK原生能力做的过滤方案,代码可以直接抄。
1. 业务场景与需求拆解
1.1 为什么项目会需要分词与过滤
当时的需求来自一个舆情分析模块:系统每天要接收几千条短文本,每条文本可能是用户对某款产品的评价,也可能是社交平台上的一段发言。下游要做关键词统计、情感判断、标签抽取,所以第一步必须先把一条文本切分成一个个有意义的词。
但分词结果不能直接用。原始文本里充斥着“的”“了”“吗”“在”“非常”这类功能词,还有“http://xxx”“【】【】”之类纯符号片段。如果不过滤,关键词统计结果会变得一团糟,“的”字出现的次数可能比产品名还高。所以真正的需求是两段式:先用分词器把句子切碎,再把切出来的词按业务规则筛一遍,剩下的才算有效关键词。
这个“按业务规则筛一遍”才是项目里真正的难点。因为不同业务的噪声词完全不一样:做电商评价,要保留“质量”“物流”“客服”,滤掉“感觉”“觉得”;做政策资讯,要保留“规定”“通知”“实施”,滤掉“我们”“你们”。分词器可能懂中文语法,但它不懂你的业务,所以过滤规则必须由业务方来定。
1.2 选型时的三个现实考量
选apdplat的WordSegmenter,不是因为它最强大,而是它最合适当前项目。当时待选的库有三个:结巴分词、HanLP和apdplat。结巴分词受众广、文档多,但Java版本的维护活跃度一般,部署时还带着一堆依赖;HanLP功能强,支持感知机模型和命名实体识别,但整体偏重,对当时那个小型服务来说有点杀鸡用牛刀。
最后选WordSegmenter主要看中三点。第一,纯Java实现,零外部依赖,打包后体积小,部署到生产环境非常省事。第二,它支持自定义词典,我可以直接把业务词塞进去,分词器就能快速识别这些词,不用等着默认词典命中。第三,调用方式足够简单,核心就是一个seg方法,没有复杂的训练流程和模型文件。
现在回头看,这个选型在“分词”这一步是没问题的,问题出在我把“自带过滤”想得太美了。任何词典类分词器,自带过滤能力都只能做通用噪声处理,不可能覆盖每个项目的特殊规则。
1.3 对“过滤”的真实预期
这里要先捋清楚一个概念:很多人说“过滤功能”,其实说的是两件不同的事。分词器自带的过滤,通常只是在分词结果里去掉标点、空白、纯数字、单字词这些“明显不是词”的东西,属于通用清洗。而业务上想要的过滤,是把“没有分析价值的词”都去掉,这个标准完全由业务场景决定。
比如一条评价“这家店的火锅非常好吃,服务也挺到位的”,通用过滤只会切掉“,”和“的”,剩下的“火锅”“非常”“好吃”“服务”“挺”“到位”都会保留下来。但做标签提取时,我希望留下“火锅”“好吃”“服务”“到位”,去掉“非常”“挺”这类程度副词。这种需求WordSegmenter不会替你判断,它只负责按规则切词,不负责理解你是要做情感分析还是关键词统计。
所以后来的方案也明确了:分词器负责“切”,过滤逻辑必须自己写。想明白这一点,后面的路就顺了。
2. 词库配置与自带过滤机制
2.1 基本接入方式
apdplat的接入方式很直白。项目是标准Maven结构,在pom.xml里引入依赖,然后直接用service层的静态方法:
<dependency> <groupId>org.apdplat</groupId> <artifactId>word</artifactId> <version>1.2</version> </dependency>核心代码就一行:
List<Word> words = WordSegmenter.seg("这家店的火锅非常好吃,服务也挺到位的");返回的List里每个Word对象就是一个切分结果,可以通过getText()拿到词文本,通过getPos()拿到词性标注。词性标注是它自带的,通过隐马尔可夫模型和词典信息共同决定,粒度包括名词、动词、形容词、副词等。
我当时先跑了一把这个默认操作,发现对于长句,切分结果整体还在可接受范围内,一些常见词和短语能切出来,但专业领域的词就明显不行了。比如有一段文本提到了“降噪处理”,默认词典不认识这个词,就会被粗暴拆成“降噪”和“处理”两个词。这类问题靠默认词典解决不了,只能上自有词库。
2.2 自有词库的两种用法
apdplat支持通过配置文件指定外部词典,也可以调用接口动态添加。配置方式是在resources目录下建一个word.conf文件,里面指定词典路径:
dic.path=/data/dict/business.txt这种方式的优势是词典和应用代码分离,运营同学直接维护文本文件就行,发布时不用重新编译。
另一种是运行时动态添加,在应用启动阶段把词库里的词逐个加载到分词器里。我当时两种都试过,最终用了配置文件的方式,因为词典要支持热更新,改完文件重启一次服务就能生效,不需要动代码。
这里有一个细节容易踩坑:词典文件的编码必须是UTF-8,而且不能带BOM头。BOM是文本文件开头的几个隐藏字节,Windows下的记事本保存UTF-8文件时经常带上,分词器一旦读到BOM头,第一行词就会乱码,匹配直接失效。而且词典每行一个词,词之间不要有多余空格,不要带标点。看起来是小事,实际排查起来每次都花不少时间。
2.3 自带过滤到底做了什么
用了自有词库之后,分词精度确实上来了,但过滤环节的问题开始凸显。我需要确认WordSegmenter在分词结果上做了哪些过滤动作。
实测下来,它的自带过滤主要是这么几层。第一层,把标点符号和空白字符从结果里剔除。第二层,对单个字符的词做一些保留判断,单字词会被压到很低的优先级,很多情况下直接进了噪声序列。第三层,通过词性标注,把一部分虚词、助词标记为“不可用词”,比如“的了啊吗”这些。
听起来逻辑没问题,但实际跑起来就发现它处理的粒度太粗。它把“单字词”统一作为弱信号处理,可很多业务词本身就是单字,比如“茶”“酒”“卡”,一旦文本里出现“这张卡很划算”,词库只认得“卡”不认得“这张卡”,滑到单字规则时就被滤掉了。业务上恰恰最需要把“卡”留住,因为它才是核心关键词。
2.4 参数与词库的常见误区
在折腾过程中,我试了不少参数和词库配置方式,有些误区值得单独提一下。
第一,误区是“自定义词典越多越好”。实际上,过多地加入无关词,反而会干扰切分结果,因为最大匹配算法在选择切分路径时,优先级和词频有关系。有些低频业务词即便加进去了,切分时也可能被默认词典的高频词顶掉。比如“火锅店”,自定义词库里加了,但默认词典有“火锅”和“店”,分词器可能宁可把长词切短,也要保证概率更高。
第二,误区是“加载停用词表就能自动过滤”。apdplat对停用词的处理并没有我当初想得那么智能,停用词表的作用范围有限,而且它不是针对所有输出词元强制过滤,更多是在词性和频率层面做综合判断。实践下来,我发现靠它自己的停用词逻辑,抓不干净那些业务相关的噪声词。
第三,误区是大幅调低默认词频阈值来保留更多词。这个方法确实能让输出词元变多,但噪声也会成倍增加,还拉低了速度。短文本场景下还好,长文本跑下来,很多无意义的大众词都被保留,给下游统计带来更多干扰。
这些误区绕一圈之后,我基本确定了一个结论:自带过滤只能当“粗洗”,不能当“精筛”,想稳定达到业务目标,必须自己接管过滤决策。
3. 差强人意的背后原因
3.1 现象:该滤的没滤掉,不该滤的被误伤
直接说现象。有一批输入文本是产品使用反馈,里面有一句:“我觉得这个产品的降噪效果很好,就是价格有点贵。”
我理想中的过滤结果应该保留:“产品”“降噪”“效果”“好”“价格”“贵”,滤掉“我觉得”“这个”“就是”“有点”。
实际自带过滤给出来的结果是:“觉得”“产品”“降噪”“效果”“很好”“价格”“贵”。其中“觉得”被留下了,“有点”没有被正确切出来,“很好”作为一个整体被保留了,而我并不需要“好”的程度副词去参与关键词统计。
反过来看另一条样本,文本是“这张会员卡能打八折,大家赶紧去买”,输出里“卡”被当噪声滤掉了,因为它是单字词且词频低。但作为某活动的核心关键词,“卡”恰恰是最重要的词。一进一出,该滤的没滤干净,该留的被误伤,这才是“差强人意”的准确写照。
3.2 原因一:词典匹配机制天然僵硬
深挖原因要看分词器的底层逻辑。apdplat属于词典类分词器,核心原理是正向最大匹配加上一些概率模型。它的一切决策依赖词典,词典里没有的词,它就不知道怎么切、怎么标。
这种机制的优点是可解释、可干预,你可以不断往词典里塞词来提升准确率。缺点也很明显:词典是有边界的,语言是没边界的。一个文本里只要出现稍微新一点的表达,或者两个词的组合中间带一个虚词,切分就可能偏离预期。
过滤功能依托的是词性和词频判断,而词性和词频本身也是词典给的。词典说“很好”是副词短语,它就标副词;词典说“卡”是量词,它就很可能把它滤掉。问题不在apdplat,而在所有词典分词器都有这条边界,只是很多人在项目初期没意识到这条边界有多近。
3.3 原因二:词性标注精度有限
词性标注是自带过滤判断的重要依据,但中文词性标注本身就是一个没有完全解决的问题。同一个词在不同语境下,词性完全不同。
比如“计划”这个词,在“按计划执行”里是名词,在“计划去上海”里是动词。如果业务方想保留名词意见类词,分词器把“计划”标成动词,那就直接滤掉了。再比如“编辑”,既是岗位名词又是动作动词,在新闻类文本里还是指代特定工具。
我当时还遇到过一个更实际的情况:用户反馈里高频出现“应用闪退”,业务上“闪退”是非常重要的负面关键词,但分词器把它切成了“闪”和“退”,并且“闪”被标成动词、“退”被标成动词,触发过滤规则后整个词直接消失,统计结果里完全看不到“闪退”。你很难去怪分词器,因为“闪退”本身就是一个词典外的组合词,词性模型拿它毫无办法。
3.4 原因三:过滤粒度与业务需求脱节
这是最根本的原因。自带过滤的目标是“去掉不像是词的噪声”,但业务过滤的目标是“去掉对这个场景没有价值的词”,两个目标不完全一致。
比如在电商场景里,“包装”“质量”“物流”是重要维度词,但“感觉”“觉得”“听说”这类心理动词就是噪声;在开发反馈场景里,“报错”“崩溃”“日志”是核心词,但“可以”“能够”“需要”这类情态动词是噪声。同一句话,在两个场景里过滤结果完全相反,非要让分词器用一套通用规则去兼顾,结果就是两头不讨好。
所以后来我给自己定了一条原则:永远不要在分词器层面做业务过滤决策,分词器产出的原始词元序列要完整保留,业务过滤逻辑全部由我自己在分词结果之上实现。这条原则在很多项目里都适用。
4. 改用JDK自带过滤的实现方案
4.1 整体思路:把过滤从分词器中剥离出来
新方案的核心思路只有一句话:WordSegmenter只负责返回完整的词元序列,我全量接收它的结果,然后用JDK标准库能力做二次清洗。
为什么用JDK自带的东西,而不是再去引一个过滤库或者规则引擎?因为我需要的过滤能力其实就三类:精确词过滤(停用词表)、模式匹配过滤(正则表达式)、词性/属性条件过滤(集合与条件判断)。这三类能力JDK原生就够用了。
- 精确词过滤用HashSet,查找时间复杂度是O(1),几万条停用词也没压力。
- 模式匹配用java.util.regex下的Pattern和Matcher,预编译正则,匹配效率足够高。
- 词性过滤用枚举集合做白名单或黑名单判断,逻辑一目了然。
这个方案还有一个副产品:过滤规则全部收敛到独立的工具类里,业务方要增加噪声词,改一个文本文件或者一个配置项就行,不碰分词器代码。相比去研究分词器的过滤扩展点,这要可控得多。
4.2 停用词表用HashSet做精准过滤
第一步最简单,把明确不要的词直接放进一个停用词集合。分词结果出来后,词文本如果在集合里,直接丢弃。
private static final Set<String> STOP_WORDS = new HashSet<>(); static { STOP_WORDS.add("的"); STOP_WORDS.add("了"); STOP_WORDS.add("吗"); STOP_WORDS.add("呢"); STOP_WORDS.add("啊"); STOP_WORDS.add("觉得"); STOP_WORDS.add("感觉"); STOP_WORDS.add("就是"); STOP_WORDS.add("我们"); STOP_WORDS.add("你们"); // 从停用词文件加载剩余内容 }这里有一个优化细节:如果要加载的停用词量很大,不要直接在static块里add几百行,而是把词放在一个文件里,启动时一次性读入。文件每行一个词,编码UTF-8,读取后用Files.readAllLines配合set.addAll一次性灌入即可。
Set<String> stopWords = Files.readAllLines(Paths.get("/data/dict/stopwords.txt"), StandardCharsets.UTF_8) .stream() .map(String::trim) .filter(s -> !s.isEmpty()) .collect(Collectors.toSet());这个方案看着笨,但它解决了自带过滤最大的问题:可解释性。哪些词被滤掉了,打开停用词文件一目了然。业务提一句“把‘听说’也滤掉”,加一行就行,完全不用分析分词器的内部决策逻辑。
4.3 正则表达式处理模糊噪声
停用词只能处理完全匹配的噪声,但文本里的噪声形态千变万化,比如“哈哈哈”有不同长度,“6666”有不同数字,“【】”里夹内容,乱七八糟的URL。这些没法靠穷举词表搞定,必须上正则。
我实际用的正则分几类:
第一类,纯标点和符号组合,比如一串“......”“——”“、、、”,直接丢掉。
private static final Pattern PURE_SYMBOL = Pattern.compile("^[\\p{P}\\p{S}\\s]+$");第二类,纯数字串。这类词在大部分业务里没有统计价值,除非你的业务本身就是做数字分析的,那就另说。
private static final Pattern PURE_DIGIT = Pattern.compile("^[0-9]+$");第三类,包含URL特征的词元,文本里有“http”“www”开头的,直接干碎掉。
private static final Pattern URL_PATTERN = Pattern.compile("^(https?://|www\\.).*", Pattern.CASE_INSENSITIVE);第四类,重复叠加词,比如“哈哈哈哈哈”“嗯嗯嗯嗯”,用一个长度加字符一致性的判断处理。
private static final Pattern REPEATED_CHAR = Pattern.compile("^(.)\\1{2,}$");需要特别注意:正则一定要用Pattern.compile在类加载时预编译,不能放在过滤循环里反复调用Pattern.compile。我最早偷懒直接写在循环里,一条长文本几千个词元,每次匹配都重新编译正则,耗时直接翻了好几倍。后来把Pattern全部提升为静态常量,速度才恢复正常。
正则匹配放在停用词判断之后,顺序上不能反过来。如果词已经精确命中了停用词,就不用再跑正则了,少一次匹配就少一点开销。先粗筛、再精筛,这个顺序在大量数据处理时对性能影响很明显。
4.4 组合词性与长度做二次过滤
光靠停用词和正则,还是有漏网之鱼。有些词本身不长也不奇怪,但它在当前业务里就是没价值。这时候就需要结合WordSegmenter返回的词性标注做二次过滤。
一个比较实用的做法是定义“保底保留词性集合”和“强制过滤词性集合”。以我当时的舆情场景为例,名词、动词、形容词是要保留的核心词性,副词、语气词、介词、连词,统统过滤。
private static final Set<String> KEEP_POS = Set.of("n", "v", "a", "vn", "an"); private static final Set<String> DROP_POS = Set.of("d", "u", "p", "c", "r", "m", "e", "y", "o");Word对象里可以通过getPos()拿到词性标签,判断的时候直接看是否在DROP_POS里。这种做法的优势是处理速度快,一个集合查找就完了,而且规则覆盖面广,不需要每个词都维护在停用词表里。
词长过滤也可以放在这一层。默认情况下,我把长度小于2的词元排除,除非它命中了KEEP_POS里的名词。这样“卡”这类重要单字词能保住,而“呢”“吧”这类语气词更早被滤掉。具体阈值要根据业务调,我实测在舆情分析场景,2字阈值比1字阈值噪声减少40%以上。
这里要强调一点:词性标签的使用规则要写在配置里,不要硬编码在代码深处。因为不同版本的apdplat对词性定义不完全一致,比如动词有时候是v、有时候是vd,一旦业务期拉长、词库变更,硬编码很容易出错。
4.5 完整示例与执行效果
把上面的逻辑串起来,完整的核心代码大概长这样:
public class WordFilter { private static final Pattern PURE_SYMBOL = Pattern.compile("^[\\p{P}\\p{S}\\s]+$"); private static final Pattern PURE_DIGIT = Pattern.compile("^[0-9]+$"); private static final Pattern URL_PATTERN = Pattern.compile("^(https?://|www\\.).*", Pattern.CASE_INSENSITIVE); private static final Pattern REPEATED_CHAR = Pattern.compile("^(.)\\1{2,}$"); private Set<String> stopWords; private Set<String> keepPos; private Set<String> dropPos; private int minLength = 1; public List<String> doFilter(List<Word> words) { List<String> result = new ArrayList<>(); for (Word word : words) { String text = word.getText().trim(); String pos = word.getPos(); if (text.isEmpty()) { continue; } if (stopWords.contains(text)) { continue; } if (PURE_SYMBOL.matcher(text).matches() || PURE_DIGIT.matcher(text).matches() || URL_PATTERN.matcher(text).matches() || REPEATED_CHAR.matcher(text).matches()) { continue; } if (pos != null && dropPos.contains(pos)) { continue; } if (text.length() < minLength && !(pos != null && keepPos.contains(pos))) { continue; } result.add(text); } return result; } }调用侧还是用WordSegmenter分词,然后把返回的words直接丢给WordFilter。
List<Word> words = WordSegmenter.seg(text); List<String> keywords = wordFilter.doFilter(words);这套代码上线后,效果对比很明显。以之前那条“我觉得这个产品的降噪效果很好,就是价格有点贵”为例,新方案输出的结果是:“产品”“降噪”“效果”“价格”。丢失的“好”和“贵”是形容词,如果做情感词统计确实需要,那我只要把形容词加入保留词性集合就行,一行配置改动,不影响其他逻辑。
从稳定性和可维护性角度看,这套方案最大的价值是:分词器升级、词库变动都不会影响我的过滤逻辑。过滤规则独立于分词器之外,业务方可以随时调整,边界非常清楚。
5. 经验总结与常见问题排查
5.1 高频问题速查
这个方案运行了一段时间,自己也踩了一些小坑,整理成一张表方便对照。
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 自定义词典里的词没有被正确切出 | 词典文件带了BOM头,或编码不是UTF-8 | 用文本编辑器另存为UTF-8无BOM,检查首行词是否正常 |
| 自定义词被拆成更短的默认词 | 默认词典词频更高,匹配优先 | 调整自定义词的TF/IDF权重配置,或者直接在过滤层做词组合并 |
| 停用词规则生效但词还在 | 词文本带标点或空白,精确匹配失败 | trim后再判断,必要时先做符号清洗 |
| 过滤后结果全是噪声 | 词性标签集合配置不当 | 打开调试日志,打印每个词的文本和词性标签 |
| 系统速度明显变慢 | 正则Pattern没有预编译 | 确认所有正则都是静态常量,不在循环内compile |
| 部分业务词被误滤 | 单字词阈值太低 | 放宽minLength,同时结合词性白名单做特判 |
5.2 编码、词库加载与内存问题
先说编码,这个坑我至少遇到两次。一次是运营同学在Windows下用记事本编辑词库,保存时自动加了BOM头,结果词库第一行的词怎么都匹配不上。另一次是词库文件里有全角空格,词看起来是一样的,但实际字符串里多了一个全角字符,用equals比较永远返回false。
解决办法很简单:加载词库的时候统一做一次清洗。
String word = line.trim().replace("\uFEFF", "").replace('\u3000', ' ');全角空格替换成普通空格后,再判断是否为空字符串,能有效避免这类问题。
内存方面,如果停用词表特别大,每条文本分词后都要对词元做HashSet查找,这个查找成本很低。真正的内存风险来自另一个地方:Word对象的词性标注数据。在高吞吐场景下,如果有大量文本同时进来,WordSegmenter的结果会持有词性信息,短时间积压可能让GC压力变大。处理方式是复用过滤方法,避免在循环里创建新的WordFilter实例,方法内部只创建必要的局部集合。
词库加载性能也要注意。如果词库文件有几万行,每次应用启动都重新加载,启动时间会长到无法接受。解决方法是把处理好的停用词集合序列化起来,或者用静态缓存。不过对于多数业务,几万行加载也就一两秒,不至于过于夸张。
5.3 定过滤规则时的几个判断标准
规则不是越多越好,我给自己定了三个标准来判断一条规则该不该写进代码。
第一个标准是可解释性。增加一条过滤规则后,要能一句话说清楚它干什么。比如“滤掉所有语气词”是可解释的,“滤掉这个词”也是可解释的,但“滤掉出现次数超过10次且长度小于3的词”就有点模糊了。模糊的规则上线后,出了问题很难定位。
第二个标准是可观测性。过滤规则要能记录被滤掉的词和比例。我一般在过滤方法里加一个计数器,定期把统计信息打出来:总共处理多少词、三元里各滤掉了多少。这样规则上线后,几天内就能看到噪声占比变化,判断规则是否有效。
第三个标准是可回滚性。不直接修改原始分词结果,过滤后的词放到新列表里,原始words一直保留。如果发现某条规则误伤,随时可以基于原始列表重新过滤,不需要重新跑分词。
这三个标准帮我避免了很多次“改完规则又回退”的无谓折腾。
5.4 常见排查流程
最后分享下项目里沉淀的一套排查流程,遇到“过滤结果不对”的时候,按这个顺序走基本不会迷茫。
第一步,确认分词本身是否正常。用WordSegmenter.seg对出问题的文本单独跑一遍,看看输出的原始词元列表里是否包含目标词。如果分词阶段就把目标词切碎了,那问题在词库不在过滤,先去补自定义词典。
第二步,逐条验证过滤条件。把原始词元按顺序打出来,逐个对照停用词集合、正则规则、词性规则,找到第一个中断的位置。绝大多数情况下,问题都出在某个词的trim没做或者词性标签不在预设集合里。
第三步,打开调试日志。在过滤方法的关键分支打日志,记录每个词元命中了哪条过滤规则。日志不用一直开着,出问题时现场开一下,定位后去掉。
第四步,验证规则调整效果。调整词库或规则后,用一批带标注的测试样本回归跑一遍,对比过滤前后的精确率和召回率变化。我一般准备五十到一百条典型文本,固定成单元测试,每次改动代码就跑一遍,防止修了A问题又弄坏B场景。
这套流程看着基础,实际非常管用。尤其当分词、词库、过滤三层耦合在一起的时候,靠直觉猜问题原因效率太低,按流程排查基本都能在半小时内定位。
最后再说点个人感受。经过这个项目,我最大的体会是:框架的能力要不要用,取决于这个能力是否贴合你的业务边界。WordSegmenter的词典和分词值得用,但它的过滤逻辑和我的业务目标不一致,那就干脆自己来。JDK的正则和集合虽然基础,却是每个Java工程师都能掌控、都能调试、都能稳稳落地的工具。与其花时间研究分词器里的过滤扩展点,不如把规则握在自己手里。这个思路,换到任何分词器、任何业务场景里,都成立。