文本预处理:分词技术、停用词过滤与词形处理
文本预处理,说白了就是给搜索引擎喂饭前的「洗菜切菜」环节。你想想看,如果直接把「我喜欢吃苹果」扔给搜索引擎,它根本不知道「苹果」是一个词还是两个字。我做了这么多年搜索,见过太多因为预处理没做好,导致召回率惨不忍睹的案例。
今天咱们就聊聊文本预处理的三个核心环节:分词技术、停用词过滤、词干提取与词形还原。嗯,这里要注意,这三个步骤的顺序不能乱——先分词,再过滤,最后做词形处理。
一、分词技术:正向最大匹配、逆向最大匹配、双向最大匹配
分词是中文搜索的基石。英文天然有空格分隔,中文没有。所以我们必须用算法把句子切成有意义的词。
我个人习惯把分词算法分为两类:基于词典的和基于统计的。今天重点讲基于词典的最大匹配法,这是最经典、最直观的方法。
1. 正向最大匹配(FMM)
什么叫正向?就是从句子左边往右边切。最大匹配,就是每次切的时候,尽量切最长的词。
举个例子。词典里有「我们」「我们俩」「俩」「是」「好朋友」。句子是「我们俩是好朋友」。
正向最大匹配的流程:
- 从左边开始,取最大词长(假设是4个字),看「我们俩是」在不在词典?不在。
- 缩短为3个字,「我们俩」在词典里,切出来。
- 剩下「是好朋友」,取4个字「是好朋友」?不在。取3个字「好朋友」?在,切出来。
- 剩下「是」,单字词,切出来。
结果:我们俩 / 好朋友 / 是
核心要点:正向最大匹配依赖词典质量,词典越大、词越长,准确率越高。但有个致命问题——它倾向于切出长词,有时候会「贪心过头」。
我在项目中遇到过一个问题:用户搜索「南京市长江大桥」,正向最大匹配切成了「南京市 / 长江大桥」。但用户其实想搜的是「南京市长 / 江大桥」?嗯,这就是歧义。正向匹配解决不了这种问题。
2. 逆向最大匹配(RMM)
逆向匹配,就是从句子右边往左边切。原理一模一样,只是方向反了。
还是「我们俩是好朋友」。从右边开始:
- 取4个字「是好朋友」?不在词典。取3个字「好朋友」?在,切出来。
- 剩下「我们俩是」,取4个字「我们俩是」?不在。取3个字「我们俩」?在,切出来。
- 剩下「是」,单字词。
结果:好朋友 / 我们俩 / 是
你看,顺序反了。但词是一样的。不过对于「南京市长江大桥」,逆向匹配会怎么切?
从右边开始:取4个字「江大桥」?不在。取3个字「大桥」?在。剩下「南京市长江」,取4个字「市长江」?不在。取3个字「长江」?在。剩下「南京」,切出来。
结果:南京 / 长江 / 大桥
嗯,还是不对。但至少和正向匹配的结果不同。这就引出了第三种方法。
我的经验:逆向匹配在中文分词中,准确率通常略高于正向匹配。为什么?因为中文的「中心词」往往在右边。比如「好朋友」,「朋友」是中心,「好」是修饰。逆向匹配更容易抓住核心词。
3. 双向最大匹配(BMM)
双向匹配,就是把正向和逆向的结果都算出来,然后选一个「更好」的。
怎么选?规则很简单:
- 如果正反向结果一样,直接输出。
- 如果不一样,选词数更少的那个。
- 如果词数一样,选单字词更少的那个。
为什么选词数少的?因为分词的目标是「用最少的词表达最完整的意思」。词数越少,说明切得越「粗」,通常越准确。
举个例子,「他说的确实在理」。正向切:他 / 说的 / 确实 / 在理(4个词)。逆向切:他 / 说 / 的确 / 实在 / 理(5个词)。选正向。
我曾经踩过的坑:双向匹配不是万能的。遇到「结婚的和尚未结婚的」这种句子,正反向都切不好。正向切成「结婚 / 的 / 和 / 尚未 / 结婚 / 的」,逆向切出「结婚 / 的 / 和尚 / 未 / 结婚 / 的」。词数一样,单字词数也一样,算法就懵了。这种时候,必须引入统计模型或上下文信息。
代码实现其实不复杂。我贴一个简化版的正向最大匹配:
def forward_max_match(sentence, dictionary, max_len=4):
result = []
i = 0
while i < len(sentence):
matched = False
for j in range(min(max_len, len(sentence) - i), 0, -1):
word = sentence[i:i+j]
if word in dictionary:
result.append(word)
i += j
matched = True
break
if not matched:
result.append(sentence[i])
i += 1
return result
逆向匹配就是把循环方向反过来,从右往左扫。双向匹配就是调用两次,然后比较结果。
二、停用词过滤
分词之后,你会发现很多词对搜索毫无帮助。比如「的」「了」「是」「在」「一个」。这些词叫停用词。
为什么要过滤?两个原因:
- 节省空间:停用词在文档中出现的频率极高,索引它们会浪费大量存储。
- 提升精度:用户搜「中国的首都」,如果「的」被索引了,可能会召回「我的中国心」这种无关文档。
停用词表怎么建?我建议分三步:
- 通用停用词表:网上有现成的,比如哈工大停用词表、百度停用词表。直接拿来用。
- 领域停用词:根据你的业务场景定制。比如电商搜索,「包邮」「正品」可能不是停用词,但在新闻搜索里可能就是。
- 高频低价值词:统计你的文档集,把出现频率极高但信息量极低的词加进去。
注意:停用词过滤不是一刀切。有些场景下,「不」「没」这种否定词不能过滤。用户搜「不想要红色的」,如果把「不」过滤了,搜索结果就完全反了。
我曾经在做一个电商搜索项目时,发现「包邮」这个词被误加到了停用词表里。结果用户搜「包邮商品」什么都搜不到。排查了半天才发现是停用词表的问题。嗯,从那以后我养成了一个习惯——停用词表必须经过人工审核。
三、词干提取与词形还原
这两个概念经常被混淆。我简单解释一下:
| 特性 | 词干提取(Stemming) | 词形还原(Lemmatization) |
|---|---|---|
| 目标 | 去掉词缀,得到词干 | 还原为词典中的原形 |
| 结果 | 可能不是真实单词 | 一定是真实单词 |
| 例子 | running → runn | running → run |
| 复杂度 | 低,基于规则 | 高,需要词典和词性标注 |
| 速度 | 快 | 慢 |
中文需要词干提取吗?说实话,中文没有英文那种复杂的词形变化。中文的「跑」「跑了」「跑着」「跑过」——词干都是「跑」,但中文分词已经把它们当成不同词了。
所以,中文搜索通常不需要词干提取。但词形还原在中文里有个变种——繁简转换和同义词归一。
比如用户搜「计算机」,你希望也能召回「电脑」相关的文档。这就不是词形还原了,而是同义词扩展。我一般会在预处理阶段做同义词映射,把「计算机」「电脑」「PC」映射到同一个标准词上。
我的建议:对于中文搜索引擎,把精力花在分词和同义词扩展上,比花在词干提取上划算得多。英文搜索才需要重点考虑词干提取。别搞反了。
英文的词干提取,我常用Porter Stemmer。代码很简单:
from nltk.stem import PorterStemmer
stemmer = PorterStemmer()
print(stemmer.stem('running')) # runn
print(stemmer.stem('happiness')) # happi
你看,结果不是真实单词。但搜索引擎不在乎,它只需要把「running」「runs」「ran」都映射到同一个词干上,就能召回所有相关文档。
词形还原用WordNet Lemmatizer:
from nltk.stem import WordNetLemmatizer
lemmatizer = WordNetLemmatizer()
print(lemmatizer.lemmatize('running', 'v')) # run
print(lemmatizer.lemmatize('better', 'a')) # good
需要传入词性,否则默认当成名词处理。这就是为什么词形还原比词干提取慢——它需要先做词性标注。
性能考量:在搜索引擎的预处理流水线中,词干提取通常放在索引构建阶段,而不是查询阶段。因为索引构建可以离线做,慢一点没关系。查询阶段必须快,毫秒级响应。
好了,文本预处理的三个核心环节就讲完了。总结一下我的个人经验:
- 分词用双向最大匹配打底,遇到歧义再上统计模型。
- 停用词表要分通用和领域两层,定期更新。
- 中文搜索别折腾词干提取,把时间花在同义词扩展上。
下一章我们会聊倒排索引的构建与优化,这是搜索引擎的核心数据结构。到时候你会看到,今天做的这些预处理工作,最终都会体现在索引的质量上。