🦙 第5章 基于 Llama 2 定制行业大模型
📄 一句话概括

行业数据和对话数据不一样:它是文档,不是问答对。 里面混着页眉页脚、软换行、表格碎行、乱码、重复段落——每一样都会让检索找不到东西、让微调学歪。 这一节把十八篇脏文档真的洗一遍,六道工序,逐道量去掉多少。

📝 本章和前面几章的关系

上一节定了做哪一块,这一节备料。 文本抽取与编码,10 课 计算机基础 里讲过; 这一节讲的是从 PDF 里抽出来的文本为什么是脏的,以及脏在哪几个固定位置。

一、数据源:四类,四种脏法

图纸与技术方案:表格多、图注多,抽出来是一堆碎行,还常带页眉页脚。 规范与标准:条文编号密集,跨页断句严重,一条规范被切成两半是常态。 工单与聊天记录:口语、错别字、指代满天飞,但它最贴业务,价值最高。

合同与报价:格式最规整,但一个字都不能错,只适合做检索,不适合做生成。 四类里最该先做的是工单——它不用清洗太多,而且直接对应 5.1 算出来的高频类别。

二、六道清洗工序

第一道去页眉页脚。 这类行的特征是「每页都出现、内容几乎一样」,按行频统计,出现次数接近页数的行就是它。 第二道合软换行。PDF 抽出来的文本按物理行断,一句话常被切成三四行。 规则是「行尾没有标点就与下一行合并」。 第三道去乱码与控制字符。替换符、换页符、零宽字符,这些进模型只会变成噪声。

第四道去重。分两层:篇内重复(同一段写了两遍)和全库重复(同一段在多个文档里都有)。 演示里篇内去重砍掉 1110 字,占全部的 20%。 第五道空白规范化。多空格合一、全角空格转半角、连续空行压成一个。 演示里这道没减少字符数,但它让后面的切块稳定。 第六道切块。按段落累加到 400 字切一刀,切完再看块长分布——块长差别太大,说明前面的清洗没做到位。

三、切块:粒度的取舍

块太大,检索到一大段无关内容,模型的注意力被稀释;块太小,答案被切断,检索出来一半。 常见做法 300 到 500 字,块间留 50 字重叠。重叠是为了「答案正好跨在两块之间」时不被切死。 重叠的代价是同一段内容被检索到两次,所以检索之后要做去重——按内容哈希去,不要按块编号去。

演示里九块,平均 399 字,块内平均 448 token。 这个量级塞三五块进上下文刚好,塞十块就开始挤占生成空间。

四、合规:脱敏与授权

脱敏要在入库前做,不是在使用前做。库里有原始数据,早晚会以某种方式漏出去。 要脱的三类:人名与联系方式、金额与报价、项目地址与甲方名称。前两类用替换,第三类用代号。 替换要一致:同一个客户在全文里都替换成同一个代号,否则检索会把同一家的资料拆散。

授权要留痕。 行业语料常来自甲方文档,能不能拿来训模型,要写在合同里,不能默认「给我们了就能用」。

五、动手 Demo:十八篇脏文档,六道清洗工序,看每道去掉多少

造十八篇「PDF 抽出来」的技术文档,每篇都带:页眉页脚、软换行、表格碎行、替换符、控制字符、 重复段落。 六道工序逐道跑,每道都量前后字符数。最后看切块之后的块长分布。

SEP = u'\n===DOC===\n'

# 1 去页眉页脚:每页都出现的那一行
t = re.sub(u'^.*第 \\d+ 页.*$\\n?', u'', txt, flags=re.M)

# 2 合软换行:行尾不是标点就并下一行
if re.search(u'[。;!?:]$', ln):
    buf.append(acc); acc = u''

# 3 去乱码与控制字符
t = txt.replace(u'\ufffd', u'').replace(u'\x0b', u'')

# 4 篇内段落去重:按篇切段,各自记 seen
for seg in txt.split(SEP):
    ...

# 6 切块:按段落累加到 400 字切一刀
if len(cur) + len(p) > 400 and cur:
    chunks.append(cur); cur = p

实跑输出:

--------------------------------------------------------------------
十八篇脏文档,六道清洗工序,看每道去掉多少
--------------------------------------------------------------------


  ① 先看脏在哪
  --------------------------------------------------------------------
| 指标 | 值 |
|---|---:|
| 篇数 | 18 |
| 字符数 | 5407 |
| 行数 | 233 |
| 含替换符 | 36 |
| 含控制字符 | 18 |
| 表格碎行 | 72 |

  这六项是 PDF 抽文本之后最常见的残留:页眉页脚、软换行、表格碎行、乱码、重复段落。
  ! 真实文档比这脏得多,还会混进扫描件的 OCR 错字。
  清洗的目标不是「干净」,是「不把噪声喂进模型」——两个目标不一样,前者做不到,后者做得到。

  ② 六道工序,逐道量
  --------------------------------------------------------------------
