跳到主要内容

多格式解析与文本分块:block 数据模型的诞生

30 秒导读: 上一章 讲了知识库这个"容器"和它底下的双存储。 这一章讲容器怎么被喂饱:你把一堆 PDF、Word、图片、录音丢进 library.add_files(), llmware 会按扩展名把每个文件送到对应的解析器,再把长文切成一小段一小段带元数据的 block(文本块)。block 是整个 llmware 唯一的最小数据单元 —— 后面的向量化、检索、 生成,处理的全是 block。本章只讲"文件 → block"这一段。


1. 这是什么(零基础也能懂)

一句话定义

解析(parsing)= 把任意格式的文件读进来、抽出纯文本(和表格、图片、说话人),再切成 均匀的小段。 每一小段就是一个 block

为什么要"切"

大模型和向量检索都吃不下"整篇文档"这种庞然大物 —— 太长、太杂。RAG 的第一步永远是把 长文档切成便于检索的小片:检索时命中的是片段,喂给模型的也是片段。

所以入库真正的动作不是"存文件",而是:

一份 30 页的 PDF → 几百个 block(每个约 400-600 字符,尽量停在句子边界)

它给谁用、解决什么

假设你有一个文件夹,里面混着合同 PDF、季报 Excel、扫描件 JPG、会议录音 WAV。你不想为 每种格式写一套读取 + 切分逻辑。llmware 让你一行搞定:

from llmware.library import Library

lib = Library().create_new_library("contracts")
lib.add_files("/path/to/mixed_folder") # PDF/Word/图片/录音…一次全吃进去

add_files 内部会:按扩展名分流 → 各自解析 → 统一切成 block → 写进库。你拿到一个 统计字典(加了多少文档、多少 block、多少页、多少表),文件夹里的原始文件也被复制进库 留档。

一句话直觉

add_files 想成一条分拣流水线:入口一个总闸口,按包裹上的"标签"(扩展名) 分到不同传送带(PDF 带、Office 带、文本带、OCR 带、语音带),每条带上有一台专用机器 把内容拆成标准尺寸的小盒子(block),最后所有小盒子汇入同一个仓库。

本节不碰代码细节。记住一件事:入口只有一个想法(分流 + 切块),出口只有一种东西(block)。


2. 顶层全景(它大概怎么转)

主线一张图

从"一个文件夹"到"一堆 block",数据这样流动(从上到下):

lib.add_files(folder) library.py:516


Parser(…config…).ingest(folder) parsers.py:382

┌──────────┴───────────┐
│ _collator: 按扩展名 │ parsers.py:285
│ 把文件复制到各工作区 │
└──────────┬───────────┘
│ work_order = {pdf:n, office:n, text:n, ocr:n, voice:n}

┌───────┬────────┬────────┬────────┬────────┐
▼ ▼ ▼ ▼ ▼ (按计数分派)
parse_ parse_ parse_ parse_ parse_
pdf office text image voice
│ │ │ │ │
(C 库) (C 库) TextParser ImageParser VoiceParser
│ │ │ │ │
│ │ TextChunker「凿子式切块」 util.py:1441
│ │ │ │ │
└────────┴────────┴─────────┴────────┘
│ 每段 → 一个 block(dict / DB 行)

block 写入 collection DB (机制见第 01 章)


CollectionWriter.build_text_index() library.py:623

怎么读这张图

  • 从上到下是时间顺序:一个总入口 → 一个分拣器 → 五条并行的解析路 → 汇成 block。
  • 左右五条路互不干扰:每种文件类型有自己的解析器,但产出的 block 格式完全一样
  • 两种解析引擎:PDF / Office 走原生 C 库(高性能,直接写库);文本 / 图片 / 语音 走纯 Python,共用一个 TextChunker 切块器。

部件一句话职责

