☰
Python爬虫与AI语义去重:构建高质量中文训练数据集全流程
2026/10/17 2:56:52 网站建设 项目流程

先说个结论:一份看起来够大的文本数据,和一份真正能用来训练的数据之间,差的往往不是数量,而是清洗。我在一个模拟项目X里训练小型对话模型时,一开始抓了十几万条多源中文语料,没怎么处理就直接投入训练。结果模型在验证集上表现还行,一到实际使用就打折扣。后来我把多源语料清洗的整个流程完整梳理了一遍,才发现核心就两件事:Python爬虫负责把分散来源的文本统一收拢,AI去重负责把语义上重复的文本剪干净。这篇就把整条流程、代码思路、参数选择和实际踩坑都写透。

1. 先理解需求:高质量训练数据到底难在哪

1.1 一个让模型“变蠢”的隐藏原因

重复文本对训练的影响,很多人一开始是不重视的。我最初拿到的十几万条语料,来自好几个不同来源,新闻类、问答类、百科类混在一起。粗略统计一下,近似重复的文本占了将近三成。同一则新闻在三个网站换标题、换开头、换几个形容词,语义完全一样。模型训练时反复看到这些相似样本,就等于把某个主题的权重不断放大。验证集和训练集来源一样,所以看起来指标不差,但一到真实场景就会暴露出“偏科”问题。

更麻烦的是,这种重复不完全是编辑复制粘贴那种逐字重复,更多是语义重复。比如“今天天气不错”和“今天天气挺好”,字面不同但意思一致。传统脚本根本抓不住。后来我把清洗流程补上,训练数据规模从十万多降到了七万多,反而效果更稳。少了的那几万条,基本全是无效冗余。

所以高质量训练数据的第一道门槛,不是量,而是去重。

1.2 一份合格语料的三条标准

我现在判断一份语料能不能投进训练管线,通常只看三件事:相关性、洁净度、去重度。

相关性是指文本和目标任务有关。做对话模型就别硬塞一堆纯技术论文,做文本分类就尽量别让无关的广告文混进来。相关性不够,模型学到的特征就是散的。

洁净度是指文本本身是否规整。爬回来的HTML页面里往往夹着导航、版权信息、脚本片段、无意义的换行和乱码。这些噪声如果不清掉,模型会去学一些“页面结构特征”而不是语意。

去重度不是指完全不能出现相似句子,而是指不能让同一主题的相似表达占据过高的比例。要控制的是分布,不是绝对数量。中长篇文档里保留必要的背景重复可以接受,但近似的整段文本必须去掉。

1.3 为什么是“Python爬虫 + AI去重”这个组合

选择Python爬虫做采集,原因很直接:生态成熟,几十行代码就能完成抓取、解析、清洗、落盘的完整链路。凡是处理网页文本的开源库都有很成熟的选择,调试也方便。

AI去重则是因为传统去重手段在语义维度上失效。精确哈希只能处理逐字相同,SimHash能处理一部分局部改写的近似重复,但面对真正的换词、换句式,效果有限。而基于向量模型的语义去重,可以把一句话变成一个固定长度的向量,再通过向量之间的距离判断相似度,换表达但意思相近的内容也能被抓出来。

这两者组合起来,其实就是一条流水线:采集端把多源文本汇聚,清洗端把噪声过滤,去重端把语义冗余消除。顺序不能乱,后面的去重必须建立在前面相对干净的文本上,否则会把“噪声文本”也当成一种相似模式去重。

2. 多源爬虫采集:把散落的语料收拢成统一格式

2.1 语料来源怎么选,合规怎么把握

我采集时习惯把来源分成几类:公开新闻文章、百科知识条目、论坛问答文本、开放论文摘要。新闻文本结构规整、表达偏书面化;百科条目信息密度高、说法规范;问答文本更口语化,适合对话场景但干扰也多;论文摘要专业但字量稳定。不同类型混在一起,能让语料覆盖更多表达风格。

关于合规,这个必须多说几句。哪怕技术上能抓,也不代表应该抓。我给自己定的底线是:只抓公开可访问的页面,遵循robots.txt约定,不采集任何个人隐私信息,不放任请求频率打满目标站点,涉及版权保护的内容不批量下载。这不是口号,是为了让数据工程少惹麻烦,也让整个项目经得起回溯。

注意:爬虫采集一定要控制访问频率,并在代码里保留来源渠道。很多项目的质量问题,最后都要回溯到“这条数据是从哪来的”,没有来源记录会非常被动。