| 工序 | 前 | 后 | 变化 |
|---|---:|---:|---:|
| 1 去页眉页脚 | 5407 | 5101 | -306 |
| 2 合软换行 | 5101 | 5029 | -72 |
| 3 去乱码与控制字符 | 5029 | 4975 | -54 |
| 4 篇内段落去重 | 4975 | 3865 | -1110 |
| 5 空白规范化 | 3865 | 3865 | +0 |
| 6 切块(400 字) | 3865 | 3596 | -269 |

  合计从 5407 字降到 3596 字,保留 67%。
  ! 第 4 道去重砍得最多——因为我在造数据时把每段正文写了两遍。
  真实文档里重复来自「同一段话在多个文档里都出现」,去重不能只看篇内,要看全库。

  ③ 切块之后的形状
  --------------------------------------------------------------------
| 指标 | 值 |
|---|---:|
| 块数 | 9 |
| 最短 / 最长 | 398 / 400 |
| 平均 | 399 |
| 块内平均 token | 448 |

  切块大小决定了检索的粒度:块太大,检索到一大段无关内容;块太小,答案被切断。
  ! 常见做法是 300~500 字、块间留 50 字重叠。
  重叠是为了「答案正好跨在两块之间」时不被切死——代价是同一段内容会被检索到两次,
  去重要在检索之后做。

  ④ 真机上的流水线
  --------------------------------------------------------------------

  [真机代码 · 本沙箱不执行]
  | pip install unstructured[pdf] langchain-text-splitters
  | 
  | from langchain_text_splitters import RecursiveCharacterTextSplitter
  | spl = RecursiveCharacterTextSplitter(chunk_size=400, chunk_overlap=50)
  | docs = spl.split_text(text)
  要 langchain 与 pdf 解析库,本沙箱没有,不执行。

  它做的事跟上面六道工序一样,只是把「按什么切」做得更细:先按段落切,不行按句号,再不行按逗号,
  保证不把一句话切断。
  ! 清洗这一步最容易被跳过,因为「跑通了」。
  但脏数据带来的损失是隐性的:模型答得不好,你查不出是数据脏还是模型弱。

①先看脏在哪:18 篇、5407 字、36 个替换符、18 个控制字符、72 行表格碎行。 这几项就是 PDF 抽文本的固定残留,位置固定,所以能用规则清掉。 ②六道工序里砍得最多的是第四道去重,1110 字,占全部的 20%。因为我在造数据时把每段正文写了两遍。 真实文档的重复不是这样来的, 它来自「同一段话在多个文档里都出现」——比如同一份规范被三个项目各存了一份。 所以去重不能只按篇做,要按全库做。 第一道去页眉页脚砍掉 306 字,看着不多,但它是「每页都错」的那种脏:不清掉, 检索到任何一段都会带着「XX 方案 第 3 页」这种噪声。 第三道只砍掉 54 字,但替换符的危害不在于占地方,在于它把一句话打断成两半,让检索和生成都读不通。 合计从 5407 字降到 3596 字,保留 67%。 清洗不是越多越好——砍掉一半以上就要警惕,是不是把正文也砍了。 ③切完九块,平均 399 字,块内平均 448 token。 这个粒度下,检索取三块进上下文是 1300 多个 token,对 4k 上下文来说刚好。 如果块太大(比如 1000 字),三块就 3000 token,生成空间被挤没了; 如果块太小(比如 100 字),一句话被切成三段,检索出来接不上。 ④那段真机代码本沙箱跑不了。 要注意的是 RecursiveCharacterTextSplitter 的切法是「先按段落,不行按句号,再不行按逗号」, 它保证不把一句话切断——这才是它比手写切块强的地方。

30 秒实验

  1. 把第六道的 400 改成 800,看块数从九降到几,块内平均 token 涨到多少。 再想想:这个大小适不适合 4k 上下文的模型?
  2. 给切块加上 50 字重叠,看总字符数涨多少。再想想:重叠的那部分,检索时会带来什么麻烦?
  3. 把第四道去重改成「全库去重」(不按篇切),重跑。这次会砍掉多少? 想一想:全库去重会不会误删不同文档里恰好相同的标题?

常见误区

  1. 跳过清洗直接切块。页眉页脚会被切进每一个块里,检索时块块都像,排序全乱。
  2. 以为清洗越狠越好。砍掉一半以上的字,多半是把正文也砍了。清洗之后要抽三段人工读一遍,比对原文。
  3. 先入库再脱敏。 库里有原始数据,早晚会以某种方式漏出去——导出的 CSV、备份的压缩包、同事的截图, 任何一个环节都不受你控制。
自测三问(点开对答案) 1. 页眉页脚为什么必须先去掉? —— 因为它每页都出现、内容几乎一样。 切块之后每个块都带它,检索时任何查询都跟它有点相似,排序会被它拉平; 生成时它还会被当成正文复述出来。 2. 切块为什么要留重叠? —— 因为答案常常跨在两块之间。 不留重叠,答案的前半句在第一块末尾、后半句在第二块开头,两块都不完整,检索出来都答不上。 3. 脱敏为什么要在入库前做? —— 因为入库之后的复制不受你控制:导出、备份、截图、外包。 入库前脱敏只需要守住一个入口,入库后要守住无数个出口。

料备好了,下一节把基座模型加载起来——先读配置,再算体积,最后决定怎么切。

相关笔记