🦞 一只用 AI Agent 搭副业产线的程序员


上篇文章拆完 LangChain,有读者问我:“虾哥,我做 RAG 应用,到底用 LangChain 还是 LlamaIndex?”

这个问题问得好。LangChain 是通用框架,LlamaIndex 是专用框架——LangChain 什么都能干,LlamaIndex 只干一件事,但干到了极致:把非结构化数据变成 LLM 能检索的索引

但我想挖的更深。不是"用哪个",而是"它们分别是怎样设计的"。这篇文章,我们拆开 LlamaIndex 的源码,看它那套 Ingestion -> Index -> Query 的数据管道。


项目简介

LlamaIndex(GitHub 36k+ Stars)是专门为 RAG(检索增强生成)场景设计的框架。它的核心工作流分三步:Ingestion(把各种格式的文档吃进去)-> Index(构建可检索的索引)-> Query(根据用户问题检索并生成答案)。听起来简单,但每一层都有精心设计的抽象。


架构全景

┌─────────────────────────────────────────────────────────────┐
│                      Query Engine 层                         │
│   "用户问了一个问题,我从索引里找到相关内容,拼给 LLM 回答"       │
│   ┌──────────┐   ┌──────────┐   ┌──────────────────┐       │
│   │ Retriever │ → │ Postprocessor │ → │ Response Synthesizer│  │
│   └──────────┘   └──────────┘   └──────────────────┘       │
├─────────────────────────────────────────────────────────────┤
│                       Index 层                               │
│   "把 Node 组织成可高效检索的数据结构"                          │
│   VectorStoreIndex · SummaryIndex · KeywordTableIndex · ... │
├─────────────────────────────────────────────────────────────┤
│                    Ingestion 管道                             │
│   "文档 → Node → Embedding → 写入索引"                       │
│   Reader → [Transformations] → Embedding → VectorStore      │
├─────────────────────────────────────────────────────────────┤
│                    底层存储                                   │
│   Chroma · Pinecone · Weaviate · Qdrant · Milvus · ...     │
└─────────────────────────────────────────────────────────────┘

关键设计一:Node——比 Chunk 多走了一步

做 RAG 的人都知道"切块"(chunking)很重要。绝大多数框架的做法是:文档 → 切块 → 存向量。LangChain 就是这个模型。

LlamaIndex 多了一个抽象:Node

# llama_index/core/schema.py —— 核心数据结构
from dataclasses import dataclass, field
from typing import Dict, Any, List, Optional

@dataclass
class BaseNode:
    """所有 Node 类型的基类"""
    id_: str                          # 全局唯一 ID
    metadata: Dict[str, Any]          # 元数据(来源、页数、作者...)
    excluded_embed_metadata_keys: List[str]  # 哪些元数据不参与 Embedding
    excluded_llm_metadata_keys: List[str]    # 哪些元数据不传给 LLM
    relationships: Dict[str, "RelatedNodeInfo"]  # 与其他 Node 的关系
    text: str                         # 文本内容
    embedding: Optional[List[float]]  # 向量

@dataclass
class TextNode(BaseNode):
    """纯文本 Node"""
    text_template: str = "{metadata_str}\n\n{content}"

@dataclass
class ImageNode(BaseNode):
    """图片 Node——多模态支持"""
    image: Optional[str] = None
    image_path: Optional[str] = None

Node 比普通 Chunk 多出来的东西

  1. relationships:Node A 是 Node B 的"上一段",Node C 是 Node B 的"来源文档"。检索到 Node B 后,框架能沿着关系图找到邻居 Node,做上下文扩展。
  2. 元数据控制excluded_embed_metadata_keys 让某些元数据只用于过滤、不参与向量相似度计算。比如"文档创建日期"对语义匹配没帮助,但对过滤有帮助。
  3. 多模态支持TextNodeImageNode 继承同一个 BaseNode,索引不区分文本和图片——统一检索,按需返回。

设计洞察:Chunk 是"一段文本",Node 是"一段有上下文关系的文本"。多出来的关系信息,让检索结果的质量提升了一个档次——检索到一段相关文本后,能顺着 PREVIOUS / NEXT 关系把前后文也拉出来。


关键设计二:IngestionPipeline——可组合的转换链

拿到文档后,怎么把它变成可以入库的 Node?LlamaIndex 的设计是 IngestionPipeline——一个由 Transformations 组成的管道。

# llama_index/core/ingestion/pipeline.py —— 核心管道
class IngestionPipeline:
    """文档摄取管道:Reader → Transformations → VectorStore"""

    transformations: List[TransformComponent]

    def run(
        self,
        documents: List[Document],
        in_place: bool = True,
    ) -> List[BaseNode]:
        nodes = documents  # 起始:原始 Document
        for transform in self.transformations:
            nodes = transform(nodes)  # 每个 Transform 处理并返回
        return nodes

每一个 Transformation 都是一个可替换的组件:

# llama_index/core/ingestion/transformations.py —— 转换组件基类
class TransformComponent:
    def __call__(self, nodes: List[BaseNode], **kwargs) -> List[BaseNode]:
        """同步调用"""
        ...
    
    async def acall(self, nodes: List[BaseNode], **kwargs) -> List[BaseNode]:
        """异步调用"""
        ...

实际使用时,管道是这样组装的:

from llama_index.core.ingestion import IngestionPipeline
from llama_index.core.node_parser import SentenceSplitter
from llama_index.embeddings.openai import OpenAIEmbedding

