jieba分词原理解析:一个老牌中文分词器的工程智慧
最近在做中文NLP项目的时候,又用到了jieba这个老朋友。说起jieba,大概是每个做中文处理的程序员都绕不开的工具。简单一个jieba.cut()就能把中文文本切得明明白白,但你有没有好奇过,这背后到底是怎么实现的?
中文分词和英文不一样,英文有天然的空格分隔,而中文就是一串连续的字符。要把"我爱北京天安门"切成"我/爱/北京/天安门",看似简单,实际上需要很多巧妙的算法设计。今天就来扒一扒jieba的实现原理。
核心架构:模块化的设计思路
打开jieba的源码,你会发现它的结构其实挺清晰的:
jieba/
├── 分词引擎 # 主要的切分逻辑
├── 词典管理 # 各种词典的加载和查找
├── 词性标注 # 给词汇打标签
├── 关键词提取 # TF-IDF和TextRank
└── 性能优化 # 缓存、并行等
jieba的分词流程也不复杂,大概分这几步:
- 预处理:把文本规范化一下
- 词典匹配:用Trie树快速找可能的词
- DAG构建:把所有可能的切分方案列出来
- 最优路径:用动态规划找最好的切分
- 后处理:处理一些边界情况
听起来挺学术的,其实每一步都有很实用的工程考虑。
核心算法深度解析
Trie树:词典查找的效率神器
jieba用Trie树(字典树)来存词典,这个选择很聪明。为什么?因为中文分词需要大量的前缀匹配,而Trie树在这方面就是天生的好手。
# Trie树的节点结构,其实很简单
class TrieNode:
def __init__(self):
self.children = {} # 子节点,key是字符,value是子节点
self.is_word = False # 标记这里是不是一个完整的词
self.frequency = 0 # 词频,用来后面算权重
假设要存储"北京"和"北京大学",Trie树是这样的:
root
└── 北
└── 京 [is_word=True, freq=1000] # 这里"北京"是个词
└── 大
└── 学 [is_word=True, freq=500] # 这里"北京大学"也是个词
这样设计的好处是,当我们要匹配"北京大学很美"时,可以一次遍历就找到所有可能的词边界。
前缀匹配:找出所有可能的词
前缀匹配的逻辑就是暴力一点,从每个位置开始,看能匹配到多长:
def find_prefix_matches(text, trie):
"""找出文本中所有可能的词,返回(起始位置, 结束位置, 词)"""
matches = []
for i in range(len(text)):
node = trie.root
for j in range(i, len(text)):
char = text[j]
if char not in node.children:
break # 这条路走不通了
node = node.children[char]
if node.is_word:
# 找到一个词!
matches.append((i, j+1, text[i:j+1]))
return matches
比如对"北京大学很美",这个函数会返回:
- (0, 2, “北京”)
- (0, 4, “北京大学”)
- (2, 4, “大学”)
- …
现在我们有了所有可能的词,但问题来了:怎么从这么多种切分方案中选出最好的?
DAG + 动态规划:选出最优切分
这里jieba用了一个很经典的思路:把问题转换成图论问题。
构建DAG(有向无环图)
把所有可能的词当作图的边,位置当作节点:
def build_dag(text, matches):
"""基于匹配结果构建DAG"""
dag = [[] for _ in range(len(text) + 1)]
for start, end, word in matches:
dag[start].append((end, word)) # 从start到end有一条边,权重是word的得分
return dag
对"北京大学",DAG大概是这样:
0 --北京--> 2 --大学--> 4
0 ----北京大学-----> 4
现在问题变成了:从位置0到位置4,走哪条路径得分最高?
动态规划找最优路径
这就是经典的动态规划了,思路是:到达每个位置的最优分数 = 前面某个位置的最优分数 + 这条边的得分。
def viterbi_segmentation(dag, text):
"""动态规划求最优分词路径"""
n = len(text)
dp = [float('-inf')] * (n + 1) # dp[i]表示到位置i的最优得分
path = [0] * (n + 1) # 记录路径,用于回溯
dp[0] = 0 # 起点得分为0
# 动态规划过程
for i in range(n + 1):
if dp[i] == float('-inf'):
continue # 这个位置不可达
for end, word in dag[i]:
score = dp[i] + get_word_score(word) # 词频越高得分越高
if score > dp[end]:
dp[end] = score
path[end] = i
# 回溯构建结果
result = []
pos = n
while pos > 0:
prev_pos = path[pos]
result.append(text[prev_pos:pos])
pos = prev_pos
return result[::-1] # 逆序得到正确顺序
get_word_score函数一般基于词频计算,常见词得分高,生僻词得分低。这样就能保证切分结果比较符合常识。
jieba的三种分词模式
jieba提供了三种不同的分词模式,应对不同的使用场景。
精确模式:日常使用的默认选择
list(jieba.cut("我来到北京清华大学"))
# 输出: ['我', '来到', '北京', '清华大学']
这就是我们刚才分析的算法,通过动态规划找最优路径。优点是结果质量高,缺点是可能会漏掉一些有用的分词信息。
适用场景:日常的文本处理,比如搜索、推荐等
全模式:把所有可能的词都找出来
list(jieba.cut("我来到北京清华大学", cut_all=True))
# 输出: ['我', '来到', '北京', '清华', '清华大学', '华大', '大学']
全模式就是把所有能识别的词都输出,不管是否合理。这个模式计算量大(可能是指数级的),结果也冗余,但在某些场景下很有用。
适用场景:关键词提取的预处理、需要覆盖所有可能词汇的场合
搜索引擎模式:兼顾精确和召回
list(jieba.cut_for_search("小明硕士毕业于中国科学院计算所"))
# 输出: ['小明', '硕士', '毕业', '于', '中国', '科学', '学院', '科学院', '中国科学院', '计算', '计算所']
这个模式很聪明,先用精确模式切分,然后对长词再细分。这样既保持了基本的分词质量,又提供了更多的检索可能性。
比如"中国科学院"既保留了完整的机构名,又拆分出了"中国"、“科学院"等子词,这样用户搜索任何一个都能命中。
适用场景:搜索引擎、信息检索系统
词性标注:给每个词打标签
除了分词,jieba还能进行词性标注,告诉你每个词是名词、动词还是形容词。这用的是HMM(隐马尔可夫模型)。
import jieba.posseg as pseg
words = pseg.cut("我爱自然语言处理")
for word, flag in words:
print(f"{word} - {flag}")
# 输出:
# 我 - r (代词)
# 爱 - v (动词)
# 自然语言 - l (习语)
# 处理 - v (动词)
HMM的基本思路是:给定前面的词性,预测当前词的词性。这需要三个概率表:
- 初始概率:句子开头是某个词性的概率
- 转移概率:从一个词性转到另一个词性的概率
- 发射概率:某个词性下出现特定词汇的概率
然后用动态规划(又是它!)找出概率最大的词性序列。
这里的实现又是Viterbi算法,跟分词时用的动态规划思路一样:
def hmm_tagging(words, hmm_model):
"""用HMM给词序列标注词性"""
n = len(words)
tags = list(hmm_model.emission_probs.keys())
# dp[t][i]表示第t个词标注为第i个词性的最大概率
dp = [[0] * len(tags) for _ in range(n)]
path = [[0] * len(tags) for _ in range(n)] # 记录路径
# 初始化第一个词
for i, tag in enumerate(tags):
dp[0][i] = hmm_model.initial_probs.get(tag, 0) * \
hmm_model.emission_probs.get(tag, {}).get(words[0], 0)
# 动态规划填表
for t in range(1, n):
for i, current_tag in enumerate(tags):
max_prob = 0
best_prev = 0
# 枚举前一个词的所有可能词性
for j, prev_tag in enumerate(tags):
# 概率 = 前面的最优概率 × 转移概率 × 发射概率
prob = dp[t-1][j] * \
hmm_model.transition_probs.get(prev_tag, {}).get(current_tag, 0) * \
hmm_model.emission_probs.get(current_tag, {}).get(words[t], 0)
if prob > max_prob:
max_prob = prob
best_prev = j
dp[t][i] = max_prob
path[t][i] = best_prev
# 回溯得到最优词性序列
best_path = []
current_tag = max(range(len(tags)), key=lambda i: dp[n-1][i])
for t in range(n-1, -1, -1):
best_path.append(tags[current_tag])
current_tag = path[t][current_tag]
return best_path[::-1]
看出来了吗?不管是分词还是词性标注,jieba都很喜欢用动态规划这个万能工具。
关键词提取:找出文档的核心概念
jieba不只是分词,还能帮你从一篇文档中提取关键词。主要有两种算法:
TF-IDF:经典的统计方法
TF-IDF的思路很直观:一个词在当前文档中出现频率高,但在所有文档中出现频率低,那它就很可能是关键词。
import jieba.analyse
text = "程序员小明在北京的互联网公司工作,每天都要写Python代码"
keywords = jieba.analyse.extract_tags(text, topK=5)
print(keywords)
# 输出: ['Python', '程序员', '小明', '代码', '互联网']
实现起来就是经典的TF-IDF公式:
def tfidf_keywords(text, top_k=10):
"""TF-IDF关键词提取"""
word_freq = {}
words = list(jieba.cut(text))
total_words = len(words)
# 计算词频(TF)
for word in words:
if len(word.strip()) > 1: # 过滤标点和单字
word_freq[word] = word_freq.get(word, 0) + 1
# 计算TF-IDF得分
tfidf_scores = {}
for word, freq in word_freq.items():
tf = freq / total_words
idf = get_idf_score(word) # 从预训练的IDF表中获取
tfidf_scores[word] = tf * idf
# 返回得分最高的top_k个词
return sorted(tfidf_scores.items(), key=lambda x: x[1], reverse=True)[:top_k]
TextRank:把PageRank用到文本上
TextRank借鉴了Google的PageRank思想:把词汇看作网页,词与词之间的共现关系看作链接。如果一个词经常和其他重要的词一起出现,那它自己也很重要。
keywords = jieba.analyse.textrank(text, topK=5)
print(keywords)
# 输出可能和TF-IDF不同,因为考虑的是词之间的关系
TextRank的核心是构建一个词汇共现图:
def textrank_keywords(text, top_k=10, window_size=4):
"""TextRank关键词提取"""
words = [w for w in jieba.cut(text) if len(w.strip()) > 1]
# 构建共现图:在窗口内一起出现的词有边相连
graph = {}
for i, word in enumerate(words):
if word not in graph:
graph[word] = {}
# 看看这个词的前后window_size个词
start = max(0, i - window_size)
end = min(len(words), i + window_size + 1)
for j in range(start, end):
if i != j:
neighbor = words[j]
if neighbor not in graph[word]:
graph[word][neighbor] = 0
graph[word][neighbor] += 1 # 共现次数作为边权重
# 运行PageRank算法
scores = pagerank_iteration(graph)
return sorted(scores.items(), key=lambda x: x[1], reverse=True)[:top_k]
def pagerank_iteration(graph, max_iter=100, damping=0.85):
"""迭代计算PageRank得分"""
scores = {word: 1.0 for word in graph}
for _ in range(max_iter):
new_scores = {}
for word in graph:
# PageRank公式:(1-d)/N + d * Σ(PR(neighbor)/L(neighbor))
new_score = (1 - damping) / len(graph)
for neighbor, weight in graph[word].items():
if neighbor in scores:
# 邻居的贡献 = 邻居的得分 / 邻居的出度
neighbor_out_degree = len(graph[neighbor])
new_score += damping * scores[neighbor] / neighbor_out_degree
new_scores[word] = new_score
scores = new_scores
return scores
相比TF-IDF,TextRank更能发现文档中的潜在主题词,因为它考虑了词与词之间的语义关系。
性能优化:工程实践的智慧
jieba不只是算法牛,工程优化也做得很到位。
并行处理:多核时代的必然选择
当你要处理大量文本时,jieba支持多进程并行:
import jieba
jieba.enable_parallel(4) # 开启4进程并行
# 现在jieba.cut()会自动并行处理
# 也可以手动并行处理大批量文本
from multiprocessing import Pool
def batch_segment(texts):
with Pool(4) as pool:
results = pool.map(lambda text: list(jieba.cut(text)), texts)
return results
缓存机制:避免重复计算
jieba内部实现了多层缓存:
# jieba内部的缓存逻辑大概是这样的
class SegmentationCache:
def __init__(self, max_size=10000):
self.cache = {}
self.max_size = max_size
self.access_count = {}
def get(self, text):
if text in self.cache:
self.access_count[text] += 1
return self.cache[text]
return None
def put(self, text, result):
if len(self.cache) >= self.max_size:
# LFU淘汰策略:删除访问次数最少的
least_used = min(self.access_count.items(), key=lambda x: x[1])[0]
del self.cache[least_used]
del self.access_count[least_used]
self.cache[text] = result
self.access_count[text] = 1
对于相同的文本,第二次分词几乎是瞬间完成的。
词典优化:让查找更快
jieba还在词典存储上下了不少功夫:
- 分层词典:把常用词和生僻词分开存储
- 前缀索引:快速定位可能的匹配
- 词频缓存:避免重复的词频计算
这些优化让jieba在处理大规模文本时依然保持高效。
实际应用:把jieba用起来
在实际项目中,jieba通常不是单独使用,而是作为文本处理流水线的一部分:
import jieba
import jieba.posseg as pseg
import jieba.analyse
class TextProcessor:
def process_article(self, text):
"""一站式文本处理"""
# 分词
words = list(jieba.cut(text))
# 词性标注
pos_tags = list(pseg.cut(text))
# 关键词提取
keywords_tfidf = jieba.analyse.extract_tags(text, topK=10)
keywords_textrank = jieba.analyse.textrank(text, topK=10)
# 实体识别(基于词性)
entities = self.extract_entities(pos_tags)
return {
'words': words,
'pos_tags': pos_tags,
'keywords_tfidf': keywords_tfidf,
'keywords_textrank': keywords_textrank,
'entities': entities
}
def extract_entities(self, pos_tags):
"""从词性标注中提取命名实体"""
entities = {'persons': [], 'locations': [], 'organizations': []}
for word, pos in pos_tags:
if pos == 'nr': # 人名
entities['persons'].append(word)
elif pos == 'ns': # 地名
entities['locations'].append(word)
elif pos == 'nt': # 机构名
entities['organizations'].append(word)
return entities
这样一个简单的处理器就能从原始文本中提取出丰富的结构化信息,为后续的搜索、推荐、分析等任务提供基础。
总结:jieba给我们的启发
通过深入分析jieba的实现,我觉得有几个点特别值得学习:
算法选择的智慧
jieba没有追求最新最炫的算法,而是选择了最合适的:
- Trie树:查找效率高,实现简单
- 动态规划:既用于分词又用于词性标注,一招鲜吃遍天
- 传统统计方法:TF-IDF和TextRank至今依然好用
工程优化的重要性
光有好算法还不够,jieba在工程实现上的优化同样出色:
- 缓存机制避免重复计算
- 并行处理充分利用多核CPU
- 分层存储提高查找效率
接口设计的用户友好
import jieba
result = jieba.cut("我爱自然语言处理") # 就这么简单
简单的API背后是复杂的算法实现,但用户不需要关心。这种设计理念值得所有开发者学习。
最后的感想
jieba虽然诞生于深度学习兴起之前,但到现在依然是中文NLP的主力工具。这说明了什么?技术的价值不在于新,而在于解决实际问题。
当然,现在有了BERT、ChatGPT这些更强大的模型,但在很多场景下,jieba依然是性价比最高的选择。毕竟,不是所有的钉子都需要用大锤来敲。
如果你对NLP有兴趣,建议去读读jieba的源码。虽然只有几千行Python代码,但里面的算法思想和工程实践都很值得学习。