LlamaIndex - 02 NodeParser(节点解析器)

LlamaIndex 文档切分 Node 教程(完整版)

一、核心概念:Document vs Node

  • Document:原始文档对象,可以是一整个文件或一段文本
  • Node:文档切分后的"块",包含文本内容、元数据以及节点间的关系信息(如前后节点、父节点引用)
from llama_index.core.schema import TextNode, NodeRelationship, RelatedNodeInfo

# 手动创建 Node 并设置关系
node1 = TextNode(text="这是第一个文本块", id_="node_1")
node2 = TextNode(text="这是第二个文本块", id_="node_2")

# 设置节点间的前后关系
node1.relationships[NodeRelationship.NEXT] = RelatedNodeInfo(node_id=node2.node_id)
node2.relationships[NodeRelationship.PREVIOUS] = RelatedNodeInfo(node_id=node1.node_id)

四、最常用的切分方式:SentenceSplitter

SentenceSplitter 是 LlamaIndex 默认的 Node Parser,它会按句子边界进行切分,避免在句子中间切断。

4.1 基本用法

from llama_index.core import SimpleDirectoryReader
from llama_index.core.node_parser import SentenceSplitter

# 1. 加载文档
documents = SimpleDirectoryReader("./data/").load_data()

# 2. 创建切分器
parser = SentenceSplitter(chunk_size=512, chunk_overlap=20)

# 3. 执行切分,得到 Nodes
nodes = parser.get_nodes_from_documents(documents)

print(f"原始文档数量: {len(documents)}")
print(f"切分后节点数量: {len(nodes)}")

4.2 全局配置方式

如果你希望在整个应用中统一使用相同的切分配置,可以通过 Settings 对象设置:

from llama_index.core import Settings
from llama_index.core.node_parser import SentenceSplitter

# 方式一:设置完整的 Node Parser
Settings.text_splitter = SentenceSplitter(chunk_size=1024, chunk_overlap=20)

# 方式二:只修改默认的 chunk 参数
Settings.chunk_size = 512
Settings.chunk_overlap = 20

# 之后所有的操作都会自动应用这个切分设置

五、进阶切分器详解

5.1 MarkdownNodeParser - 保留 Markdown 结构

适用于处理 Markdown 格式的文档,能识别标题、代码块、列表等结构:

from llama_index.core.node_parser import MarkdownNodeParser

parser = MarkdownNodeParser()
nodes = parser.get_nodes_from_documents(documents)
# 输出的 node 会保留标题层级等结构信息

5.2 CodeSplitter - 代码专用切分

用于切分代码文件,它会按抽象语法树(AST)节点进行分割,保证每个 Node 包含完整的函数或类定义:

from llama_index.core.node_parser import CodeSplitter

# 指定代码语言和块大小
parser = CodeSplitter(
    language="python",
    chunk_lines=40,          # 每个块的最大行数
    chunk_lines_overlap=15,  # 块之间的重叠行数
    max_chars=1500,          # 每个块的最大字符数
)

nodes = parser.get_nodes_from_documents(documents)

5.3 SemanticSplitterNodeParser - 语义切分

这是更高级的切分方式:根据句子间的语义相似度来决定在哪里切分,而不是机械地按字符数切割。

from llama_index.core.node_parser import SemanticSplitterNodeParser
from llama_index.embeddings.openai import OpenAIEmbedding

# 需要提供嵌入模型来计算语义相似度
embed_model = OpenAIEmbedding()

parser = SemanticSplitterNodeParser(
    embed_model=embed_model,
    buffer_size=1,                      # 每次比较的句子数量
    breakpoint_percentile_threshold=95, # 相似度阈值
)

nodes = parser.get_nodes_from_documents(documents)

参数说明

参数说明
buffer_size分组评估语义相似度时的句子数量,设为1则逐句比较
breakpoint_percentile_threshold余弦相似度百分位数阈值,值越小切分越细
embed_model用于计算语义相似度的嵌入模型(必填)

