我们是大模型团队的前置数据组,工作任务和数据有关:清洗、整理、归档,把合适的样本交给下游训练团队。 昨天团队简会时,新来的小陈领取了 3500 万条 brainly 数据归档的任务

前天新到的 Brainly 原始数据,逻辑上是 3500 万个独立 JSON 文件并且均匀装进 350 个 zip 压缩包,每个包大约 10 万条。每个 JSON 对应 1 组题目-解析-答案。
下游团队挑数据
午睡刚醒,下游的训练团队的王宇在飞书沟通群里发信息说要一组大学的 STEM (数理化、工程、科学类)数据投入到增强训练,问我们晚上吃饭之前能不能给他们,这样他们可以早点挂起来训练,早点下班回家。
我看任务看板上 brainly 数据归档任务的状态是已完成,时间上相当充裕,就应承下来了:"可以的 哥,晚饭刷你的卡"

"GET",王宇在飞书上用表情回了我
数据交过去了吗
两点五六分的时候,我创建了个任务给小陈,跟他大概说了一声,让他具体跟王宇那边对接。
"好的 师兄,我尽快完成",小陈看起来挺开心
下午我参加了几个会,有点困顿。准备散场的时候收到小陈的信息"师兄,感觉来不及,现在才跑了不到一半,怎么办?"
我看小陈发这个,有点不好的感觉
" ??? 筛选数据不就是一会儿的事,怎么花这么长时间?",好在会议内容不关我的事,我可以分心回几句。
小陈发来一条新消息:"3500 多万个文件,我不知道为什么这么慢"
我一下清醒了
让小陈给我发了简化的文件 tree,再看了他筛选数据的代码,看来晚饭要刷小陈的卡了
python
# 小陈数据筛选代码示意|已脱敏
from pathlib import Path
from zipfile import ZipFile
import json
zip_dir = Path("/data/brainly")
unpack_dir = Path("/tmp/brainly-unpack")
for zip_path in sorted(zip_dir.glob("*.zip")):
with ZipFile(zip_path) as archive:
for member in archive.infolist():
if member.is_dir() or not member.filename.endswith(".json"):
continue
# 先把 ZIP 内的 JSON 解压出来
archive.extract(member, unpack_dir)
json_path = unpack_dir / member.filename
# 解压完成后,再打开 JSON 文件读取
with open(json_path, "r", encoding="utf-8") as f:
record = json.load(f)
# 业务筛选逻辑
question_images = record.get("question_images", [])
answer_images = record.get("answer_images", [])
image_count = (len(question_images) + len(answer_images))
if image_count >= 3:
save_result(record)
为什么处理速度很慢
数据到我们这里的时候,这 3500 万条数据是放到 350 个 zip 压缩包的,每个压缩包定量 10 万个文件。小陈没有进行改动 ,也没建立索引 ,直接将这几百个压缩包原封不动 push 到了内网的存储。
当前小陈的解法在筛选数据前得经历解压、文件读取、JSON 反序列化、提取指定字段、符合的数据写入新文件,最费时间就是文件内容读取和 JSON loads,可偏偏还写了个解压到新文件后再读...
海量小文件的读写会有大量的 open 和 close,磁盘 I/O 会被打满,怪不得这小子一下午跑不完

现在怎么办
临时补救吧,好在他程序处理日志打印了已处理文件清单,我让他整理好给我。
我这边给大模型下了需求:
1、将小陈的单线程 Python 改为多 worker 并行处理的 rust;
2、先跳过已处理的文件,但是记录下来;
3、优化操作路径,将解压完-逐一读取文件-JOSN反序列化-取值-判断-逐个写入的流程,优化为流式读取压缩包内容-基于字符串位置拿到ID-去重/跳过-JSON反序列化取值-判断-放到内部队列-额外线程定量/定时写入;
4、在写入的时候不仅是挑选王宇他们要的 STEM,原本的数据也按我的字段规划拆好,定量封片到 Parquet 文件;
5、第 3 步去重跳过的数据是为了快速交付给下游团队,但是我们自己要封片 Parquet 还是要完整数据的。
rust
// 大模型写的多 Worker 实时处理示意
rayon::ThreadPoolBuilder::new()
.num_threads(8) // 8 个 Worker
.build_global()?;
zip_paths.par_iter().try_for_each(|zip_path| {
let file = File::open(zip_path)?;
let mut zip = ZipArchive::new(file)?;
for i in 0..zip.len() {
let mut item = zip.by_index(i)?;
let id = get_id_from_filename(item.name());
let record: BrainlyRecord =
serde_json::from_reader(&mut item)?;
// 先判断并交付下游需要的数据
if !delivered.contains(&id)
&& seen.insert(id.clone())
&& is_stem(&record)
&& image_count(&record) >= 3
{
delivery_tx.send(record.clone())?;
}
// 完整记录进入 Parquet 归档队列
parquet_tx.send(to_parquet_row(&id, &record))?;
}
Ok::<_, anyhow::Error>(())
});
到这里,两条处理路径不是换个编程语言,而是从读取方式、并行粒度到输出目标都发生了变化,请看对比图

先把下游要的数据交出去,同时把完整记录写入 Parquet,不要为了快速交付而跳过长期归档
经过小样运行测试、王宇和我俩人的双检,确定数据可用,然后找了台 CPU 性能强并且磁盘 I/O 能力强的机器来跑这批数据。
多了一个全量 Parquet 环节
全量数据 Parquet 操作看起来是多余的,因为王宇他们只要他们想要的那部分。
小陈的归档工作其实并没有真的完成,既然是我操作,那必不可能跑两轮。
对于少写、多读的文本类数据,我们会以 Parquet 格式归档存储。
对,既不是 csv,也不是 zip,更不是独立多个 JSON,对需要频繁按字段过滤的文本数据来说,即使已经定量写成大 JSON,也仍然不如 Parquet 适合查询
为什么是 Parquet
被使用才有价值,数据一定要用起来 - 这是我们老板早些年的理念,也是现实场景和需求的归纳。
大模型训练不可能一次就完成,同一份数据可能会在不同时期投入到不同的训练里。那么数据需要被快速访问、快速挑选、快速写入、快速传输和快速使用。

在我们的场景里,Parquet 相比于 csv、zip、jsonl、txt 或者是数据库这种存储形式更贴近需求,Parquet 文件内部按列组织 ,并且以 Row Group 保存统计信息。基于它自身配合列裁剪 、谓词下推 和合理分片 的特性,天然支持我们的筛选和归档场景,也天然支持并发读取,像这种千万级的数据过滤,只需要几秒钟。

再看 csv、zip、jsonl、txt,除非你专门维护一套索引,否则对于这种字段多、文本长的 JOSN 数据处理,每次都要耗费几个小时
索引的问题很明显,你无法确保索引跟原始数据任何时候都一致,万一文件被改动,很有可能出现货不对版的尴尬事。
简单复盘
数据如约交给下游的王宇,归档工作也并行完成了
晚饭刷王宇的卡,宵夜是小陈点的烧烤。生活似乎还不错!
吃烧烤的时候和队长简单聊了聊今天这事,小陈有点粗心,过程里没有按照部门规则处理数据,也没有在规划好方案后让我们给他确认一下
些许波折,都在可控范围内,算不得什么。小陈就算不给我发求助信息,队长到下午四点也会问他数据交付进度
烧烤是好吃,但小陈只能请这一次