2.2 基础采集框架:单页抓取、批量调度、异常重试

我通常在正式采集前,先写一个小型框架,把抓取、解析、调度分开。下面是简化版,核心思想是:每一步都留好日志,异常时能退避重试。

import json import re import random import time import requests from bs4 import BeautifulSoup USER_AGENT = "Mozilla/5.0 (compatible; DataCollector/0.1)" def safe_fetch(url, timeout=8): """带超时和错误捕获的请求函数""" try: resp = requests.get( url, headers={"User-Agent": USER_AGENT}, timeout=timeout ) if resp.status_code == 200: resp.encoding = resp.apparent_encoding return resp.text except Exception as exc: print("fetch error:", url, exc) return None def parse_main_text(html): """从HTML里提取正文,去掉脚本和页面框架噪声""" soup = BeautifulSoup(html, "html.parser") for tag in soup(["script", "style", "nav", "footer", "header"]): tag.decompose() text = soup.get_text(separator="\n") text = re.sub(r"\n{2,}", "\n", text) text = re.sub(r"[ \t]{2,}", " ", text) return text.strip() def collect_from_seed(seed_url, max_pages=50): results = [] queue = [seed_url] seen = set() while queue and len(results) < max_pages: url = queue.pop(0) if url in seen: continue seen.add(url) html = safe_fetch(url) if html: parsed = parse_main_text(html) record = { "text": parsed, "url": url, "source": "seed_site", "timestamp": int(time.time()) } if len(parsed) > 100: results.append(record) time.sleep(random.uniform(0.5, 1.2)) return results

这里有几个点很容易踩。第一,超时时间不能设太长,8秒已经比较稳妥,否则碰到一直不响应的页面会把整个队列拖死。第二,页面编码要用apparent_encoding去判断,很多非UTF-8页面直接解码会变成乱码。第三,parse_main_text里把script、style、nav这类标签整个移除,是最简单有效的正文提取方法,比逐段过滤快得多。

爬虫调度层面,我还会维护一个已处理URL集合。这个set可以持久化到本地文件,程序中断后重启能接着跑。假如一次要抓几万个页面,断点续跑的价值会非常明显。

2.3 多源字段统一:为什么清洗前先做结构化

爬虫抓回来的原始结果可能是HTML、纯文本、JSON接口,甚至PDF抽取出来的字符串。如果直接丢进清洗脚本,后面对字段的区分会很痛苦。所以我习惯把所有来源统一转成JSONL格式,每条记录固定保留这几个字段:text、source、url、timestamp。

source字段尤其重要。去重阶段如果要追溯某条文本是从哪个渠道来的,没有它是寸步难行。url是为了复查原始内容,timestamp则是为了做时间维度的样本分层。格式统一之后,后续的清洗、去重、抽样分析都只要写一套逻辑,不需要再区分原始来源。

这一步看似简单,但直接决定后面流程能不能自动化。我见过不少项目把数据存在一堆乱七八糟的CSV和TXT里,每次处理都要写不同解析逻辑,非常浪费精力。

3. AI去重:传统方法做不到的那一步

3.1 传统去重的盲区在哪

传统去重最常见的是精确哈希。把所有文本转成MD5或SHA值,值相同就是重复。这个对逐字复制的场景非常有效,但稍微改一个字,哈希值就完全变了,根本认不出来。

再进一步是SimHash。它能把文本转成固定长度的哈希,然后通过汉明距离判断文本相似度。对付“同一篇文章改了标题、删了一句”这种情况,SimHash是有用的。但它本质上还是在字面特征层面打转,如果两段文本用完全不同的词表达同一个意思,比如“如何提升学习效率”和“怎样让学习更高效”,SimHash算出来的距离可能很远。

AI去重之所以必要,就是为了覆盖这个语义盲区。它关心的是“这句话想表达什么”,而不是“这句话用了哪些词”。

3.2 语义去重的基本原理:把语言变成坐标

把文本丢进预训练模型,能得到一个向量,比如768维或1024维的浮点数数组。可以把它理解成一句话在语义空间里的坐标。两句话意思越接近,它们向量的方向就越一致;意思差得越远,夹角就越大。

计算两个向量之间是否相似,通常用余弦相似度。值越接近1,说明文本语义越接近。传统方法看字面,向量方法看语义,这就是本质差别。实际项目里,我用中文BERT类的向量模型来处理中文语料效果比较稳,前提是语料本身已经做过基础清洗。

3.3 基于向量相似度的去重实操

