跳到主要内容

从一份文件到一堆块 — 读进来、洗干净、切碎

这一章讲三件事: 一份文件读进程序之后长什么样;为什么读出来的文字直接不能用; 以及为什么必须把它剁碎、按什么剁。

它在全书链条里的位置: 第 04 章让文字可以变成数, 但那是对一句话说的。这一章解决「一整本书怎么办」。 切出来的块,第 06 章会一块一块算成数存进库里。

本章主走查的输入:南瓜书那份 PDF,pumpkin_book.pdf 它会依次变成 196 个对象、一段洗干净的文字、720 个块。 下面每一个数都是原书里真实打印出来的,只有「平均每块 429 字」是我们除出来的。

1. 一份 196 页的 PDF,读进来是 196 个对象

这一节是主走查的第 1 步。

原书用的读取工具是 PyMuPDFLoader,理由写得很直接: 它是 PDF 解析器中速度最快的一种,结果会包含 PDF 及其页面的详细描述数据,并且每页返回一个文档1

代码只有两行,读完打印一下2:

loader = PyMuPDFLoader("…/pumpkin_book.pdf")
pdf_pages = loader.load()

载入后的变量类型为:<class 'list'>, 该 PDF 一共包含 196 页

图说:注意最后那个数——196。它等于这份 PDF 的页数,
也就是说「一页 = 一个对象」,不是「一份文件 = 一个对象」。

这个「每页一个」的选择后面会有代价。 一段话如果正好跨了页, 它一开始就被切成了两半,而这一刀不是你切的,是读取工具切的。

书还用几乎完全一样的方式读了另一种格式的文件(md,一种用井号和星号表示标题、 加粗的纯文本写法),读出来的对象类型完全一致——区别只有:那一份整份算一页3

2. 一个对象里有两样:正文,和一张写着来处的卡片

这一节讲那张卡片,它在第 07 章会派上大用场。

书写明:每个对象有两个格子——page_content 装正文,metadata 装关于这份文档的描述性数据4

元数据就是「关于这份东西本身的信息」,不是内容,是标签。 南瓜书那一页打印出来的卡片上有这些4:

卡片上的格子这一页的值
source / file_path./data_base/knowledge_db/pumkin_book/pumpkin_book.pdf
page1(从 0 数起,所以这是第 2 页)
total_pages196
format / creator / creationDatePDF 1.5 / LaTeX with hyperref / 2023-03-03

这张卡片会一路跟着这块文字走:切碎之后每一小块都带着它的副本。 第 07 章要把「答案是从哪一页来的」还给用户,靠的就是这张卡片。

3. 读出来的文字很脏,书用三步洗

这一节是主走查的第 2 步。请先看它有多脏。

PDF 提取出来的正文,每一行结尾都带着换行符,而且是按印刷版式换的行, 不是按句子换的。原书直接指出:它不仅把一句话按原文的分行添加了换行符, 也在原本两个符号中间插入了换行5

书洗它的三步是这样的。第 ① 步要用到一样东西,先在这里讲清楚: 所谓正则表达式,就是一条用符号写出来的查找规则——你用它一次说明白 「什么样的字符组合算命中」,程序就能把全文里所有命中的地方一次找出来、一次换掉。 书这一步写的规则,说的是「换行符的前后两个字都不是汉字」:

第几步干掉什么怎么干
句子中间那些多余的换行用一条正则表达式,只删「前后都不是汉字」的那些换行5
项目符号 一次替换6
多余的空格一次替换6

第 ① 步那条规则值得看一眼,它体现了一个判断: 中文句子内部换行时,换行符两边多半都是汉字;而英文单词之间、标点前后的换行才是版式造成的。 所以只删「两边不都是汉字」的那些——这是个近似规则,不是精确的,书自己也没说它一定对。

洗完之后,那一页从这样:

“周志华⽼师的《机器学习》
(西瓜书)是机器学习领域的经典入门教材之一,周⽼师为了使尽可能多的读
者通过西瓜书对机器学习有所了解, 所以在书中对部分公式的推导细节没有详述

变成这样5:

“周志华⽼师的《机器学习》(西瓜书)是机器学习领域的经典入门教材之一,周⽼师为了使尽可能多的读
者通过西瓜书对机器学习有所了解, 所以在书中对部分公式的推导细节没有详述

