LlamaIndex:框架骨架与数据模型
「本系列第 12/17 章」。分析基线:LlamaIndex
0.14.23。前十一章分别讲训练、GGUF 与本地推理;从本章起进入编排层。读本章时请把“对象、默认值、集成包”三件事分开,不要把源码目录里的一切都当成已经安装好的能力。
1. 先认清它是什么
LlamaIndex 不是“再包一层向量库客户端”。向量库只负责存向量和按相似度返回候选;LlamaIndex 要回答的是另一类问题:文档从哪里读入、如何切成节点、嵌入在哪一步发生、检索之后怎样过滤和重排、最后怎样把上下文交给 LLM 生成答案。这些步骤可以各自替换,所以它是编排框架,默认路径恰好是 RAG。
初学者最容易把 Index 当成黑盒问答器。正确的心智模型是:Index 管生命周期和存储拓扑,真正回答问题的是后面组合出来的查询引擎。你换成别的 Retriever、换一个后处理器、换一种合成模式,Index 里的向量不必重算。反过来,换了嵌入模型或维数,再精巧的查询组合也救不了旧索引。
理解 0.14.23 的第一件安装事实,是分清元包和集成包。
1 | pip install llama-index |
这一条只安装三样东西:llama-index-core、OpenAI 的 LLM/Embedding 集成,以及 nltk。monorepo 里大约 620 个带独立 pyproject.toml 的集成——向量库、Reader、图存储、其他厂商模型——都要另装。看到仓库目录里有 Qdrant 或 OpenAI-like,不代表当前 Python 环境已经能 import。生产项目应显式安装所需包,并锁住 core 与各集成声明的兼容区间,不要只锁一个元包版本。
多个 wheel 共同写入顶层 llama_index 命名空间(PEP 420)。应用里不要再造一个普通的 llama_index/__init__.py,否则会遮蔽插件。出现 ModuleNotFoundError 时,先查发行包名,例如 llama-index-llms-openai-like,而不是在 core 里翻厂商模块。
2. 骨架:从 Document 走到答案
标准阅读顺序可以画成一条单向链:
1 | Reader → Document → Transform → Node → Index |
左边是“知识如何进入系统”,右边是“一次提问如何变成答案”。中间的 Index 是分界:写入阶段决定向量和文本存在哪,读取阶段不再重新解释原始文件,只对已经索引的节点提问。
索引本身不“回答问题”。BaseIndex.as_query_engine() 先取出 Retriever,再交给标准编排器:
1 | RetrieverQueryEngine = retriever + postprocessors + synthesizer |
三层刻意解耦。Retriever 只找候选,不生成句子;postprocessor 只按顺序过滤、扩邻或重排,不拥有查询引擎的控制流;Synthesizer 才组织 prompt 并调用 LLM。之后第 13 到 15 章会分别展开摄取、合成与 Agent。先把这条组合关系记住,调试时才能分层看:候选错了查检索,上下文错了查后处理,措辞错了查合成,而不是整链重来。
公开入口集中在 llama_index.core:Document、VectorStoreIndex、Settings、StorageContext 都从这里重导出。厂商能力走独立子包。StorageContext 只是存储实现的容器,不是又一个数据库;它把 docstore、index_store、vector store 和图存储聚在一起,方便 persist 与加载。会话记忆不属于它,那是 Memory / chat store 子系统。
同步与异步成对出现:query / aquery、retrieve / aretrieve。这不表示框架会自动把同步实现线程化。不少基类的异步方法只是直接调用同步版本,真正会不会阻塞事件循环,取决于具体集成。把这一点放在骨架里,是为了后面各章不再重复解释“为什么我写了 await 还是卡住”。
3. 数据模型:Document 包着 Node
0.14.23 的准确继承是:
1 | BaseComponent |
也就是 Document(Node(BaseNode))。这是本章必须记准的类型关系。Document 既不直接继承 BaseNode,也不是 TextNode 的子类。把 Document 想成“还没切分的完整来源”,把后续 chunk 想成带 SOURCE 关系的子节点,比死记类名更有用。BaseIndex 的构造器只接受 BaseNode;直接把 Document 丢进 __init__ 会被要求改走 from_documents()。
Node 用 MediaResource 同时持有文本、图像、音频、视频。get_content() 仍以文本为主,属于兼容接口;多模态路径才走 get_content_blocks(),按 metadata、文本、图片、音频、视频依次产出内容块。嵌入和送进 LLM 时,还会分别尊重 excluded_embed_metadata_keys 与 excluded_llm_metadata_keys,所以“节点里有的字段”不等于“模型看得到的字段”。
BaseNode 上真正要会用的字段不多:id_(node_id 是别名)、embedding、metadata、relationships。关系用 SOURCE / PREVIOUS / NEXT / PARENT / CHILD 指向轻量 RelatedNodeInfo,只存对方的 id、类型、metadata 和 hash,避免把整棵对象图嵌进去。切分后的 chunk 通过 SOURCE 指回原文档,ref_doc_id 由此推导。按文档删除、按文档刷新,靠的都是这条回指,而不是文件名碰巧相同。
TextNode 的类注释写明是 backward compatibility,但它仍是活跃兼容层:大量既有切分器和存储接口继续产出、接受它。新代码按 Node + MediaResource 理解模型即可,不要假设 TextNode 已经消失,也不要把旧教程里的 extra_info、doc_id 当成推荐字段——它们还在,规范名字是 metadata 和 id_。
IndexNode 是另一类“节点里嵌对象”的挂钩:它可以指向另一个 Retriever 或 QueryEngine。第 13 章检索模板会递归展开它。现在只需知道:不是所有返回节点都是普通文本块。
4. Settings 是模块级单例
1 | from llama_index.core import Settings |
Settings 不是类,也不是每次访问新建的对象。源码在模块导入时执行 Settings = _Settings(),进程内只有这一份。属性按首次读取惰性解析:仅仅 import Settings 不会创建 OpenAI 客户端;第一次读 Settings.llm 才会走 resolve_llm("default")。默认路线就是 OpenAI;缺包或缺 key 会明确报错,不会静默切到本地模型。测试环境若设置了 IS_TESTING,解析器会换成 Mock,这是进程级开关,不适合当作租户隔离手段。
构造组件时的惯例是“显式参数优先,否则回落 Settings”。局部传入的 llm= / embed_model= 只影响新对象,不会写回全局。已经造好的 Index 或 QueryEngine,通常在构造时保存了解析后的实例,之后再改 Settings.llm,不会可靠地替换引擎内部的 synthesizer。因此多租户服务里不要在请求处理中改 Settings——它没有 contextvars 隔离,也没有线程局部语义。
还有两处容易误判的缓存。第一,Settings.node_parser 与已经初始化过的 Settings.transformations 不会自动同步;需要一致时要同时赋值。第二,IngestionPipeline 未传 transformations 时,并不读取 Settings.transformations,而是自己创建切分器加 embed model。第 13 章会回到这件事。prompt_helper 也是惰性缓存:先读 helper 再换大窗口 LLM,旧 helper 不会按新模型重建。稳妥顺序是先定 LLM,再创建索引和引擎。
旧的 ServiceContext、ServiceContext.from_defaults()、set_global_service_context() 在本版本会立即抛 ValueError。它们仍被导出,只为给旧代码一个明确迁移错误,不是“有警告但仍可用”的容器。新代码只用 Settings 或构造参数。遇到仍在教 ServiceContext 的材料,按过期文档处理。
5. 读这一章时该建立的判断
把 LlamaIndex 看成三件事的交汇:稳定的数据对象(Document / Node)、进程内默认值(Settings)、以及可替换的集成包。对象决定“什么被索引”,Settings 决定“没写参数时用谁”,集成包决定“向量和模型实际跑在哪”。三者混谈,就会出现“我已经 pip 过 llama-index,为什么还是导入失败”这类假问题。
下一章进入摄取与检索。你会看到同一份 Document 可以走两条完全不同的嵌入路径:一条在 Index 内部嵌入,一条在 Pipeline 默认链里嵌入。先分清对象和配置,才不会把两条路径叠在一起,把同一批节点算两遍向量。