我常用的去重流程分三步。第一步,把文本批量编码成向量;第二步,计算两两之间或簇内部的相似度;第三步,超过阈值就删掉其中一条。下面给一个简化版:

def load_embedding_model(): # 这里用你的中文句子向量模型替换,返回一个能接收文本列表并输出向量的对象 return None def encode_texts(texts, batch_size=64): model = load_embedding_model() vectors = [] for i in range(0, len(texts), batch_size): batch = texts[i:i+batch_size] batch_vecs = model.encode(batch) # 输出 shape: [batch, dim] vectors.extend(batch_vecs) return vectors def cosine_similarity(vec_a, vec_b): dot = sum(a * b for a, b in zip(vec_a, vec_b)) norm_a = sum(a * a for a in vec_a) ** 0.5 norm_b = sum(b * b for b in vec_b) ** 0.5 if norm_a == 0 or norm_b == 0: return 0.0 return dot / (norm_a * norm_b) def dedup_by_similarity(texts, threshold=0.85): vectors = encode_texts(texts) keep = [] removed = [] for i in range(len(texts)): is_dup = False for j in keep: if cosine_similarity(vectors[i], vectors[j]) >= threshold: is_dup = True removed.append({"index": i, "dup_of": j}) break if not is_dup: keep.append(i) return [texts[i] for i in keep], removed

这里的阈值选择是个经验活。我一开始设0.99,结果只有完全改写很少的内容才会被判重,语义重复的大头全漏掉了。后来降到0.85,效果立刻不同。但0.85也不是万能的,领域文档和专业术语太多的场景,0.9以上才稳妥;对话类的口语文本,0.8左右更好。

注意:阈值需要在小样本上人工验证。先抽100对“你觉得是不是重复”的文本,用不同阈值跑一遍,挑一个平衡点再用到全量数据上,不要拍脑袋定数字。

这个简单实现的问题在于复杂度是O(n^2),几万条勉强能跑,几十万条就很吃力。量大的时候,我会先做聚类。把向量聚类成若干个簇,每个簇里只看簇内样本,这样能大幅减少两两比较。更稳的做法是结合“最近邻索引”,但中小项目聚类已经够用。十万条文本,聚类加阈值判定,在一台普通机器上大约一两个小时能跑完。

4. 完整清洗流水线:从原始文本到训练集

4.1 清洗环节拆解:五个环节各有分工

我把清洗流程固定为五个环节,顺序很重要。

第一,格式统一。把所有来源的文字统一成UTF-8编码,把换行符统一为LF,去掉每行首尾空格,去掉多余空行。这个阶段不需要动内容,只做规范化。

第二,噪声过滤。去掉HTML标签残留、脚本片段、导航菜单文字、版权声明、乱码字符。我用正则移除连续特殊符号,比如“【】【】”、“……”这种没有实质内容的部分。

第三,规则过滤。设定文本长度上下限。太短的内容,比如少于50个字,往往没有上下文信息,不适合做训练样本;太长的内容截断到4096字符以内,避免向量模型处理超长文本时不稳定。

第四,语义去重。就是前面讲的AI向量去重。这一步必须在清洗噪声之后做,否则噪声文本本身也会变成一个“正常模式”,干扰向量语义。

第五,人工抽检。随机抽一二百条,肉眼过一遍,确认格式统一、噪声干净、没有大面积截断。这一环节是兜底。

4.2 清洗参数与效果评估

做数据清洗不能只凭感觉,要有可观测的指标。我记录了这么几个关键指标。

检查项使用工具/方法观测指标
冗余度向量相似度重复率
洁净度规则过滤统计噪声占比
覆盖度主题分布/n-gram分布类别覆盖情况
稳定性抽样重复检查人工判定偏差

重复率我计算得比较简单:去重前十万条,去重后剩七万条,重复率就是30%。但这个指标要在去重后的样本里再抽一次,用同样的向量模型看是否还有明显的相似簇,用来校验阈值是否合适。

洁净度主要通过两条正则来判断:一条统计HTML残留标签的数量,另一条统计乱码字符的占比。这两项指标归零,基本可以认为格式层面没问题。

覆盖度不是每次清洗都要跑。如果语料来源结构比较单一,比如全是新闻文本,覆盖度天然就低;我一般会在涉及多来源混合时做一次主题分布,确认没有某一个来源把其他来源完全淹没。

4.3 模拟项目X的实操记录

