前言
上篇文章介绍了数据治理的完整流水线:先用 MinerU 把 PDF、docx 等异构文档统一解析成Markdown格式,再调用多模态模型把图片替换为语义描述,最后依次完成去重、质量过滤和隐私脱敏三道清洗工序。如果大家跟着笔者实操下来,手里应该已经有了一批结构清晰的Markdown文档啦~
但干净的文档还不等于可以直接训练的数据集。一篇博士论文动辄十几万字,一篇漏洞分析可能只有三五百字,篇幅长短不一。大家都知道大模型的上下文长度是有限的,预训练阶段期望接收的是一条条规整的文本单元。本篇笔者要分享的就是如何将转化后的markdown文档切成合适的语义块并组装成LLamaFactory开箱即用的预训练数据集。
一、认识预训练切片
笔者知识库相关的文章中 一文带你了解RAG核心原理!不再只是文档的搬运工 和 RAG系统效果不达预期?一定要看看这篇详细高效的优化指南!分享过固定长度、递归、文档结构等几种分块算法。不过那两篇的背景是RAG知识库,现在面对预训练场景下的文本切片,目标不同,参数取法也略有不同。
1.1 为什么不能把整篇文档喂给模型?
原因主要有以下三点:
- 长度差异过大:书籍章节、CVE 描述、短篇资讯的篇幅相差数百倍,直接一股脑全篇学习会导致大模型在批处理数据时样本长度严重不齐,短文本被大量填充符浪费算力。
- 模型存在上下文上限:预训练配置里的
cutoff_len(大模型上下文长度,笔者在 大模型训练全流程实战指南工具篇(十)—— 小白也能懂的大模型训练参数万字详解 中介绍过该参数)决定了每次送入大模型的token长度,超长文档必须断开。 - 知识单元需要边界:切片本质上是在告诉模型“这是一段相对完整的知识”,边界切得合理,模型学到的上下文依赖才完整。
1.2 切片大小和上下文重合怎么定?
首先是切片长度会有些许不同,RAG中切片通常只有三五百字,因为RAG切片只承载“答案片段”,靠检索命中;而预训练要让模型读懂完整的论述过程,块长建议要放宽到1000-2000字符。
上下文重合长度也略有不同,RAG中的overlap是为了方便检索时关键句被拦腰截断,而预训练中重叠的内容会被模型重复学习,重合过大既浪费token又容易造成局部过拟合,所以一般不需要重合
下面是笔者在处理本次网络安全预训练数据时使用的具体参数:
| 参数 | 笔者取值 | 作用与注意事项 |
|---|---|---|
块长 | 1500 字符 | 目标块长。太小语义会被切碎,太大长短样本失衡 |
最小块长 | 100 字符 | 小于该长度的文本块并入上一块,避免文本过短造成的残片 |
| 单节最大切片数 | 20 | 超过则告警,多半是塞进了整页表格或代码,需要人工看一眼 |
二、Markdown结构化切片实战
2.1 切片器的设计思路
当前大家手里已经有统一的markdown语料,markdown格式的标题层级是天然的切片依据,只需要在它的基础上完成如下操作:
首先定义后面反复用到的正则,用来匹配标题和代码片段:
importos,re,glob,json,hashlib INPUT_DIR="./cleaned_md"# 第15篇产出的干净 Markdown 目录OUTPUT_DIR="./llamafactory_data"CHUNK_PATH=os.path.join(OUTPUT_DIR,"chunks.jsonl")CHUNK_SIZE,MIN_SIZE=1500,100MAX_SLICES=20# 单节切片数告警阈值HEADING_RE=re.compile(r"^(#{1,6})\s+.+$",re.MULTILINE)FENCE_RE=re.compile(r"```.*?```",re.DOTALL)接下来按标题切分,以#~######标题为边界把文档拆开,标题行保留在小节开头;另外很多文档在第一个标题之前还有一段导言,也要单独留出来:
defsplit_sections(md_text):"""按标题切成小节,每节保留标题行;标题前的导言单独成节"""matches=list(HEADING_RE.finditer(md_text))ifnotmatches:return[("",md_text.strip())]sections=[]ifmatches[0].start()>0:head=md_text[:matches[0].start()].strip()ifhead:sections.append(("",head))fori,minenumerate(matches):end=matches[i+1].start()ifi+1<len(matches)elselen(md_text)sections.append((m.group(0).strip(),md_text[m.start():end].strip()))returnsections然后再把拆分的小节进一步拆分为段落,一个小节可能由多段内容组成,不同段落之间通过空行来区分。不过这里要注意的是一些代码块中存在#开头的注释行,也存在空行,因此应该先用正则将每段代码整块抠出来,再对剩余的内容按空行切分成段落:
defatomic_paragraphs(block):"""拆出段落:代码块整体保留,其余按空行拆"""units,cursor=[],0forminFENCE_RE.finditer(block):forpinre.split(r"\n\s*\n",block[cursor:m.start()]):ifp.strip():units.append(p.strip())units.append(m.group(0).strip())# 围栏作为一个整体单元cursor=m.end()forpinre.split(r"\n\s*\n",block[cursor:]):ifp.strip():units.append(p.strip())returnunits经过上述拆分后的部分段落可能由于过分冗长,导致超过了长度限制,此时需要对长段落进一步处理,先按句号拆分成句子,再重新凑成 1500 字一组,能不切开句子就不切;万一某个句子单独就超过 1500 字,才按字数硬切:
defsplit_oversized(unit,size,overlap):"""单个超长单元:先按句子降级,单句仍超长才硬滑窗;代码块不切"""ifunit.lstrip().startswith("```"):return[unit]sents=re.findall(r".+?[。!?;\n.!?;]+",unit,flags=re.DOTALL)or[unit]pieces,buf=[],""forsinsents:iflen(s)>size:ifbuf:pieces.append(buf)buf=""foriinrange(0,len(s),size-overlap):pieces.append(s[i:i+size])eliflen(buf)+len(s)<=size:buf+=selse:pieces.append(buf)buf=(buf[-overlap:]ifoverlapelse"")+sifbuf:pieces.append(buf)returnpieces除了过长的情况,也可能出现某些段落的长度较短,小于设定的chunk_size,这时候就需要使用贪心算法遍历段落,如果下一段加入后还未超过chunk_size,就与上一段放在一个切片中;如果加入会超限制就新开一个切片,实现代码如下:
defpack_units(units,size):"""把原子段落贪心装入块,放不下就封箱开新箱;超长单元先切后入库"""windows,cur=[],[]defflush():ifcur:# 空箱子不封箱,否则会混进空切片windows.append("\n\n".join(cur))cur[:]=[]forunitinunits:iflen(unit)>size:flush()windows.extend(split_oversized(unit,size))continueifnotcurorlen("\n\n".join(cur))+2+len(unit)<=size:cur.append(unit)else:flush()cur.append(unit)flush()returnwindows以上针对不同情况的文档处理函数编写完成后,就可以编写编排整篇文档的函数了。一篇文档里,大节、小节往往混在一起:比如一个 CVE 描述可能只有两三行,单独成 chunk 太短。笔者给这些短小节准备了一个buf数组:短小节先丢进buf,几个凑够 1500 字后再一起成 chunk;遇到长小节时,先清空buf,再让长小节按上面的规则自行切分。要是某个小节一口气切出 20 个以上 chunk,多半是里面塞了整页表格或大段代码,程序会打印一条告警,提醒人工检查:
defchunk_markdown(md_text,source):chunks,buf=[],[]fortitle,blockinsplit_sections(md_text):iflen(block)>=CHUNK_SIZE:ifbuf:# 先吐出积攒的短小节chunks.extend(pack_units(buf,CHUNK_SIZE))buf=[]slices=pack_units(atomic_paragraphs(block),CHUNK_SIZE)iflen(slices)>MAX_SLICES:print(f"[告警]{source}小节《{title[:20]}》切出{len(slices)}片,请人工检查")chunks.extend(slices)else:buf.append(block)iflen("\n\n".join(buf))>=CHUNK_SIZE:chunks.append("\n\n".join(buf))buf=[]ifbuf:chunks.extend(pack_units(buf,CHUNK_SIZE))merged=[]# 过短块优先并入上一块forcinchunks:ifmergedandlen(c)<MIN_SIZE:merged[-1]+="\n\n"+celse:merged.append(c)iflen(merged)>1andlen(merged[0])<MIN_SIZE:merged[1]=merged[0]+"\n\n"+merged[1]# 首块没有上一块,只能并入下一块merged=merged[1:]records=[]fori,textinenumerate(merged):cid=hashlib.md5(f"{source}-{i}-{text[:64]}".encode()).hexdigest()[:12]records.append({"id":cid,"source":source,"index":i,"text":text})returnrecords上面这段逻辑还有一个兜底,处理完chunk后还会遇到一些零碎的不足最小字数的chunk,这时候直接丢弃还是会造成语义损失,笔者一般会将这些小chunk并入前一个chunk的末尾;如果这些小chunk的前一个chunk也比较短(比如文档开头的导言只有几十个字),那就只能将其并入下一个chunk的头部。为了信息的完整最后给每个chunk算一个id,并记下它来自哪个文件、排第几,后面判别环节淘汰任何一张卡片,都能顺着这两个字段找回原文。
最后编写入口函数,遍历目录下的所有markdown文档,逐篇切片,每张卡片写成一行JSON存进chunks.jsonl:
defrun_chunk():files=glob.glob(os.path.join(INPUT_DIR,"**","*.md"),recursive=True)total=0withopen(CHUNK_PATH,"w",encoding="utf-8")asf:forpathinfiles:withopen(path,encoding="utf-8")asfp:records=chunk_markdown(fp.read(),os.path.basename(path))forrinrecords:f.write(json.dumps(r,ensure_ascii=False)+"\n")total+=len(records)print(f"文档{len(files)}篇,产出切片{total}个 ->{CHUNK_PATH}")最后测试一下脚本效果:在指定目录下放入一份处理完成的 Markdown 文档,执行脚本后可以看到,指定文件夹内的 md 文档已经被切分成切片;检查切片内容,整体也比较完整。
2.2 中间产物抽检
运行完成如上代码之后会得到一个chunks.jsonl的文件,每行表示一个切片。建议运行完成后任意抽几条人工看看,确认一下标题有没有与正文正确衔接、代码块是否完整、参考文献区有没有混进来等不合理的地方,这一步的中间产物抽检能够帮助检查一下切片策略的参数合不合理,有不合理的地方再调整。
三、大模型判别切片
3.1 为什么切片还需要进一步大模型判别?
大家可能会问上篇文章已经做过大模型的文章质量判别,为什么还需要对切片再过一次大模型?主要是因为上篇文章还是文档级别的筛选,即使是合格的文档切片后也会出现一些新问题,比如论文正文质量很高,但末尾的参考文献列表、致谢、作者简介会被切成独立的小块;表格被分页截断后,某个切片可能只剩半张表头;OCR 残留的公式符号堆也可能集中在某一块里。这些碎片在整篇文档中占比很小,文档级过滤感知不到,切块后却成了独立样本,必须再筛一遍。
3.2 规则前置,模型兜底
鉴于工作实践中往往会考虑到节约成本,不会将每个切片都用大模型提示词过滤,一般会先用规则匹配快速筛除残片,只有拿不准的切片才交给大模型判别。具体代码如下:
DROP_TITLES=("参考文献","References","致谢","Acknowledgement","目录","作者简介","版权声明","版权所有")defrule_filter(rec):"""返回 (是否保留, 原因)"""text=rec["text"]plain=re.sub(r"[\s#>*\-|`\[\]()]","",text)iflen(plain)<MIN_SIZE:returnFalse,"有效字数过少"first_line=next((lnforlnintext.splitlines()ifln.strip()),"")ifany(kinfirst_lineforkinDROP_TITLES):returnFalse,f"无价值章节:{first_line[:20]}"lines=[lnforlnintext.splitlines()ifln.strip()]cite_hits=sum(bool(re.search(r"https?://|\[\d+\]|arxiv|doi",ln))forlninlines)iflinesandcite_hits/len(lines)>0.5:returnFalse,"疑似参考文献/链接列表"symbol_ratio=len(re.findall(r"[^\w\s\u4e00-\u9fa5]",text))/max(len(text),1)ifsymbol_ratio>0.35:returnFalse,"特殊符号占比过高,疑似乱码"returnTrue,""这四条规则分别对应字数残片、关键字匹配的无价值章节、参考文献列表和乱码情况。经过规则匹配后,再将剩余段落交给大模型,从多个维度进行评判。笔者一般会采用以下三个判别维度:
- 完整性:标题后没有正文、半截表格、被截断的公式或代码,均可判别为不合格。
- 关联性:广告导流、网站导航等与安全技术无关的内容。
- 洁净度:残留乱码、无意义符号堆砌和排版噪声等。
针对以上三个维度编写的简单提示词如下。大模型服务沿用上一篇使用的阿里云百炼平台:
importtimefromconcurrent.futuresimportThreadPoolExecutor,as_completedfromopenaiimportOpenAI JUDGED_PATH=os.path.join(OUTPUT_DIR,"chunks_judged.jsonl")MAX_WORKERS=8client=OpenAI(api_key=os.getenv("DASHSCOPE_API_KEY",""),base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",)JUDGE_PROMPT="""你是网络安全领域的语料质量审核员。请判断以下文本切片是否适合作为大模型预训练语料。 评估维度: 1. 完整性:不是标题残片、半截表格、被截断的公式或代码 2. 相关性:包含网络安全或计算机技术的实质知识,而非广告、导航、致谢、参考文献列表 3. 洁净度:无 OCR 乱码、无意义符号堆砌、无导流信息 只输出 JSON:{"keep": 1或0, "score": 0到10的整数, "reason": "简短理由"}"""defjudge_with_retry(rec,retries=3):forattemptinrange(retries):try:resp=client.chat.completions.create(model="qwen-plus",messages=[{"role":"user","content":JUDGE_PROMPT+"\n---\n"+rec["text"][:3000]}],response_format={"type":"json_object"},temperature=0,)data=json.loads(resp.choices[0].message.content)rec.update(keep=bool(data.get("keep")),score=data.get("score",0),judge_reason=data.get("reason",""))returnrecexceptExceptionase:ifattempt==retries-1:rec.update(keep=True,score=-1,judge_reason=f"判别失败保留:{e}")returnrec# 失败默认保留,避免接口抖动误杀数据time.sleep(2**attempt)defload_jsonl(path):ifnotos.path.exists(path):return[]withopen(path,encoding="utf-8")asf:return[json.loads(line)forlineinfifline.strip()]defrun_judge():chunks,results=load_jsonl(CHUNK_PATH),load_jsonl(JUDGED_PATH)done_ids={r["id"]forrinresults}todo=[]forrecinchunks:ifrec["id"]indone_ids:continueok,why=rule_filter(rec)ifnotok:rec.update(keep=False,score=0,judge_reason="规则过滤:"+why)results.append(rec)else:todo.append(rec)withThreadPoolExecutor(MAX_WORKERS)aspool:futures={pool.submit(judge_with_retry,r):rforrintodo}fori,futinenumerate(as_completed(futures),1):results.append(fut.result())ifi%100==0:# 每100条落盘一次,中断后可续跑withopen(JUDGED_PATH,"w",encoding="utf-8")asf:forrinresults:f.write(json.dumps(r,ensure_ascii=False)+"\n")print(f"已判别{i}/{len(todo)}")withopen(JUDGED_PATH,"w",encoding="utf-8")asf:forrinresults:f.write(json.dumps(r,ensure_ascii=False)+"\n")dropped=sum(1forrinresultsifnotr.get("keep"))print(f"判别完成:共{len(results)}片,剔除{dropped}片")执行结果如下,对于刚才得到的 31 个切片,剔除了其中 15 个。
LLamaFactory 预训练数据集的格式非常简单:JSONL 文件每行一个 JSON 对象,对象中只需要一个text字段存放文本。如果大家有遗忘,可以回顾文章 大模型训练全流程实战指南工具篇(五)——大模型训练全流程步骤详解与对应工具推荐 中的数据格式。只要把上面处理得到的chunks_judged.jsonl文件中每行的text字段提取出来,就可以组织为数据。具体的预训练过程,笔者会在下篇文章中详解,同时说明预训练需要注意的相关事项。
四、总结
本篇围绕预训练数据集的构建,先明确了预训练切片与 RAG 分块的目标差异,给出了块长、最小块长等参数建议;随后通过 Markdown 标题切分、段落原子化、超长单元降级、贪心装箱等步骤,实现了结构化切片;最后采用“规则前置、模型兜底”的策略,对切片进行质量判别,最终得到 LLamaFactory 可直接使用的chunks_judged.jsonl。下一篇笔者将正式进入预训练实战,详细讲解LLamaFactory预训练过程及需要注意的关键事项,大家敬请期待~