摄取管线:从文件到 chunk(dsparse)
30 秒导读: dsRAG 往知识库里加一篇文档时,第一件事是把「一个文件」变成「一串小块(chunk)」。 这一章讲这段前半程:读文件 → 让 LLM 把文档切成语义连贯的节(section) → 再把每节切成 块(chunk),每块都带上页码和它属于哪一节。给块「补上下文头」是下一章 AutoContext 的事。
本章覆盖 dsrag/dsparse/ 这个子包。它是 dsRAG 里相对独立的一层:输入一个文件或一段文本,
输出 (sections, chunks) 两个列表——不涉及向量、检索、数据库。
1. 这是什么(零基础也能懂)
一句话定义: dsparse 是 dsRAG 的文档预处理器——把「一个文件」拆成「一串适合做检索的 小文本块」,并在拆的时候尽量不破坏语义。
为什么需要它。 RAG 检索的基本单位是「块」。块怎么切,直接决定检索质量:
- 切得太碎 → 一个完整意思被拆到两块,检索到半句话。
- 切得太整(整篇一块)→ 检索粒度太粗,召回一大坨无关内容。
- 按固定字符数硬切 → 会把一句话、一张表格从中间劈开。
dsparse 的答卷是两阶段切:先按「语义」把文档分成几个大节,再在每个节内部按字符数切成 块。这样块的边界总是落在语义节的内部,不会横跨两个主题。
输入与输出。 对外的唯一入口是 parse_and_chunk,它吃一个文件路径或一段文本,吐两样东西:
| 产物 | 是什么 | 关键字段 |
|---|---|---|
sections | 语义节列表(整篇分成几大块主题) | title / start / end / content |
chunks | 检索用的小块列表 | content / line_start / line_end / page_start / page_end / section_index / is_visual |
字段定义在 dsrag/dsparse/models/types.py:20(Section)和 :26(Chunk)。
用起来什么样。 一段最小调用(示意,非源码):
from dsrag.dsparse.main import parse_and_chunk
# 给它一个文件路径,拿回 sections 和 chunks 两个列表
sections, chunks = parse_and_chunk(
kb_id="my_kb",
doc_id="doc_1",
file_path="/path/to/report.pdf",
)
# chunks[0] 大致长这样:
# {"content": "...", "line_start": 0, "line_end": 12,
# "page_start": 1, "page_end": 1, "section_index": 0, "is_visual": False}
一句话直觉。 把 dsparse 想成一个编辑:先通读全文、用铅笔在页边划出「这里是引言、这里是方法、 这里是结论」(分节),再把每一节裁成便于归档的卡片(分块),每张卡片背面记上「第几页、属于哪一节」。
2. 顶层全景(它大概怎么转)
整条管线是三步流水线:解析(parse)→ 分节(section)→ 分块(chunk)。三步之间靠一个统一的
中间表示 document_lines(带行号的行列表) 串起来。
parse_and_chunk (main.py:23)
│
┌─────────────────────────┼──────────────────────────┐
│ 分支① │ 分支② │ 分支③
use_vlm 且 .pdf 非 VLM 的文件 纯 text 文本
│ │ │
▼ ▼ ▼
parse_and_chunk_vlm parse_and_chunk_no_vlm parse_and_chunk_no_vlm
│ │ │
┌─────┴─────┐ ┌─────┴─────┐ ┌─────┴─────┐
│ ① VLM 解析 │ │ ① pypdf/ │ │ ①(已是 │
│ 每页→图片 │ │ docx2txt │ │ 文本, │
│ →LLM 抽元素│ │ 抽文本 │ │ 跳过解析)│
└─────┬─────┘ └─────┬─────┘ └─────┬─────┘
│ elements[] │ text, pdf_pages │ text
▼ ▼ ▼
┌───────────────────────── 统一中间层 ─────────────────────────┐
│ 转成 document_lines(每行:content/page_number/is_visual) │
└───────────────────────────────┬───────────────────────────┘
▼
② 语义分节 Semantic Sectioning (semantic_sectioning.py)
给行标号 → 分窗口 → LLM 按行号标 section → 合并/校验
│ sections[](start/end/title)
▼
③ 分块 chunk_document (chunking.py:5)
按 section 切 · 遇 visual 单独成块 · 太短不切 · 否则按 chunk_size 切
回填 page_start/page_end/section_index
│
▼
返回 (sections, chunks)
怎么读这张图: 上到下是数据流。三个入口分支最终都汇到同一个 document_lines 中间层,之后
「分节 → 分块」两步对三条分支是完全一样的。行号(line index)是贯穿全程的坐标系——分节用它
标边界,分块用它定位,页码也挂在行上。
部件一句话职责:
| 部件 | 干什么 | 在哪 |
|---|---|---|
parse_and_chunk | 总入口,按配置三选一分派 | main.py:23 |
parse_and_chunk_vlm | VLM 路径:PDF 每页转图 → LLM 抽结构化元素 | main.py:211 |
parse_and_chunk_no_vlm | 非 VLM 路径:pypdf/docx2txt 抽纯文本(或直接用 text) | main.py:336 |
| 文件解析 | 把原始文件变成 text/elements | non_vlm_file_parsing.py、vlm_file_parsing.py |
| 语义分节 | 把行列表切成语义节 | semantic_sectioning.py |
| 分块 | 把每个节切成 chunk | chunking.py |
主线走一遍(高层): 文件进来 → 三分支之一把它变成「行的列表」→ LLM 通读并标出「哪几行是一节」 → 遍历每一节、按字符预算切成块 → 每块回填页码和 section 序号 → 返回。
3. 第一步:解析(把文件变成可处理的形式)
这一节讲什么: parse_and_chunk 怎么在三条路径里选一条,以及每条路径怎么把原始输入变成
下游要的中间数据。
3.1 三分支分派
入口先看一个开关 use_vlm(来自 file_parsing_config),再看给的是文件还是文本,分出三条路。
# main.py:98 起(简化)
use_vlm = file_parsing_config.get("use_vlm", False)
if use_vlm and file_path and not file_path.lower().endswith(".pdf"):
raise ValueError(...) # VLM 只吃 PDF
if use_vlm:
sections, chunks = parse_and_chunk_vlm(...) # 分支①
else:
if file_path:
sections, chunks = parse_and_chunk_no_vlm(file_path=..., ...) # 分支②
else:
sections, chunks = parse_and_chunk_no_vlm(text=..., ...) # 分支③
真实逻辑见 main.py:107-174(parse_and_chunk)。三分支的分工:
| 分支 | 触发条件 | 解析器 | 产出中间物 |
|---|---|---|---|
| ① VLM PDF | use_vlm=True 且是 .pdf | 视觉语言模型逐页读图 | elements[](带类型) |
| ② 非 VLM 文件 | use_vlm=False 且有 file_path | pypdf / docx2txt / 直接读 | text + 可选 pdf_pages |
| ③ 纯文本 | use_vlm=False 且只给 text | 无(已是文本) | text |
为什么 VLM 只接受 PDF: VLM 路径要把每页渲染成图片再喂给模型看,只有 PDF 有稳定的分页可渲染;
所以 main.py:107 一开始就对非 PDF 报错。
三条分支之后各自都跑同样的三步(解析 → 分节 → 分块),parse_and_chunk_vlm(main.py:211)和
parse_and_chunk_no_vlm(main.py:336)的骨架几乎一样,区别只在第一步用哪个解析器、以及
document_lines 里 page_number/is_visual 是否有值。
3.2 非 VLM 解析:抽纯文本
parse_file_no_vlm(non_vlm_file_parsing.py:27)按扩展名分派,非常朴素:
| 扩展名 | 用什么 | 额外产物 |
|---|---|---|
.pdf | pypdf.PdfReader 逐页 extract_text() | pdf_pages(每页一个字符串) |
.docx | docx2txt.process | 无 |
.txt / .md | 直接 file.read() | 无 |
| 其它 | 抛 ValueError | — |
关键在 PDF 分支:extract_text_from_pdf(non_vlm_file_parsing.py:4)不但拼出全文,还逐页保留
一个 pages 列表返回。这个 pdf_pages 就是后面页码的来源——有它,下游才能给每一行标上第几页
(main.py:406-415,有 pdf_pages 就走 get_sections_from_pages,否则走 get_sections_from_str)。
3.3 VLM 解析:让模型「看」每一页
非 VLM 解析对着扫描件、复杂排版、图表会失灵——它只会抽出可复制的文字。VLM 路径解决的就是这个: 把每页当成图片,让视觉模型描述页面上的每个元素。流程三步:
PDF ──pdf_to_images──▶ page_1.jpg, page_2.jpg, ... (每页渲染成图,存到 file_system)
│
并发(ThreadPoolExecutor)每页调一次 VLM
▼
每页 ── parse_page ──▶ [ {type, content, page_number}, ... ] (结构化元素)
│
按页号排序、拼成一个大 elements 列表
▼
elements[] ──▶ 交给分节
- 渲染成图:
pdf_to_images(vlm_file_parsing.py:89)用pdf2image(底层 poppler)按dpi分批把页转成.jpg,存进file_system。 - 逐页抽元素:
parse_file(vlm_file_parsing.py:329)用线程池并发,对每页调parse_page(vlm_file_parsing.py:138)。parse_page把页图 + 一段系统提示喂给 VLM,要求它返回一个 JSON 元素数组(schema 见vlm_file_parsing.py:54的response_schema)。它内置最多 10 次重试 和主/备模型交替,还会对429限流退避 10 秒(vlm_file_parsing.py:191-322)。 - 拼回顺序: 并发结果按页号排序后
extend成一个扁平elements列表(vlm_file_parsing.py:390-392)。
element 类型与「视觉元素」
VLM 被要求把页面内容归到有限几类元素里。默认清单 default_element_types
(element_types.py:43)共 8 种,每种带一个 is_visual 布尔:
| 元素类型 | is_visual | 含义 |
|---|---|---|
NarrativeText | 否 | 正文段落、列表、标题(用 Markdown 表示) |
Figure | 是 | 图表、示意图 |
Image | 是 | 照片、插画等其它视觉内容 |
Table | 是 | 表格 |
Equation | 是 | 数学公式 |
Header | 否 | 页眉(默认被排除) |
Footnote | 否 | 脚注 |
Footer | 否 | 页脚(默认被排除) |
is_visual 这面旗子是整条管线的关键分水岭,它有两个下游 后果:
- 视觉元素的 content 是「描述」不是「原文」。 系统提示明确要求:对视觉元素给一段详细描述,
而不是照抄里面的文字(
vlm_file_parsing.py:42)。所以一张图表在文本流里表现为「一段讲这张图 在说什么的话」——这段描述之后能被向量检索命中。 - 视觉元素在分块时单独成块、绝不被切开(见 §5)。
element_types.py:4-26 的几个小工具(get_visual_elements_as_str 等)只是把这份清单渲染进给 VLM
的系统提示里,告诉模型「有哪些视觉类型、哪些文本类型」。
默认排除页眉页脚。 VLM 配置里
exclude_elements默认是["Header", "Footer"](main.py:257),分节前会把这两类整行丢弃(见 §4 的elements_to_lines)——它们对检索是噪声。
VLM 客户端抽象
vlm_clients.py 定义了一个 VLM 抽象基类(vlm_clients.py:10),两个实现:GeminiVLM
(vlm_clients.py:72,走 google-genai)和 VertexAIVLM(vlm_clients.py:169,走 Vertex AI)。
两者都实现 make_llm_call(image_path, system_message, response_schema, ...),统一返回一段 JSON 文本。
基类用 __init_subclass__ 做了个按类名的注册表,支持从配置字典 from_dict 反序列化(vlm_clients.py:26-57)——
这让 VLM 客户端能被序列化进配置、跨进程传递。
4. 第二步:语义分节(Semantic Sectioning)
这一节讲什么: 拿到 elements(或 text/pages)后,怎么让 LLM 把整篇文档切成几个语义连贯的节。
这是整个 dsparse 里最巧的一块,在 semantic_sectioning.py。
4.1 核心难题与思路
难题: 让 LLM 直接「输出每一节的完整文本」既慢又容易改写原文、还对不齐边界。
思路:一切都用行号说话。 dsparse 不让 LLM 复述内容,而是:
- 先把文档摊平成一行一行,每行一个全局行号。
- 把带行号的文本给 LLM,只让它回答:每一节从第几行开始(一个整数
start_index)。 - 拿回这些起始行号,程序自己算出每节的
end、再回填content。
LLM 只需吐行号,不碰正文——又快又不会篡改,边界也精确到行。
4.2 先把一切变成带 is_visual 的「行」
三个入口函数把不同来源统一成 document_lines(List[Line]):
| 入口 | 来源 | 转换器 | page_number | is_visual |
|---|---|---|---|---|
get_sections_from_elements (:943) | VLM 的 elements | elements_to_lines (:320) | 元素自带 | 按元素类型 |
get_sections_from_pages (:1069) | 非 VLM 的 pdf_pages | pages_to_lines (:412) | 页序号(1 起) | 恒 False |
get_sections_from_str (:1010) | 纯文本 | str_to_lines (:378) | 恒 None | 恒 False |
(以上行号均在 semantic_sectioning.py。)三者的共同规则:
- 超长行会被拆。 一行超过
max_line_length=200字符就按词边界拆成多行(split_long_line,:293), 防止单行过长干扰行号标注。 - 视觉元素整块保留、不拆行、不参与分行。
elements_to_lines里视觉元素直接作为一整行加入、 标is_visual=True(:343-350);排除清单里的元素(默认页眉页脚)直接跳过(:341)。
一个字符串来源的行大致长这样(示意):
# str_to_lines 产出的一行(semantic_sectioning.py:392 起)
{"content": "本节介绍方法……", "element_type": "NarrativeText",
"page_number": None, "is_visual": False}