六、使用 IngestionPipeline 进行切分

IngestionPipeline 可以将切分与其他数据转换步骤(如元数据提取、嵌入生成)组合在一起:

from llama_index.core.ingestion import IngestionPipeline
from llama_index.core.node_parser import SentenceSplitter
from llama_index.core.extractors import TitleExtractor

pipeline = IngestionPipeline(
    transformations=[
        SentenceSplitter(chunk_size=512, chunk_overlap=20),
        TitleExtractor(),  # 自动提取每个节点的标题
    ]
)

# 执行管道
nodes = pipeline.run(documents=documents, show_progress=True)

七、第三方切分器集成

7.1 DashScopeJsonNodeParser(阿里通义)

使用阿里巴巴通义实验室开发的文本切分模型,特别适合中文文档:

from llama_index.node_parser.dashscope import DashScopeJsonNodeParser
from llama_index.core.ingestion import IngestionPipeline

os.environ["DASHSCOPE_API_KEY"] = "your_api_key"

node_parser = DashScopeJsonNodeParser(
    chunk_size=100, 
    overlap_size=0,
    separator=" |,|,|。|?|!|\n|\?|\!",
    language="cn"  # 中文模式
)

pipeline = IngestionPipeline(transformations=[node_parser])
nodes = pipeline.run(documents=documents)

7.2 Preprocess API

使用外部 API 服务进行智能切分,能识别文档的布局和语义结构:

from llama_index.readers.preprocess import PreprocessReader

loader = PreprocessReader(
    api_key="your-api-key", 
    filepath="document.pdf"
)
nodes = loader.get_nodes()  # 直接获取切分好的 nodes

八、Node 的后续使用

切分完成后,最典型的用途是构建向量索引:

from llama_index.core import VectorStoreIndex

# 使用 nodes 构建索引
index = VectorStoreIndex(nodes)

# 创建查询引擎
query_engine = index.as_query_engine()

# 执行查询
response = query_engine.query("你的问题")
print(response)

二、什么数据需要切分?什么时候不需要?

这是实际应用中最常见的问题。并非所有数据都需要切分,选择错误的处理方式会严重影响系统效果。

2.1 需要切分的数据类型

以下数据类型必须进行切分,否则无法有效检索:

数据类型切分必要性推荐切分器
📄 纯文本文档✅ 必须切分SentenceSplitter
📝 Markdown文档✅ 必须切分MarkdownNodeParser
🌐 HTML网页✅ 必须切分MarkdownNodeParser(先转Markdown)
💻 代码文件✅ 必须切分CodeSplitter
📑 长文本日志✅ 必须切分SentenceSplitter

2.2 不需要切分的数据类型

以下结构化数据天然具有原子单位,直接检索即可,切分反而会破坏其结构:

数据类型原子单位LlamaIndex 推荐方式
🗄️ SQL数据库行/记录SQLTableRetriever + 文本到SQL
📋 JSON/XML字段/路径字段路径访问 + Jsonalyzer
📊 CSV表格行(如果行级语义完整)PandasQueryEngine
🕸️ 知识图谱三元组/节点KnowledgeGraphIndex
📈 时间序列时间点/时段TimeIndex
🔢 纯数值数组元素直接数学计算
# SQL 数据示例 - 不需要切分
from llama_index.core.query_engine import NLSQLTableQueryEngine

query_engine = NLSQLTableQueryEngine(
    sql_database=sql_database,
    tables=["users", "orders"],
)
response = query_engine.query("查询过去30天订单金额前10的用户")

# JSON 数据示例 - 不需要切分
import json
data = json.load(open("data.json"))
# 直接按路径访问即可,不需要切分
value = data["users"][0]["address"]["city"]

2.3 半结构化数据:混合处理策略

实际数据常常是表格 + 自然语言描述的混合体,需要分别处理:

# 产品手册(半结构化示例)
## 规格参数(表格形式)
| 型号 | 价格 | 重量 | 库存 |
|------|------|------|------|
| A100 | $10000 | 30kg | 50台 |
| V100 | $8000 | 25kg | 30台 |

