Python 原生实现文本(句子/词语)切分
文本切分(Text Segmentation)就是把一段连续的文字按规则切成更小的单元——先切成句子,再在每个句子内部切成词语,它是几乎所有文本分析任务(情感分析、关键词提取、机器翻译)的第一步,也是中文自然语言处理中最经典的基础问题。

很多同学一开始就上 jieba、NLTK 这些库,一句 jieba.cut() 就把词分好了,却说不清分词器内部到底在做什么。本文换一种方式:只用 Python 标准库(re、unicodedata)从零手写文本切分,分别处理英文和中文,覆盖两层结构——句子切分与词语切分。我们分五步来看:先认识”两级切分”的结构与两种语言的难点,然后依次实现英文句子切分、中文句子切分、英文词语切分、中文词语切分,最后把组件组装成一个完整的原生切分器。文中所有代码都完整可运行,复制到任意 Python 3.9+ 环境即可直接跑。
切分的两级结构:先句子,后词语
英文切分的难点在”标点歧义”,中文切分的难点在”没有空格”——这句话是理解全文的总纲。
原始文本是一串连续的字符。第一层切分(句子切分,sentence segmentation)以句子为单位划出边界,边界信号通常是标点;第二层切分(词语切分,word segmentation / tokenization)在每个句子内部继续切,产出词或更小的 token。为什么要先切句子再切词?因为翻译、摘要、情感分析等下游任务都以句子为处理单元,而且先划出句子边界,可以避免跨句子切出毫无意义的词组合——比如把”A. Smith. He”里的句点误当作缩写的一部分,导致两个句子被错误地粘在一起。
| 对比维度 | 英文 | 中文 |
| 词间分隔 | 天然有空格 | 没有空格,词界全靠判断 |
| 句子边界信号 | . ! ?,但句号有歧义 | 。!?… 等显式标点 |
| 主要难点 | 缩写词句点、小数点、省略号造成的假阳性 | 词边界完全隐形,需要词典 |
| 常用策略 | 规则 + 缩写词表(或统计模型) | 词典 + 最大匹配(或统计模型) |

