Chapter 02

Document 与 Node · 数据的原子

RAG 里 99% 的"检索不准""答得不对",根子在数据怎么切、怎么带元数据。这一章把 LlamaIndex 里最基础的两个数据结构 Document 和 Node 讲透——后面所有的花里胡哨都建立在这两个之上。

一、两个核心抽象

Document

  • 一段原始文本 + 元数据
  • 通常对应"一个文件 / 一条记录 / 一个网页"
  • 不直接参与检索,需要先切成 Node
  • 类比:数据库的表行

Node

  • Document 切成的一个 chunk
  • 是索引与检索的最小单位
  • 带回指向 Document 的 relationships
  • 类比:搜索引擎的倒排项

二、Document 最小构造

from llama_index.core import Document

doc = Document(
    text="古法编程是 AI 时代之前的编程技艺传承网站...",
    metadata={
        "source": "about.md",
        "author": "nina",
        "created_at": "2026-05-07",
    },
    doc_id="about-001",           # 可选,不传会用 uuid
)
print(doc.text[:30])
print(doc.metadata)

几个要点:

三、metadata 是 RAG 的秘密武器

新手常把 metadata 当成"只是记一下来源",但在 LlamaIndex 里 metadata 会参与三件事:

  1. 参与嵌入:默认 metadata 的可见字段会被拼到文本里一起 embed,这样"查今年的财报"的 query 才能匹配上带 year: 2025 的 chunk
  2. 参与检索过滤:MetadataFilters 可以硬过滤——"只在 2025 年的文档里搜"
  3. 进入 LLM prompt:合成答案时,LLM 能看到 chunk 来自哪本手册,才能引用 [Source: chapter3.pdf]

精细控制 metadata 怎么用

doc = Document(
    text="2025 Q3 营收 120 亿,同比 +15%...",
    metadata={
        "filename": "Q3-report.pdf",
        "page": 12,
        "year": 2025,
        "internal_id": "xyz-secret-42",
    },
    # 不让 LLM 看见 internal_id
    excluded_llm_metadata_keys=["internal_id"],
    # 不让 embedding 看见 internal_id(也不参与向量)
    excluded_embed_metadata_keys=["internal_id"],
    # metadata 拼到正文时的分隔符
    metadata_seperator="\n",
    # metadata 每一项的模板
    metadata_template="{key}: {value}",
    # 内容模板:{metadata_str}\n\n{content}
    text_template="{metadata_str}\n---\n{content}",
)

# 看看它怎么拼给 embedding / LLM
print(doc.get_content(metadata_mode="embed"))
print(doc.get_content(metadata_mode="llm"))
生产经验:metadata 少而精,不是越多越好。每条 metadata 都吃 token,而且太多会稀释语义。推荐 3-5 个关键字段(如 source/date/category/lang),其它用 excluded_embed_metadata_keys 藏起来仅供过滤/展示。

四、Node 的三种常见类型

类型承载典型场景
TextNode一段文本 + metadata所有文本 chunk(主力)
ImageNode图片数据/路径多模态 RAG,图文混合
IndexNode指向另一个 Index/QueryEngine 的"桥"节点层级/递归检索、路由

TextNode 完整字段

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

node = TextNode(
    text="2025 Q3 营收 120 亿...",
    id_="node-42",
    metadata={"page": 12, "section": "营收概览"},
    embedding=None,                         # 建索引时会填
    relationships={
        NodeRelationship.SOURCE: RelatedNodeInfo(node_id="doc-001"),
        NodeRelationship.PREVIOUS: RelatedNodeInfo(node_id="node-41"),
        NodeRelationship.NEXT: RelatedNodeInfo(node_id="node-43"),
    },
)

五、relationships:Node 不是孤岛

Node 之间可以通过 relationships 建图。LlamaIndex 预定义的关系类型:

SOURCE
指向 Node 来自的原 Document,最常用——用于"找回原始出处"。
PREVIOUS / NEXT
同一 Document 内的上下文邻居。PrevNextNodePostprocessor 检索后可以把左右邻居拉出来扩充上下文。
PARENT / CHILD
层级关系——比如章节是 parent,段落是 child。用在层级检索和"小块嵌入、大块回答"模式。

