指出传统NLP预处理(lowercasing、去停用词、stemming等)是为稀疏词袋模型压缩设计的,直接用于LLM会删除有用信号,需区别对待。
Every NLP 教程里的预处理流水线——小写化、去标点、删停用词、词干提取——原本是为了让稀疏的词袋模型能跑起来。把它的输出喂给语言模型,等于在删除信号,白白浪费。
经典顺序是:解码成文本、小写化、剥除标点、按空白字符分词、丢弃停用词、词干提取、然后计数。每一步的存在都是为了压缩词表。2005 年这至关重要:在一个包含百万文档、20 万词项的词袋模型上,内存是真实存在的问题,而把 Running、running 和 runs 压缩成一个特征,能让矩阵更小、计数更密集。这些步骤是压缩,它们在那个目标是正确的。
这套推理跟使用学来的子词词表的模型完全不沾边。词表大小在 3 万到 20 万个词元之间是固定的,跟你送什么进去无关;大小写是模型训练时就学到的信息;标点也承载句法。你不是在压缩,你是在删除证据。
小写化。它摧毁了英文实体识别最强的一个信号——大小写。apple 和 Apple 是不同的东西,现代分词器故意把它们的表示弄得不一样。
标点剥离。句子边界、引号内的对话、否定范围和代码,全都存在于标点里。去掉标点会把两个句子变成一个不合语法的句子。
词干提取,用于模型输入。字节对编码器已经把 running 拆成了跟 run 共享前缀的片段。先行词干提取会交给分词器一个不存在的词,而且通常产生更多词元,而不是更少。
停用词删除,用于模型输入。功能词是模型用来解析谁对谁做了什么的那类词。详见完整论述;简言之,删除 not 是这个领域代价最昂贵的两行改动。
流水线没有变短,只是变了。这些四个步骤值得真正的工程关注,其中三个在那些仍然把所有东西都小写化的代码库里通常根本找不到。
字节流解码错误导致的乱码,以及同一个可见字符有多种字节表示这一事实,会悄无声息地把检索召回率砍一半。这是第一步要做且要做对的步骤——各种规范化形式及其引发的 bug 本身就能写一页。
如果文本来自 HTML,导航栏、Cookie 横幅和页脚通常占字符数的很大比例,却毫无意义。它们还会在每个页面重复出现,这既污染 TF-IDF 统计,也会搞乱 embedding 聚类。trafilatura 和 Readability 这类库正是为解决这个而生的;错误在于跑一个 naive 的标签剥离器就以为完事了。
近似重复会抬高词项统计、主导聚类,而且——如果文本是训练或评估数据——会造成泄漏。Lee 等人在 Deduplicating Training Data Makes Language Models Better(ACL 2022)中报告,从训练语料中移除重复序列能提升模型并大幅减少记忆输出;在检索语料这个小得多的尺度上,同样的道理也适用。对字符 shingles 做 MinHash 是标准的廉价工具。
对于任何将被检索的内容,如何切分文档比任何分词选择对质量的影响都更大,而且它有自己的失败模式——spliting strategies compared 这篇文章涵盖了这些。
注意这四个步骤的共同点。没有一个跟词表大小有关——那是经典流水线在优化的事情;四个步骤全都关乎输入是错误的而非过大的:错误的字节、错误的内容、重复的内容、或者一个不对应任何答案的文本单元。它们也很便宜——每一个都在 CPU 上以每文档微秒到低毫秒级运行,没有按文档收费——这就是为什么在任何昂贵的东西接触文本之前值得把它们做彻底。一份带着 Cookie 横幅到达 embedding 模型的文档,已经让你为横幅付出了 token,embedding 向量也被它稀释了,而且每次重新索引都会继续这两件事。
解决所有相关争论的规则:预处理不是语料的属性,而是消费者的属性。同一份文档可以去三个地方,在每个地方应该区别对待。
共享的前半段——每个目的地都需要的那部分——足够短,值得自己掌握而不是引入:
import re, unicodedata
ZERO_WIDTH = re.compile(r"[\u200b-\u200f\ufeff]")
WS = re.compile(r"[^\S\n]+") # runs of spaces, not newlines
BLANKS = re.compile(r"\n{3,}")
def clean(text: str) -> str:
# 1. one canonical byte representation per visible character
text = unicodedata.normalize("NFC", text)
# 2. invisible characters that break matching and tokenizing
text = ZERO_WIDTH.sub("", text)
text = text.replace("\u00a0", " ") # nbsp -> space
# 3. collapse whitespace but keep paragraph structure
text = WS.sub(" ", text)
text = BLANKS.sub("\n\n", text)
return text.strip()
它刻意没做三件事:不小写化、不动标点、也不把换行符全部合并——段落边界是你拥有的最廉价的分块信号。之后的一切都属于某个特定目的地,应该放在那个目的地的代码旁边,而不是放在一个四个调用方悄悄各执一词的共享 preprocess() 里。