ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

多语言AI应用数据收集与清洗实战:从Pandas到Spark的完整避坑指南

多语言AI应用数据收集与清洗实战:从Pandas到Spark的完整避坑指南 做多语言AI应用大家一开始最容易低估的就是数据环节。去年我负责一个面向跨境场景的多语言客服助手覆盖英语、西班牙语、阿拉伯语和中文模型选型和Prompt工程进展都挺快结果真正拖住进度的反而是数据收集与数据清洗语料来源杂、编码乱、重复率高、语言标签错位各种问题轮番来。这篇就是那次项目的实战记录也是我在多语言AI应用的数据收集与清洗这条路上踩坑之后的完整复盘。准备接手多语言NLP项目、AI Agent相关任务或者正在用Pandas、Spark处理文本数据清洗的朋友应该都能从这里找到一些可以“抄作业”的东西。1. 动手之前先把多语言数据需求拆清楚1.1 多语言数据为什么不能用单语言思路很多团队处理中文或英文数据时已经形成了固定套路去掉HTML标签、正则过滤特殊符号、按标点分句、按空格分词这套流程搬过来就能用。但在多语言场景里这套思路往往会直接翻车原因有三个。第一语言形态差异很大。中文和日文没有空格泰语和老挝语词与词之间连空格都没有阿拉伯语、希伯来语是从右往左书写德语有复合词法语、西班牙语到处是重音符号。如果清洗脚本里写死了“按空格分词”或者“只保留ASCII字符”泰语语料基本会被清空阿拉伯语的标点位置也会被改得面目全非。第二数据来源差异很大。多语言数据通常不是同一个平台生成的工人可能是某国电商评论、政府公开数据库、本地化论坛采集设备、录入系统、导出编码都不一样。我在项目里见过同一个西语词出现三种编码变体正常的“á”、变成“á”的乱码、被强行转成ASCII后的“a”。单语言项目也偶尔见乱码但多语言项目里乱码率会高出一个量级。第三质量分布差异很大。英语、中文的数据又多又杂清洗时可以下狠手阿拉伯语、泰语这种资源稀缺的语言本来能用的语料就不多清洗规则稍微严格一点数据量直接掉到不可用。所以多语言清洗永远不是“一套规则打天下”而是要先把需求拆开给每种语言定制清洗策略和验收标准。1.2 数据清单要回答的5个关键问题动手采集之前我建议先做一份数据需求清单只回答五个问题就可以了。问题确认内容我常用的做法覆盖哪些语言是固定四语还是未来要扩展先按“当前MVP语言 预留语言”规划数据字段里永远带lang标签每种语言的数据量模型训练、微调或检索场景各自的量级按每条语料平均长度估算token数反推需要多少条噪声上限你能容忍多少比例的垃圾数据先清洗一轮看数据量衰减再定“最多只能丢掉百分之几”的底线数据来源哪些是合法可用的哪些要自己采集每一批数据都必须有来源字段写清楚是公开数据集还是自采交付格式下游要的是JSONL、CSV还是数据库表团队统一用JSONL后面接入训练和评测都比较方便这五个问题在项目开始前花半天就能理清楚但作用很大。我在另一个项目里就吃过亏一开始只说了“要收集欧洲三国语言的数据”结果采购的同学买回来的语料在领域上完全不对模型在客服场景里跑出大量新闻稿风格的回答后面返工清洗了好几个星期。先定义清楚后面就能少走很多弯路。1.3 在清洗之前先定义“干净”的标准很多人对“干净数据”的理解就是“没有乱码、没有HTML标签、去重”这个标准太粗了。对多语言AI应用来说干净至少还要包含四个维度文本本身无噪声语言标签可信隐私信息被合理处理数据分布和你要解决的业务问题匹配。我在项目里会把“干净标准”写成一份简短文档里面明确几条硬杠杠文本长度小于10个字符的丢弃检测出来的语言跟声明字段不一致的丢弃包含个人手机号、邮箱、身份证号的样本做脱敏每条语料必须在来源、时间、语言三个字段上都有有效值。清洗代码可以迭代但这些标准在项目中途不要轻易改否则前面产出的数据全部作废后面又要重新跑一遍。用大白话讲你是在给清洗工作定义一个“完成”的终点线不然数据永远洗不完而且每次清洗结果之间没什么可比性。后面我会讲其实最好的办法是把清洗做成带统计报告的流水线每次跑完都自动输出一份“还剩多少条、各语言占比多少、重复率多少”的仪表盘这样标准才有意义。2. 数据收集渠道选择、采集策略与统一落地2.1 公开数据集怎么选许可证、领域匹配与多语种覆盖多语言AI应用的数据收集第一选择永远是公开数据集不要一上来就写爬虫。我常用的几类包括多语言Wikipedia的dump文件适合做预训练和通用领域Common Crawl的多语言子集数据量大但噪声也多适合用来扩充低资源语言OPUS项目里的平行语料适合翻译和跨语言任务各类多语言任务数据集比如MASSIVE、XTREME这种适合做评测和基准测试。选公开数据集不只要看数据量还要看三个点。许可证是不是允许商用或内部使用这个特别重要很多知名数据集只允许学术研究商用会有版权风险领域跟你的AI应用方向是否匹配做客服用新闻语料语义分布会对不上数据的新鲜程度一些数据集更新到某年之后就停了如果你的应用要处理最近两年的新词和说法光靠老数据集是不够的。我自己的经验是公开数据集至少要找三个不同来源做交叉验证不要只听名字。下载之后先跑一次简单的抽样统计看看每种语言的实际条数和文本长度分布是否正常再决定值不值得用。这一步花不了多少时间但能避免后面整个训练过程都被带偏。2.2 定向爬取与API采集字段标准化是关键总会遇到公开数据集不够用的情况比如你要做某个垂直品类的多语言评论数据这时候就需要自己采集。市面上的做法基本分两类能用官方API的尽量用API数据结构和权限都清晰没有API的才考虑写爬虫。写爬虫我用的组合是httpx或requests加BeautifulSoup或lxml。一个典型的采集流程大概长这样先检查目标网站的robots.txt和版权说明配置好合理的请求间隔不要上来就并发轰炸设置User-Agent、重试机制和超时时间网页解析后只保留正文部分丢弃导航、广告等噪音。import httpx import time from bs4 import BeautifulSoup headers { User-Agent: Mozilla/5.0 (research-project/1.0) } def fetch_article(url, retries3): for i in range(retries): try: resp httpx.get(url, headersheaders, timeout30) resp.raise_for_status() return resp.text except Exception: time.sleep(2 ** i) return None html fetch_article(https://example.com/some-page) if html: soup BeautifulSoup(html, html.parser) # 按实际页面结构调整选择器 title soup.select_one(h1).get_text(stripTrue) content \n.join( p.get_text(stripTrue) for p in soup.select(article p) ) # 存入统一的JSONL结构关键是字段标准化。不管数据来自哪个网站落库前都要统一成同一套结构id、text、lang、source、timestamp。世界上最痛苦的事不是没有数据而是洗了半天才发现A来源的时间字段是时间戳、B来源的是字符串、C来源的直接没有时间字段导致后面所有按时间过滤的操作全部报废。采集合规方面多说一句公开网页爬取要尊重版权和数据使用边界涉及个人隐私的信息不要碰社交媒体平台数据要特别小心授权条款。数据安全这条底线不能碰宁可少要一部分数据也不要给自己埋雷。2.3 统一落地为什么我坚持用JSONL存多语言语料多语言文本里引号、逗号、换行符到处都是如果用CSV存字段解析出错概率很高一次转义没处理好整列数据就错位了。我习惯把所有清洗前后的语料统一存成JSONL每行一个JSON对象一行就是一条样本结构清晰而且天然支持逐行读取坏了哪一行也不会影响前面已经处理完的数据。{id: kb-000001, lang: en, text: The product works well and the delivery was fast., source: public_review, timestamp: 2024-11-05} {id: kb-000002, lang: es, text: El producto funciona bien y la entrega fue rápida., source: public_review, timestamp: 2024-11-05}这套采集和初步清洗的脚本我会把依赖版本固定好打包成一个便携的工具环境跟项目容器走换一台机器跑也不需要重新折腾一两个小时。后来同事说这就像常备一个“多语言便携包”到哪个项目都能直接打开用我觉得这个比喻很贴切省下来的时间全都可以花在更有价值的数据分析上。3. 数据清洗从原始文本到可训练语料的完整流水线3.1 清洗流水线总览先定步骤再写代码清洗不是上来就用正则一顿删而是按流水线走每一步都有输入、输出和统计日志。我在多语言项目里用的流程基本是原始数据落地 → 编码修复 → HTML与噪声过滤 → 语言识别与标签校正 → 重复与近重复检测 → 隐私信息脱敏 → 质量抽检 → 输出干净语料。每一步都应该产出统计数字比如这一步处理了多少条、丢掉了多少条、为什么丢。为什么要这个因为数据清洗本质上是一个不断做取舍的过程没有统计日志后面模型效果差了你根本不知道是数据哪里出了问题。我给团队的硬性要求是不允许出现一个“一步到位”的巨型清洗脚本。所有清洗逻辑必须拆成小函数每个函数只负责一件事而且可以单独跑、单独测试。这样排查问题的时候可以精确到具体环节而不是在一个几百行的脚本里来回翻。3.2 第一道关卡编码修复与乱码处理多语言项目最常见的乱码是由编码双重转换引起的。典型的案例是西语字符原本是UTF-8编码的áUTF-8字节是C3 A1结果被某个程序先用Latin-1解码再按UTF-8编码就变成了两个字符á。处理这种情况思路是尝试做一次反向转换先把文本按Latin-1编码还原为字节再按UTF-8解码。def fix_mojibake(text): if not isinstance(text, str): return text try: # 如果真是双重编码通过这条路可以恢复 fixed text.encode(latin-1, errorsstrict).decode(utf-8, errorsstrict) return fixed except (UnicodeEncodeError, UnicodeDecodeError): return text但这里有个大坑反向转换并不能百分之百解决问题。有些文本本身是正常的强行用Latin-1重新编码反而会把原始数据搞坏。所以我在写修复逻辑之前一定先手动抽样几万条样本人工确认乱码比例和具体模式再决定要不要批量替换。修复之后还要做一轮新的抽样验证确保恢复后的字符在业务语言里是合法的。另外所有清洗后的文本一定要统一转成标准Unicode格式。比如NFKC规范化可以处理全角半角字符、兼容字符之类的问题尤其对中文、日文文本很重要。这些细节不起眼但直接决定了后续语言识别和分词的准确率。3.3 规则清洗与文本标准化正则不是万能的规则清洗是每一轮清洗里最核心的部分也是多语言场景最容易出问题的地方。常见规则包括HTML标签移除、URL和邮箱过滤、多余空白与不可见控制字符清理、统一标点符号格式等。一个基础版本用Python写出来是这样的import re import html URL_PATTERN re.compile(rhttps?://\S|www\.\S) EMAIL_PATTERN re.compile(r\b[\w.][\w.-]\.\w\b) def clean_common(text): # 去掉HTML实体并剥离标签 text html.unescape(text) text re.sub(r[^], , text) # 过滤URL和邮箱按业务需要决定是否保留 text URL_PATTERN.sub( , text) text EMAIL_PATTERN.sub( , text) # 清除控制字符和不可见字符 text re.sub(r[\x00-\x08\x0b\x0c\x0e-\x1f\x7f], , text) # 压缩空白 text re.sub(r\s, , text).strip() return text“正则不是万能的”这句话在多语言场景下特别真实。比如你为了清洗英文写了规则把所有带重音符号的字符转成ASCII这在英语数据里没问题但西班牙语、法语的语义可能就在重音上转完之后很多词变成了另一个词或者彻底失去意义。又比如大小写规则德语里的ß不能简单转成大写阿拉伯语和希伯来语根本没有大小写概念你在清洗英文时可以lower()在其他语言上就要非常小心。所以我的建议是清洗规则按语言拆成两份一份是全局通用规则负责所有语言都安全的部分比如去除HTML、压缩空白另一份是语言定制规则按每种语言单独维护宁可写得保守一些也不要一刀切。3.4 语言识别与语言标签校验给每条数据打上语言戳多语言语料清洗里语言标签是后续所有处理的基础但这个标签经常是错的。数据源声称是西语实际是葡萄牙语或者夹杂大量英语数据源声称是阿拉伯语结果文本混杂了法语。所以清洗流程中一定要加一步自动语言识别用识别结果去校验或重写声明字段。我常用的工具是快速文本的lid.176模型、langid库或者CLD3。lid.176覆盖语言多、速度快适合大批量处理langid更轻量CLD3对短文本效果不错。用法很简单import fasttext # 加载官方预训练模型 lid.176 model fasttext.load_model(lid.176.bin) def detect_lang(text): if not text.strip(): return unknown label, prob model.predict(text.replace(\n, ), k1) return label[0].replace(__label__, ), float(prob[0])在实际项目里我会定义一个阈值比如概率低于0.6的文本标记为“confident: false”然后再单独抽检这些低置信度的样本。里面的坑也很典型模型会把英语误判为德语或法语尤其是短文本西班牙语和意大利语的混淆也很常见。所以语言识别的结果不要直接覆盖原始标签而是作为一列辅助字段让后面人工抽检时能快速对比。多语言项目里还会有“混杂语言”文本比如墨西哥裔用户一段话里夹一半英语一半西语。这种要不要保留取决于你的业务。如果AI应用本身就期望能理解混用语那就保留并在language字段里标记为mixed如果应用只处理纯西语这类样本就放到待定区不直接进训练集。3.5 重复与近重复检测多语言场景的独特难点精确重复很好处理用Pandas的duplicated()就能搞定。麻烦的是“几乎相同但细节不同”的近重复文本比如同一篇商品评论在不同平台被改写后转载或者新闻稿在不同地区媒体上的版本。如果不做近重复检测训练集里会塞进大量相似样本导致模型对某些句式过度拟合。近重复检测我用的方案是MinHash加LSH。核心思路是把文本切成一个一个的shingle小片段用哈希函数把每个片段映射成指纹再取最小哈希值代表整篇文本最后用Jaccard相似度判断两篇是否接近。Python里可以直接用datasketch库实现from datasketch import MinHash, MinHashLSH def get_minhash(text, num_perm128): m MinHash(num_permnum_perm) # 对英语等有空格语言按词切分 for token in set(text.lower().split()): m.update(token.encode(utf-8)) return m def get_minhash_char(text, num_perm128, k8): m MinHash(num_permnum_perm) # 对泰语、中文等按字符切分k是滑窗长度 chars [text[i:ik] for i in range(len(text) - k 1)] for ch in set(chars): m.update(ch.encode(utf-8)) return m这段代码里藏着多语言的核心坑英语、西语这类有空格的语言按词切分shingle没问题但泰语、老挝语、高棉语这些没有空格的语言如果按词切分整个句子会变成一长串连续字符串MinHash完全失效必须按固定长度的字符滑窗来切分。还有一种情况是短文本比如几句客服问答本身只有二三十个字符shingle切不出几个片段Jaccard计算不稳定。对这种样本我一般会把长度阈值抬到更高或者干脆交给语义向量相似度处理但语义向量的成本比较高大规模场景要慎重。3.6 Pandas实战边清洗边统计语言分布多数多语言AI项目的数据量在千万到亿级以内单机Pandas其实完全能扛住前提是不要踩“到处用apply慢慢遍历”的坑。我一般会把清洗流程组织成链式的DataFrame操作尽量向量化最后再分组统计各语言的分布。import pandas as pd import re df pd.read_json(raw_corpus.jsonl, linesTrue) # 1. 基础字段检查 df df.dropna(subset[text, lang]) df df[df[text].str.len() 20] # 2. 通用噪音过滤正则向量化 df[text_clean] df[text].str.replace(rhttps?://\S, , regexTrue) df[text_clean] df[text_clean].str.replace(r[^], , regexTrue) df[text_clean] df[text_clean].str.replace(r\s, , regexTrue) # 3. 去除精确重复 df df.drop_duplicates(subset[text_clean]) # 4. 打上语言识别结果可分批处理 df[detected_lang], df[lang_conf] zip(*df[text_clean].map(detect_lang)) # 5. 语言一致性过滤 df df[(df[lang] df[detected_lang]) | (df[lang_conf] 0.7)] # 6. 各语言占比一目了然 print(df[lang].value_counts()) # 7. 导出为干净的JSONL df.to_json(clean_corpus.jsonl, orientrecords, linesTrue)这里有个细节apply处理语言识别模型时会非常慢几百万条可能要跑几个小时。我的做法是先对文本去重之后再识别语言能省掉大量重复计算另外会把数据按分成多个区块用多进程并行处理比如用multiprocessing或者concurrent.futures把耗时压到原来的四分之一甚至更低。统计这一步特别重要。清洗前先输出一次各语言数量、清洗中每步输出一次、清洗后再输出一次。通过数据量的衰减曲线你能清晰看到是从哪一步开始把数据洗没了是规则太狠还是语言识别阈值太高马上就能定位。4. 数据量变大从单机Pandas到MapReduce与Spark4.1 什么信号说明该从Pandas升级到分布式Pandas不是万能的。当数据量突破单机内存、清洗任务要跑十几个小时或者每次迭代清洗策略都要等上一整晚的时候就该考虑分布式方案了。我自己判断是否升级主要看三个信号数据文件已经大到让read_json直接内存告急清洗过程中的排序、分组操作开始频繁触发磁盘溢出代码里到处是手动写文件分片、手动拼接结果的操作。但我也要强调另一面不要一上来就上Spark。单机3000万条文本以内的清洗任务Pandas经过优化完全能在一两个小时跑完。上线一套Spark集群本身要投入不少精力网络、调度、依赖一堆问题性价比不一定高。先用好手里的工具数据量真正撑不住了再升级。4.2 MapReduce清洗实战以招聘数据清洗为例用MapReduce做数据清洗思路和单机是一样的只不过要把清洗逻辑拆成Mapper和Reducer两个阶段。之前我在处理招聘数据清洗时就用过这个方案招聘数据的特点是字段很长职位描述、公司信息、技能要求、薪资范围到处都有噪声而且不同来源的数据字段不齐非常适合MapReduce来做“按来源清洗后聚合”。Mapper阶段负责逐行读取JSONL解析字段、补齐语言标签、执行基础清洗把无效数据直接丢弃Reducer阶段负责汇总、统计和输出。用MRJob写大致是这样的from mrjob.job import MRJob import re def clean_text(text): text re.sub(rhttp\S, , text) text re.sub(r[^], , text) return re.sub(r\s, , text).strip() class CleanJob(MRJob): def mapper(self, _, line): import json try: rec json.loads(line) except Exception: return text clean_text(rec.get(description, )) if len(text) 30: return lang rec.get(lang, unknown) # 直接输出可用于下游统计的键值对 yield lang, {id: rec[id], text: text} def reducer(self, lang, values): count 0 with open(fclean_{lang}.jsonl, a, encodingutf-8) as f: for v in values: f.write(json.dumps(v, ensure_asciiFalse) \n) count 1 yield lang, count if __name__ __main__: CleanJob().run()硬要说这个案例对一般人的启发其实是“清洗任务也可以并行化复用”。你可以把任意文本清洗任务拆成两条能并行处理的逐条清洗放Mapper需要跨样本统计的去重和聚合放Reducer。套这个思路招聘数据的清洗、多语言语料的分语言处理、网约车数据的轨迹文本清洗逻辑都是一样的。4.3 Spark清洗实操从网约车数据场景看分布式清洗如果用Spark处理多语言数据会更容易一些因为DataFrame API本身就支持各种转换。网约车大数据项目里经常要按城市、时段清洗订单的文本备注和用户反馈逻辑跟多语言语料清洗几乎一样解析字段、过滤无效记录、识别语言类型、按关键维度去重聚合。一个简单的PySpark清洗片段长这样from pyspark.sql import SparkSession from pyspark.sql.functions import udf, col, length from pyspark.sql.types import StringType spark SparkSession.builder.appName(multilingual_clean).getOrCreate() df spark.read.json(s3://path/to/raw/*.json) # 基础过滤 df df.filter(col(text).isNotNull() (length(col(text)) 20)) # 通用清洗UDF def clean_text(text): import re text re.sub(rhttps?://\S, , text) text re.sub(r[^], , text) return .join(text.split()) clean_udf udf(clean_text, StringType()) df df.withColumn(text_clean, clean_udf(col(text))) # 精确去重 df df.dropDuplicates([text_clean]) # 按语言聚合统计 df.groupBy(lang).count().show()Spark场景下最容易翻车的不是代码逻辑而是数据倾斜。多语言语料天然不平衡英语可能占了60%阿拉伯语可能只有2%。如果按语言做groupBy或者repartition英语所在的分区数据量巨大其他分区空转整个任务卡在那一两个Executors上。我的解法是清洗阶段不要强制按语言重分区让它按原始文件分区并行处理真要按语言聚合时可以给稀有语言做采样扩容或者给主要语言做二次拆分让键值分布更均匀。5. 常见问题与排查技巧实录5.1 高频坑速查表我遇到过的9个典型问题问题现象多语言场景下的典型表现排查思路文本乱码西语“á”变成“á”中文变问号抽查原始字节流确认是不是双重编码语言标签错位声明西语实为葡语或混用语被标记成单语用fasttext或CLD3跑一遍对比标签清洗后数据量骤降某语言从100万条变成2万条检查是否有语言定制规则误杀逐个规则回滚验证近重复检测失效泰语、老挝语文本完全没法去重看是不是按空格分词切了无空格语言模型效果偏差英语效果好西语明显变差检查西语数据是否被全局规则误清洗数据时间分布失衡早几年数据多最近数据稀缺按时间戳做分布统计考虑重新采样隐私数据未脱敏邮箱、电话混在语料里用正则加人工抽检双重过滤训练集和评测集重叠评估指标虚高清洗时按ID哈希预留评测集禁止交集清洗结果不可复现同一份数据两次清洗结果不一致固定脚本版本、依赖版本输出哈希记录这张表是我们在实际项目中不断补充出来的每次踩坑都会往里面加一行。多语言AI应用的数据链路很长任何一个环节出问题最终都会反映在模型行为和业务指标上所以宁可前面排查仔细一点也不要后面一头雾水。5.2 复现三个让我头疼的真实bug第一个是西语数据被英语清洗规则误删。当时我写了一个全局规则把所有长度小于10的“疑似无意义短词”过滤掉结果西语里很多高频短词被大量删除西语语料直接少了一截。更麻烦的是这个规则还会把“El”“La”“Y”这种西语高频词误判为噪声。后来我把所有长度过滤规则都改成“按语言分别配置”并增加了“删除前保留200条抽样供人工复核”的机制再也没出现这种批量误杀。第二个是阿拉伯语从右到左带来的标点混乱。阿拉伯语文本里夹杂拉丁字母、数字时显示顺序和逻辑顺序可能不一致从网页拉下来的文本里经常出现括号、冒号位置错乱。我们的代价是花了两天处理RTL乱序最后的关键措施是统一采用Unicode BiDi算法处理后的标准文本并在清洗规则里剔除首尾反向标点保留内部内容完整性。第三个是泰语近重复检测彻底失效。因为泰语没有词边界空格只出现在句子之间我的MinHash按空格切词之后几乎所有长句都变成连续一大串Jaccard值要么是0要么是1随机性极强。改成按字符滑窗切分shingle之后相似度计算才恢复正常。这个bug让我意识到多语言清洗里的“语言无关假设”是最危险的假设。5.3 让清洗过程可复现、可审计分享一个我一直坚持的习惯每次清洗都必须产出三样东西——清洗脚本的版本号、输入数据的哈希值、输出数据的完整统计报告。统计报告里至少要包含原始条数、清洗后条数、各语言占比、重复率、平均文本长度、被过滤样本的Top 10丢弃原因。这个报告我每次都会保留模型效果差的时候回来翻一翻基本都能找到线索。还有一个碰了很多次壁之后才养成的习惯清洗工作流里可以引入AI辅助比如用大模型帮忙标注可疑样本、判断复杂文本是不是该保留但AI判断结果绝不能直接当作权威结论。我会把模型置信度低的样本收集起来固定抽样几万个由人工复核后再回流到清洗规则里。换句话说AI可以当质检员但清洗规则的最终解释权必须握在自己手里。数据清洗这件事做一次容易难的是在模型上线、数据更新、业务调整的过程中反复清洗还能保持一致的质量。固定工具链、坚持写统计报告、给每种语言留一套定制规则是我目前最推荐的组合拳。任何项目只要照着这条路走一遍应该都能少走很多无谓的弯路。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进