需要说明的是,本文的”原生实现”定位是讲原理:每一段代码都力求短小、可读、可验证。生产环境直接调用成熟库更稳妥,但理解这些规则的局限,恰恰是正确使用成熟库的前提。
句子切分:找到真正的结束符
句子切分本质上是两个动作:找出句子结束符,并排除”假阳性”——英文要小心缩写词的句点,中文则基本可以信任标点。
英文句子切分:排除缩写词的假阳性
英文句子的边界几乎总是标点:句号、问号、感叹号。最朴素的实现是用正则 re.split(r'(?<=[.!?])\s+’, text)——在结束符之后、空白之前切一刀。但拿到真实文本,它立刻翻车:
import re text = "Dr. Smith arrived at 3.14 p.m. He was very happy!" sentences = re.split(r'(?<=[.!?])\s+', text) print(sentences) # ['Dr.', 'Smith arrived at 3.14', 'p.m.', 'He was very happy!']
三个错误都出在句号上:”Dr.”被切开(它只是称谓缩写)、”3.14″被切开(那是小数点)、”p.m.”被切开(时间缩写)。这些句点并不是句子结束,却被当成了结束符——这就是”假阳性”。
改进思路很直接:维护一张缩写词表,遇到句点时先回看它前面的内容——如果前一词在缩写词表里、是单个字母(如 U.S.A. 里的 U)、或左右都是数字(小数点),就跳过不切:
# 常见缩写词:句点不是句子结束
ABBREVIATIONS = {
"dr", "mr", "mrs", "ms", "mx", "prof", "sr", "jr", "st",
"vs", "etc", "inc", "ltd", "co", "corp", "fig", "no", "vol",
"approx", "dept", "est", "min", "max", "avg",
"jan", "feb", "mar", "apr", "jun", "jul", "aug", "sep", "oct", "nov", "dec",
"mon", "tue", "wed", "thu", "fri", "sat", "sun",
}
def is_abbreviation(text: str, dot_pos: int) -> bool:
"""判断 dot_pos 处的句点是否属于缩写(而非句子结束)。"""
# 1) 小数点:左右都是数字,如 3.14
if (dot_pos > 0 and text[dot_pos - 1].isdigit()
and dot_pos + 1 < len(text) and text[dot_pos + 1].isdigit()):
return True
# 2) 取句点前最近的字母词
word_start = dot_pos
while word_start > 0 and text[word_start - 1].isalpha():
word_start -= 1
word = text[word_start:dot_pos].lower()
# 3) 单个字母缩写,如 U.S.A. 中的 U、S
if len(word) == 1:
return True
# 4) 常见缩写词表
return word in ABBREVIATIONS
def split_sentences_en(text: str) -> list[str]:
"""英文句子切分:识别句子结束符,排除缩写词的假阳性。"""
text = text.strip()
if not text:
return []
sentences = []
start = 0
i = 0
while i < len(text): # 逐个字符扫描
ch = text[i]
if ch in ".!?":
# 找到连续结束符的末尾,如 ...、!?、?!
j = i
while j < len(text) and text[j] in ".!?":
j += 1
# 句点是缩写词的一部分 → 不在这里切分
if ch == "." and is_abbreviation(text, i):
i = j
continue
sentences.append(text[start:j].strip())
start = j
i = j
else:
i += 1
tail = text[start:].strip()
if tail:
sentences.append(tail)
return sentences
核心逻辑只有一句话:句子切分的全部功夫,都花在”区分真句号与假句号”上。三个规则依次检查(小数点 → 单字母 → 缩写词表),命中任一就说明这个句点属于缩写,跳过;否则在此处切分。连续标点(…、!?)被当作一个整体处理,避免切出空片段。来看效果:
text = "Dr. Smith works at the National Lab in Boston. He is very happy! Is it true?" print(split_sentences_en(text)) # ['Dr. Smith works at the National Lab in Boston.', 'He is very happy!', 'Is it true?'] text = "Hello... Is anyone there? Yes!" print(split_sentences_en(text)) # ['Hello...', 'Is anyone there?', 'Yes!']

这套规则仍会犯错:比如”It’s 3 p.m. He left.”,单字母规则会把 p.m. 后面的句点误判为缩写而不切分。要彻底解决这类问题,需要像 NLTK 的 Punkt 那样用统计模型判断”句点后是缩写还是新句首”。规则法是不完美的,但理解它,你就知道统计模型在解决什么问题。
中文句子切分:标点就是边界
中文句子的边界几乎完全由显式标点标记:句号 。、感叹号 !、问号 ?、省略号 ……。中文没有”用句点做缩写”的习惯,也没有英文那种大小写提示,所以句子切分反而比英文简单:按句末标点切分即可,并把标点保留在句子末尾(下游分词常常还需要它):
import re
def split_sentences_zh(text: str) -> list[str]:
"""中文句子切分:按句末标点切分,并保留标点。"""
pattern = re.compile(r'[。!?…]+') # … 也覆盖连续省略号 ……
parts = pattern.split(text)
ends = pattern.findall(text)
sentences = []
for i, part in enumerate(parts):
if not part.strip():
continue
if i < len(ends):
sentences.append(part + ends[i])
else:
sentences.append(part)
return sentences
text = "今天天气真好。他决定去公园散步!你觉得呢?也许吧……让我们看看。"
for s in split_sentences_zh(text):
print(s)
# 今天天气真好。
# 他决定去公园散步!
# 你觉得呢?
# 也许吧……
# 让我们看看。
实现上借助了正则的两个动作:split 把文本按标点切成若干段,findall 把切掉的标点原样收集回来,然后按位置”段落 + 标点”拼回完整句子。这样既切了句,又不丢标点。
一个要注意的边角:引号内的句号也会被切分,比如”他说:’我很好。’然后离开了。”会切成”他说:’我很好。”和”‘然后离开了。”。对大多数任务这无伤大雅(引号内确实是一个完整句子),但如果你要识别”某某说”这类前置引语结构,就需要额外处理引号配对,这里不再展开。
| 维度 | 英文 | 中文 |
| 边界标点 | . ! ? | 。!?… |
| 最大干扰 | 缩写词句点、小数点、省略号 | 引号内嵌套(影响较小) |
| 判断依据 | 标点 + 回看前文 | 标点本身 |
| 相对难度 | 中(规则法有边角案例) | 低 |
英文词语切分:空格只是起点
英文词切分最朴素的做法是 text.split() 按空白切——但真实文本里标点与词粘连(”Hello,”),所以要用正则把”词”和”标点”分别提取出来。先看三种策略的差别:
text = "Hello, world! It's a nice day, isn't it? The value is 3.14."
# 策略一:按空白切分 —— 标点还粘在词上
print(text.split())
# ['Hello,', 'world!', "It's", 'a', 'nice', 'day,', "isn't", 'it?', 'The', 'value', 'is', '3.14.']
# 策略二:只提取词 —— 干净,但丢掉标点、拆散数字
import re
print(re.findall(r"[A-Za-z']+", text))
# ['Hello', 'world', "It's", 'a', 'nice', 'day', "isn't", 'it', 'The', 'value', 'is', '3', '14']
策略二把 3.14 拆成了 '3' 和 '14',因为模式只认字母和撇号。要同时保留数字、词和标点,就用"三路正则"——把 token 分成三类,各配一个匹配分支:
import re
# 三类 token:英文词(含撇号)| 数字(含小数)| 单个标点
TOKEN_RE = re.compile(
r"[A-Za-z']+" # 词:It's、don't
r"|[0-9]+(?:\.[0-9]+)?" # 数字:3.14、1024
r"|[^\sA-Za-z0-9']" # 其余非空白字符,逐个成 token(标点)
)
def tokenize_en(text: str) -> list[str]:
"""英文词语切分:词、数字、标点各归其位。"""
return TOKEN_RE.findall(text)
def normalize_en(token: str) -> str:
"""小写 + 去掉首尾引号,统一形态。"""
return token.lower().strip("'\"")
tokens = tokenize_en("Hello, world! It's a nice day, isn't it? The value is 3.14.")
print(tokens)
# ['Hello', ',', 'world', '!', "It's", 'a', 'nice', 'day', ',', "isn't", 'it', '?', 'The', 'value', 'is', '3.14', '.']
print([normalize_en(t) for t in tokens])
# ['hello', ',', 'world', '!', "it's", 'a', 'nice', 'day', ',', "isn't", 'it', '?', 'the', 'value', 'is', '3.14', '.']

原理是 re.findall 会在每个位置按”词 → 数字 → 标点”的顺序尝试第一个能匹配的分支,并自动跳过空白。撇号被保留在词内部(don’t 是一个 token),首尾引号则在归一化时去掉。这样得到的 token 序列,词、数字、标点各归其位,下游做词频统计或建模时信息一点不丢。
| 策略 | 做法 | 优点 | 缺点 |
| 按空白切分 | text.split() | 最快,零成本 | 标点粘在词上,数字带标点 |
| 只提取词 | re.findall(r”[A-Za-z’]+”) | 结果干净 | 丢标点,小数被拆散 |
| 词 + 数字 + 标点 | 三路正则 findall | 信息完整,各归其位 | 需要一点正则功底 |
中文词语切分:没有空格,就用词典匹配
中文分词的核心思想是:准备一本词典,用”最长匹配”原则在句子里找词——这是最经典的规则法,也是 jieba 这类现代工具的地基。英文的词与词之间有空格,边界是”看得见的”;中文没有,所以边界只能靠先验知识——词典来推断。给定词典和句子,从某个位置开始,尝试用词典里尽可能长的词去匹配:匹配到就把词切出来,指针前移;一个都匹配不到,就把当前这一个字作为词(单字兜底,保证不丢字)。
正向最大匹配:贪婪地从左往右
正向最大匹配(Forward Maximum Matching,FMM)是规则法最基础的实现。它维护一个”窗口”,窗口长度从设定的最大值(max_len,一般 3~5 个字)开始,逐字缩短,直到窗口内的字符串命中词典:
def forward_max_match(text: str, word_dict: set, max_len: int = 4) -> list[str]:
"""正向最大匹配(FMM):从左往右,优先切出最长的词。"""
tokens = []
i = 0
n = len(text)
while i < n:
matched = None
# 窗口从 max_len 开始逐渐缩小,找第一个命中词典的词
for length in range(min(max_len, n - i), 0, -1):
word = text[i:i + length]
if word in word_dict:
matched = word
break
if matched: # 词典命中 → 切词并前进
tokens.append(matched)
i += len(matched)
else: # 未命中 → 单字兜底,只前进一格
tokens.append(text[i])
i += 1
return tokens
word_dict = {"南京", "南京市", "长江", "大桥", "市长", "江大桥"}
print(forward_max_match("南京市长江大桥", word_dict, max_len=4))
# ['南京市', '长江', '大桥']

配合上图看代码:第 1 步窗口先盖住 4 个字”南京市长”(不在词典),缩到 3 个字”南京市”命中,输出后指针跳到第 3 个字符;第 2 步”长江大桥””长江大”都没命中,”长江”命中;第 3 步”大桥”命中,扫描结束。整个过程是”贪心”的——永远优先取最长的词。
逆向最大匹配与词典歧义
FMM 有个固有毛病:它永远偏向”左边尽量长”。当”左边长词 + 右边单字”和”左边短词 + 右边成词”两种切法都成立时,它可能选错。最著名的例子是”南京市长江大桥”:如果词典里恰好有”市长”和”江大桥”,逆向切分就会得出完全不同的错误结果。逆向最大匹配(Backward Maximum Matching,BMM)实现几乎与 FMM 对称,只是从右往左扫描:
def backward_max_match(text: str, word_dict: set, max_len: int = 4) -> list[str]:
"""逆向最大匹配(BMM):从右往左,优先切出最长的词。"""
tokens = []
i = len(text)
while i > 0:
matched = None
for length in range(min(max_len, i), 0, -1):
word = text[i - length:i]
if word in word_dict:
matched = word
break
if matched:
tokens.append(matched)
i -= len(matched)
else:
tokens.append(text[i - 1])
i -= 1
return tokens[::-1] # 倒序切分,最后反转回来
word_dict = {"南京", "南京市", "长江", "大桥", "市长", "江大桥"}
print(backward_max_match("南京市长江大桥", word_dict, max_len=4))
# ['南京', '市长', '江大桥'] # 错了!真正的词是 南京市 / 长江 / 大桥

同一本词典、同一个句子,两个方向给出不同结果——这就是”词典歧义”。有意思的是,两个方向各有输赢:
| 例句 | 词典中的相关词 | FMM 结果 | BMM 结果 | 正确切分 |
| 南京市长江大桥 | 南京市、长江、大桥、市长、江大桥 | 南京市 / 长江 / 大桥 ✓ | 南京 / 市长 / 江大桥 ✗ | 南京市 / 长江 / 大桥 |
| 研究生命 | 研究、研究生、生命 | 研究生 / 命 ✗ | 研究 / 生命 ✓ | 研究 / 生命 |
FMM 在”南京市长江大桥”上赢(词典里没有”南京市长”),BMM 在”研究生命”上赢(”研究生 + 命”明显不如”研究 + 生命”)。结论是:没有哪个方向总是对的——这为双向最大匹配埋下了伏笔。
双向最大匹配:让两个方向互相纠错
既然两个方向各有失误,那就两个方向都跑一遍,再用启发式规则裁决。常用的裁决规则有三条:
- 词数更少的切分更可信——切出的块越少,通常越接近真实的词边界;
- 词数相同时,单字词更少的更可信——单字多半是”单字兜底”的产物,是不得已的选择;
- 仍然打平,默认取正向结果(FMM 是实际使用中的默认偏好)。
def bidirectional_match(text: str, word_dict: set, max_len: int = 4) -> list[str]:
"""双向最大匹配(BM):两个方向互相纠错。"""
fmm = forward_max_match(text, word_dict, max_len)
bmm = backward_max_match(text, word_dict, max_len)
if len(fmm) != len(bmm): # 规则 1:词数少者胜
return fmm if len(fmm) < len(bmm) else bmm
f_single = sum(1 for w in fmm if len(w) == 1)
b_single = sum(1 for w in bmm if len(w) == 1)
if f_single != b_single: # 规则 2:单字词少者胜
return fmm if f_single < b_single else bmm
return fmm # 规则 3:打平取正向
word_dict = {"研究", "研究生", "生命"}
print(bidirectional_match("研究生命", word_dict, max_len=4))
# ['研究', '生命'] # BM 纠正了 FMM 的错误

注意 BM 也不是万能的:两条规则都打平时它只能”赌”一个方向,而且规则本身是启发式经验,不是语言学理论。真正可靠地消解歧义,需要统计语言模型——这正是 jieba 用前缀词典 + 动态规划 + HMM 所做的事情,规则法是它的地基。
未登录词:词典之外的词怎么办
规则法有一个天花板:词典里没有的词永远切不出来——人名、地名、新词、专业术语统称”未登录词”(Out-Of-Vocabulary,OOV)。词典匹配到这些词时只能落入单字兜底,把”林黛玉”切成”林 / 黛 / 玉”。应对办法有三个层次:
- 单字兜底:词典未命中就单字成词,保证结果不丢字(前文代码已经实现);
- 更大的词典:词典越大,未命中越少,但存储和匹配成本上升;
- 统计方法:靠”字与字共现的频率”推断成词,比如互信息、左右邻接熵——jieba 的 HMM 就是干这个的。

还有一个不需要增加词典的提速思路:给词典建一个”真前缀集”,匹配时可以快速剪枝——如果某个前缀根本不可能成为词,就不用再试更长的窗口了:
def build_prefixes(word_dict: set) -> set:
"""提取词典中所有词的真前缀,供匹配时快速剪枝。"""
prefixes = set()
for word in word_dict:
for i in range(1, len(word)):
prefixes.add(word[:i])
return prefixes
# 例:{"南京市", "长江"} → {"南", "南京", "长"}
组装:一个完整的原生切分器
把前面所有组件拼起来,就是一个”文本 → 句子 → 词”的两阶段流水线,全部只用标准库,可以直接运行:

完整流水线:句子切分 → 粗切块(英文词 / 数字 / 中文块 / 标点)→ 中文块送 FMM → 合并成最终 token 序列。
ZH_DICT = {"我", "今天", "天气", "真好", "武汉市", "武汉", "长江",
"大桥", "位于", "坐", "高铁", "去", "小时", "后", "到"}
def segment_document(text: str, lang: str = "zh") -> list[list[str]]:
"""两阶段切分:先句子,后词语。返回「句子 → 词」的二维结构。"""
if lang == "zh":
sentences = split_sentences_zh(text)
return [forward_max_match(s, ZH_DICT) for s in sentences]
sentences = split_sentences_en(text)
return [[normalize_en(t) for t in tokenize_en(s)] for s in sentences]
zh_text = "武汉市长江大桥位于武汉。今天天气真好!"
for i, sentence in enumerate(segment_document(zh_text), 1):
print(f"句子{i}: {sentence}")
# 句子1: ['武汉市', '长江', '大桥', '位于', '武汉', '。']
# 句子2: ['今天', '天气', '真好', '!']
en_text = "Dr. Smith works at the National Lab in Boston. He is very happy! Is it true?"
for i, sentence in enumerate(segment_document(en_text, "en"), 1):
print(f"Sentence {i}: {sentence}")
# Sentence 1: ['dr', '.', 'smith', 'works', 'at', 'the', 'national', 'lab', 'in', 'boston', '.']
# Sentence 2: ['he', 'is', 'very', 'happy', '!']
# Sentence 3: ['is', 'it', 'true', '?']
注意中文输出里的 ‘。’ 和 ‘!’:它们不在词典里,是”单字兜底”分支的产物——这也从侧面验证了兜底逻辑在起作用。如果只想保留纯词,加一步按 Unicode 类别过滤标点即可:
import unicodedata
def is_punct(ch: str) -> bool:
"""判断字符是否属于 Unicode 标点类别(P 开头)。"""
return unicodedata.category(ch).startswith("P")
sentence_tokens = segment_document("武汉市长江大桥位于武汉。")[0]
# ['武汉市', '长江', '大桥', '位于', '武汉', '。']
pure_words = [t for t in sentence_tokens if not (len(t) == 1 and is_punct(t))]
# ['武汉市', '长江', '大桥', '位于', '武汉']
中英混排怎么办
真实文本常常中英混排,比如”我今天坐 G102 次高铁去武汉,3.5 小时后到。”。规则法的一个实用做法:先用一条正则把文本切成”英文词 / 数字 / 连续中文块 / 标点”四类块,中文块再送进 FMM:
MIXED_RE = re.compile(
r"[A-Za-z0-9']+(?:\.[0-9]+)?" # 英文词与数字(含小数)
r"|[\u4e00-\u9fff]+" # 连续中文
r"|[^\s\u4e00-\u9fffA-Za-z0-9']" # 单个标点
)
def tokenize_mixed(text: str) -> list[str]:
"""中英混排:粗切块 → 中文块再 FMM。"""
blocks = MIXED_RE.findall(text)
tokens = []
for block in blocks:
if re.fullmatch(r"[\u4e00-\u9fff]+", block):
tokens.extend(forward_max_match(block, ZH_DICT, max_len=4))
else:
tokens.append(block)
return tokens
print(tokenize_mixed("我今天坐高铁去武汉,3.5小时后到。"))
# ['我', '今天', '坐', '高铁', '去', '武汉', ',', '3.5', '小时', '后', '到', '。']
这条正则本质上是把前面英文、中文两套逻辑统一到一个模式里:英文词与数字整块保留,中文连续块交给词典匹配,标点单列。这就是原生实现”够用即可”的典型姿态——先把结构拆出来,再逐块细化。
结论
回顾全文,我们只用了标准库就完成了四件事:英文句子切分(排除缩写词假阳性)、中文句子切分(信任显式标点)、英文词语切分(三路正则提取词与标点)、中文词语切分(词典 + FMM / BMM / 双向最大匹配)。四件事背后是同一个思想:切分 = 定义边界 + 处理例外。英文的例外是假句号,中文的例外是隐形词界,而规则法面对例外的共同对策,是”词典 + 启发式”。
这套原生实现存在两个现实局限:一是词典歧义只能靠启发式部分解决;二是未登录词(人名、新词)无法靠词典覆盖。当文本规模变大、领域变专时,就该换成熟工具了:
- jieba:中文分词的事实标准,前缀词典 + 动态规划 + HMM,cut()一行搞定,还能 add_word 扩充领域词;
- NLTK:学术生态最全,其 Punkt 句子切分器正是前文提到的”统计版句子切分”;
- spaCy:工业级流水线,句子、词、词性一步到位,支持多种语言;
- HanLP:中文 NLP 全家桶,适合更重的中文任务。
但正如开头所说,用原生 Python 手写一遍文本切分,不是为了替代 jieba,而是为了真正理解”分词到底在解决什么问题”——边界、歧义、未登录词,这三个词贯穿所有自然语言处理的预处理环节。建议拿到本文代码后做三件事:
- 把缩写词表扩充成你自己领域里的词表(比如单位、机构简称);
- 把ZH_DICT 换成 jieba 的词典文件,对比准确率变化;
- 构造一个 FMM 与 BMM 都打平的句子,验证 BM 的第三条规则。
参考链接
- jieba 结巴分词官方仓库,https://github.com/fxsjy/jieba
- NLTK 文档:Punkt 句子切分器,https://www.nltk.org/api/nltk.tokenize.punkt.html