图说:第一行和第二行之间那个换行没了,因为它两边是「》」和「(」,都不是汉字。
而「读」和「者」之间那个换行留着——两边都是汉字,规则认为那是原本的分行。

注意最后那句:那个换行其实也是版式造成的,规则没删掉它。 这就是「近似」的意思。

4. 为什么必须切碎:两个理由,都很硬

这一节回答「能不能不切」。答案是不能,而且理由有两条。

第一条:模型一次装不下。 书的原话是: 单个文档的长度往往会超过模型支持的上下文,导致检索得到的知识太长、超出模型的处理能力7

一个模型一次最多能读进去多少字,这个上限叫上下文窗口。 第 02 章那张文心的限制就是它:20480 个字符、5120 个词元。 南瓜书一本 30 万字符,是那个上限的十几倍——整本塞进去,请求根本发不出去。

第二条更要紧:取资料时是以块为单位取的。 书写明: 在检索时,我们会以块作为检索的元单位,每一次检索到 k 个块作为模型可以参考的知识8

所以块的大小直接决定了「你一次能拿到多少上下文」: 块切得太小,取回来的东西七零八落;块切得太大,取回 3 块就把窗口占满了,还塞了一堆无关内容。

5. 怎么切:两个数,一条优先级

这一节是主走查的第 3 步。切法比想象的简单。

书说:框架里的切分器都按两个数工作9——

参数意思
chunk_size一块里装多少个字符(或词元)
chunk_overlap相邻两块共享多少个字符,用来保持上下文连贯、避免切开时丢信息

第二个数需要多解释一句,因为它反直觉:为什么要故意让两块重叠? 因为那一刀是按长度切的,它完全可能切在一句话中间。 让下一块把上一块的尾巴带上一截,那句被切开的话至少在其中一块里是完整的。

书列了八种切分器,这门课全程只用一种:RecursiveCharacterTextSplitter—— 它按一串分隔符依次去试,优先级是 ["\n\n", "\n", " ", ""], 目的是尽量把语义相关的内容留在同一块里10

这条优先级读起来就是一句话: 能在空行处断就在空行处断; 不行就在换行处断;再不行就在空格处断;实在不行才硬切。

这门课用的两个数是11:

CHUNK_SIZE = 500 # 一块 500 个字符
OVERLAP_SIZE = 50 # 相邻两块重叠 50 个字符

图说:重叠 50 占一块的十分之一。
换句话说,每 10 块里有 1 块的量是重复存储、重复计费的。

6. 切完是多少:三个数

这一节是主走查的终点。

原书对整份南瓜书跑了一遍,打印出两个数12:

切分后的文件数量:720
切分后的字符数(可以用来大致评估词元数):308931

图说:308931 ÷ 720 ≈ 429。
也就是说平均每块 429 个字符,而不是设定的 500——
因为切分器优先在空行、换行处断开,断点很少正好落在第 500 个字符上。
(429 这个数是我们除出来的,书里没有。)

这三个数值得记住,因为第 06 章要用它们算账: 720 块意味着建库要调 720 次向量接口,而书为了省时间只放了前 20 块进去—— 那一章开头那个「什么都答不上来」的库,根子就在这里。

7. 边界:切法是这门课交代得最含糊的一步

这一节是本章唯一的坏消息,而且它是书自己说的。

书在这一节末尾留了一段很坦白的话: 如何对文档进行分割,其实是数据处理中最核心的一步,它往往决定了检索系统的下限; 但怎么选取分割方式具有很强的业务相关性,针对不同业务、不同源数据要设定个性化的分割方式, 所以本章仅简单根据 chunk_size 进行分割13

翻译成人话:书知道这一步最关键,也知道自己讲得最浅,并且明说了。

这笔账会在第 11 章被兑现。 那一章讲检索出问题时的四类归因, 第一类就是「知识片段被割裂导致答案丢失」——它的病根正是这一章这刀切得太粗。

还有一处不一致要当心:同一套代码在书里出现了两组参数。 这一章用的是 500 / 50;而第三部分那个项目里的建库代码用的是 500 / 15014书没有解释为什么改,也没有说哪一组更好。 本组拆解第 12 章会把这处不一致再点一次。

判断(我们的,不是书里的): 这一章最容易被初学者跳过,而它恰恰是最该动手改的一章。 理由是成本结构:换向量模型要重算全库,改提示词要重跑全部验证集, 而改切法只要重跑一次建库——却能同时改善检索和生成两头。 如果错,会错在: 如果你的资料本身就是短条目(比如常见问题清单、商品描述), 每条天然就是一块,那么切法这一步几乎不存在,这条判断也就无所谓了。

8. 可带走的

  1. 一份 196 页的 PDF 读进来是 196 个对象,每页一个——跨页的段落一开始就被切成两半了;
  2. 每个对象两个格子:正文 page_content,卡片 metadata;
  3. 卡片上有 sourcepagetotal_pages;page 从 0 数起;第 07 章标出处靠的就是它;
  4. PDF 提取出来的文字很脏:换行是按印刷版式打的,不是按句子;
  5. 书用三步洗:用一条正则表达式(用符号写的查找规则)删掉「两边不都是汉字」的换行、删项目符号、删空格;这是近似规则,会漏;
  6. 必须切碎的两个理由:模型一次装不下(上下文窗口),以及取资料时以块为单位;
  7. 切法只有两个数:一块多长(chunk_size)、相邻两块重叠多少(chunk_overlap);
  8. 重叠是故意的:因为那一刀可能切在句子中间;
  9. 这门课用 500 / 50,切出 720 块、308931 个字符、平均每块 429 字;
  10. 书自己承认:怎么切决定了检索系统的下限,而它只做了最简单的一种;
  11. 同一套代码在书里有两组参数:这一章 500/50,第三部分项目 500/150,书没解释为什么。

9. 原文地图

主题原书章原文位置
读 PDF、每页一个对象、196 页数据处理text/04-p61-80.txt:715(搜「PDF 解析器中速度最快」) · text/04-p61-80.txt:729(搜「共包含 196」)
两个格子:正文与卡片数据处理text/04-p61-80.txt:731(搜「变量类型包含两个属性」) · text/04-p61-80.txt:741(搜「pumpkin_book.pdf」)
读 md 文件数据处理text/04-p61-80.txt:800(搜「UnstructuredMarkdownLoader」) · text/04-p61-80.txt:809(搜「共包含 1」)
清洗第一步:正则删换行数据处理text/04-p61-80.txt:827(搜「正则表达式匹配并删除掉」) · text/04-p61-80.txt:830(搜「re.compile」)
清洗第二、三步数据处理text/04-p61-80.txt:867(搜「和空格」)
为什么切:装不下、按块取数据处理text/05-p81-100.txt:49(搜「超出模型的处理能」) · text/05-p81-100.txt:52(搜「作为检索的元单位」)
两个参数的意思数据处理text/05-p81-100.txt:55(搜「chunk_size 指每个块包含的字符」) · text/05-p81-100.txt:57(搜「chunk_overlap 指两个块之间共享的字符数量」)
递归切分器的优先级数据处理text/05-p81-100.txt:72(搜「RecursiveCharacterTextSplitter 将按不同的字符递归地分割」)
500 / 50数据处理text/05-p81-100.txt:85(搜「CHUNK_SIZE = 500」) · text/05-p81-100.txt:88(搜「OVERLAP_SIZE = 50」)
720 块、308931 字符数据处理text/05-p81-100.txt:103(搜「数量:720」) · text/05-p81-100.txt:107(搜「308931」)
书自己承认切法决定下限数据处理text/05-p81-100.txt:109(搜「往往决定了检索系统的下限」) · text/05-p81-100.txt:110(搜「个性化的」)
项目里那组 500 / 150个人知识库助手项目text/08-p141-160.txt:651(搜「chunk_size=500, chunk_overlap=150」)

Footnotes

  1. 出处:「数据处理」第 715 段(text/04-p61-80.txt:715,搜「PDF 解析器中速度最快」)。原文用的词是「详细元数据」,本章把它换成了大白话「描述数据」,并在第 2 节才正式挂出这个名字。

  2. 出处:「数据处理」第 717 至 729 段(text/04-p61-80.txt:729,搜「共包含 196」)。196 是南瓜书那一版的实际页数,后面第 07 章那次检索打印出来的卡片上也是这个数(text/07-p121-140.txt:525,搜「total_pages': 196」)。

  3. 出处:「数据处理」第 796 至 809 段(text/04-p61-80.txt:800,搜「UnstructuredMarkdownLoader」;结果见 text/04-p61-80.txt:809,搜「共包含 1」)。原文的说法是「我们可以以几乎完全一致的方式读入 markdown 文档」,读出来的对象类型与 PDF 完全一致。

  4. 出处:「数据处理」第 731 至 741 段(text/04-p61-80.txt:731,搜「变量类型包含两个属性」;打印出来的那张卡片见 text/04-p61-80.txt:741,搜「pumpkin_book.pdf」)。表里那几个值取自原书打印的两处:这一处的卡片被转码截断了,完整的一份在第 07 章那次检索里(text/07-p121-140.txt:523,搜「page': 1」)。 2

  5. 出处:「数据处理」第 825 至 838 段(text/04-p61-80.txt:827,搜「正则表达式匹配并删除掉」;规则本身见 text/04-p61-80.txt:830,搜「re.compile」)。原文那条规则的写法是 [^一-鿿](\n)[^一-鿿]——方括号里那一段是汉字的编码范围,前面的 ^ 表示「不是」。 本章那两个方框是我们从原文洗前洗后的两段输出里各截了三行,字一个没改。「近似规则会漏」那句判断是我们的观察。 2 3

  6. 出处:「数据处理」第 867 至 871 段(text/04-p61-80.txt:867,搜「和空格」)。原文的原话是「我们的简单实用 replace 方法即可」——「实用」应为「使用」,是书里的错别字。 2

  7. 出处:「数据处理」第 49 至 50 段(text/05-p81-100.txt:49,搜「超出模型的处理能」)。补充(不在书里,来自通用知识):「上下文窗口」这个名字书里没有直接给,书说的是「模型支持的上下文」;本组拆解把这个名字挂出来,因为你在所有接口文档里看到的都是它。

  8. 出处:「数据处理」第 52 段(text/05-p81-100.txt:52,搜「作为检索的元单位」)。原文写明 k 是可以自由设定的,第 06 章用的是 3,第 12 章那个项目用的是 4。

  9. 出处:「数据处理」第 54 至 57 段(text/05-p81-100.txt:55,搜「chunk_size 指每个块包含的字符」;第二个参数见 text/05-p81-100.txt:57,搜「chunk_overlap 指两个块之间共享的字符数量」)。

  10. 出处:「数据处理」第 59 至 80 段(text/05-p81-100.txt:72,搜「RecursiveCharacterTextSplitter 将按不同的字符递归地分割」)。原文一共列了八种切分器:按字符的、按标题的、按词元的、按句子的等等;这门课只用了递归字符这一种。

  11. 出处:「数据处理」第 84 至 93 段(text/05-p81-100.txt:85,搜「CHUNK_SIZE = 500」;text/05-p81-100.txt:88,搜「OVERLAP_SIZE = 50」)。「每 10 块里有 1 块的量是重复的」是我们按这两个数算的,书里没有这句。

  12. 出处:「数据处理」第 100 至 107 段(text/05-p81-100.txt:103,搜「数量:720」;字符数见 text/05-p81-100.txt:107,搜「308931」)。原文那句括号里写着「可以用来大致评估 token 数」——注意它只是「大致」,中文的字符数与词元数并不是一比一。429 这个平均值是我们除出来的,书里没有。

  13. 出处:「数据处理」第 109 至 111 段(text/05-p81-100.txt:109,搜「往往决定了检索系统的下限」;后半句见 text/05-p81-100.txt:110,搜「个性化的」)。原文最后还指了一条路:去看第三部分的项目示例,参考已有项目是怎么分割的——本组拆解第 12 章就在做这件事。

  14. 出处:「个人知识库助手项目」第 651 段(text/08-p141-160.txt:651,搜「chunk_size=500, chunk_overlap=150」)。同一份代码在那一章里出现了两次,两次都是 500 / 150。项目那一章还给了一条这里没有的理由:块与块之间有一些重叠的内容,能保证检索的时候能够检索到相关的文档片段(text/08-p141-160.txt:622,搜「能保证检索的时候能够检索到相关的文」)。