Day 04 / 共 20 天 · 第 1 周 RAG 全景与数据摄入

切块 NodeParser(流水线第 ② 步)

文档读进来了,但一份 100 页的手册没法整个用。第 ② 步:切成小块(Node)。切块质量直接决定检索质量——切得好,检索精准;切得烂,后面再强也白搭。这是 RAG 最该花心思调的一环。

📍 你在整条链的位置
① 读 ② 切块(最影响效果) ③ 嵌入 ④ 存索引 ⑤检索 ⑥合成
L01

为什么必须切块

🤔 直接拿整份文档去检索/喂 LLM,会怎样? 用户问"报销上限多少",如果检索单位是"整份 100 页的《财务制度》"——① LLM 塞不下 100 页;② 就算塞下,答案里 99% 是无关内容,反而干扰;③ 整份文档主题太杂,"它的向量"没法代表"报销"这个具体点。
💡 切块 = 把大文档切成"大小合适、主题集中"的小块 切成一段一段后:检索能精准命中"报销那一段"、LLM 只看到相关内容、每块的向量能准确代表其语义。切块是"让检索精准"的前提。
L02

切大 or 切小?两难

切太大 🚫 一块含 5 个主题 → 检索不精准、塞不下、向量"糊" 切太小 🚫 → 半句话、丢上下文、语义不全 刚好 ✅ → 一块=一个完整意思,主题集中 🎯 目标:每块尽量接近 chunk_size,且在"自然边界"(句子/段落)切开
切块的核心权衡:太大不精准、太小丢上下文,要找"刚好"的大小 + 在自然边界切。
这就是为什么切块最该实验 没有万能的切块大小——技术文档适合小块(信息密集)、故事叙述适合大块(要连贯上下文)。chunk_size 是 RAG 调优第一个该动的旋钮。
L03

两个关键参数:chunk_size 与 overlap

🤔 一个完整意思正好被切在两块的边界上,怎么办? 比如"报销需在 30 天内提交,逾期不予受理"——如果"30 天内提交"在块 A 末尾、"逾期不予受理"在块 B 开头,检索到块 A 就漏了后半句。
原文(一长条) 块1 (chunk_size) 块2 overlap 相邻块重叠一段(overlap):边界处的信息在两块里都有,不会因切割而丢失
chunk_overlap:相邻块故意重叠一部分,保证边界处的完整意思不被切断。
💡 两个参数 chunk_size(默认 1024 token):每块多大。chunk_overlap(默认 200 token):相邻块重叠多少。overlap 就像裁纸时每页留点重叠——接缝处的字不会丢。
L04

SentenceSplitter:默认切块器

node_parser/text/sentence.py:34

class SentenceSplitter(MetadataAwareTextSplitter):
    chunk_size: int = 1024                    # :43 每块目标大小(token)
    chunk_overlap: int = 200                  # :48 重叠
    def __init__(self, chunk_size=1024, chunk_overlap=200, ...):
        if chunk_overlap > chunk_size:        # :83 校验:重叠不能比块还大(否则荒谬)
            raise ValueError("overlap 不能大于 chunk_size")
💡 它比"每 1024 字硬切"聪明在哪 名字里的 "Sentence" 和 "MetadataAware" 揭示两个聪明点:① 尽量在句子边界切(不把一句话砍断,L05);② MetadataAware——切时会算上 metadata 占的空间(回想 Day 02:metadata 会拼进文本),保证"文本+metadata"不超 chunk_size。
L05

层层降级:按自然边界切

💡 优先在"越自然的边界"切 SentenceSplitter 按优先级尝试分隔:先按段落(\n\n)→ 段落太长就按句子(。!?.!?)→ 句子还太长就按词 → 最后才按字符。
📝 一段话怎么被切(chunk_size 假设很小) 原文:报销上限3000元。需附发票。逾期不受理。出差标准另见附录。
→ 优先在句号切,得到:
  块1 = 报销上限3000元。需附发票。(凑到接近 chunk_size 的完整句子)
  块2 = 需附发票。逾期不受理。(overlap 保留"需附发票")
每块都是完整句子的集合,不会出现"报销上限30"这种半句。
底层按 token 数,不是字符数 因为大模型按 token 计费和限长(token ≈ 词/字的一部分)。所以 SentenceSplitter 用 tokenizer 数 token 来判断"够不够 chunk_size"。更高级的 SemanticSplitter 甚至用嵌入相似度找"语义突变点"来切(L07)。
L06

切完自动建关系(呼应 Day 02)

💡 切块不只"切碎",还要"记住每块从哪来、和谁相邻" node_parser/node_utils.pybuild_nodes_from_splits:给每个新 Node 设 metadata + 关系——SOURCE(指向原 Document,溯源)、PREV/NEXT(同文档前后块),并记 start_char_idx/end_char_idx(在原文的位置)。
📝 切出的 Node 自带"档案" 块2 = TextNode(text="需附发票。逾期不受理。", relationships={SOURCE: 财务制度.pdf, PREV: 块1, NEXT: 块3}, start_char_idx=15)
→ Day 14 检索到块2 觉得上下文不够时,能顺着 NEXT 取块3 一起给 LLM(回收 Day 02 的关系伏笔)。
L07

其他切块器:按数据类型选

  • SentenceSplitter(默认):普通文本,句子边界 + overlap。
  • TokenTextSplitter:纯按 token 硬切,最简单(不管句子)。
  • SentenceWindowNodeParser:每句一个 Node,但 metadata 存"前后几句窗口"——检索用单句(准),给 LLM 时带窗口(全)。
  • SemanticSplitter:用嵌入相似度找"语义断点"切(相邻句意思突变处)。
  • CodeSplitter:按代码结构(函数/类)切。
  • HierarchicalNodeParser:切成多层级(大块套小块,配 Day 11 的 auto-merging 检索)。
怎么选? 普通文档 → SentenceSplitter;想"精准命中+丰富上下文" → SentenceWindow;追求语义完整 → Semantic;切代码 → CodeSplitter。它们都是 TransformComponent(Day 05 讲),能插进摄入管道。切块策略是 RAG 调优最该实验的地方——同一批数据换个切法,效果可能天差地别。
L08

今日小结 + 动手

🧠 今天你应该能回答

  • 为什么必须切块?切太大/太小分别有什么问题?
  • chunk_size 和 chunk_overlap 各控制什么?overlap 为什么像"裁纸留重叠"?
  • SentenceSplitter 为什么"层层降级"按边界切?为什么按 token 而非字符?
  • 切完为什么要建 SOURCE/PREV/NEXT 关系?

✋ 动手

cd /Users/bitmart/work/codes/github/llama_index/llama-index-core/llama_index/core
sed -n '34,100p' node_parser/text/sentence.py
ls node_parser/text/
grep -n "build_nodes_from_splits\|NodeRelationship" node_parser/node_utils.py | head
明天预告 · Day 05(第 1 周收官):读、切都会了——把它们串成一条可复用、可缓存的流水线(IngestionPipeline)。看它怎么把多个步骤串起来、怎么用缓存省下重复处理(尤其是省嵌入的钱)。
← Day 03 Day 05 · Ingestion →