文档预处理:分词技术、停用词过滤与词干提取
文档预处理这一步,说白了就是给倒排索引「投喂」干净的数据。你想想看,如果原始文档里全是乱七八糟的标点、无意义的虚词,或者同一个词的不同形态满天飞,那索引建出来能好用吗?
我个人习惯把预处理分成三个核心环节:分词、停用词过滤、词干提取与词形还原。咱们一个一个来拆解。
中文分词:这事儿没那么简单
中文分词和英文分词完全是两码事。英文单词天然用空格隔开,中文呢?「我爱北京天安门」——计算机根本不知道「我爱」「北京」「天安门」哪个是词。
我在项目中遇到过最典型的坑:用户搜索「南京市长江大桥」,如果分词器不够聪明,可能给你切成「南京/市长/江大桥」。嗯,这结果就离谱了。
目前主流的中文分词方案有几种:
- 基于词典的最大匹配法:简单粗暴,但遇到新词就歇菜
- 基于统计的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." 这种缩写怎么处理?
我建议用 NLTK 或 spaCy 的 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', '.']
"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" 了。