跳到主要内容

摄取管线:从文件到 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_vlmVLM 路径:PDF 每页转图 → LLM 抽结构化元素main.py:211
parse_and_chunk_no_vlm非 VLM 路径:pypdf/docx2txt 抽纯文本(或直接用 text)main.py:336
文件解析把原始文件变成 text/elementsnon_vlm_file_parsing.pyvlm_file_parsing.py
语义分节把行列表切成语义节semantic_sectioning.py
分块把每个节切成 chunkchunking.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 PDFuse_vlm=True 且是 .pdf视觉语言模型逐页读图elements[](带类型)
② 非 VLM 文件use_vlm=False 且有 file_pathpypdf / 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_linespage_number/is_visual 是否有值。

3.2 非 VLM 解析:抽纯文本

parse_file_no_vlm(non_vlm_file_parsing.py:27)按扩展名分派,非常朴素:

扩展名用什么额外产物
.pdfpypdf.PdfReader 逐页 extract_text()pdf_pages(每页一个字符串)
.docxdocx2txt.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:54response_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 这面旗子是整条管线的关键分水岭,它有两个下游后果:

  1. 视觉元素的 content 是「描述」不是「原文」。 系统提示明确要求:对视觉元素给一段详细描述, 而不是照抄里面的文字(vlm_file_parsing.py:42)。所以一张图表在文本流里表现为「一段讲这张图 在说什么的话」——这段描述之后能被向量检索命中。
  2. 视觉元素在分块时单独成块、绝不被切开(见 §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 复述内容,而是:

  1. 先把文档摊平成一行一行,每行一个全局行号
  2. 把带行号的文本给 LLM,只让它回答:每一节从第几行开始(一个整数 start_index)。
  3. 拿回这些起始行号,程序自己算出每节的 end、再回填 content

LLM 只需吐行号,不碰正文——又快又不会篡改,边界也精确到行。

4.2 先把一切变成带 is_visual 的「行」

三个入口函数把不同来源统一成 document_lines(List[Line]):

入口来源转换器page_numberis_visual
get_sections_from_elements (:943)VLM 的 elementselements_to_lines (:320)元素自带按元素类型
get_sections_from_pages (:1069)非 VLM 的 pdf_pagespages_to_lines (:412)页序号(1 起)False
get_sections_from_str (:1010)纯文本str_to_lines (:378)NoneFalse

(以上行号均在 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}

4.3 该不该分节?——先过一道门槛

不是所有文档都值得调 LLM 分节。三个入口在真正分节前都有同一个门槛判断(以 get_sections_from_str :1051 为例):

# 只有「开启语义分节」且「文档够长」才真的调 LLM
if use_semantic_sectioning and len(document) > min_length_for_chunking:
sections = get_sections(...) # 真·并行分节
else:
sections = no_semantic_sectioning(...) # 回退:整篇当一节
  • use_semantic_sectioning 默认 True(:1039)。设为 False 直接回退。
  • 这里的 min_length_for_chunking 取的是 chunking_config 里的值,默认 0(:1045)—— 注意它和第 §5 步 chunk 时用的默认 1600 不是同一处,此处仅作「文档太短就别分节」的开关。
  • 回退函数 no_semantic_sectioning(:447)极简:返回一个覆盖全文的节(start=0,end=行数-1, title="")。

4.4 真·分节:分窗口 → 并行问 LLM → 合并

真正干活的是 get_sections(semantic_sectioning.py:780),六步:

document_lines

①create_document_windows ── 按 ~0.9×max_chars 把行切成不重叠窗口
│ [(0, 42), (43, 88), ...]

②并行 process_window_with_retries ── 每个窗口一次 LLM 调用(带重试)
│ 每窗 → StructuredDocument{ sections:[{title,start_index}] }

③validate_and_fix_window_sections ── 窗内:去重/排序/夹到窗口边界

④merge_sections_across_windows ── 把相邻窗口交界处的节缝合

⑤validate_and_fix_global_sections ── 全局:去重/排序/保证首节从第 0 行起

⑥get_sections_text ── 算每节 end、回填 content

▼ List[Section]

① 为什么要分窗口。 长文档一次塞不进一次 LLM 调用,create_document_windows(:470)按累计字符数 把行切成多个不重叠窗口,每窗目标 max_characters_per_window=20000(由 parse_and_chunk_vlm/no_vlm 传入,见 main.py:271)。有个细节:阈值实际用的是 0.9 * max_characters_per_window——留 10% 给行号, 因为给 LLM 的文本每行都会加个 [123] 前缀,占额外 token(:503)。

② 每行带号喂给 LLM。 get_document_text_for_window(:43)把窗口内每行渲染成 [<全局行号>] <内容>get_structured_document_for_window(:64)用 instructor 库要求 LLM 返回一个 Pydantic 结构 StructuredDocument——本质是一串 DocumentSection{title, start_index}(:13-19)。系统提示 (SYSTEM_PROMPT,:22)反复强调「用行号标节的起点、首节必须从窗口首行起、节要按序覆盖整个窗口」。 支持 anthropic/openai/gemini 三家(默认 openai + gpt-4o-mini,:977-978),并发上限 llm_max_concurrent_requests=5,失败按 process_window_with_retries(:515)指数退避重试 3 次。

反滥切保护。 若某窗口切出多节、但平均每节字符数低于 min_avg_chars_per_section=500, 会被判定为「切太碎」,直接塌成一个「Consolidated Section」(:876-889)。

③ 窗内校验。 validate_and_fix_window_sections(:160)对单窗结果做清洗:去掉重复 start_index、 排序、跳过越界的、并强制首节起点等于窗口首行、后续节严格递增。

④ 跨窗合并。 相邻两个窗口在交界处很可能把「同一节」切成了两半(前窗末节 + 后窗首节)。 merge_sections_across_windows(:599)负责缝合:当两窗都不止一节(或前窗多节且后窗是最后一窗)时, 把前窗末节和后窗首节合成一节,标题拼成 "前节标题 / 后节标题"、起点取靠前的那个(:664-680)。 若某窗只有单节(比如刚被塌缩成一节的),则不合并、保持独立——避免把两个本就完整的节错误粘连。

⑤ 全局校验。 validate_and_fix_global_sections(:698)再来一遍全局清洗:去重、排序、跳过越界, 并保证第一节从文档第 0 行开始——若不是,就在最前面插一个「Document Beginning」节(:770-774), 确保节能无缝覆盖全文、不漏行。

⑥ 回填 content 和 end。 到这里每节只有 startget_sections_text(:244)算 end: 每节的 end = 下一节 start - 1,最后一节 end = 文档末行(:262-266);再按 [start, end] 把行内容 "\n".joincontent。产出最终 List[Section](title/start/end/content)。

这一步产出的 Section 长这样(示意):

{"title": "方法:两阶段分块", "start": 43, "end": 87, "content": "……整节文字……"}

5. 第三步:分块(chunk_document)

这一节讲什么: 有了 sections(语义节)和 document_lines(带页码、带 is_visual 的行), chunk_document(chunking.py:5)把每个节切成检索用的 chunk。它是本章的终点。

5.1 三条切块规则

chunk_document 逐个 section 处理。对每个 section 内部,再按下面的决策切:

对每个 section:
├─ 找出节内所有 is_visual 的行,用它们把节切成若干「子段」
│ 文本…│ 图 │…文本…│ 表 │…文本 (图/表把文本从中劈开)

└─ 对每个子段,三选一:
① 这段是视觉元素 → 整段单独成一块(绝不切) chunking.py:59
② len(text) < 阈值 → 整段作为一块(太短不值得切) chunking.py:71
③ 否则 → RecursiveCharacterTextSplitter chunking.py:83
按 chunk_size 递归切成多块

先按视觉元素切子段(chunking.py:38-52)。 先扫出节内所有 is_visual 行的下标,以它们为界把 节切成「文本子段 / 视觉行 / 文本子段 / …」。这样每张图、每个表都会落在自己的子段里、单独成块—— 既不会被文本稀释,也不会被字符切割器劈开。

三条规则的阈值。 min_length_for_chunking 默认 1600chunk_size 默认 800(在 parse_and_chunk_vlm/no_vlm 里取,见 main.py:304-305)。规则②的含义:短于 1600 字符的子段 整段成一块——因为它已经够短、再切只会制造碎片。注意默认 min_length_for_chunking(1600) > chunk_size(800), 这是有意的:它让「短节/短文档」倾向于不被切碎。

5.2 规则③:按 chunk_size 递归切

真正的字符级切割在 chunk_sub_section(chunking.py:99),核心是 LangChain 的 RecursiveCharacterTextSplitter(chunking.py:119):

# chunking.py:119(简化)
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=max_length, # 即 chunk_size,默认 800
chunk_overlap=0, # dsRAG 不做块间重叠——重叠靠检索期的 RSE 补偿
length_function=len,
)
documents = text_splitter.create_documents([concatenated_text])