部件干什么在哪(file:symbol)
Library.add_files入库总入口,把配置下传给 Parser 并触发建索引library.py:516 add_files
Parser解析调度中枢,持有全部分块配置parsers.py:60 Parser
Parser._collator按扩展名把文件分拣到各工作目录parsers.py:285 _collator
Parser.ingest按分拣结果依次调用各专用解析器parsers.py:382 ingest
Parser.parse_pdf / parse_officectypes 调用 C 解析库,直接写 blockparsers.py:672 / :932
TextParser处理 txt/md/csv/tsv/json/jsonlparsers.py:4465 TextParser
ImageParserOCR(pytesseract)扫描件/图片parsers.py:4100 ImageParser
VoiceParserWhisper 转写 wav/mp3parsers.py:4333 VoiceParser
WikiParser / DialogParser抓维基 / 解析 AWS 通话转录parsers.py:4600 / :4657
TextChunker"凿子式"把长文切成 ~500 字符的 blockutil.py:1441 TextChunker
block schemablock 的字段定义configs.py:874 LLMWareTableSchema

3. 入口:add_files 如何把配置下传给 Parser

3.1 一个入口,一串旋钮

add_files 的签名把所有分块 / 抽取策略暴露成参数,默认值就是 llmware 调好的常用配置:

def add_files (self, input_folder_path=None, encoding="utf-8", chunk_size=400,
get_images=True, get_tables=True, smart_chunking=1, max_chunk_size=600,
table_grid=True, get_header_text=True, table_strategy=1, strip_header=False,
verbose_level=2, copy_files_to_library=True, set_custom_logging=-1,
use_logging_file=False):

真源码见 library.py:516 add_files。这些旋钮的含义:

参数默认作用
chunk_size400目标 block 大小(字符),分块的基准
max_chunk_size600block 的硬上限
smart_chunking1智能切块策略开关(传给 C 解析器)
get_imagesTrue是否抽取内嵌图片
get_tablesTrue是否把表格抽成独立 block
table_gridTrue表格是否保留行列网格结构
get_header_textTrue是否为每个 block 附上所属章节的标题文本
strip_headerFalse是否丢弃页眉页脚
copy_files_to_libraryTrue是否把原始文件也复制进库留档

3.2 配置怎么"下传"

add_files 自己不解析,它只做三件事:构造一个 Parser(把上面的旋钮全传进去)、 调 .ingest()、最后建文本索引。关键几行:

parsing_results = Parser(library=self,
encoding=encoding,
chunk_size=chunk_size,
max_chunk_size=max_chunk_size,
smart_chunking=smart_chunking,
get_tables=get_tables,
get_images=get_images,
get_header_text=get_header_text,
table_strategy=table_strategy,
strip_header=strip_header,
table_grid=table_grid,
...).ingest(input_folder_path, dupe_check=True)

library.py:585 add_filesParser.__init__(parsers.py:186-198)把这些参数原样存成 实例属性(self.chunk_sizeself.get_tables …),后面每个解析器都从 self. 上读。

3.3 结尾:统计与建索引

解析完,add_files 用"解析前 / 解析后"的库计数器一减,给你一份增量报告:

output_results = {"docs_added": ... , "blocks_added": ... ,
"images_added": ... , "pages_added": ... ,
"tables_added": ... , "rejected_files": ...}

library.py:610 add_files。最后一步是补建全文检索索引(第 04 章文本检索的底座):

CollectionWriter(self.library_name, account_name=self.account_name).build_text_index()

library.py:623 add_files。注意:SQLite 后端下这是空操作(索引在建虚拟表时就有了, 见 resources.py:2588 build_text_index),MongoDB / Postgres 后端才真正建索引。

3.4 专门入口:绕过分流,直取一种格式

add_files 是"混装分拣"的通用入口。llmware 还给每种来源留了专门入口,内部直接调对应 的 parse_* 方法,跳过 _collator 分拣:

方法做什么file:symbol底层
add_pdf只加 PDFlibrary.py:1071parse_pdf(C 库)
add_office只加 pptx/xlsx/docxlibrary.py:1093parse_office(C 库)
add_image图片 / 扫描件 OCRlibrary.py:1027parse_image
add_pdf_by_ocr扫描版 PDF 逐页 OCRlibrary.py:1049parse_pdf_by_ocr_images
add_website抓网页(可跟链接)library.py:956parse_website
add_wiki按主题抓维基百科library.py:982parse_wiki
add_dialogsAWS 通话转录 JSONlibrary.py:1005parse_dialog

