数据截至 (上游 commit 081bc084a5bc)
多格式解析与文本分块: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_office | ctypes 调用 C 解析库,直接写 block | parsers.py:672 / :932 |
TextParser | 处理 txt/md/csv/tsv/json/jsonl | parsers.py:4465 TextParser |
ImageParser | OCR(pytesseract)扫描件/图片 | parsers.py:4100 ImageParser |
VoiceParser | Whisper 转写 wav/mp3 | parsers.py:4333 VoiceParser |
WikiParser / DialogParser | 抓维基 / 解析 AWS 通话转录 | parsers.py:4600 / :4657 |
TextChunker | "凿子式"把长文切成 ~500 字符的 block | util.py:1441 TextChunker |
| block schema | block 的字段定义 | 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_size | 400 | 目标 block 大小(字符),分块的基准 |
max_chunk_size | 600 | block 的硬上限 |
smart_chunking | 1 | 智能切块策略开关(传给 C 解析器) |
get_images | True | 是否抽取内嵌图片 |
get_tables | True | 是否把表格抽成独立 block |
table_grid | True | 表格是否保留行列网格结构 |
get_header_text | True | 是否为每个 block 附上所属章节的标题文本 |
strip_header | False | 是否丢弃页眉页脚 |
copy_files_to_library | True | 是否把原始文件也复制进库留档 |
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_files。Parser.__init__(parsers.py:186-198)把这些参数原样存成
实例属性(self.chunk_size、self.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 | 只加 PDF | library.py:1071 | parse_pdf(C 库) |
add_office | 只加 pptx/xlsx/docx | library.py:1093 | parse_office(C 库) |
add_image | 图片 / 扫描件 OCR | library.py:1027 | parse_image |
add_pdf_by_ocr | 扫描版 PDF 逐页 OCR | library.py:1049 | parse_pdf_by_ocr_images |
add_website | 抓网页(可跟链接) | library.py:956 | parse_website |
add_wiki | 按主题抓维基百科 | library.py:982 | parse_wiki |
add_dialogs | AWS 通话转录 JSON | library.py:1005 | parse_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 ingest。self.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_chunks 与 util.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:
| 扩展名 | handler | content_type | 说明 |
|---|---|---|---|
| txt / md | text_file_handler parsers.py:4540 | text | 整篇读入 → TextChunker 切块 |
| csv / tsv | csv_file_handler parsers.py:4553 | text 或 table | 按行列切;interpret_as_table 时标为表 |
| json / jsonl | jsonl_file_handler parsers.py:4486 | text | 逐行取 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_grid、get_header_text、strip_header、smart_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_formatparsers.py:4681)。这是 唯一会认真用到"说话人"字段的解析器。
7. block:全库统一的最小数据单元
7.1 block 长什么样
不管来自哪条解析路,最终每个 block 都被拼成同一个字典,字段定义在
configs.py:880 LLMWareTableSchema._block。核心字段:
| 字段 | 含义 | 谁填 |
|---|---|---|
block_ID | 块序号(库内逐块自增) | 全体 |
doc_ID | 所属文档号(每文档自增) | 全体 |
content_type | text / table / image | 全体 |
file_type | txt / 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_ID、
doc_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:1508smooth_edge。 - 分块配置一路直传到 C 层。
add_files的旋钮 →Parser属性 →ctypes参数,同一套配置 贯穿 Python 与原生解析器,PDF 的高性能解析和 txt 的 Python 解析共享一致的语义。parsers.py:825parse_pdf。 - 异构输入,同构输出。 文本 / PDF / 图片 / 语音 / 维基 / 通话六种来源,最终都收敛到同一个
block schema。下游(向量化、检索、生成)因此完全不必关心"这段话原来是 PDF 还是录音"。
configs.py:880_block。 - 表格双写。 表格块把内容同时写进
table和text字段 —— 既保留结构供展示,又能被全文 检索命中。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:516 | add_files |
| 单文件便捷入口 | llmware/library.py:491 | add_file |
| 专门入口(pdf/office/image/web/wiki/dialog) | llmware/library.py:956-1113 | add_website add_wiki add_dialogs add_image add_pdf add_office |
| Parser 构造 / 配置属性 / 类型表 | llmware/parsers.py:60 | Parser.__init__ |
| 按扩展名分拣到工作区 | llmware/parsers.py:285 | _collator |
| 按分拣结果分派解析器 | llmware/parsers.py:382 | ingest |
| 不写库、只出 JSON | llmware/parsers.py:452 | ingest_to_json |
| PDF 解析(ctypes → C 库) | llmware/parsers.py:672 | parse_pdf |
| Office 解析(ctypes → C 库) | llmware/parsers.py:932 | parse_office |
| 文本类分流 | llmware/parsers.py:1198 | parse_text |
| block 元组组装 | llmware/parsers.py:1410 | _write_output_to_db |
| block 字典组装 + 落库 | llmware/parsers.py:1489 | add_create_new_record |
| 图片 OCR | llmware/parsers.py:4100 | ImageParser.process_ocr |
| 语音转写 | llmware/parsers.py:4333 | VoiceParser |
| 文本 handler(txt/csv/jsonl) | llmware/parsers.py:4465 | TextParser |
| 维基抓取 | llmware/parsers.py:4600 | WikiParser |
| AWS 通话解析 | llmware/parsers.py:4657 | DialogParser.parse_aws_json_file_format |
| "凿子式"切块算法 | llmware/util.py:1441 | TextChunker / smooth_edge |
| block 字段 schema | llmware/configs.py:874 | LLMWareTableSchema._block |
上一章: 知识库容器与双存储底座 · 下一章: 嵌入层:一套接口收编 11 种向量库 · 总览: index