围绕 node_document_split 节点,梳理目标 chunk 数据结构、节点作用与参数、四大切割原则与项目切割参数,并拆解语义切割的实现步骤。

本篇要点

  • 明确入库目标:list[dict] 形式的 chunk 列表与它要携带的字段
  • 说清 node_document_split 的作用、输入与输出(暂时不含 item_name 和向量化)
  • 掌握四大切割原则与项目切割参数 CHUNK_MAX_SIZE / CHUNK_SIZE / CHUNK_OVERLAP / CHUNK_MIN
  • 拆解步骤一:参数校验与数据清洗 load_markdown_content
  • 拆解步骤二:按 # 标题语义切割 split_document

内容回顾

1、上一天主题(Day03)

  • 实现了图片处理节点 node_md_img:scan_images 取图片上下文 → 视觉模型 summarize_images 识别 → 上传 MinIO 并替换 md 内容
  • scan_images 会为每张图片生成 (图片名, 图片地址, (上文 100 字, 下文 100 字)) 作为识别上下文
  • 图片处理后由 ![](images/xxx.jpg) 变为 ![图片的内容描述](http...),图片得以参与内容回答,且客户端任何位置都能访问
  • MinIO 侧完成了 docker 部署、可视化操作、桶权限 policy 修改与 Python 增删查,并封装为 MinioGateway
  • 模型侧封装了 LLMProvider,提供 chat(mode_name, json_mode) 与 vision_chat()

今天内容

1、node_document_split 节点

1.1 存储向量数据库回顾(目标结构)

向量库最终存的是 document -> list[chunk],每个 chunk 是一个 dict:

1
2
3
4
5
6
7
8
9
document -> list[chunk] -> chunk : dict {
1 content = "标题 + 内容"
2 title = "标题"
3 parent_title = "title 二次切割后"
4 part = "二次切割后的记录 1 2 3"
4 file_title = "文件名" [拿不到]
item_name = "主体名"
稠密和稀疏 ... [拿不到]
}

也就是:

1
list[dict] = {content, title, file_title, parent_title, part}  ->  chunks -> chunks

注意: file_title、item_name、稠密与稀疏向量在当前阶段是”拿不到”的,需要在后续节点中补全。

1.2 节点说明

  • 作用:获取没有 item_name 和向量化的切块列表 chunks
  • 参数:
    • 使用:md_content、file_title、md_path
    • 响应:chunks: list[dict],字段为 content、title、file_title、parent_title、part

1.3 切割原则和切割参数说明

四大切割原则:

  1. 保证语义优先
    例如 md → 按标题切,表格 → 按 sheet 切,ppt → 按页切 ……
  2. 长度可控(真实开发中要动态调整)
    • 考虑嵌入式模型:至多不超过 70%,bge-m3 为 8192 token
    • 考虑重排序模型:bge-reranker-large 为 512
    • 一些规律指标:500 - 1200 token 之间
  3. 边界清晰、有合理的重叠
    • 边界清晰,不能跨域语义
    • 在同一个语义下,二次切块的时候应该有合理的 overlap
  4. 可追溯
    除了切块内容,应该携带关键的筛选数据,例如 title、file_title、item_name、parent_title、part ……

面试题:你的切块是多长?为什么?

答:600。核心原因是根据项目测评系统反复调试,600 时召回率和准确率更高;同时要考虑模型的问题(嵌入式模型、重排序模型的大小),并结合不同文档的切割思路和经验。

项目切割参数:

1
2
3
4
5
6
7
8
9
10
11
# 文本切块最大长度:单个文本块最多包含 1000 字符(防止过长导致向量失真)
CHUNK_MAX_SIZE = 1000

# 文本切块基准长度:单个文本块理想大小为 600 字符(兼顾语义完整性 + 检索精度)
CHUNK_SIZE = 600 # -> 我们的理想切块

# 文本块重叠长度:相邻块之间重叠 20 字符,保证语义不被切断、上下文连贯
CHUNK_OVERLAP = 50 # 8% - 12%

# 最小碎片阈值:低于这个长度判定为短碎片,需要尝试合并
CHUNK_MIN = 400 # -> 小于 400 合并 -> 1000

整体切割流程:

1
2
根据标题切割 -> 语义 -> 大于 600 -> 递归切割器切割 -> 600 [400 300]
-> 碎块更危险 -> 碎片合并 -> 超过 600 -> 1000

目标: 600 左右 → 减少碎块(提高召回率)→ 避免过大 1000(提高准确率)。

1.4 步骤分析

4.4.1 参数获取和校验

1
md_content, file_title = load_markdown_content(state)
  1. 获取三个参数 md_content、file_title、md_path
  2. md_content 非空校验 → 若为空 → 用 md_path 校验并读取
  3. file_title 非空校验 → 若为空 → 用 md_path 校验并取 stem → 取不到则给默认值
  4. 统一换行符号(数据清洗):md_content 中的 \n\r、\r → 统一为 \n → 写回 state[md_content]
  5. 返回 md_content、file_title

4.4.2 按照语义切割(#…)

1
split_document(md_content, file_title) -> list[dict{title, content, file_title}]

准备数据:

1
2
3
4
chunks               # 结果列表
current_title # 当前标题
current_title_lines # 当前标题下的所有行
is_code_block # 是否处于代码块中

处理流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# 对 md_content 进行分行处理
md_content_lines = md_content.split("\n")

# 定义正则:匹配 1~6 级标题
reg = re.compile("^\s*#{1,6}\s.+")

# 循环处理每一行
for line in lines:
# 判断是不是空行
...

# 判断在不在代码块:~~~ 或 ``` 开头则 append,并取反 is_code_block,continue
...

# 判断没有在代码块且符合正则 -> 这是一行标题
if not is_code_block and reg:
# 结算(不是第一次进来)
if current_title and len(current_title_lines) > 1:
chunks.append(title, content, file_title)

current_title = line

if not current_title and len(current_title_lines) > 0:
current_title_lines.append(line)
else:
current_title_lines = [line]
else:
# 普通行
current_title_lines.append(line)

# 收尾:xxx # xxxx xxx xxx
if current_title and len(current_title_lines) > 1:
chunks.append(title, content, file_title)

# 一个标题都没有的情况
if len(chunks) == 0 and len(current_title_lines) > 0:
# 没有标题 -> 给 default 标题
...

return chunks

后续步骤(留到下一次):

  • 4.4.3 进行精细切割:长 → 切短(带 overlap)| 短 → 合并 → 保证边界清晰
  • 4.4.4 补充相关属性:parent_title、part ……

注意: 4.4.3 与 4.4.4 属于下一次课的内容,本次只完成到按标题语义切割并产出 chunks。