
简介面向自然语言处理入门学习者与中文文本相似度计算需求者的代码资源包。压缩包围绕中文文本预处理与Word2Vec词向量训练展开完整演示从原始语料到计算文本相似度的流程。资源共3个文件包含2个Python脚本与1个CSV数据集脚本分别负责文本预处理与词向量训练数据集为酒店评论情感语料整体压缩包大小1.19MB非常适合快速上手。目前已有658人学习下载。通过该资源可掌握jieba分词、停用词过滤、CBOW或Skip-gram模型训练等关键环节并能利用训练好的词向量计算文本余弦相似度适用于推荐系统、情感分析等场景。脚本结构清晰便于二次修改和扩展是理解中文NLP基础流程的实用范例。1. 中文文本相似度计算为什么要自己训练Word2Vec而不是直接用现成词向量拿到“中文文本预处理Word2Vec训练计算文本相似度.zip”这个项目包绝大多数人第一反应是现在都大模型时代了还自己训练 Word2Vec 干嘛可真实场景里你要对一批中文工单、简历、商品描述算相似度用通用预训练向量会被领域词汇打得很难看——比如“重置网络配置”里的“重置”在通用语料里学到的往往是“重置密码”而“网络配置”干脆成了未登录词。这个压缩包的核心思路是用 jieba 做清洗分词用 gensim 训练领域 Word2Vec再拿词向量把句子变成向量后算余弦相似度。整条链路不需要显卡一台普通 CPU 机器就能跑适合做客服工单去重、文本聚类、法规条文匹配这类离线任务比硬套预训练模型更可控。我按这个路线把每个环节的代码和参数重新理了一遍下面照着做就能复现。2. 中文文本预处理为什么清洗规则比Word2Vec模型更决定相似度上限很多人在 Word2Vec 上反复调参结果相似度还是不合预期问题往往出在文本预处理环节。Word2Vec 本质上是在学「词的上下文共现」如果输入语料里全是标签、URL、数字噪声那学出来的词关系也会带着噪声。中文文本预处理尤其敏感因为词是连在一起的分词错了后面的模型全跟着错。我一般会花三分之二的时间清洗语料剩下三分之一才用来调模型。2.1 jieba分词与停用词过滤几个默认值没那么可靠的参数jieba 的默认配置能应付大多数文本但处理聊天记录、日志、客服工单这类噪声多的内容时有几个参数必须调整。先看一个最基础的分词脚本import jieba import re def clean_text(text: str) - str: # 去掉 HTML 标签和 URL避免它们成为一个“词” text re.sub(r[^], , text) text re.sub(rhttps?://\S, , text) # 全角转半角否则中文标点容易混进来 text text.replace( , ).replace(, ,).replace(。, .) return text.strip() text 这个客服工单说的是路由器断网问题需要先重置网络配置。 # cut_allFalse 走精确模式HMMTrue 可以尝试切分未登录词 words jieba.cut(clean_text(text), cut_allFalse, HMMTrue) print( .join(words))这里的关键点有两个cut_allFalse必须关掉全模式全模式会把“路由器”切成“路由”、“由器”、“路由器”等多个片断Word2Vec 会把这些碎片当成独立词看待稀释掉真实词的统计强度。HMMTrue保留隐马尔可夫模型对未登录词有一定的发现能力。但 HMM 在网络新词上也会切出奇怪的结果如果你的语料非常垂直后面加自定义词典比 HMM 更有效。分词之后的停用词过滤在 Word2Vec 训练里容易被低估。许多教程把停用词过滤当作文本分类的固定步骤但实际上如果你过滤太狠我们、这个、的时候这些词全部删掉会让句子的共现窗口断开影响窗口内有效词的搭配统计。我通常只过滤空格、换行、数字串、单个标点以及长度小于 1 的字而不是用网上下载的一千多个停用词列表一刀切。一个简单的过滤函数import re def filter_tokens(tokens, extra_stopwordsNone): stopwords set() if extra_stopwords: stopwords.update(extra_stopwords) result [] for w in tokens: if w in stopwords: continue if w.isspace(): continue if re.fullmatch(r[\d.]{2,}, w): # “1.2”“2023”这种数字串 continue result.append(w) return result如果你手头的语料里“的时间”“的时候”这类短语经常把上下文冲淡再单独把它们放进extra_stopwords。停用词过滤不是越狠越好要一边看model.wv.most_similar的结果一边收油。2.2 自定义词典把领域词汇变成Word2Vec能吃的特征中文分词最让人头疼的是领域术语“重置网络配置”会被切成“重置”、“网络配置”这还算好的遇到“自然语言处理”切成“自然”、“语言”、“处理”也勉强能用。但像“学而思”“拼多多”这种品牌词或者“五险一金”“深度学习”这类专名切错了就完全没有独立向量相似度直接失真。解决办法是提供一个自定义词典给 jieba。import jieba # domain_words.txt 每行一个词可带词频和词性 # 重置网络配置 3 n # 智能客服 5 n # 退货退款 3 v jieba.load_userdict(domain_words.txt) text 申请退货退款之后需要重置网络配置才能继续使用智能客服 words jieba.lcut(text) print(words) # [申请, 退货退款, 之后, , 需要, 重置网络配置, 才能, 继续, 使用, 智能客服]自定义词典的词频建议设置为 3 到 10太小 jieba 依然会用 HMM 切分太大又可能把不该结合的短语强行绑在一起。这里的lcut直接返回 list和cutlist()等价后续可以少一步转换。另外一条隐藏规则自定义词典里不要放标点和空格也不要放纯数字。因为 Word2Vec 是基于字符片段统计的这些词进去后会影响min_count和词表大小。如果语料包含大量邮箱、手机号正则里先替换再分词而不是靠词典去适配否则词典会越来越厚但效果越来越差。2.3 语料格式与生成器避免内存被大文件击穿中文文本预处理到 Word2Vec 训练之间有一个很容易翻车的点直接把 10GB 的语料读进内存再做成 list of list机器直接卡死。gensim 的 Word2Vec 支持可迭代输入但如果你传入一个 list它会先加载一遍更推荐的方式是用生成器逐行产出分词后的列表这样训练时才读文件。import jieba def sentence_stream(file_path, stopwordsNone): 逐行读取文件并切分返回分词后的列表。 with open(file_path, r, encodingutf-8) as f: for line in f: line line.strip() if not line: continue words jieba.lcut(line) words filter_tokens(words, stopwords) # 过滤掉长度过短的句子空句子让模型报错 if len(words) 2: yield words上面的生成器每读取一行就切分一行内存里永远只保留一行数据。使用它作为 Word2Vec 的sentences参数时gensim 内部会做两遍扫描第一遍建立词表第二遍训练。如果你的语料里有重复句最好先做整行去重否则那些重复句子会主导梯度让低频词权重被压得几乎为 0。语料格式方面文本文件字符编码统一用 UTF-8换行符统一为\n。Windows 下从 Excel 导出的文本经常是 GBK 或带\r轻则日志里出现乱码重则 jieba 把\r当做一个词最后模型的wv[\r]还会出现在相似词里。用生成器读文件时把newline\n作为open()的参数去掉\r或者在strip()时把\r一并去掉。一句话总结文本预处理分词决定词表质量自定义词典决定领域覆盖率停用词过滤决定上下文纯净度三个都做好后面的训练和相似度计算才说得上可靠。3. Word2Vec训练模型选型、参数调整与训练结果验证预处理完成之后进入核心训练环节。这里我按标题顺一遍自己的习惯训练脚本不做太多包装先把模型跑通再去迭代参数。问题在于Word2Vec 的参数没有一个万能组合需要结合语料量和下游相似度任务来定。下面从模型选择、参数、验证三部分展开。3.1 Skip-gram还是CBOW中文场景下的选择逻辑gensim 的 Word2Vec 里有sg1表示 Skip-gramsg0表示 CBOW。选择依据只有一条你的语料规模低频词多不多。客服工单、商品标题、日志片段这类短文本存在大量低频专有词选 Skip-gram。它每次用一个中心词预测上下文对低频词的向量更新更充分训练出的相似度往往更“硬”。新闻、百科、全量网页这类长篇大语料CBOW 训练快且平滑因为每个上下文窗口会平均足够多的词噪声更小。如果语料超过 5GB 且硬件一般CBOW 是更务实的选择。中文短文本相似度任务里我建议直接用sg1。虽然训练时间大概是 CBOW 的一到两倍但在 100MB 语料量级差别也就是从 10 分钟到 20 分钟换来的低频词质量很值得。如果你的任务只是粗粒度聚类CBOW 也够用后面相似度可以做加权补偿。3.2 核心训练参数与一个最小可跑通的示例下面这段代码是把预处理后的语料直接传入Word2Vec训练并且加了训练 loss 的监控方便看模型有没有收敛。import logging from gensim.models import Word2Vec from gensim.models.callbacks import CallbackAny2Vec logging.basicConfig(format%(asctime)s : %(levelname)s : %(message)s, levellogging.INFO) class LossMonitor(CallbackAny2Vec): 每个 epoch 打印一次 loss用于观察是否收敛。 def on_epoch_end(self, model): loss model.get_latest_training_loss() print(fepoch {model.epochs} loss {loss:.2f}) # sentence_stream 是上一章的生成器注意不要在外面转成 list corpus_path corpus.txt model Word2Vec( sentencessentence_stream(corpus_path), vector_size200, window5, min_count2, sg1, hs0, negative15, sample1e-5, epochs10, workers4, seed42, compute_lossTrue, callbacks[LossMonitor()] ) model.save(word2vec.model)这里每个参数的值是我在中文短文本语料上常用的起点具体含义和调整方向如下参数初始值调参建议vector_size200语料小用 100语料超过 1GB 可以升到 300短文本用 200 足够window5中文短文本窗口 3~8窗口越大越偏向主题越小越偏向词语搭配min_count2过滤掉只出现一次的词否则噪声词会占大量内存但低频词过于关键时设 1sg11 为 Skip-gram0 为 CBOWhs00 表示负采样速度更快小语料用层级 softmax 效果可能略好negative15负采样数量5 到 20 之间调整语料大可以减到 5sample1e-5高频词下采样阈值越小过滤越多高频词epochs10小语料 10 次够用语料零散可到 20过大会过拟合workers4线程数Windows 下注意脚本要放到if __name__ __main__:里seed42固定随机种子便于复现你的结果compute_lossTrue配合callbacksLossMonitor()可以观察到训练 loss。如果 loss 没有逐渐下降而是乱跳大概率是语料里有脏数据或者学习率过高。gensim 默认学习率是 0.025一般不需要改。3.3 训练后如何验证词向量质量三种不上当的检查办法训练完不要急着算文本相似度先检查词向量本身靠不靠谱。我用三个办法检查紧密度依次增加。第一个办法是most_similar看语义最近的词是否合理from gensim.models import Word2Vec model Word2Vec.load(word2vec.model) wv model.wv for word in [路由器, 退货]: print(f与「{word}」最相似的词) for w, score in wv.most_similar(word, topn5): print(f {w} {score:.4f})“路由器”附近应该出现“交换机”“网线”“光猫”这类词而不是“你”“的”。如果结果是后者说明预处理停用词没做好或min_count太小。第二个办法是doesnt_match用它检查一组词里哪个不搭。这个模型自带的填空题很能暴露词向量是不是学会了真正的语义# 输出应当是成词而不是“电话” print(wv.doesnt_match([手机, 电话, 路由器, 话费]))第三个办法是词向量可视化把 200 维降到二维做简单散点。这里不展开大段代码一般用sklearn.manifold.TSNE采样几百个高频词降维肉眼检查同一类词是否聚在一起。可视化只是辅助重点是看前两个检查结果否则容易陷入“画图很漂亮但相似度还是不对”的假象。4. 文本相似度计算三种常用方案与代码实现Word2Vec 训完之后我们拿到的是每个词的稠密向量但最终任务是要算“文本”之间的相似度。中文文本由多个词组成如何从词向量得到句向量直接决定相似度分数。这一章讲三种我常用的方案从简单到较精确按需求选即可。4.1 平均词向量基础但易受噪声影响的句子表示最简单也最自然的做法是把一个句子中的每个词向量取平均。它假设句子语义是各词语义的平均对中文短文本来说在没有复杂语法结构时表现尚可。import numpy as np def average_word_vector(tokens, wv, vector_size, remove_stopwordsTrue): 对分词后的 token 列表求词向量平均。 vectors [] for word in tokens: if word not in wv: continue if remove_stopwords and word in {的, 了, 在, 是}: continue vectors.append(wv[word]) if not vectors: return np.zeros(vector_size) # 默认是简单平均也可改为 numba 加速但数据量小时没必要 return np.mean(vectors, axis0) sent_a [智能, 客服, 处理, 退货] sent_b [售后, 支持, 退货, 退款] vec_a average_word_vector(sent_a, wv, 200) vec_b average_word_vector(sent_b, wv, 200) print(vec_a.shape) # (200,)这个函数的逻辑很直接先遍历句子里的 token跳过没有词向量的词再按维度做平均。注意remove_stopwords不是把整张停用词表都拿进来只过滤最影响语义的高频词否则会把“不”“没”这类否定词也滤掉相似度直接失真。平均词向量的缺陷在于长文本会被高频词带偏。比如“这个手机的质量很好电池耐用”和“这个手机的质量很差电池不耐用”两句话的高频词一样多平均向量会几乎相同但真实语义完全相反。这就是为什么后面要用 TF-IDF 加权来压低无实义词的贡献。4.2 TF-IDF加权词向量用idf修正词对句子语义的贡献TF-IDF 加权的想法是一个词在当前文本中出现次数多、在整个语料中出现次数少说明它对这段文本更重要。我们可以在平均之前乘上这个词的 TF-IDF 权重。from gensim.corpora import Dictionary from gensim.models import TfidfModel # 假设 trained_sentences 是已经分词后的语料注意这里不能是生成器必须能重复遍历 dictionary Dictionary(trained_sentences) corpus_bow [dictionary.doc2bow(sent) for sent in trained_sentences] tfidf_model TfidfModel(corpus_bow) def tfidf_weighted_vector(tokens, dictionary, tfidf_model, wv, vector_size): 返回 TF-IDF 加权的句向量。 bow dictionary.doc2bow(tokens) # gensim 的 tfidf_model[bow] 返回 [(term_id, idf_weight), ...] idf_weights dict(tfidf_model[bow]) total_weight 0.0 vector np.zeros(vector_size) for term_id, term_freq in bow: word dictionary[term_id] if word not in wv: continue # 在 IDF 权重后加一个平滑项避免新生词权重为 0 weight idf_weights.get(term_id, 0.0) 0.01 vector wv[word] * weight total_weight weight return vector / total_weight这里的Dictionary必须基于训练语料构建否则doc2bow时新词会被忽略。有一个关键坑gensim 的TfidfModel默认会做“词频 × IDF”还是“仅 IDF”实际返回的是 TF 乘 IDF 的归一化结果但在句子较短时最重要区分度往往在 IDF 那一部分。上面的实现直接使用idf_weights等价于用 IDF 对词向量加权这样短文本里出现的高频但无实义词会被压下来。这段代码还有个细节vector / total_weight是权重归一化避免句子长度影响最终向量的模长。如果你算出来两个相似句子的相似度比平均向量还低不要奇怪先检查是不是训练语料的 IDF 和当前文本不匹配。离线计算相似度时最好保留训练语料的dictionary和tfidf_model不要把新文本的语料混进来重建否则权重会漂移。4.3 余弦相似度与曼哈顿距离选哪个指标更合适句向量算出来后最常用的是余弦相似度。它的定义是向量的夹角余弦值域 [-1, 1]对向量模长不敏感所以很适合比较两个表示“方向”是否一致的句向量。曼哈顿距离L1在高维稀疏向量上有时比余弦好用但 Word2Vec 得到的向量是稠密的L1 会被大量微小维度差异主导结果很不稳定。因此在词向量派生的句向量上我只推荐余弦相似度。import numpy as np def cosine_similarity(vec_a, vec_b): norm_a np.linalg.norm(vec_a) norm_b np.linalg.norm(vec_b) if norm_a 0 or norm_b 0: return 0.0 return float(np.dot(vec_a, vec_b) / (norm_a * norm_b)) sim cosine_similarity(vec_a, vec_b) print(sim)这段代码里有两个细节如果其中一个句向量是全零向量直接返回 0.0否则会出现除零错误。全零向量通常来自“整句话都是未登录词”或“停用词过滤后没有有效词”。使用vec_a / np.linalg.norm(vec_a)提前归一化之后再用np.dot点积可以减少一次重复计算。如果句向量数量很大建议把每条向量提前归一化存入内存相似度变为查表点积。动手算之前我还习惯先看一下两个句向量的模长。模长接近 0 的句子基本都是垃圾可以提前过滤不让它们参与后续的最近邻搜索这一步能省掉不少无效计算。5. Word2Vec训练与相似度计算避坑常见问题、原因与解决办法这一章记录我在中文语料上反复踩过的五个问题。每个都按“现象 → 原因 → 解决”结构写清楚核心目的是让你在遇到相似情况时直接定位到环节而不是怀疑模型库里某个函数写错了。5.1 训练完 most_similar 出来的全是高频功能词现象训练后查询“路由器”的相似词得到“的”“和”“我们”这类词相似度极高。原因文本预处理时没有过滤标点和停用词Word2Vec 只统计共现频次功能词几乎在每个窗口里都出现自然成了所有词的“近邻”。解决回到预处理阶段先用filter_tokens过滤标点和纯数字再过滤高频的“的、地、得、了、在、是”等词。min_count也可以从 2 调到 5进一步压低单次出现的噪声。注意过滤后重新训练而不是在训练好的模型上删词因为词表已经按语料统计好了删词不会重建共现关系。5.2 相似度计算结果一片虚高所有句子都大于 0.9现象任意两个不相关短文本算余弦相似度都在 0.9 以上聚类结果几乎没有区分度。原因平均词向量把每个维度都“平均”了一遍向量之间在方向上的差异会被噪声覆盖。尤其是短文本只有四五个词向量本身稠密加上归一化后模长固定为 1余弦相似度就在 0.85 到 0.98 之间波动区分度很差。解决改用 TF-IDF 加权向量至少能把高频词的贡献压下来。其次对词向量做中心化减去所有句向量的均值再去算余弦相似度会显著拉开分布。也可以观察是否因为句子太短只有两三个词这种情况建议改用字向量或增加训练语料里的句子长度下限。5.3 训练时 loss 突然变成 NaN 或持续下降后崩溃现象训练到一半Compute_loss显示nan最终生成的模型加载时也报错。原因语料里存在非法字符如\x00或者某个词向量更新时学习率变得过大。更常见的是语料里有全 “空格” 或只有空 token 的句子gensim 内部在对空句求梯度时会出现除零或无穷量。解决在sentence_stream生成器里丢弃所有长度小于 2 的句子。同时检查训练语料的原始文件里是否有\x00、\ufeff这类不可见字符用正则text re.sub(r[\x00-\x1f], , text)先清洗干净。如果你的语料里有大量重复句尽量去重后再训练重复句会让模型梯度异常间接引发 loss 崩掉。5.4 保存和加载模型时版本不兼容现象用model.wv.save(word2vec.kv)保存词向量换一台机器或者换 gensim 版本后加载失败报AttributeError或KeyError。原因gensim 4.x 把model.wv从KeyedVectors改成了独立的KeyedVectors对象3.x 保存的文件格式不兼容而 4.x 的加载器在读取时又不会自动迁移。解决训练完建议保存两份。一份是整个模型model.save(word2vec.model)另一份只存词向量model.wv.save(word2vec.kv)。加载时用Word2Vec.load(word2vec.model).wv或KeyedVectors.load(word2vec.kv, mmapr)。如果是从老版本存下来的模型先在新版本里用model Word2Vec.load(...)加载一次再重新保存能解决大部分问题。5.5 相似度计算把“没”和“没有”当相似词否定句识别失败现象输入“我不喜欢”和“我喜欢”两者的平均词向量相似度依然极高导致下游误判。原因平均词向量会忽略词的修饰关系尤其会把否定词和中心词合并到一起。Word2Vec 本身只做词向量的线性组合没有语法结构“不” “喜欢”与“喜欢”的向量在平均后差值被稀释。解决先用 TF-IDF 加权把“不”“没”“否”这类否定词的权重单独提高。理想一点的方案是引入否定词列表在分词后对“不动词”或“没名词”做短语合并生成“不喜欢”“没有用”这种独立词再丢给 Word2Vec 训练。经验是对否定词做短语合并比单纯调权重更有效因为模型能直接学到“不喜欢”和“喜欢”的反义关系。6. 一个实用技巧用“平滑逆频率”修正短文本向量提升相似度区分度平时做客服工单相似度纯平均词向量已经能满足要求但到了短文本去重场景尤其是每句话只有三五个词时平均向量几乎分不清“苹果不错”和“苹果坏了”这种相反语义。我的经验是引入“平滑逆频率”加权也就是给句子里的每个词一个权重高频词权重低低频词权重高。和 TF-IDF 的区别在于SIF 还引入了一个平滑参数a专门不是为了体现文档内频次而是为了调节整个语料的高频词权重。一个可用的中文短文本 SIF 向量实现如下import numpy as np from collections import Counter def compute_sif_weights(tokens_list, a1e-3): 输入分词后的语料返回每个词的 SIF 权重。 freq Counter(word for sent in tokens_list for word in set(sent)) total_docs len(tokens_list) word_weight {} for word, count in freq.items(): prob (count 1) / (total_docs 1) weight a / (a prob) word_weight[word] weight return word_weight def sif_sentence_vector(tokens, word_weight, wv, vector_size): # 对词向量做加权平均后再去掉所有句向量的主成分 vec np.zeros(vector_size) total_w 0.0 for word in tokens: if word not in wv: continue w word_weight.get(word, 0.1) vec w * wv[word] total_w w return vec / total_w这个实现里有两个关键点。第一set(sent)避免同一句话里的重复词把权重叠加太夸张第二prob (count 1) / (total_docs 1)做了拉普拉斯平滑防止出现权重无穷大。真正的 SIF 方法还会对所有句向量做一次主成分分析减去第一主成分这一步在你计算的句子数量较少时可以省略但超过 1000 条时最好加上。我通常先算 500 条句向量用np.linalg.svd取第一主成分然后整个向量空间减去它在第一主成分上的投影相似度分数会明显铺开。还有一个更轻量的修正在每条句向量生成后直接做向量中心化即减去整个句向量矩阵的均值向量。这个操作在短文本相似度上的提升甚至比换模型更明显因为它把语料级的偏置去掉了让“苹果”和“手机”这种上下文相似的词在方向角度上真的区分开。我现在的习惯是先用平均向量跑一版结果做基线再切换成 SIF 加权向量观察相似度分数的分布。如果分数拉伸不明显就回去检查预处理而不是继续调 Word2Vec 的 window。这几年做下来的经验是短文本相似度 70% 的效果在预处理和加权策略上模型本身的影响反而没有想象中大。按照这套流程把分词、清洗、训练、加权、余弦相似度串起来你离一套可投入的短文本匹配服务就不远了希望帮到你。本文还有配套的精品资源点击获取