文档预处理:分词技术、停用词过滤与词干提取

文档预处理这一步,说白了就是给倒排索引「投喂」干净的数据。你想想看,如果原始文档里全是乱七八糟的标点、无意义的虚词,或者同一个词的不同形态满天飞,那索引建出来能好用吗?

我个人习惯把预处理分成三个核心环节:分词停用词过滤词干提取与词形还原。咱们一个一个来拆解。

中文分词:这事儿没那么简单

中文分词和英文分词完全是两码事。英文单词天然用空格隔开,中文呢?「我爱北京天安门」——计算机根本不知道「我爱」「北京」「天安门」哪个是词。

我在项目中遇到过最典型的坑:用户搜索「南京市长江大桥」,如果分词器不够聪明,可能给你切成「南京/市长/江大桥」。嗯,这结果就离谱了。

目前主流的中文分词方案有几种:

  • 基于词典的最大匹配法:简单粗暴,但遇到新词就歇菜
  • 基于统计的HMM/CRF模型:准确率更高,但需要标注语料
  • 基于深度学习的BERT/LSTM:效果最好,但资源消耗大

实际工程中,我建议优先考虑 jieba 分词HanLP。它们开箱即用,而且支持自定义词典。比如:

import jieba

text = "倒排索引是搜索引擎的核心数据结构"
words = jieba.lcut(text)
print(words)
# 输出: ['倒排', '索引', '是', '搜索引擎', '的', '核心', '数据结构']
小技巧:如果你的业务场景有专业术语(比如「倒排索引」),记得加载自定义词典。我习惯把行业词库单独维护一个文件,每次分词前先加载。

英文分词:看似简单,实则暗藏玄机

英文分词听起来简单——按空格和标点切分不就完了?但实际做起来,问题也不少。

举个例子:"I'm" 应该拆成 ["I", "am"] 还是保留原样?"state-of-the-art" 要不要拆开?"U.S.A." 这种缩写怎么处理?

我建议用 NLTKspaCy 的 tokenizer。它们内置了常见的缩写规则和特殊处理逻辑:

from nltk.tokenize import word_tokenize

text = "I'm building an inverted index for search engines."
tokens = word_tokenize(text)
print(tokens)
# 输出: ['I', "'m", 'building', 'an', 'inverted', 'index', 'for', 'search', 'engines', '.']
注意:NLTK 默认会把 "I'm" 拆成 ['I', "'m"]。如果你需要合并成 ['I', 'am'],得额外做一步处理。我曾经因为这个疏忽,导致索引里出现了大量 "'m" 这种垃圾 token,查了半天才发现。

停用词过滤:给索引做减法

停用词就是那些高频但没啥实际意义的词。中文里的「的」「了」「在」「是」,英文里的 "the" "a" "an" "is"。把它们过滤掉,索引体积能缩小 30%-50%。

为什么要这么做?你想想看,用户搜索「搜索引擎的优化」,如果保留「的」这个停用词,它会在几乎每篇文档里出现,对相关性计算毫无帮助,反而浪费存储空间和计算资源。

我一般这样处理:

stop_words = set(["的", "了", "在", "是", "the", "a", "an", "is", "are"])

def filter_stopwords(tokens):
    return [token for token in tokens if token not in stop_words]

tokens = ["倒排", "索引", "是", "搜索引擎", "的", "核心"]
filtered = filter_stopwords(tokens)
print(filtered)
# 输出: ['倒排', '索引', '搜索引擎', '核心']
核心要点:停用词表不是一成不变的。我建议根据你的业务场景动态调整。比如做电商搜索,「买」「价格」这种词可能就不是停用词。

词干提取与词形还原:让不同形态的词「归一」

英文里同一个词有各种形态:"running" "ran" "runs" 都来自 "run"。如果不做归一化,用户搜 "run" 就找不到包含 "running" 的文档。

这里有两个常用技术:

技术 原理 例子 优缺点
词干提取 粗暴地砍掉词缀 "running""run"
"studies""studi"
速度快,但结果可能不是合法单词
词形还原 基于词典还原到原形 "running""run"
"studies""study"
结果准确,但速度慢

我个人习惯:对精度要求高的场景用词形还原,对吞吐量要求高的场景用词干提取

举个例子:

from nltk.stem import PorterStemmer, WordNetLemmatizer

stemmer = PorterStemmer()
lemmatizer = WordNetLemmatizer()

words = ["running", "ran", "runs", "better", "studies"]

print("词干提取:", [stemmer.stem(w) for w in words])
# 输出: ['run', 'ran', 'run', 'better', 'studi']

print("词形还原:", [lemmatizer.lemmatize(w) for w in words])
# 输出: ['running', 'ran', 'run', 'better', 'study']
避坑指南:我曾经在一个搜索项目里用了词干提取,结果用户搜 "study" 时,索引里存的是 "studi",匹配不上。后来改成词形还原才解决问题。所以,如果你的用户会输入完整单词,建议用词形还原。

中文需要做词干提取吗?

这个问题我经常被问到。中文没有英文那种复杂的形态变化,所以一般不需要做词干提取或词形还原

但有一种情况例外:如果你的文档里混入了英文单词(比如技术文档里的 "indexing" "indexed"),那还是得对英文部分做归一化处理。

我建议的做法是:先做中文分词,然后对识别出的英文 token 单独做词干提取或词形还原。

完整的预处理流水线

好了,把上面这些串起来,一个完整的预处理流水线大概长这样:

def preprocess(text, language="zh"):
    # 1. 分词
    if language == "zh":
        tokens = jieba.lcut(text)
    else:
        tokens = word_tokenize(text)
    
    # 2. 停用词过滤
    tokens = [t for t in tokens if t not in stop_words]
    
    # 3. 词形还原(仅英文)
    if language == "en":
        tokens = [lemmatizer.lemmatize(t) for t in tokens]
    
    return tokens

嗯,这里要注意:流水线的顺序很重要。先分词,再过滤停用词,最后做归一化。如果顺序搞反了,比如先做词形还原再做停用词过滤,那 "running" 变成 "run" 后,可能就匹配不上停用词表里的 "running" 了。

一句话总结:文档预处理做得好,倒排索引的质量就成功了一半。分词要准、停用词要狠、归一化要稳——这三板斧砍下去,你的搜索引擎就赢在起跑线上了。