它「递归」的含义是:优先按段落切,切不够小再按句、再按词——尽量在自然边界断开。dsparse 补了两件事:

  1. 把字符位置映射回行号。 切割器只认字符,但下游 chunk 要 line_start/line_end。所以先把子段各行 拼成一个大字符串、记下每行的字符区间,切完后用 find_lines_in_range(chunking.py:178)把每个块的 字符范围反查回它覆盖的行号区间
  2. 合并过小的尾块。 若最后一块小于 chunk_size 的一半,就把它并进倒数第二块(chunking.py:162-172), 避免尾部留一个碎渣块。

注意 chunk_overlap=0 dsRAG 刻意不在切块时做重叠。它靠查询期的 RSE(相关段落抽取) 把相邻碎块重新拼回长段(见 03-rse-query),所以摄取期不必用重叠来「保险」。

5.3 回填元数据:页码与 section 归属

无论走哪条规则,每个 Chunk 都会挂上三样从行上回填的元数据(chunking.py:61-95):

字段从哪来怎么算
page_start / page_end块首行/末行的 page_number直接取 document_lines[i]['page_number']
section_index当前所在 section 的序号外层 enumerate(sections) 的下标
is_visual该子段是不是视觉元素规则① 为 True,②③ 为 False

这就是页码怎么一路传到 chunk 的: 页码在解析期挂到行上(VLM 元素自带 / pages_to_lines 按页赋值), 分节、分块全程只搬行号不动页码,最后在这里按块的首末行读回页码。所以一个 chunk 能精确回答 「我来自第几页、属于哪一节」——这对之后带引用的回答(05-chat-and-citations)很关键。