这些方法几乎都是同一个模板 —— 例如 add_website(library.py:977):

Parser(library=self).parse_website(url, get_links=get_links, max_links=max_links)
CollectionWriter(self.library_name, account_name=self.account_name).build_text_index()

注意 add_website / add_wiki 结尾也补了 build_text_index(),而 add_image / add_pdf / add_dialogs 没有补(因为它们常和别的调用串在一起用)—— 这是个易踩的小坑。


4. 分拣:_collator 如何按扩展名路由

4.1 类型注册表

Parser 构造时声明了几张"扩展名 → 类型"的对照表(parsers.py:150-157):

self.ACCEPTED_FILE_FORMATS = ["pptx","xlsx","docx","pdf","txt","csv","html","jsonl",
"jpg","jpeg","png","wav","zip", "md", "tsv"]
self.office_types = ["PPTX","pptx","XLSX","xlsx","DOCX","docx"]
self.pdf_types = ["PDF","pdf"]
self.text_types = ["txt","csv","html","jsonl","md","tsv"]
self.ocr_types = ["jpg","jpeg","png"]
self.voice_types = ["wav","mp3","mp4","m4a"]
self.zip_types = ["zip"]

4.2 分拣动作:复制到各自的工作区

_collator(parsers.py:285)遍历输入文件夹,对每个文件取扩展名 filename.split(".")[-1], 命中哪张表就把它复制到对应的工作目录,并给该类型计数 +1:

filetype = filename.split(".")[-1]
if filetype.lower() in self.office_types:
shutil.copy(..., self.office_work_folder); office_found += 1
if filetype.lower() in self.pdf_types:
shutil.copy(..., self.pdf_work_folder); pdf_found += 1
if filetype.lower() in self.text_types:
shutil.copy(..., self.text_work_folder); text_found += 1
# ocr / voice / zip 同理

parsers.py:324-354 _collator。几个要点:

  • zip 会被展开:发现 zip 就调 zip_extract_handler(),把里面的文件再归到各类计数上 (parsers.py:359-370)。
  • 去重在这一步:dupe_check=True 时,用集合差集把"库里已有同名文件"剔掉,只处理新增 (parsers.py:312-322),被跳过的进 duplicate_files
  • 返回一个 work_order 字典:{"pdf":n, "office":n, "text":n, "ocr":n, "voice":n, ...}

4.3 分派:按计数依次调解析器

ingest(parsers.py:382)先做两道前置校验 —— 必须有 library、且能连上 collection DB, 否则直接劝你改用 parse_one 系列(parsers.py:393-401)。校验通过后,按 work_order 的计数 逐类调用:

if work_order["office"] > 0:
self.parse_office(self.office_work_folder, save_history=False)
if self.copy_files_to_library: self.uploads(self.office_work_folder)
if work_order["pdf"] > 0:
self.parse_pdf(self.pdf_work_folder, save_history=False)
...
if work_order["text"] > 0: self.parse_text(...)
if work_order["ocr"] > 0: self.parse_image(...)
if work_order["voice"] > 0: self.parse_voice(...)

parsers.py:412-441 ingestself.uploads(...) 把原始文件复制进库的 file_copy_path 留档(受 copy_files_to_library 控制)。最后 input_ingestion_comparison 比对"进去的 vs 处理 成功的",算出 rejected_files(parsers.py:444)。

是否写库,构造时就定了。 Parser.__init__test_connection() 探一下 collection DB: 连得上才把 self.parse_to_db = True(parsers.py:127-135)。连不上就退化为"写 JSON 文件" 模式。所以"文件 → block → DB"这条链的开关,在解析开始前就已拨好。


5. 切块:TextChunker 的"凿子式"算法(核心)

5.1 要解决的小问题

把一长串文本切成小段,最笨的办法是每 600 字符硬切一刀。问题:很容易切在单词中间、 句子中间,切出来的片段读着别扭,检索和向量化的质量都受损。

