文本切分(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 的第三条规则。

参考链接

0