最终 chunk 长这样(示意):

{"line_start": 43, "line_end": 55, "content": "……",
"page_start": 3, "page_end": 3, "section_index": 1, "is_visual": False}

到此 (sections, chunks) 就绪并返回。给每个 chunk 补一段上下文头(让碎块也能自解释)是下一章 AutoContext 的工作——本章到产出 sections + chunks 为止。


6. 巧妙之处(可借鉴的技术)

  • 让 LLM 只吐行号,不吐正文。 分节把 LLM 的输出压成「每节起始行号」这一个整数,既省 token、又 杜绝了模型改写原文、还让边界精确可校验。真源码:DocumentSection.start_index(semantic_sectioning.py:15)、 回填在程序侧的 get_sections_text(:244)。
  • 窗口阈值留 10% 给行号。 create_document_windows0.9 * max_characters 而非满额,预留行号前缀 占用的 token(semantic_sectioning.py:503)——一个很实际的工程细节。
  • 交界缝合而非硬切。 分窗口必然在边界切断语义,merge_sections_across_windows(:599)用「相邻窗口 末/首节缝合」把这种人为断裂补回来,还带条件判断避免误粘。
  • 视觉元素当一等公民。 is_visual 从 VLM 抽取一路贯穿到分块:视觉内容存的是描述(可被检索)、 且单独成块永不被切(chunking.py:38-70)。
  • 摄取不重叠,检索期再拼。 chunk_overlap=0(chunking.py:121)把「保上下文」的责任后移给 RSE, 避免重叠带来的存储与去重负担。