详细说明:A100适用于大规模AI训练,支持FP64精度...

混合处理方案

from llama_index.core.node_parser import SentenceSplitter
from llama_index.core.extractors import TableExtractor

# 方案1:表格转文本 + 正常切分
table_text = "型号A100价格10000美元重量30kg库存50台。型号V100价格8000美元重量25kg库存30台。"
nodes = SentenceSplitter(chunk_size=512).get_nodes_from_documents([table_text])

# 方案2:使用专门的TableNodeParser
from llama_index.core.node_parser import TableNodeParser
table_nodes = TableNodeParser().get_nodes_from_documents(table_documents)

# 方案3:混合索引 - 表格走结构化查询,文本走向量检索
from llama_index.core import VectorStoreIndex, SQLTableNodeMapping

# 文本部分:切分后建向量索引
text_nodes = SentenceSplitter().get_nodes_from_documents(text_docs)
vector_index = VectorStoreIndex(text_nodes)

# 表格部分:保留原结构,不走切分
sql_query_engine = NLSQLTableQueryEngine(sql_database)

2.4 判断流程图

def decide_chunking_strategy(data):
    """
    判断数据类型应采用切分还是直接检索
    """
    # 1. 是否为纯文本/半文本
    if is_free_text(data):
        return "USE_CHUNKING", "SentenceSplitter"
    
    # 2. 是否有明确的原子单位(行、字段、记录)
    if has_clear_atomic_unit(data):  # 如:SQL行、JSON字段
        return "NO_CHUNKING", "Direct query / Path access"
    
    # 3. 单个记录是否超过token限制
    if estimate_tokens(data) > 8192:  # LLM上下文窗口
        return "USE_CHUNKING", "SentenceSplitter (large chunk)"
    
    # 4. 数据结构是否严格层级化
    if is_strictly_hierarchical(data):  # 如:深度嵌套JSON
        return "NO_CHUNKING", "Jsonalyzer"
    
    return "USE_CHUNKING", "SentenceSplitter (default)"

2.5 核心原则总结

原则说明示例
原子性优先如果数据本身具有原子单位(行、记录、字段),直接使用该单位检索SQL行、JSON字段
破坏结构不切切分会破坏数据关系(如父-子引用、外键约束)时,不切分树形JSON、XML
超限才切只有当单个单位超过LLM上下文窗口时,才考虑切分超长文本字段
混合分别处理半结构化数据中的表格和文字分开处理,使用不同策略产品规格表 + 描述文字

三、自动切分 vs 手动切分

3.1 VectorStoreIndex 的自动切分

VectorStoreIndex 在构建索引时会自动完成文档的切分

from llama_index.core import VectorStoreIndex, SimpleDirectoryReader

# 加载文档
documents = SimpleDirectoryReader("./data/").load_data()

# 构建索引 —— 这里会自动完成切分!
index = VectorStoreIndex.from_documents(documents)

自动完成的操作

  1. 将每个 Document 切分成更小的 Node 对象(文本块)
  2. 为每个 Node 生成向量嵌入(embedding)
  3. 将向量存储到索引中,准备检索

控制自动切分的方式

from llama_index.core import Settings
from llama_index.core.node_parser import SentenceSplitter

# 方式一:通过全局 Settings 配置
Settings.text_splitter = SentenceSplitter(chunk_size=512, chunk_overlap=50)
index = VectorStoreIndex.from_documents(documents)

# 方式二:通过 transformations 参数直接传入
text_splitter = SentenceSplitter(chunk_size=500, chunk_overlap=50)
index = VectorStoreIndex.from_documents(
    documents, 
    transformations=[text_splitter]
)

3.2 为什么需要手动切分?

既然 VectorStoreIndex 会自动切分,为什么还要手动操作?因为手动切分给你精细控制权。自动切分是"一键傻瓜式"操作,适合快速原型开发;而生产环境中,你往往需要以下手动控制能力。

