Day 04 / 共 20 天 · 第 1 周 RAG 全景与数据摄入
切块 NodeParser(流水线第 ② 步)
文档读进来了,但一份 100 页的手册没法整个用。第 ② 步:切成小块(Node)。切块质量直接决定检索质量——切得好,检索精准;切得烂,后面再强也白搭。这是 RAG 最该花心思调的一环。
📍 你在整条链的位置
① 读→
② 切块(最影响效果)→
③ 嵌入→
④ 存索引→
⑤检索 ⑥合成
L01
为什么必须切块
🤔 直接拿整份文档去检索/喂 LLM,会怎样?
用户问"报销上限多少",如果检索单位是"整份 100 页的《财务制度》"——① LLM 塞不下 100 页;② 就算塞下,答案里 99% 是无关内容,反而干扰;③ 整份文档主题太杂,"它的向量"没法代表"报销"这个具体点。
💡 切块 = 把大文档切成"大小合适、主题集中"的小块
切成一段一段后:检索能精准命中"报销那一段"、LLM 只看到相关内容、每块的向量能准确代表其语义。切块是"让检索精准"的前提。
L02
切大 or 切小?两难
切块的核心权衡:太大不精准、太小丢上下文,要找"刚好"的大小 + 在自然边界切。
这就是为什么切块最该实验
没有万能的切块大小——技术文档适合小块(信息密集)、故事叙述适合大块(要连贯上下文)。chunk_size 是 RAG 调优第一个该动的旋钮。
L03
两个关键参数:chunk_size 与 overlap
🤔 一个完整意思正好被切在两块的边界上,怎么办?
比如"报销需在 30 天内提交,逾期不予受理"——如果"30 天内提交"在块 A 末尾、"逾期不予受理"在块 B 开头,检索到块 A 就漏了后半句。
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 假设很小)
原文:
→ 优先在句号切,得到:
块1 =
块2 =
每块都是完整句子的集合,不会出现"报销上限30"这种半句。
报销上限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.py 的 build_nodes_from_splits:给每个新 Node 设 metadata + 关系——SOURCE(指向原 Document,溯源)、PREV/NEXT(同文档前后块),并记 start_char_idx/end_char_idx(在原文的位置)。📝 切出的 Node 自带"档案"
块2 =
→ Day 14 检索到块2 觉得上下文不够时,能顺着 NEXT 取块3 一起给 LLM(回收 Day 02 的关系伏笔)。
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)。看它怎么把多个步骤串起来、怎么用缓存省下重复处理(尤其是省嵌入的钱)。