pipeline = IngestionPipeline(
    transformations=[
        SentenceSplitter(chunk_size=1024, chunk_overlap=200),  # 切块
        OpenAIEmbedding(model="text-embedding-3-small"),        # 向量化
    ]
)

nodes = pipeline.run(documents=docs)

这个管道的精妙之处:每个 Transformation 只管自己的事,不关心上下文。你可以把 SentenceSplitter 换成 SemanticSplitter(按语义切块),把 OpenAIEmbedding 换成 HuggingFaceEmbedding——管道代码不动。跟 LangChain 的 __or__ 管道异曲同工,但 LlamaIndex 的管道是专门为数据摄取优化的,支持缓存、并行处理、断点续传。

设计洞察:管道的本质是"把复杂的串行流程拆成可独立替换的步骤"。LangChain 和 LlamaIndex 独立得出了这个结论——这不是巧合,是必然。


关键设计三:QueryEngine 的组装器模式

Index 建好了,怎么查?LlamaIndex 没有像 LangChain 那样用管道——它用了一个更灵活的"组装器"模式。

# llama_index/core/query_engine/ —— QueryEngine 的组装逻辑
from llama_index.core import VectorStoreIndex
from llama_index.core.retrievers import VectorIndexRetriever
from llama_index.core.postprocessor import SimilarityPostprocessor
from llama_index.core.response_synthesizers import CompactAndRefine

# 方式 1:一行搞定(默认组合)
index = VectorStoreIndex.from_documents(docs)
query_engine = index.as_query_engine()

# 方式 2:手动组装每个组件(灵活控制)
retriever = VectorIndexRetriever(
    index=index,
    similarity_top_k=10,   # 粗筛 10 条
)
postprocessor = SimilarityPostprocessor(
    similarity_cutoff=0.75,  # 精筛:相似度 > 0.75 才保留
)
synthesizer = CompactAndRefine(
    llm=llm,
    streaming=True,
)

query_engine = RetrieverQueryEngine(
    retriever=retriever,
    node_postprocessors=[postprocessor],
    response_synthesizer=synthesizer,
)

response = query_engine.query("什么是 PagedAttention?")

这里的关键抽象是 QueryEngine 的三个插件化组件

组件 职责 类比
Retriever 从索引里粗筛候选 Node 搜索引擎的召回阶段
NodePostprocessor 对候选 Node 精筛/重排 搜索引擎的排序阶段
ResponseSynthesizer 把 Node 拼成 prompt 调用 LLM 生成摘要

每个组件都是一个可插拔的接口。你可以把 VectorIndexRetriever 换成混合检索(向量 + BM25),把 SimilarityPostprocessor 换成 Reranker(用 Cross-Encoder 重排序),完全不影响其他部分。


核心代码拆解:from_documents 到底做了什么

新手最爱的一行代码是 VectorStoreIndex.from_documents(docs)。拆开看它背后发生了什么:

# llama_index/core/indices/vector_store/base.py —— 简化版
class VectorStoreIndex:
    @classmethod
    def from_documents(
        cls,
        documents: List[Document],
        embed_model: Optional[BaseEmbedding] = None,
        transformations: Optional[List[TransformComponent]] = None,
        **kwargs,
    ) -> "VectorStoreIndex":
        # Step 1: 运行 IngestionPipeline
        pipeline = IngestionPipeline(
            transformations=transformations or [
                SentenceSplitter(),
                embed_model or OpenAIEmbedding(),
            ]
        )
        nodes = pipeline.run(documents=documents)

        # Step 2: 把 Node 的 embedding 写入向量库
        index = cls(
            nodes=nodes,
            embed_model=embed_model,
            **kwargs,
        )

        # Step 3: 构建 Node → Vector 的映射
        # 本质上就是调 vector_store.add(embeddings, nodes)
        return index

每一步都可以被替换。from_documents 是"一键默认",而不是"唯一路径"。当你需要自定义时,回到三步走的底层 API 重新组装就好。


你可以抄的作业

1. Node 的关系建模

如果你在做搜索结果展示(不只是 RAG),给每个"结果项"加上关系字段:上一项、下一项、来源、同类推荐。检索到一条结果后,沿着关系图扩展上下文,用户体验天差地别。

2. 管道优于一体化

LlamaIndex 的 IngestionPipeline 告诉你:任何超过 3 步的数据处理流程,都值得拆成一个可替换的管道。每步只做一件事,测试和优化都变得简单。

3. 组装器模式比继承树好

QueryEngine 的三组件不是通过继承(SummaryQueryEngineRefineQueryEngine…)实现的,而是通过组合(把 Retriever + Postprocessor + Synthesizer 拼在一起)。当你有多种变体需要支持时,组合优于继承——这不是口号,这是 LlamaIndex 用代码证明的。

4. 默认要好,但要有逃生舱

from_documents 是一行 API,足够好用。但底层每步都能单独调。做框架最怕的就是"隐藏得太好,用户想改的时候改不了"。


最后

LlamaIndex 和 LangChain 的对比是个经典话题。我的看法:LangChain 赢了"通用编排",LlamaIndex 赢了"数据处理深度"。如果你只做 RAG,用 LlamaIndex;如果你要做 Agent + 多工具编排,用 LangChain。如果你两个都要——学它们的设计思路,自己写。

下一讲拆 Ollama。一行 ollama run llama3 背后,到底发生了什么?


本文拆解的 LlamaIndex 版本:v0.11.x。源码地址:github.com/run-llama/llama_index


🦞 一只用 AI Agent 搭副业产线的程序员

全平台同名:虾哥不加班 | 源码:GitHub - lobster-bujiaban

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