
简介面向中文自然语言处理入门与进阶学习者这份压缩包把文本预处理、Word2Vec训练和文本相似度计算串成完整可运行的流程。资源从原始语料到语义距离展开先完成数据导入、噪声清洗、中文分词、停用词过滤等步骤再基于预处理结果训练CBOW或Skip-gram词向量最终用平均词向量配合余弦相似度衡量文本相似程度。包内共3个文件包含2个Python脚本和1个CSV语料数据脚本分别负责文本预处理与词向量训练CSV数据用于验证流程整体约1.19MB轻量易用。已有658人学习下载。读者可通过运行脚本、调整窗口大小和嵌入维度等参数直观理解分词与停用词过滤对词向量效果的影响掌握中文文本相似度计算的核心方法为推荐、情感分析、问答匹配等任务打基础。1. 中文文本预处理和 Word2Vec 这套组合究竟能帮你省下什么事假设你手上有几万条用户评价、工单记录或者新闻标题想找出哪些话其实是在说同一件事比如“开机速度慢”和“这手机启动也太肉了”。直接拿字符串匹配根本做不到用词向量做相似度计算是常见做法。而要让 Word2Vec 训练出来的向量靠谱中文文本预处理往往比模型参数更值得投入精力。这个标题对应的是一整套可落地的流程先把中文语料清洗、分词、去停用词再用 Word2Vec 训练词向量最后把句子/文档表示成向量并计算文本相似度。压缩包形式的交付物一般就是把这三段脚本和样例数据打包解压后按顺序跑就能看到效果。适合正在做文本聚类、语义搜索、重复内容识别、问答匹配的工程师以及想系统跑通一遍 NLP 入门流程的学习者。2. 中文文本预处理清洗、分词、停用词三道工序定成败2.1 清洗层去符号、去 URL、去乱码正则方案与保留策略中文文本预处理第一步不是分词而是清洗。爬虫拿来的数据里往往混着 HTML 标签、URL、邮箱、电话号码、Emoji还有编码错误导致的乱码。这些东西对 Word2Vec 训练没有任何语义帮助反而会把词表撑大把向量空间搅乱。我一般会写一个清洗函数按顺序处理几类噪声import re def clean_text(text: str) - str: # 去掉HTML标签 text re.sub(r[^], , text) # 去掉URL text re.sub(rhttps?://\S|www\.\S, , text) # 去掉邮箱 text re.sub(r\w\w\.\S, , text) # 去掉连续空白字符包括换行和制表符 text re.sub(r\s, , text) # 只保留中文、英文、数字和常用标点 text re.sub(r[^\u4e00-\u9fa5a-zA-Z0-9。、《》], , text) return text.strip()这里的清洗顺序有讲究先删 HTML 标签再删 URL最后才做字符级过滤。如果先用[^\u4e00-\u9fa5]把非中文字符删掉URL 和 HTML 标签也会被顺带清理但代价是英文缩写、数字、版本号这些可能有用的信息会一并消失。实际场景里可以先跑一遍字符过滤再用清洗后的样本训练一轮看看词表再决定要不要保留英文和数字。有一个点容易被忽略标点符号。上面这个正则保留了常用中文标点但 Word2Vec 训练时标点会被单独当成 token如果语料里顿号、逗号特别多词表里会混入大量标点符号。我通常会在分词阶段按标点切句而不是让标点进入词表。这一步在下面分词部分会讲到。2.2 分词层jieba 精确模式、自定义词典与词性过滤中文不像英文有天然空格分词结果直接决定“词”的边界也就决定了 Word2Vec 学习的窗口上下文是什么。jieba 是主流选择但默认词典对垂直领域词汇覆盖不够需要加载自定义词典。import jieba import jieba.posseg as pseg # 加载自定义词典每行格式词 词频 词性 jieba.load_userdict(custom_dict.txt) # 示例自动驾驶 100000 n def tokenize(text: str) - list[str]: words [] # 使用词性标注模式方便过滤 for word, flag in pseg.cut(text): # 过滤停用词和单字词 if word.strip() : continue if flag in (x, u, d, p, c): continue if len(word) 1: continue words.append(word) return words这段代码的核心是pseg.cut它返回词和词性两个字段。按词性过滤是个很实用的技巧语气词、助词、介词、连词这类词对相似度计算贡献很小直接去掉能减少噪声。我一般保留名词n、动词v、形容词a和副词d的子集遇到特定任务再调整。自定义词典的格式要特别注意一行三个字段词、词频、词性。词频可以不准确但得有否则 jieba 会按默认频率处理。比如“光刻机”这个词如果不在词典里可能被切成“光刻”和“机”加了自定义词典后才会作为一个整体参与训练。词频填一个较大的值比如 100000能提升该词被识别为独立词的概率。停用词表是另一层过滤。网上下载的通用中文停用词表动辄上千词但不要直接照搬。我习惯把停用词过滤放在词性过滤之后因为有些词在特定语境下有实际含义比如“根据”在口语里是废词但在法律文本里就是关键信息。我会先跑一遍不带停用词的版本看 top 频繁词再手动补充停用词表而不是一开始就用大而全的名单。2.3 语料组织一行一“句”、按标点切分、为训练做格式准备分词之后还有一个关键步骤决定什么长度作为一条训练样本。Word2Vec 的窗口是词级别的上下文一条样本太长或太短都会影响训练效果。常见做法是按句子切分而不是按整篇文档切分。import re def split_sentences(text: str) - list[str]: # 按句末标点切分 parts re.split(r[。!?], text) return [p.strip() for p in parts if len(p.strip()) 5] def build_corpus(raw_texts: list[str]) - list[list[str]]: corpus [] for text in raw_texts: cleaned clean_text(text) for sent in split_sentences(cleaned): tokens tokenize(sent) if len(tokens) 3: # 太短的句子没有上下文信息 corpus.append(tokens) return corpus为什么要按句子切分因为 Word2Vec 窗口一般设 5 到 10如果一条样本是整篇 2000 字的长文窗口上下文跨越多个主题词向量会被拉向多个方向学出来的向量“平均化”严重。切成短句后每个词的上下文主题相对一致向量质量会明显提升。我通常会把处理好的corpus写成一行的文件每行是空格分隔的词序列。这样方便后续直接喂给 gensim也方便排查分词质量。可以先随机抽 20 行出来人工看一眼如果分词结果里到处是莫名其妙的拼接训练质量大概率不行。3. Word2Vec 训练gensim 训练脚本与核心参数调优指南3.1 训练代码从语料到 word2vec 模型的最小可运行版本预处理做完正式进入 Word2Vec 训练。gensim 的Word2Vec封装得很好代码量很短但参数选择直接决定训练效果。先给一个最小可运行版本from gensim.models import Word2Vec # corpus 是上面 build_corpus 输出的 list[list[str]] model Word2Vec( sentencescorpus, vector_size100, # 词向量维度 window5, # 上下文窗口大小 min_count2, # 低于该词频的词不参与训练 sg1, # 1skip-gram, 0CBOW negative10, # 负采样数量 epochs10, # 训练轮数 sample1e-4, # 高频词降采样阈值 workers4, # 并行线程数 seed42, # 随机种子保证可复现 ) # 训练完成后查看词表大小 print(f词表大小: {len(model.wv.key_to_index)})这个训练代码没有做任何花哨的事情但参数组合值得逐个说清楚。vector_size100对几万到几十万词的语料100 维足够语料超过百万词可以升到 200。维度不是越大越好维度太高小语料反而学不满向量稀疏且噪声大。window5窗口太小上下文不够窗口太大远距离的词引入噪声。短文本任务用 5长文本任务可以试 8-10具体可以用下游相似度任务验证。sg1skip-gram 对低频词更友好适合规模一般的语料。CBOW 训练速度快适合大规模语料但低频词向量质量不如 skip-gram。中文语料如果只有几万条直接选 sg1。negative10负采样数量。语料小可以设 5语料大设 10-15。大于 20 会增加训练时间但收益不明显。sample1e-4高频词降采样。中文语料的词频分布非常陡峭“的”“了”“是”这类词即使预先过滤训练时仍会主导梯度更新。这个参数会按频率概率丢弃一些高频词的出现让中频词有更多学习机会。这是中文语料值得重点调的一个参数。min_count2出现次数低于 2 的词直接不参与训练。语料规模小就设 2语料大可以设 5。设太高会丢掉长尾词设太低又会引入噪声。3.2 训练效果验证most_similar 看近义词人工审计向量质量训练完成后立即看训练日志是没用的得用most_similar做验证这是检验词向量质量最直接的手段# 验证查看目标词的相似词 test_words [电脑, 价格, 运行] for w in test_words: if w in model.wv.key_to_index: similar model.wv.most_similar(w, topn10) print(f{w} 的近义词) for term, score in similar: print(f {term}: {score:.4f})如果训练效果正常“电脑”的近义词应该出现“手机”“笔记本”“产品”这类物品词“价格”附近应该出现“售价”“性价比”“贵”等词。如果结果中出现大量无关词比如“电脑”的近义词里有“的”“非常”说明预处理阶段没有把停用词过滤干净或者sample参数设置太小。另一个常用审计方法是词对相似度对比# 预期高相似度词对 pairs [(手机, 智能手机), (物流, 快递), (屏幕, 显示屏)] for a, b in pairs: if a in model.wv and b in model.wv: print(f{a} vs {b}: {model.wv.similarity(a, b):.4f})这一步审计的是模型内部一致性。如果“手机”和“智能手机”相似度低于 0.5多半是训练语料里这两个词很少共现或者窗口设置太小。这里要注意Word2Vec 学出来的是“同一上下文下的共现关系”不是逻辑上的近义关系所以相似度不高不一定是训练失败也可能是语料分布确实是那样的。3.3 模型保存三种存法的区别与选型训练完成之后保存方式会影响后续使用方式。gensim 提供三种主流保存方式很多人在这里翻过车# 方式一完整保存可增量训练 model.save(w2v_full.model) # 方式二只保存词向量体积小适合部署 model.wv.save_word2vec_format(w2v_vectors.bin, binaryTrue) # 方式三加载方式一的模型 from gensim.models import Word2Vec loaded_model Word2Vec.load(w2v_full.model)第一种model.save保存整个模型对象包括训练参数和词向量矩阵之后可以调用loaded_model.train()做增量训练。缺点是文件体积大加载慢。第二种save_word2vec_format只保存词和向量文件小加载快但失去了训练参数信息不能再增量训练。部署阶段一般用这种格式。还有一种常见需求把词向量转成 numpy 矩阵供模型外部调用。可以这样import numpy as np vocab list(model.wv.key_to_index.keys()) vectors np.array([model.wv[w] for w in vocab]) np.savez(w2v_matrix.npz, vectorsvectors, vocabvocab)这个工具函数的价值在于当文本数量很大不想为每一句话都动态查词向量时预先固化向量矩阵能省掉大量model.wv[w]的字典查表时间。4. 文本相似度计算从词向量到句向量的三种做法4.1 平均池化与 TF-IDF 加权把一句话变成一条向量Word2Vec 训练出来的是词级向量而文本相似度计算需要的是句级或文档级向量。最简单也最常用的方法把句子中所有词的向量取平均。这个做法简单但有效尤其是句子不长的时候。import numpy as np def sentence_to_vector(model, tokens: list[str], dim: int 100) - np.ndarray: # 收集句中所有词的向量 vectors [] for token in tokens: if token in model.wv: vectors.append(model.wv[token]) if not vectors: return np.zeros(dim) # 平均池化 return np.mean(vectors, axis0)这个函数有个细节对于词表中不存在的词直接跳过。如果跳过太多句子向量会被少数几个高频词主导所以我们之前分词和 min_count 的设计就变得重要了。len(tokens)太短的句子返回np.zeros(dim)避免出现维度不匹配问题。平均池化的一个问题是一句话里的每个词权重相同“很”和“非常”这种程度词对语义贡献应该更高但平均池化把它们和普通词等同处理。一个更精细的方案是用 TF-IDF 加权from sklearn.feature_extraction.text import TfidfVectorizer # 先做 TF-IDF 特征学习这里需要原始的 token 列表 corpus_texts [ .join(tokens) for tokens in corpus] tfidf_vec TfidfVectorizer() tfidf_vec.fit(corpus_texts) # 特征名对应词构建词到 idf 值的映射 word_idf dict(zip(tfidf_vec.get_feature_names_out(), tfidf_vec.idf_)) def sentence_to_vector_tfidf(model, tokens: list[str], dim: int 100) - np.ndarray: vectors [] weights [] for token in tokens: if token in model.wv and token in word_idf: vectors.append(model.wv[token]) weights.append(word_idf[token]) if not vectors: return np.zeros(dim) # 按 idf 加权平均 weights np.array(weights) weights weights / weights.sum() return np.average(np.array(vectors), axis0, weightsweights)TF-IDF 加权的思路是停用词等高频词在语料中出现的次数多IDF 值低对句向量的贡献小一些领域专有名词出现频率低IDF 值高贡献大。这在长文本匹配场景下效果比纯平均好一点但 TF-IDF 有个前提训练语料里已经能看到词的分布情况如果语料太小IDF 值可能不准确。平均池化和 TF-IDF 加权是两种经典做法中间还有一种是直接用句子中所有词向量的拼接但拼接维度随句长变化无法对齐实际中很少直接用。更进阶的做法有Doc2Vec、SentenceBERT不过那超出了 Word2Vec 的范畴这里不展开。4.2 相似度度量余弦相似度、Top-N 检索与阈值设定有了句向量之后文本相似度就成了向量之间的距离计算。最常用的是余弦相似度from numpy.linalg import norm def cosine_similarity(vec_a: np.ndarray, vec_b: np.ndarray) - float: if norm(vec_a) 0 or norm(vec_b) 0: return 0.0 return float(np.dot(vec_a, vec_b) / (norm(vec_a) * norm(vec_b)))这里要注意零向量问题如果某句话的 tokens 全部不在词表中返回的句向量是零向量余弦相似度会出现除零错误所以函数里要加零向量判断。有了相似度函数批量的文本两两比较就可以做了。一个常见的场景给一批文本做去重保留相似度低于阈值的文本。def deduplicate_texts(model, texts_tokens: list[list[str]], threshold: float 0.85): vectors [sentence_to_vector(model, tokens) for tokens in texts_tokens] keep [] for i in range(len(texts_tokens)): duplicate False for j in keep: if cosine_similarity(vectors[i], vectors[j]) threshold: duplicate True break if not duplicate: keep.append(i) return keep这个去重函数适合几千条的小语料复杂度是 O(n²)文本量超过一万条就要用向量检索工具来做近似最近邻搜索了比如faiss或annoy。阈值怎么定是个经验问题。0.85 到 0.95 这个区间适合完全重复或者高度复述的文本0.7 到 0.8 适合语义相近但文字不完全一样的文本低于 0.6 的相似度基本没有参考价值。实际项目里我会先把几十条已标注的数据跑出来画出相似度分布图再定阈值而不是拍脑袋定。4.3 从词向量到业务落地一个文本匹配的完整小案例这里给一个真正能跑通的小案例假设你有两个文本库一个是标准问题库一个是用户查询要找出与用户查询最匹配的标准问题。def match_query(model, query_tokens: list[str], doc_tokens_list: list[list[str]], topn: int 3): query_vec sentence_to_vector(model, query_tokens) scores [] for idx, doc_tokens in enumerate(doc_tokens_list): doc_vec sentence_to_vector(model, doc_tokens) score cosine_similarity(query_vec, doc_vec) scores.append((idx, score)) scores.sort(keylambda x: x[1], reverseTrue) return scores[:topn] # 示例标准问答库 faq [ 如何切换支付方式, 订单如何申请退款, 修改收货地址的流程是什么, ] faq_tokens [tokenize(t) for t in faq] query tokenize(怎么改收件地址) print(match_query(model, query, faq_tokens))这个案例演示了完整的链路分词 → 句向量 → 余弦相似度 → Top-N 排序。实际部署时标准问答库的文本量可能达到百万级逐条计算余弦相似度太慢常见做法是先把所有标准问题的向量用 faiss 建索引然后用index.search(query_vec, topn)去检索。这一步在代码层面很简单但能极大提升检索效率。5. Word2Vec 训练与相似度计算中容易翻车的 5 个坑5.1 分词和训练用的词表不一致现象训练出来的模型most_similar结果看起来还行但一到计算句子向量时很多词查不到句向量大量为零。原因tokenize函数和训练时用的corpus来自同一个流程吗如果后来修改了分词规则或者停用词表但没有重新训练模型就会出现这种不匹配。解决修改任何预处理环节后强制重新训练或者干脆写一个 pipeline 校验函数随机抽 1000 条文本检查分词后的 token 覆盖率如果低于 90%直接报警。5.2 min_count 设置太小导致噪声词进入词表现象词表里有大量奇怪的词“哈”“嘿”“额”这种语气词、单个字、乱码词都在里面而且most_similar结果中出现生僻字词。原因min_count1或者语料原始质量太差低频噪声词被学习成了向量又因为低频词只出现一两次向量本身不可靠。解决把min_count从 2 调到 5观察词表大小变化。词表缩小但核心词还在说明之前确实混入了噪声。对中文来说单字词如果没有任何业务含义也可以直接在分词阶段过滤掉不给它进入训练语料的机会。5.3 停用词过滤顺序不对导致“的、了、是”大量残留现象训练收敛很快但词向量之间区分度很低任意两个词相似度都在 0.9 以上。原因停用词过滤放在分词之后没错但如果你用的是词性过滤停用词过滤并且停用词表过小“的”“了”“是”仍然大量存在。这些词出现频率极高在窗口内频繁参与上下文学习把所有词向量都拉向同一个方向。解决检查停用词表是否包含高频虚词把“的、了、是、在、和、就、都、而、及、与”这类词补充进去。更稳妥的做法是直接用词性过滤把d副词、u助词、p介词、c连词全部排除。5.4 语料太短导致所有句子向量都是零向量现象计算文本相似度时返回 0.0 的比例特别高大量文本之间相似度是 0。原因sentence_to_vector里所有 token 都不在词表比如句子分词后停用词过滤完只剩下两个词而这两个词min_count没过被剔出词表了。解决训练之前先统计分词后句子的平均 token 数。如果平均低于 5说明预处理切分过细。两个对策一是把min_count降低到 1 保住更多词二是把句子切分的长度阈值从 3 调到 5。我一般会保证至少 80% 的句子有 5 个以上 token 进入训练语料。5.5 模型文件很大加载慢部署时内存不够现象训练产出的.model文件几百 MB线上部署内存吃紧加载要等几十秒。原因model.save保存了完整的训练状态包括训练缓存。而线上推理只需要词向量矩阵。解决部署时用save_word2vec_format只保留词和向量加载时用gensim.models.KeyedVectors.load_word2vec_format。如果还想再压缩可以把向量转成float16格式再存成 numpy 数组精度损失可接受但内存减半。6. 从能跑到能上线相似度计算的 3 个进阶技巧6.1 全量向量化 numpy 矩阵运算代替逐条调接口如果文本量在几千到几万这个量级不需要上向量数据库用 numpy 矩阵运算就能把相似度计算速度提升两个数量级# 把所有文本向量堆成矩阵一次性算相似度矩阵 vec_matrix np.array([sentence_to_vector(model, t) for t in all_tokens]) # 归一化后矩阵乘法即余弦相似度矩阵 norm_matrix vec_matrix / np.linalg.norm(vec_matrix, axis1, keepdimsTrue) sim_matrix np.dot(norm_matrix, norm_matrix.T)这个矩阵化方案适合离线去重、聚类、数据审计场景。上万条文本的相似度矩阵也能在秒级算完比循环调用cosine_similarity快得多。6.2 长文本用分段最大相似度不用全局平均长文档直接平均池化会把语义抹平比如一篇讲“性能”的文章和一篇讲“外观”的文章平均后向量可能落在完全无关的区域。我习惯把长文本按段落或句子切成片段分别算片段向量最终取所有片段与目标文本的最大相似度def long_text_similarity(model, long_a_tokens, long_b_tokens): # 按句切分两个长文本 sents_a split_sentences( .join(long_a_tokens)) sents_b split_sentences( .join(long_b_tokens)) # 两两片段计算相似度取最大值 best 0.0 for sa in sents_a: va sentence_to_vector(model, tokenize(sa)) for sb in sents_b: vb sentence_to_vector(model, tokenize(sb)) sim cosine_similarity(va, vb) best max(best, sim) return best这个方案的合理性在于两篇长文本只要有一个段落语义高度重合它们大概率是在讨论同一件事。直接用全局平均反而会把这种局部重合稀释掉。代价是复杂度变高所以适合离线场景。6.3 词向量归一化后直接用内积替代余弦相似度如果把每个词向量提前归一化到单位长度那么余弦相似度就等于向量内积。这个技巧在部署时很有价值pre-normalize 后的向量矩阵直接做矩阵乘就行不需要每次算范数。更重要的是很多向量检索库faiss、annoy默认就是按内积或者欧氏距离建的索引归一化后IndexFlatIP直接等效于余弦相似度检索。把 Word2Vec 训练计算文本相似度这套流程完整跑过一遍之后你会发现最花时间的不是模型训练本身而是语料的清洗、切分和参数调试。我自己的习惯是每改一次预处理逻辑就重新训练一次并且用一条固定的验证词表检查相似度变化一旦发现词向量质量下降立刻回滚到上一个版本的预处理脚本。这种做法在项目初期更稳妥。希望帮到你。本文还有配套的精品资源点击获取