7. 边界与局限(诚实)

  • VLM 只支持 PDF。 非 PDF 用 VLM 会在入口直接报错(main.py:107)。
  • 非 VLM 解析很朴素。 扫描件、复杂表格、多栏排版下 pypdf.extract_text() 质量有限——这正是 VLM 路径 存在的理由。docx/txt/md 无页码(pdf_pages=None),其 chunk 的 page_* 会是 None
  • 分节质量依赖 LLM。 行号标注、跨窗缝合都可能出错;代码用了多层校验(窗内/全局 validate、反滋碎塌缩) 和回退(no_semantic_sectioning)兜底,但边界仍非 100% 精确。
  • VLM 逐页调用、成本高。 每页一次视觉模型调用,长 PDF 昂贵且慢(靠线程池并发 + 重试缓解, vlm_file_parsing.py:368)。
  • 默认丢弃页眉页脚。 exclude_elements 默认排除 Header/Footer(main.py:257);若这些信息重要需自行配置。

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

主题文件路径符号名
总入口 / 三分支分派dsrag/dsparse/main.pyparse_and_chunk
VLM 路径编排dsrag/dsparse/main.pyparse_and_chunk_vlm
非 VLM / 纯文本路径编排dsrag/dsparse/main.pyparse_and_chunk_no_vlm
非 VLM 文件解析dsrag/dsparse/file_parsing/non_vlm_file_parsing.pyparse_file_no_vlm / extract_text_from_pdf
VLM 逐页解析dsrag/dsparse/file_parsing/vlm_file_parsing.pyparse_file / parse_page / pdf_to_images
VLM 系统提示 / JSON schemadsrag/dsparse/file_parsing/vlm_file_parsing.pySYSTEM_MESSAGE / response_schema
元素类型清单(is_visual)dsrag/dsparse/file_parsing/element_types.pydefault_element_types
VLM 客户端抽象dsrag/dsparse/file_parsing/vlm_clients.pyVLM / GeminiVLM / VertexAIVLM
分节入口(三来源)dsrag/dsparse/sectioning_and_chunking/semantic_sectioning.pyget_sections_from_elements / get_sections_from_str / get_sections_from_pages
行转换dsrag/dsparse/sectioning_and_chunking/semantic_sectioning.pyelements_to_lines / str_to_lines / pages_to_lines
分节主编排dsrag/dsparse/sectioning_and_chunking/semantic_sectioning.pyget_sections
分窗口dsrag/dsparse/sectioning_and_chunking/semantic_sectioning.pycreate_document_windows
单窗 LLM 调用dsrag/dsparse/sectioning_and_chunking/semantic_sectioning.pyget_structured_document_for_window / process_window_with_retries
LLM 输出结构dsrag/dsparse/sectioning_and_chunking/semantic_sectioning.pyDocumentSection / StructuredDocument
跨窗合并 / 全局校验dsrag/dsparse/sectioning_and_chunking/semantic_sectioning.pymerge_sections_across_windows / validate_and_fix_global_sections
回填 content/enddsrag/dsparse/sectioning_and_chunking/semantic_sectioning.pyget_sections_text
分节回退dsrag/dsparse/sectioning_and_chunking/semantic_sectioning.pyno_semantic_sectioning
分块主逻辑dsrag/dsparse/sectioning_and_chunking/chunking.pychunk_document
字符级切割dsrag/dsparse/sectioning_and_chunking/chunking.pychunk_sub_section / find_lines_in_range
数据结构定义dsrag/dsparse/models/types.pyLine / Section / Chunk / Element