我们想要的是:大小大致均匀(~500 字符),但尽量停在句子或段落边界。

5.2 思路:先量到上限,再"往回凿"

llmware 的 TextChunker凿子(chisel)思路(源码注释原话,util.py:1448): 先从当前位置量出一个 max_char_size 的粗块,然后在末尾一段 look_back_range 的范围里 往回找一个干净的切点(句号、换行),找到就切在那。

起点 max_char_size(如600)
│──────────────────────────────────────│
│ ◄───── look_back(如300)─────│
│ [.....在这段里倒着找句号/换行.....]│
│ ▲
│ └─ 找到句号 → 切在这里(smooth_stop)

5.3 原理演示

一段示意代码,把核心想法演出来(示意,非源码):

# 演示"凿子式"切块:量到上限,再往回找句子边界
def chunk(text, max_size=600, look_back=300):
chunks, start = [], 0
while start < len(text):
stop = min(start + max_size, len(text)) # 先量一个粗块
cut = smooth_edge(text, start, stop, look_back) # 往回凿到句号/换行
chunks.append(text[start:cut])
start = cut # 下一块从切点接着走
return chunks
# 重点看:每块的"停点"由 smooth_edge 决定,不是死板的定长

5.4 真实实现里的三个讲究

真源码在 util.py:1464 convert_text_to_chunksutil.py:1508 smooth_edge。有三处值得记:

(1) 句号必须"像句子结尾"。 找到 .(ASCII 46)还不够,得后面跟空白或就是文末,并排除 三类假句号(util.py:1529-1546):

排除的情况例子原因
附近还有别的点x.y.像缩写 / 编号,不是句末
点前面是数字point 2.列表编号
常见称谓缩写Mr. Mrs. Dr.缩写点不是句末

(2) 段落分隔也算好切点。 连续换行 \n\n / \r\r 通常标志段落结束,同样作为切点 (util.py:1550-1553)。

(3) 太短就并回上一块。 切出来若比 look_back_range 还短,就拼接到上一个 block 尾巴上, 避免产生一堆零碎小块(util.py:1483-1487):

if len(chunk) < self.look_back_range:
if len(self.chunks) > 0:
self.chunks[-1] += chunk # 并入上一块
else:
self.chunks.append(chunk)

源码注释自评:实测平均块大小 ~500 字符,90%+ 的块停在句号或换行上(util.py:1449)。

5.5 谁在用这把凿子

TextParser / ImageParser / VoiceParser 都复用同一个 TextChunker。它们初始化时把 text_chunk_size 设成 library.block_size_target_characters + 200(parsers.py:4478), look_back 固定 300 —— 即目标 block 大小上再放 200 字符余量,给"往回凿"留空间。

注意: PDF / Office 走的是 C 库,不用这把 Python 凿子;它们的分块由原生解析器按 chunk_size / max_chunk_size / smart_chunking 在 C 层完成(见第 6 节)。


6. 五条解析路,一种产物

每条解析路读的东西不同,但都产出同一种 block。逐条看它们的差异:

6.1 文本类:parse_text(txt / md / csv / tsv / json / jsonl)

parse_text(parsers.py:1198)按扩展名二次分流到 TextParser 的三个 handler:

扩展名handlercontent_type说明
txt / mdtext_file_handler parsers.py:4540text整篇读入 → TextChunker 切块
csv / tsvcsv_file_handler parsers.py:4553texttable按行列切;interpret_as_table 时标为表
json / jsonljsonl_file_handler parsers.py:4486text逐行取 key_list(默认 ["text"])字段

关键:同一个 block,content_type 可能是 text 也可能是 table —— CSV 当表格解读时 content_type="table"(parsers.py:1272-1273)。

6.2 PDF / Office:parse_pdf / parse_office(原生 C 库)

parse_pdf(parsers.py:672)不在 Python 里切块,而是用 ctypes 调 C 函数 add_pdf_main_llmware_config_new(parsers.py:771),把上面那串配置逐个转成 C 参数传进去, 由 C 库直接把 block 写进 DB、返回处理了多少页:

user_block_size = c_int(self.chunk_size) # 400-600
table_extract = c_int(1 if self.get_tables else 0)
smart_chunking = c_int(self.smart_chunking)
get_header_text = c_int(1 if self.get_header_text else 0)
table_grid = c_int(1 if self.table_grid else 0)
max_chunk_size = c_int(self.max_chunk_size)
# …打包成一长串参数,调用 C 库…
pages_created = pdf_handler(account_name, library_name, fp_c, ...)

parsers.py:825-909 parse_pdf。这解释了几个 add_files 旋钮只对 PDF/Office 生效: table_gridget_header_textstrip_headersmart_chunking 都是喂给 C 解析器的 —— 因为只有结构化文档(PDF、Word)才有"表格网格""章节标题""页眉页脚"这些概念。

6.3 图片:parse_image(OCR)

ImageParser.process_ocr(parsers.py:4122)用 pytesseract.image_to_string 把图片转成文字, 再交给 TextChunker 切块,block 的 file_type="ocr"(parsers.py:1730)。需要本机装 tesseract,缺了会抛 DependencyNotInstalledException

6.4 语音:parse_voice(Whisper)

VoiceParser(parsers.py:4333)默认用 whisper-cpp-base-english 转写,可按语音段 (segment)切块(chunk_by_segment=True),block 的 file_type="voice-wav"(parsers.py:1817)。

6.5 维基 / 对话:parse_wiki / parse_dialog

  • WikiParser(parsers.py:4600)按主题抓取维基百科正文,file_type="wiki"(parsers.py:1644)。
  • DialogParser(parsers.py:4657)解析 AWS 通话转录 JSON,把每段发言按说话人聚合, 填进 block 的 author_or_speaker 字段(parse_aws_json_file_format parsers.py:4681)。这是 唯一会认真用到"说话人"字段的解析器。

7. block:全库统一的最小数据单元

7.1 block 长什么样

不管来自哪条解析路,最终每个 block 都被拼成同一个字典,字段定义在 configs.py:880 LLMWareTableSchema._block。核心字段:

字段含义谁填
block_ID块序号(库内逐块自增)全体
doc_ID所属文档号(每文档自增)全体
content_typetext / table / image全体
file_typetxt / pdf / ocr / voice-wav / wiki全体
text块的正文(检索/向量化的主对象)全体
text_search供全文检索的文本副本全体
table表格内容(仅表格块非空)表格块
header_text该块所属章节的标题PDF/Office(get_header_text)
master_index / master_index2页码 / 页内序(即"page")全体
coords_x/y/cx/cy块在页面上的坐标框PDF/Office
author_or_speaker作者 / 说话人Dialog 等
file_source原始文件名全体
embedding_flags记录该块已被哪些嵌入模型向量化第 03 章

一个易混点: header_text 只有 PDF / Office 这类有章节结构的文档才填得上 —— 纯 txt 走 Python 路径时该字段留空(add_create_new_record 里取 new_entry[13], 文本分支填的是 "",见 parsers.py:1429)。所以"每个块带着它的小标题"这个能力,是结构化 解析器的专利。

7.2 block 是怎么被拼出来的

Python 侧解析器切完块后,统一走 _write_output_to_db(parsers.py:1410)把每段包成一个元组, 再由 add_create_new_record(parsers.py:1489)组装成最终字典 + 落库。文本块和表格块的差异 就藏在这一步(parsers.py:1427-1437):

if content_type == "text":
# 第 7 位(table 字段)留空,正文放在第 11 位(text)
new_entry = (content_type, file_type, (page_num,0), counter, "", "", file, "", entries, ...)
else:
# 表格:table 字段和 text 字段都填 entries —— 既能全文检索,又保留表结构
new_entry = (content_type, file_type, (page_num,0), counter, "", "", file, entries, entries, ...)

add_create_new_record 再逐位映射成字典(parsers.py:1510-1539),其中 block_ID = library.block_IDdoc_ID = library.doc_ID,并在写入后自增 library.block_ID(parsers.py:1445)。doc_id 则在每个 文件开始处 get_and_increment_doc_id() 领一个新号(parsers.py:1246)。

