跳到主要内容

LlamaIndex 数据模型 — 那块到处流动的「砖」

这章讲什么: RAG 流水线里从头到尾流动的单位是 Node。先把这块砖看清楚——它由什么组成、几种变体、节点之间怎么连成关系、hash 怎么用于去重、MetadataMode 怎么让「给嵌入看的文本」和「给 LLM 看的文本」不一样。后面三章全靠这套词汇。

1.1 为什么不是「一段字符串」就够了

最朴素的想法:检索就是「存一堆文本片段,查相似的」。但真实 RAG 需要更多:

  • 这片段是从哪份文档切出来的?(引用溯源、按文档删除)
  • 它的前一段 / 后一段是什么?(窗口扩展、自动合并)
  • 哪些元数据该参与嵌入、哪些只给 LLM 看、哪些都不要?
  • 内容变了没有?(增量更新时跳过没变的)

所以 LlamaIndex 不用裸字符串,而是 BaseNode:内容 + 元数据 + 关系 + 嵌入 + 哈希,一个对象全带上。

1.2 类型全家福

BaseComponent (可序列化基类)
└─ BaseNode (抽象:id/metadata/relationships/embedding)
├─ Node 多模态新基类(text/image/audio/video Resource)
├─ TextNode 纯文本块(RAG 最常用)
│ ├─ ImageNode 图文块
│ └─ IndexNode 「指向另一个对象」的节点(递归检索用)
└─ Document = Node 的别名级子类(代表「一整份」文档)

NodeWithScore = 包一层 {node, score},检索结果用这个
RelatedNodeInfo = 关系里存的轻量引用 {node_id, type, hash}
类型是什么典型场景
Document一整份输入文档Reader 读进来的原始单位,灌库前会被切成 TextNode
TextNode一个文本块(chunk)检索/嵌入的基本单位
ImageNode带图的块多模态检索
IndexNode「占位符」节点,obj/index_id 指向另一个 retriever/query engine/node递归检索(见第 03 章)
NodeWithScore{node, score}检索返回值;score 是相似度/重排分

源码锚点:schema.py:264(BaseNode)、schema.py:765(TextNode)、schema.py:948(IndexNode)、schema.py:1035(NodeWithScore)、schema.py:1097(Document)。

1.3 一个 Node 里有什么

BaseNode 的核心字段(schema.py:264-320):

字段类型作用
id_str唯一 ID,默认 uuid4
embeddinglist[float] | None该 Node 的向量(灌库时填上)
metadatadict扁平元数据;别名 extra_info
excluded_embed_metadata_keyslist[str]哪些元数据进嵌入文本
excluded_llm_metadata_keyslist[str]哪些元数据给 LLM 看
relationshipsdict指向 SOURCE/PREV/NEXT/PARENT/CHILD 的关系
metadata_template / metadata_separatorstr元数据拼成字符串时的格式

1.4 关系图:Node 不是孤岛

NodeRelationship 枚举(schema.py:207-224)定义五种关系:

SOURCE(指向原始 Document)


PREV ◀──── [ TextNode ] ────▶ NEXT (同一文档相邻块,链表)

PARENT │ CHILD (层级:大块↔小块)

  • SOURCE:每个切出来的块都记得「我来自哪份 Document」。切块时一次性算好这个关系再分发给所有块,避免对每个块重复算文档哈希(node_parser/node_utils.py:39-42 有一段注释专门解释这个优化)。
  • PREV / NEXT:同文档相邻块,用于「检索到一块后把前后文也带上」(sentence-window、auto-merging)。
  • PARENT / CHILD:层级结构(如 hierarchical node parser:大块拆小块)。

关系访问是只读 property,且做了类型校验(单个 vs 列表),例如 source_node 必须是单个,child_nodes 必须是列表(schema.py:390-448)。

1.5 MetadataMode:同一个 Node,给不同读者看不同文本

这是个容易忽略但很关键的设计。get_content(metadata_mode=...) 会按模式拼接「元数据 + 正文」(schema.py:328 抽象,TextNodeschema.py:810 实现;元数据筛选在 get_metadata_str,schema.py:337-358):

MetadataMode含义谁用
EMBED拼接时剔除 excluded_embed_metadata_keys算嵌入时
LLM拼接时剔除 excluded_llm_metadata_keys喂给 LLM 时
ALL全部元数据计算 hash / 调试
NONE只要正文不需要元数据时

为什么要分开? 举例:文件路径 metadata["file_path"] 你可能想让 LLM 引用它(LLM 可见),但想让它污染嵌入向量(EMBED 排除);反过来,一段「检索关键词」可能只想进嵌入而不想 LLM 看到。这套机制让你逐键控制。

证据链:

  • 灌库算嵌入时用的是 MetadataMode.EMBED(indices/vector_store/base.py:302 过滤空内容时即用 EMBED)。
  • 合成喂 LLM 时用的是 MetadataMode.LLM(response_synthesizers/base.py:291:n.node.get_content(metadata_mode=MetadataMode.LLM))。

1.6 hash:增量更新的「指纹」

每个 Node 有个 hash(schema.py 各子类的 hash property,如 Node.hashschema.py:741-762):对元数据 + 各资源内容sha256

用途:

# 示意,非源码:增量刷新的核心判断(对应 indices/base.py:refresh_ref_docs)
existing = docstore.get_document_hash(doc.id_)
if existing is None: # 没见过 → 插入
index.insert(doc)
elif existing != doc.hash: # 内容变了 → 删旧插新
index.update_ref_doc(doc)
# else: 一字未改 → 跳过,省下嵌入和 LLM 调用

真实实现见 indices/base.py:429-452(refresh_ref_docs)。灌库流水线的去重也基于同一个 hash(见第 02 章)。

关键细节: as_related_node_info()(schema.py:494-501)会重算整段内容的 hash。切块时如果对每个子块都去算「父文档的 RelatedNodeInfo」会非常慢,所以 build_nodes_from_splits 在循环外算一次、复用给所有块(node_parser/node_utils.py:39-42 的注释明说了这个坑)。

1.7 巧妙之处

  • 逐键的元数据可见性(EMBED / LLM 分离)让「检索召回质量」和「给 LLM 的提示」可以独立调,而不必复制两份文本。schema.py:294-301schema.py:337-358
  • 关系内嵌轻量引用(RelatedNodeInfo 只存 node_id+hash,不存整段内容,schema.py:249-257),让 Node 之间能连成图却不爆序列化体积。
  • 多模态向 Node 收敛:新 Nodetext_resource/image_resource/... 四个 MediaResource 槽位统一表达文本/图/音/视频,get_content_blocks() 再转成 LLM 的 content blocks(schema.py:685-731)。

1.8 代码地图

主题文件符号
抽象节点schema.pyBaseNode
关系枚举schema.pyNodeRelationship, RelatedNodeInfo
元数据模式schema.pyMetadataMode, BaseNode.get_metadata_str
文本块schema.pyTextNode, TextNode.get_content
检索结果schema.pyNodeWithScore
文档schema.pyDocument
关系构建优化node_parser/node_utils.pybuild_nodes_from_splits