很多人第一次接触中文分词,都是从"调一个库、跑一句话、看看切出来的词"开始的,我也不例外。但真到业务里拿分词结果做关键词抽取、做评论标签、做检索召回的时候,通用分词器给出的结果经常让人挠头——"机器学习"被切成"机器"和"学习","苹果手机"被切成"苹果"和"手机",一个行业黑话更是被切得七零八落。我最近因为一个中文短文本标签的项目,重新翻出了 pkuseg 这个由某高校语言计算团队开源的领域自适应中文分词工具,从安装、跑通、换模型、加自定义词典到训练自己的领域模型,完整走了一遍。这篇就把初试过程、实测数据、踩过的坑和最终的使用感受原原本本写下来,适合正在做中文文本处理、纠结分词方案选型、或者想尝试领域自适应分词的朋友参考。全文不讲空话,代码和参数都能直接抄。
1. 为什么我会去折腾一个"老"分词器
1.1 一次关键词抽取跑偏引出的问题
项目背景不复杂:一批中文短文本,长度大多在 15 到 60 字之间,需要从中抽取标签词。我一开始用的是业界最常用的那个基于前缀词典加动态规划的通用分词库,好处是安装零成本、速度快、社区资料多。但跑完第一轮统计之后,Top50 的高频词里出现了大量无意义碎片,比如"的""了""这个"之外,还有"操作起来""感觉还行"这类半截短语,以及被错误切开的专有名词。
我做了个简单的对照:人工标了 200 条短文本的理想切分结果,然后拿分词器输出和人工结果算词级别的准确率。通用分词器在通用新闻语料上表现还行,但在这种带点口语化、带点行业术语的短文本上,词级 F1 大概只有 0.86 左右。问题集中在三类:领域术语被切碎、数字和英文混排处理粗糙、短句里缺少上下文导致歧义切分。
这个时候我想起了 pkuseg。它在设计上的出发点恰恰就是"领域自适应"——不追求一个模型打天下,而是提供多个已经训练好的领域模型,同时开放训练接口让你用自己的语料继续训练。这一点和大部分"开箱即用、但很难改"的分词器有本质区别。
需要说明的是,这个项目已经有一段时间没有大版本更新了,社区活跃度不如当年。但"停止更新"不等于"不能用",尤其是在你需要一个可训练、可解释、依赖轻量的中文分词基线时,它仍然是一个很实用的选择。我后面会专门说清楚哪些场景该用、哪些场景建议绕开。
1.2 pkuseg 与通用分词器的根本差异在哪里
要理解 pkuseg 的行为,得先知道它在做什么。它的核心是一个基于字符级特征的序列标注模型,用平均感知机(averaged perceptron)做训练,输出每个字的 BMES 标签,再把标签还原成词。这个技术路线决定了三件事:
第一,它对未登录词(词典里没有的词)的泛化能力来自特征模板,而不是词典匹配。特征通常包括当前字、前后各一到两个字的字符、字符的类别(数字、英文、标点)、以及是否在词典中命中。这意味着遇到新词时,它靠"字与字的组合规律"来判断边界,而不是死查词典。
第二,它天然支持增量训练。因为模型参数是可以继续更新的,你可以拿一个预训练模型做初始化,再用自己的标注语料做微调,这就是官方所说的"fine-tune"模式。相比之下,纯词典类的分词器想加入领域词,只能靠扩充词典,而词典扩充对歧义切分几乎无能为力。
第三,速度不如纯词典方案。序列标注模型要对每个字做一次特征打分,复杂度随文本长度线性增长,但常数比词典匹配大得多。这是它绕不开的代价,我在实测里会给出具体数字。
这里做个直观的类比:通用词典分词像查字典做题,见过就会,没见过就靠规则硬猜;pkuseg 更像一个学过语感的学生,即使没见过某个词,也能根据"这两个字老是黏在一起"判断它们该是一个词。前者快且稳定,后者灵活但有代价。
2. 把环境搭起来:从安装到跑通第一行代码
2.1 依赖与安装过程中最容易卡住的地方
先说结论:如果你的 Python 环境是 3.7 到 3.9 之间的纯净虚拟环境,安装过程通常很顺。但如果你在 3.10 以上、或者系统里已经装了一堆科学计算包的环境里直接 pip,很可能会遇到两个问题。
第一个问题是编译依赖。这个包在安装时可能需要本地编译 Cython 扩展,如果没有 C 编译器就会报错。我在一台干净的 Linux 机器上复现过,报错信息大致是找不到Python.h或者 gcc 不可用。
# 先确认基础编译环境 gcc --version python3 -c "import sysconfig; print(sysconfig.get_paths()['include'])" # Debian/Ubuntu 系补装 sudo apt-get install -y build-essential python3-dev第二个问题是版本漂移。原作者维护的版本对新 Python 的兼容性有限,我在 3.11 环境下试过,安装能过但 import 时报了一些与旧 API 相关的告警。我的处理办法是干脆用 conda 起一个 3.9 的独立环境,不和主环境混。
conda create -n seg python=3.9 -y conda activate seg pip install pkuseg提示:不要在生产镜像里直接用
latest版本,把版本号钉死在requirements.txt里,否则哪天构建机拉到一个新版本,行为变化很难排查。
装完之后官方提供了一份预训练模型包,通常需要单独下载并解压到包目录下(或者通过参数指定模型路径)。我第一次就是因为模型路径没对上,程序起不来却报了一个很含糊的错误。模型目录结构大概是这样的:
pkuseg/ └── pretrained/ ├── spacy/ # 混合领域(新闻、网络、科技等) ├── default/ # 通用领域 ├── msr/ # 简体中文分词语料风格 └── ctb/ # 树库风格判断模型是否就位,最简单的办法是看目录里有没有model.pt之类的权重文件和一个dict.txt词典文件。缺任何一个,加载都会出问题。
2.2 首次加载模型的耗时与内存实测
跑通第一行代码之前,我特意量了一下加载成本,因为它直接影响你在服务里能不能接受这个组件。
import time import pkuseg t0 = time.time() seg = pkuseg.pkuseg() # 默认走混合模型 t1 = time.time() print(f"模型加载耗时: {t1 - t0:.2f}s") text = "这个降噪耳机的主动降噪模块在通勤场景下表现不错" t2 = time.time() for _ in range(1000): seg.cut(text) t3 = time.time() print(f"1000 次分词总耗时: {t3 - t2:.2f}s")在 4 核 8G 的机器上,默认模型首次加载大约 1.5 到 2.5 秒,常驻内存增加约 200 到 300MB。这个数字放在批处理任务里完全无感,但如果你是做在线短文本服务、每秒几十次请求,那就一定得做成进程内单例,绝对不能每个请求都新建一个分词器对象——我见过有人在接口里每次调用都pkuseg.pkuseg()一次,QPS 直接掉到个位数。
另外还有一点很关键:这个分词器实例不是线程安全的。多线程共用一个对象时,我遇到过输出结果错乱的情况,虽然不常见但确实发生过。稳妥的做法是每个线程持有一个实例,或者干脆用多进程。
import threading _local = threading.local() def get_seg(): if not hasattr(_local, "seg"): import pkuseg _local.seg = pkuseg.pkuseg() return _local.seg def cut(text): return get_seg().cut(text)注意:每个线程各持一个实例意味着内存乘以线程数。8 个线程就是 2GB 起步,容器内存限制一定要提前算好,我就因为这一点被打爆过一次 Pod。
3. 分词模式与领域模型:到底该选哪一个
3.1 内置模型的差异对照
这个工具最吸引我的地方就是多模型可选。官方提供了几个预训练模型,风格各不相同。我用同一批短文本(200 条,餐饮、数码、出行三类评论混合)跑了一遍,主要看粒度差异和术语识别情况。
| 模型 | 训练语料倾向 | 分词粒度 | 术语识别 | 我的实测耗时(千条) |
|---|---|---|---|---|
| 混合模型 | 新闻+网络+科技 | 偏细 | 中等偏上 | 约 6.2s |
| 通用模型 | 通用书面语 | 适中 | 一般 | 约 5.8s |
| 简体分词语料风格 | 规范标注语料 | 偏细 | 中等 | 约 6.0s |
| 树库风格 | 树库标注规范 | 偏粗 | 偏低 | 约 5.9s |
说人话的结论:如果你完全不知道自己的语料是什么领域,先用混合模型,它的综合表现最稳;如果你处理的是规范化文本、追求更细的粒度用于检索,可以试语料风格那个;树库风格偏粗,适合你需要短语级而不是词级切分的场景,比如构建短语索引。
我实测中最明显的差异出现在复合名词上。比如"续航焦虑"这个词,混合模型能整体切出,通用模型会切成"续航 / 焦虑"。在标签抽取任务里,前者显然更有价值。
3.2 词性标注与自定义词典的正确用法
词性标注是顺手就能用的能力,把postag打开即可:
import pkuseg seg = pkuseg.pkuseg(postag=True) text = "这个降噪耳机的主动降噪模块在通勤场景下表现不错" print(seg.cut(text)) # [('这个', 'r'), ('降噪', 'n'), ('耳机', 'n'), ...]有了词性,抽名词做标签这件事就变得很直接:保留n、nr、ns、nz、vn这几类,过滤掉代词、助词、副词。我一开始没做词性过滤,结果抽出来的标签里全是"这个""非常""感觉",纯粹是浪费。
自定义词典是我认为这个工具最实用的功能之一,但用法上有几个容易踩的点:
import pkuseg seg = pkuseg.pkuseg(user_dict="my_dict.txt") print(seg.cut("小明在用开源分词工具做领域适配"))词典文件就是每行一个词,UTF-8 编码,不要带表头、不要带额外列。如果要给词加权,可以在词后面加空格再写一个数字。我第一次写词典时在文件末尾留了一个空行,结果加载时报了一个挺难懂的解析错误,删掉空行就好了。
提示:自定义词典是"提高该词被整体切出的概率",不是"强制切出"。当上下文特征强烈指向另一种切分时,词典也可能失效。这一点后面我在排查链路里会详细展开。
4. 在真实语料上的初试:效果、速度与踩坑记录
4.1 三份语料的对比测试
为了不只凭感觉判断,我准备了三份语料做对照:
第一份是 200 条人工标注的短文本(就是前面说的那个测试集),用来算词级 F1;第二份是约 12 万字的行业资讯正文,用来测吞吐;第三份是 3 万条左右的用户评论,用来测真实口语场景下的稳定性。
先看准确率。加上自定义词典(我人工整理了 380 个领域术语)之后,混合模型在短文本测试集上的词级 F1 从通用方案的 0.86 提到了 0.915 左右。提升主要来自两块:领域术语整体切出的比例明显上升,数字与单位、英文缩写的粘连错误明显减少。
再看吞吐。12 万字正文,单进程单线程,用混合模型跑完大约 8 到 10 秒,换算下来 1.2 到 1.5 万字/秒。如果开nthread参数多线程跑,在 4 核机器上能到 3 万字/秒左右,但核数再多收益就很小了,因为 Python 层的调度开销上来了。
seg = pkuseg.pkuseg(nthread=4)第三份用户评论语料上暴露的问题最多。口语化表达、错别字、表情符号残留、标点缺失,这些都会让模型判断失准。比如"好用的一批"这种,模型很难处理得好;再比如连着几个感叹号没有空格,切分结果会带出一堆单字符片段。
我的处理方式是在分词前做一轮清洗:全角转半角、去掉连续的重复标点、把表情符号统一替换成空格、把英文缩写前后的空格补齐。清洗之后,同一个模型在评论语料上的可读性提升非常明显。这个清洗层看起来不起眼,但它是分词效果提升性价比最高的一环,比换模型管用。
4.2 自定义词典不生效的完整排查链路
这一段我想把排查过程完整写出来,因为它很典型,而且网上的答案基本都对不上。
现象:我在词典里加了"续航焦虑""降噪模块""快充协议"这几个词,但在测试文本里,"续航焦虑"依然被切成两段,"快充协议"偶尔能整体切出、偶尔又不行。
第一步,确认词典真的被读进去了。我在user_dict指定的路径上临时改成绝对路径,然后在词典里故意放一个肯定不存在的怪词,看它会不会被整体切出。结果怪词被正确整体切出,说明加载没问题。这一步排除了路径错误、编码错误、文件权限错误。
第二步,确认词典文件的格式。我用file my_dict.txt查了编码,又用cat -A看行尾。发现文件是从某表格软件导出的,行尾带\r\n,而且部分行末尾还有制表符。修复方式是统一转成 LF 并把制表符换成空格。改完之后,"快充协议"的稳定性好了很多,说明行尾脏字符确实会干扰解析。
第三步,看长度。"续航焦虑"是四个字,四个字的词在序列标注里本来就难整体切出,因为模型倾向于把长词拆成语义更常见的二字组合。我做了个对照实验:把"续航焦虑"改成"续航焦虑感"(五字),结果反而更容易整体切出;改成"焦虑"(二字),则百分百整体切出。这个规律说明,词典权重对短词更有效。
第四步,加权。词典里给词加权重,写法和效果是这样的:
续航焦虑 5 降噪模块 3权重越高,模型越倾向于把它当成一个整体单元。我实测下来,权重从 1 提到 5,四字词的整体切出率有明显改善,但副作用是相邻的边界可能被带偏,所以不建议全表统一拉高,只对真正高频的核心术语加权。
第五步,考虑微调。如果加权之后仍然不稳定,说明这个切分方式和模型训练时的语料分布冲突太大。这时候真正的解法是拿一批标注语料做微调,而不是继续调词典权重。
整个链路走下来花了大半天,但收获很实在:**词典不生效,九成情况是格式和权重问题,一成是模型本身的分布问题。**先排查前者,因为成本低。
5. 训练自己的领域模型:从语料到可用的模型文件
5.1 训练数据的格式与标注成本
这条工具链路最有价值的部分是能自己训练。训练语料是词级别的标注格式,一行一句,词之间用空格分开,标点单独成词:
这个 降噪 耳机 的 主动 降噪 模块 在 通勤 场景 下 表现 不错 快充 协议 兼容 性 一般 , 但 充电 速度 很快 。关于标注量,我做了个粗略的资源评估。我的经验值是:如果只做领域适配微调,2000 到 5000 句带标注的领域文本就能看到明显收益;如果要从零训练一个模型,那至少得几万句,而且标注质量比数量重要得多。对我这个项目来说,我标了大约 3000 句短文本,两个人各标一半,然后交叉复核,大概花了三四天。
标注过程中最容易出现的分歧是复合词边界。比如"无线充电底座",是切"无线/充电/底座"还是"无线/充电底座"?这类问题必须提前定好标注规范,否则前后不一致,模型学出来也是乱的。我们的做法是先标 200 句,把有分歧的地方全部拉出来开会定调,形成一份简短的《切分口径说明》,后面统一按这个来。
还有一个坑:训练语料和测试语料要来自同一个分布。我第一次把新闻风格的语料拿去训练、用短评论文本测试,效果反而比预训练模型还差。换成短评论文本训练之后才正常。这个道理很简单,但实操中真的很容易忘。
5.2 微调流程与验证方式
训练接口的调用形式很简单:
import pkuseg # 微调:在已有模型基础上继续训练 pkuseg.train( train_file="train.txt", test_file="dev.txt", output_dir="./my_model", nthread=4, train_iter=20, fine_tune=True, )几个参数我实测下来的体会:
train_iter控制迭代轮数。轮数太少欠拟合,太多会过拟合到训练集的口音上,在没见过的文本上反而退化。我的数据量下,从 10 轮开始试,15 到 20 轮之间稳定,25 轮之后 dev 上的指标开始掉头,这就是典型的过拟合信号。
fine_tune=True表示在预训练模型上做增量训练,False就是从零训练。除非你的领域和通用领域差异极大(比如大量专业符号、特殊格式),否则建议一律从微调开始,收敛快、需要的语料少、对标注噪声的容忍度也高。
训练完之后会产出一个模型目录,加载时把路径指过去就行:
seg = pkuseg.pkuseg(model_name="./my_model", user_dict="my_dict.txt")验证方式我用了两套。一套是自动指标:拿一份单独的 dev 集算词级 P/R/F1。另一套是人工抽检:随机抽 100 条,人工看切分结果是否合理,重点看领域术语和歧义句。自动指标高但人工抽检不理想的模型,我遇到过一次,原因是我标注的是"细粒度",但业务实际需要的是"短语级",指标和目标对不上。所以人工抽检这一步不能省。
提示:训练过程对内存消耗比推理大不少,且训练时
nthread开太多反而可能变慢。我建议先用 2 到 4 线程跑小规模验证,确认流程通了再全量跑。
6. 我的使用感受与选型建议
6.1 什么时候该用它,什么时候不该用
用了一周多,我的判断是这样的。
适合用的场景:一是领域术语密集的文本,比如垂直行业评论、专业问答,自定义词典加微调的组合效果非常好;二是需要词性标注、又不想引入重量级深度学习框架的轻量项目;三是批量离线处理,几万字几百万字一次性跑完,对首字延迟没有要求的;四是需要"可解释"分词边界的场景,因为它的特征模板相对透明,出问题时能推演。
不适合用的场景:一是对首字延迟极度敏感的在线接口,它的模型加载和单句推理成本比纯词典方案高一个量级;二是需要长期获得社区支持、BUG 修复、新特性的项目,这个工具已经很久没有版本迭代了,遇到环境兼容问题得自己动手;三是超大规模、需要分布式推理的场景,它不是为这个设计的;四是多语言混合文本,中文之外的语言它没有优势。
我最后在这个项目里的处理方式是"双通道":离线批量任务用 pkuseg 加微调模型,保证标签质量;在线接口用轻量的通用分词方案做兜底,保证响应速度。两者共用同一份自定义词典,行为尽量对齐。
6.2 工程化落地时的几个补救手段
考虑到这个工具维护状态,落地时必须做几层防护,这些都是我踩过之后补上的。
第一层是模型文件版本化管理。预训练模型和我自己训的模型都放进对象存储,带上哈希值,部署时按哈希拉取,避免"同样的镜像在不同机器上加载到不同模型"这种玄学问题。
第二层是降级路径。封装一个统一的分词接口,内部先试 pkuseg,加载失败或者超时就自动切到备用方案,并打日志。我遇到过模型文件因为容器层缓存被覆盖导致加载失败,如果没有降级,整个服务会直接起不来。
def segment(text): try: return _primary.cut(text) except Exception as e: logger.warning("primary segmenter failed: %s", e) return _fallback.cut(text)第三层是黄金测试集。维护一份 300 条左右的人工标注样本,每次升级模型或者调整词典,都跑一遍算指标,指标跌超过 1 个百分点就拒绝上线。这个做法听起来很重,但真正救过我一次——某次我无意中改了一个词典权重,整体指标只掉了 0.8 个点,但某个核心品类的术语整体崩了,黄金集里正好有这类样本,直接被拦下来。
第四层是输入清洗的前置。前面说过,清洗对效果的提升性价比最高。我把清洗做成独立模块,规则很土但很有效:全角转半角、连续标点折叠、表情符号替换、英文数字与中文之间补空格。这四条规则加上去,同一个模型的可读性和下游标签质量都有肉眼可见的提升。
还有个小技巧值得单独提一句:分词结果别直接进下游统计,先做一遍停用词和单字过滤。单字在标签抽取里几乎是纯噪声,尤其是"的""了""个""是"这类。加一个"长度大于等于 2"的过滤,标签质量立刻干净一截,成本几乎为零。
我在实际操作中的体会是,这类老牌但思路扎实的工具,价值不在于它是不是最先进的,而在于它的可改造性强。你愿意花两三天标一批数据、调一次模型,它就能给你一个贴合自己领域的、行为可预期的分词基线。反过来,如果你只是想要"装完就能用、且永远不用管",那它可能不是最省心的选择。