block 落进 DB 的机制不在本章。 add_create_new_record 之后如何写进 MongoDB / Postgres / SQLite,是第 01 章讲的双存储底座;block 被向量化的部分在 第 03 章;block 被检索第 04 章


8. 巧妙之处(可带走的技术)

  • "凿子式"切块,兼顾均匀与语义边界。 先量上限再往回找句号 / 换行,还排除 Mr. / 编号点 等假句号 —— 用极轻量的规则拿到"~500 字符且 90% 停在句子边界"的效果,不依赖任何 NLP 模型。 util.py:1508 smooth_edge
  • 分块配置一路直传到 C 层。 add_files 的旋钮 → Parser 属性 → ctypes 参数,同一套配置 贯穿 Python 与原生解析器,PDF 的高性能解析和 txt 的 Python 解析共享一致的语义parsers.py:825 parse_pdf
  • 异构输入,同构输出。 文本 / PDF / 图片 / 语音 / 维基 / 通话六种来源,最终都收敛到同一个 block schema。下游(向量化、检索、生成)因此完全不必关心"这段话原来是 PDF 还是录音"。 configs.py:880 _block
  • 表格双写。 表格块把内容同时写进 tabletext 字段 —— 既保留结构供展示,又能被全文 检索命中。parsers.py:1436 _write_output_to_db

9. 边界与局限(诚实)

  • ingest 硬依赖 library + DB 连接。 两者缺一,ingest 直接返回 0 并劝你改用 parse_one 系列(parsers.py:393-401)。想"只解析不入库"得走 ingest_to_json (parsers.py:452)。
  • PDF/Office 是编译好的 C 库,平台受限。 Mac x86 已被移除支持(parsers.py:728),Linux aarch64 走单独的老路径(parsers.py:717)。这部分是二进制,Python 层看不到分块细节。
  • OCR / 语音靠外部依赖。 OCR 需 tesseract,语音需 whisper 模型;缺依赖即抛异常 (parsers.py:4130)。
  • DialogParser 只认 AWS 一种格式。 supported_format_types = ["aws"](parsers.py:4679), 注释自承"需要更多样本、格式可能演进"。
  • 去重只看文件名。 _collator 的 dupe_check 是"库里有没有同名文件"的集合差集 (parsers.py:312-322),同内容不同名会重复入库。

10. 代码地图(导航索引)

主题文件路径符号名
入库总入口 / 配置下传llmware/library.py:516add_files
单文件便捷入口llmware/library.py:491add_file
专门入口(pdf/office/image/web/wiki/dialog)llmware/library.py:956-1113add_website add_wiki add_dialogs add_image add_pdf add_office
Parser 构造 / 配置属性 / 类型表llmware/parsers.py:60Parser.__init__
按扩展名分拣到工作区llmware/parsers.py:285_collator
按分拣结果分派解析器llmware/parsers.py:382ingest
不写库、只出 JSONllmware/parsers.py:452ingest_to_json
PDF 解析(ctypes → C 库)llmware/parsers.py:672parse_pdf
Office 解析(ctypes → C 库)llmware/parsers.py:932parse_office
文本类分流llmware/parsers.py:1198parse_text
block 元组组装llmware/parsers.py:1410_write_output_to_db
block 字典组装 + 落库llmware/parsers.py:1489add_create_new_record
图片 OCRllmware/parsers.py:4100ImageParser.process_ocr
语音转写llmware/parsers.py:4333VoiceParser
文本 handler(txt/csv/jsonl)llmware/parsers.py:4465TextParser
维基抓取llmware/parsers.py:4600WikiParser
AWS 通话解析llmware/parsers.py:4657DialogParser.parse_aws_json_file_format
"凿子式"切块算法llmware/util.py:1441TextChunker / smooth_edge
block 字段 schemallmware/configs.py:874LLMWareTableSchema._block

上一章: 知识库容器与双存储底座 · 下一章: 嵌入层:一套接口收编 11 种向量库 · 总览: index