优势一:自定义元数据(最关键)

每个 Node 可以附带不同的元数据,这对检索质量影响巨大:

from llama_index.core.schema import TextNode

# 手动创建节点,为每个块添加特定元数据
nodes = [
    TextNode(
        text="A100 显卡支持 FP64 高精度计算",
        metadata={
            "page": 5,
            "section": "技术规格",
            "product": "A100",
            "importance": "high",
            "price_range": "$10000+"
        }
    ),
    TextNode(
        text="安装 A100 需要 300W 电源和 PCIe 4.0 插槽",
        metadata={
            "page": 12,
            "section": "安装指南", 
            "product": "A100",
            "importance": "medium"
        }
    )
]

# 自动切分做不到这种细粒度的元数据
index = VectorStoreIndex(nodes)
优势二:自定义节点关系

可以手动建立节点间的引用关系,实现更精准的检索:

from llama_index.core.schema import TextNode, NodeRelationship, RelatedNodeInfo

# 创建父子节点关系
parent_node = TextNode(text="第一章:AI 芯片概述", id_="parent_1")

child_node_1 = TextNode(
    text="1.1 GPU 架构详解...",
    id_="child_1",
    relationships={
        NodeRelationship.PARENT: RelatedNodeInfo(node_id="parent_1")
    }
)

child_node_2 = TextNode(
    text="1.2 TPU 对比分析...",
    id_="child_2", 
    relationships={
        NodeRelationship.PARENT: RelatedNodeInfo(node_id="parent_1")
    }
)

# 检索时可以获得层级上下文

自动切分生成的节点是扁平的、无关系的

优势三:过滤和预处理

在切分阶段可以做很多自动切分做不到的事:

# 手动切分前可以进行智能过滤
documents = load_documents()

filtered_nodes = []
for doc in documents:
    # 1. 移除噪音内容
    if "[广告]" in doc.text:
        continue
    
    # 2. 合并太短的块
    if len(doc.text) < 50:
        # 合并到上一个节点
        pass
    
    # 3. 根据内容决定切分策略
    if "表格" in doc.metadata.get("type", ""):
        # 表格用特殊方式处理
        nodes = table_parser.parse(doc)
    else:
        nodes = text_parser.parse(doc)
    
    filtered_nodes.extend(nodes)
优势四:保证完整性约束

某些内容绝对不能被切分,自动切分无法保证:

# 代码示例:函数定义必须完整
# 自动切分可能在函数中间切断
def complex_function(a, b, c):
    # 100 行代码...
    if a > b:
        # 自动切分可能在这里切断!导致语法错误
        pass

# 手动切分:按 AST 节点切分,保证函数完整
from llama_index.core.node_parser import CodeSplitter
code_parser = CodeSplitter(language="python", chunk_lines=50)
nodes = code_parser.get_nodes_from_documents(code_docs)
优势五:多模态和复杂数据结构
# 混合内容:图片 + 文字
nodes = [
    TextNode(
        text="图 1 显示了 A100 的核心架构",
        metadata={"image_path": "a100_diagram.png", "image_id": "fig1"}
    ),
    TextNode(
        text="该架构包含 6192 个 CUDA 核心...",
        metadata={"references": ["fig1"]}
    )
]

# 或者预先生成的 embedding
from llama_index.core.embeddings import OpenAIEmbedding

embed_model = OpenAIEmbedding()
node = TextNode(text="重要内容")
node.embedding = embed_model.get_text_embedding("重要内容")  # 预计算

3.3 自动切分 vs 手动切分 对比总结

能力自动切分手动切分
快速上手✅ 一行代码❌ 需要写代码
自定义元数据❌ 只能文档级别✅ 节点级别精细控制
节点关系(父子/前后)❌ 扁平结构✅ 任意关系
内容过滤/清洗❌ 无法干预✅ 完全控制
完整性保证⚠️ 可能切断关键内容✅ 可保证
多模态支持❌ 有限✅ 灵活
性能优化(如预计算 embedding)⚠️ 批量计算✅ 精细控制