关系不是摆设,后面讲 RecursiveRetrieverAutoMergingRetriever 时用到——好的 relationships = 检索命中小块、回答时合并回大块,既准又有上下文。

六、Node 从哪来:切 vs 手构

方式 A:Splitter 自动切

from llama_index.core.node_parser import SentenceSplitter

splitter = SentenceSplitter(chunk_size=512, chunk_overlap=50)
nodes = splitter.get_nodes_from_documents(docs)
print(len(nodes), "个 nodes")
print(nodes[0].text[:80])
print(nodes[0].metadata)    # 继承自 Document + 增加 chunk 位置

自动切会自动建 PREVIOUS/NEXT/SOURCE relationships——很贴心。Ch4 详讲 Splitter 选型。

方式 B:手工构造

结构化数据(数据库行、JSON API)不需要切——一行一 Node 可能更合适:

from llama_index.core.schema import TextNode

rows = db.execute("SELECT id, title, body, tag FROM articles")
nodes = [
    TextNode(
        id_=f"article-{r.id}",
        text=r.body,
        metadata={"title": r.title, "tag": r.tag, "id": r.id},
    )
    for r in rows
]
要点:能手构就手构。数据库里每行 200-1000 字,直接一行一 Node;大段自由文本才需要 Splitter。手构的 Node 边界自然,metadata 也完整——比切出来的好用多了。

七、Document 的 hash 与增量

每个 Document 有隐藏的 hash 字段,由 text + metadata 计算得出:

doc1 = Document(text="hello", doc_id="x")
doc2 = Document(text="hello", doc_id="x", metadata={"v": 1})
print(doc1.hash, doc2.hash)      # 不同:metadata 变了

增量索引的关键——同 doc_id 不同 hash 才更新,不然跳过。配合 DocstoreStrategy.UPSERTS(Ch4 讲),你能做到"每天跑一次完整目录扫描,但只重嵌入发生变化的文件"。

八、IndexNode:递归检索的钥匙

IndexNode 不直接存文本,它是一个"桥梁"——指向另一个 Index 或 QueryEngine。

from llama_index.core.schema import IndexNode

# 先为"财报 2025"建一个子 QueryEngine
qe_2025 = VectorStoreIndex.from_documents(docs_2025).as_query_engine()

# 给总索引加一个 IndexNode 作为入口
bridge = IndexNode(
    text="财报 2025 全年数据。包含 Q1-Q4 营收、利润、现金流。",
    index_id="qe_2025",
    metadata={"year": 2025},
)

# 当总检索命中 bridge,会"下钻"到 qe_2025 继续查

这个模式就是 LlamaIndex 的"检索路由":上层是描述性文本(每本书的摘要),被命中就深入对应子索引。在 Ch7、Ch10 会深挖。

九、常见 Document/Node 反模式

  1. 把整份 300 页 PDF 当一个 Document,不切:embedding 平均化后,啥都查不准。
  2. metadata 里塞大对象或 list:LlamaIndex 要求简单类型,序列化失败或 embedding 噪声大。
  3. doc_id 不稳定:每次跑重新 uuid,增量更新变全量重建,贵。
  4. excluded_embed_metadata_keys 没设:内部 ID/时间戳全进 embedding,稀释语义。
  5. 手构 Node 不带 relationships:Postprocessor 扩上下文失效,回答缺邻居信息。
  6. TextNode 和 Document 混用不清:Document 是入口、Node 是原子;建索引前都应是 Node。
  7. metadata 和文本内容冗余:同样的信息写两遍,浪费 token 还增加 embedding 噪声。
  8. 用 Node 存整张 PDF 的 base64 图:TextNode 的 text 应是文本。图用 ImageNode 或 LlamaParse 解成文字。

十、本章小结

记住:
① Document = 原始一条(含 metadata),Node = 切出来可检索的 chunk——默认 Document 继承自 TextNode。
② metadata 三用途:参与 embed、参与过滤、进 LLM prompt;用 excluded_*_metadata_keys 精细控制各自可见。
③ relationships 不是摆设:PREVIOUS/NEXT 做邻居扩展,PARENT/CHILD 做层级检索,IndexNode 做路由下钻。
④ 能手构就手构:结构化数据直接一行一 Node,边界干净 metadata 完整,比 splitter 切好一截。