我在模拟项目X里的实际数据是这样的:新闻类约十万条,问答类约八万条,百科类约五万条,原始合计二十三万条。第一次清洗时,我犯了一个顺序错误——直接对原始文本做向量去重,结果出现了大批误判。原因是原始文本带着大量页面导航和版权噪声,这些噪声让语义向量都偏向同一种模式,导致不同文章之间看起来异常相似。

调整流程后,我先做格式和规则过滤,再跑向量去重,效果就正常了。最终清洗结果剩下十四万条左右,重复率从一开始的约31%降到不足2%,清洗耗时约四个小时。模型重新训练后,在相同验证集上的稳定性明显改善,特别是跨来源的样本表现差距缩小了很多。

我把这次流程固化成一条命令:先跑collect脚本收数据,再跑normalize脚本洗格式,再跑dedup脚本做语义去重,最后跑sample脚本出抽检报告。整个流水线跑完,日志里能看到每步删除了多少、剩余多少,方便随时回滚调整。

5. 常见问题与排查技巧实录

5.1 问题速查表

实际操作里,我遇到的大部分问题集中在几个固定模式上,直接列成表格方便对照。

现象可能原因处理方式
文本全是乱码页面原始编码与解析设置不一致用apparent_encoding,或强制按页面meta声明的编码解码
去重后文本数量暴跌阈值设置过低,误杀过多调高阈值,先抽小样本人工复核
相似文本没被识别向量模型偏科或阈值过高换更适配中文的口语向量模型,调低阈值
请求被目标拒绝访问频率过高拉大间隔,增加随机退避
去重耗时过长两两比较次数太多切换成聚类或最近邻索引
同一个页面反复被抓缺少已访问集合维护持久化的seen集合

第二行“去重后文本数量暴跌”是我最常看到的求助类型。很多时候不是向量模型有问题,而是前面没做规则过滤。明显是空白文本、短文本的向量几乎一样,阈值一卡就把几千条样本全删了。所以顺序问题在数据流水线里非常关键。

5.2 几个容易踩的隐性坑

有些坑不在表格里,但更隐蔽。

第一是编码坑。有的页面不是UTF-8,而是别的不常见编码。如果只靠requests默认规则,很容易搞出乱码。我后来统一在抓取时做一次编码探测,无论来源如何,落盘前都强制转成UTF-8,这样下游脚本才不用重复处理。

第二是HTML解析残留。BeautifulSoup去掉script标签之后,还经常留下一些隐藏的文本节点,比如页面的版权小字、评论区的头像算法生成文本。这些杂质不一定会造成乱码,但会拉低语料的整体洁净度。我在规则过滤阶段会额外加一层“短行过滤”,把所有长度小于20个字符的行先剔掉。

第三是向量模型的截断问题。中文向量模型对输入长度一般有上限,比如512或2048个token,超长文本会被截断。如果关键信息恰好在后半截,向量就抓不到重点。我的做法是:对超长文本先做段落切分,切成长度合适的句子块,再做向量编码。这样既保留信息,又能避免截断偏差。

第四是“去重必须全删”的思维误区。训练集少量重复可以容忍,目标是把重复率控制在一个合理区间,而不是追求零重复。强制把重复全部删掉,可能把同主题的不同细节也误删了,反而损失多样性。

5.3 我的调试方法与最终沉淀

调试这类流程,我习惯先做一个两百条左右的小样本集,人工标注这些样本两两之间是否重复,然后用不同阈值跑一遍,算出与人工标注的匹配度。这样调出来的阈值比纯拍脑袋可靠得多。

还有一个很好用的可视化手段:随机抽五百条文本,用向量模型编码后做降维投影,把在高维空间里明显聚成一团的样本点拿出来单独检查。如果一堆点聚在一起但内容各不相同,说明向量模型没有区分出它们的差异;如果内容几乎一样但分布很散,说明阈值可能太高。

最终我沉淀下来的清洗脚本里,每一轮处理都会输出日志文件,记录阈值、删除数量、删除原因以及被哪个样本匹配到。这个习惯在项目后续补充新语料时帮了大忙,因为每次数据更新都能快速对比上一轮的变化,不需要重新翻历史。

我在整个流程里体会最深的一点是:数据清洗不是一次性的工作,它应该像编译过程一样,每轮都有明确的日志和版本。你拿到新语料,跑一遍旧流程,再对比上一版数据分布,比在模型参数上反复调参要有效得多。如果让我给下一步建议,我会说先给清洗后语料做一次主题分层统计,这样后面无论是训练还是采样,都能对语料的构成做到心里有数。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询