3.4 实践建议:渐进式优化

# 阶段一:快速验证(用自动切分)
index = VectorStoreIndex.from_documents(documents)
# 测试效果...

# 阶段二:发现问题后,切换到手动切分
# 例如:检索结果缺失某些关键信息、上下文不连贯

# 阶段三:针对性优化
from llama_index.core.node_parser import SentenceSplitter

# 先用解析器生成基础 nodes
parser = SentenceSplitter(chunk_size=512, chunk_overlap=50)
nodes = parser.get_nodes_from_documents(documents)

# 再手动增强
for i, node in enumerate(nodes):
    # 添加缺失的元数据
    node.metadata["chunk_index"] = i
    node.metadata["source_file"] = documents[0].metadata.get("file_name")
    
    # 标记重要节点
    if "summary" in node.text.lower():
        node.metadata["is_summary"] = True

index = VectorStoreIndex(nodes)

3.5 一句话总结

方式适用场景
自动切分快且省事,适合探索和简单场景
手动切分可控且精准,适合生产和复杂场景

💡 核心提示:当你发现检索结果不够好时,手动切分往往是第一个需要优化的地方。


九、切分策略总结与建议

9.1 按文档类型选择策略

文档类型推荐切分器chunk_size / 说明是否需要切分
普通文本文档SentenceSplitter512-1024✅ 必须
Markdown文档MarkdownNodeParser保留标题结构✅ 必须
代码文件CodeSplitter按AST节点切分✅ 必须
长文档/书籍SemanticSplitterNodeParser按语义边界切分✅ 必须
SQL数据库不切分使用NLSQLTableQueryEngine❌ 不需要
JSON/XML不切分路径直接访问❌ 不需要
CSV小表不切分使用PandasQueryEngine❌ 不需要
半结构化混合表格不切分 + 文本切分混合策略⚠️ 视情况
中文文档DashScopeJsonNodeParser专门优化中文分词✅ 必须

9.2 关键提示

  • chunk_size:越小检索越精准但可能丢失上下文;越大上下文更完整但检索噪音增加
  • chunk_overlap:通常设置为 chunk_size 的 10%-20%,用于保持相邻块之间的信息连续性
  • 结构化数据优先不切:SQL、JSON等有原子单位的数据,直接检索即可
  • 半结构化混合处理:表格部分走结构化查询,文本部分走向量检索
  • 自动切分 vs 手动切分:快速验证用自动,生产环境根据需求选择手动
  • 实验验证:不同的切分方式对 RAG 效果影响显著,建议在实际任务中进行实验对比

9.3 快速决策表

# 你的数据是...?
data_type = input("数据类型: ")

if data_type in ["PDF", "TXT", "MD", "HTML", "代码"]:
    print("→ 使用 SentenceSplitter 或 MarkdownNodeParser 切分")
    
elif data_type in ["SQL", "数据库", "表格(结构化)"]:
    print("→ 不需要切分,使用 NLSQLTableQueryEngine")
    
elif data_type in ["JSON", "XML", "API响应"]:
    print("→ 不需要切分,直接路径访问")
    
elif data_type == "半结构化(表格+文字)":
    print("→ 混合策略:表格保留结构,文字切分")
    
else:
    print("→ 默认使用 SentenceSplitter")
    
# 你想控制到什么程度?
control_level = input("控制程度(快速/精细): ")

if control_level == "快速":
    print("→ 使用 VectorStoreIndex.from_documents() 自动切分")
else:
    print("→ 手动创建 Nodes,自定义元数据、关系和预处理逻辑")

这个完整版教程现在涵盖了:

  1. ✅ 核心概念(Document vs Node)
  2. ✅ 什么数据需要/不需要切分
  3. ✅ 自动切分 vs 手动切分的对比和选择
  4. ✅ 各种切分器的详细使用
  5. ✅ 进阶功能和第三方集成
  6. ✅ 实践建议和决策指南