引子:一个让团队吵翻天的真实场景
先讲一个我们踩过的坑。
去年下半年,我们团队接了一个企业知识库项目。客户给了一份 50 页的混合型 PDF——有标题层级、有表格、有代码块,还有大段大段的叙述性文字。我们的任务很明确:基于这份文档做问答系统,员工可以问“报销流程中财务审批的时限是几个工作日”“API 的鉴权方式有哪几种”这类问题。
当时我们用的是 LangChain,配了 OpenAI 的 embedding 模型和 GPT-4。Demo 阶段一切顺利,给老板演示的时候,问什么答什么,效果惊艳。但一上真实数据,问题就来了:
问“报销流程中财务审批的时限”,模型答非所问,把“差旅报销”和“对公报销”的条款混在一起; 问“API 鉴权方式”,回答里居然出现了“数据库连接字符串”这种毫不相干的内容; 更离谱的是,有些问题的答案明明就在同一页上,但模型就是“看不见”。
我们一开始怀疑是 embedding 模型不行,换了;怀疑是向量库的问题,也换了;怀疑是 LLM 的 temperature 设置不对,调了。都没用。
后来我们做了一个对比实验:同一份文档、同一个 embedding 模型、同一个 LLM、同一个向量库,只是把框架从 LangChain 换成了 LlamaIndex。结果,检索命中率肉眼可见地提升,回答质量完全不是一个量级。
团队当场就吵起来了。有人说“LlamaIndex 就是比 LangChain 强”,有人说“是我们 LangChain 的用法不对”,还有人说是“玄学”。
后来我们把整个链路拆开,一行一行地对比日志,才找到真正的答案:差异不在模型,不在向量库,而在文档进向量库之前的那一步——切分与索引。
这篇文章,就是把那次排查的全过程,以及背后的原理,完整地讲给你听。
读完这篇文章,你会收获三样东西:
- 理解 LangChain 和 LlamaIndex 在文档处理上的本质差异——不是谁好谁坏,而是设计哲学的底层不同;
- 掌握一套可落地的切分策略选型方法——什么文档用什么切分器、参数怎么配、为什么这么配;
- 一份完整的迁移避坑清单——如果你正打算从 LangChain 迁到 LlamaIndex,或者正在做框架选型,这份清单能帮你省掉至少两周的踩坑时间。
我们开始。
第一节:切分——RAG 链条里最容易被忽视、却决定上限的一环
1.1 RAG 的完整链路:切分到底卡在哪个位置
先快速对齐一下 RAG 的基本流程。一个典型的 RAG 系统,链路是这样的:
文档加载 → 文档切分 → 向量化(Embedding)→ 索引存储 → 检索(Retrieval)→ 重排序(Rerank)→ 送入 LLM 生成很多人把注意力放在 Embedding 模型选型上,放在向量库的选型上,放在 LLM 的 prompt 设计上。但行业里有一个越来越清晰的共识:检索质量的上限,由文档处理和切分策略决定,而不是由模型决定。
为什么?
因为切分是整条链路的“地基”。你切出来的每一个块(Chunk),就是后续向量化、索引、检索的最小单位。如果块本身切得不好——该拆的没拆开、该连的断掉了、语义边界被拦腰截断——那么后面无论你用多好的 Embedding 模型、多快的向量库、多聪明的 LLM,都只能在“坏块”的基础上做文章。
打个比方:切分就像是做菜之前的“备菜”。食材切得好不好,直接决定这道菜的上限。你请一个米其林三星的大厨来炒一盘土豆丝,但如果土豆丝切得跟薯条一样粗,大厨也救不回来。
1.2 切分 → 索引 → 检索 → 生成的传导链路
切分的问题,是怎么传导到最终回答质量的?我们用一条链路来解释:
第一步:切分决定了“检索单位”。 用户问“报销流程中财务审批的时限”,如果你的切分把一个包含“财务审批时限”的段落拦腰截断,一半进了 Chunk A,一半进了 Chunk B,那么检索时无论命中哪一个,拿到的都是不完整的上下文。
第二步:检索单位决定了“上下文质量”。 向量检索的本质是语义相似度匹配。一个被拦腰截断的 Chunk,它的语义是残缺的,和用户问题的相似度自然偏低。结果就是:该命中的没命中,不该命中的反而命中了。
第三步:上下文质量决定了“生成质量”。 LLM 是“垃圾进,垃圾出”的典型代表。你喂给它的上下文如果是残缺的、错位的、甚至包含噪声的,那么它的回答质量必然断崖式下跌。
所以你会发现一个很有意思的现象:很多团队在 RAG 上花了大把时间调 prompt、换模型,效果始终不理想。但把切分策略一改,效果立竿见影。这不是玄学,这是链路传导的必然结果。
1.3 为什么很多团队“先 LangChain 后 LlamaIndex”?
在进入正题之前,先回答一个大家都很关心的问题:为什么很多团队一开始用 LangChain,后来都转向了 LlamaIndex?
答案很简单:LangChain 的入门门槛低,但天花板也低。
LangChain 的定位是“应用编排框架”,它的核心优势在于把各种组件(模型、向量库、工具、Agent)以链式的方式组装起来。你不需要理解太多底层原理,照着文档写几行代码就能跑通一个 RAG demo。这是它风靡全球的原因。
但当你进入真实项目,面对复杂的文档结构、多样的检索需求、以及“效果不好但不知道哪里不好”的困境时,LangChain 的“胶水层”定位就暴露了它的短板——文档处理只是它众多能力中的一环,不是它的核心优势。
而 LlamaIndex 的定位是“数据框架”,它从第一天起就是围绕“如何让 LLM 更好地理解你的数据”来设计的。文档加载、切分、索引、检索,这些是它的核心能力,也是它打磨最深的地方。
所以,很多团队的经历是:先用 LangChain 快速搭出 demo,然后发现效果不行,再转向 LlamaIndex 重新设计文档处理流程。这不是说 LangChain 不行,而是它的默认行为和真实需求之间的错位,在文档处理这一层被放大了。
第二节:核心原理——文档抽象与切分机制的底层差异
这一节是全文的干货核心。我们一层一层拆开,看 LangChain 和 LlamaIndex 在文档处理上的本质差异。
2.1 文档抽象的底层差异:Document 与 Document/Node
LangChain 的 Document:一个没有“关系”的文本块
在 LangChain 里,文档的抽象非常朴素。一个 Document 只有两个核心属性:
page_content:文本内容metadata:元数据(来源、页码、标题等)
当你对文档做切分时,LangChain 的切分器(TextSplitter)会把一个大的 Document 切成多个小的 Document。注意,切分后的每个块仍然是一个 Document。整个链路中,“文档”的概念没有变化,只是内容被截短了。
这个设计的好处是简单、直观、容易理解。但坏处也很明显:切分后的块之间没有任何关系。它们是一堆孤立的文本片段,彼此之间不知道谁在前、谁在后、谁是谁的父节点。
这意味着什么?意味着如果你想让 LLM 回答一个需要跨多个块才能完整理解的问题(比如“这篇文章的核心论点是什么”),LangChain 的默认行为是做不到的。你得自己想办法把相关的块重新拼起来。
LlamaIndex 的 Node:一个“有身份、有关系”的语义单元
LlamaIndex 的抽象层级则完全不同。它的核心概念是 Node(节点)。
在 LlamaIndex 中,原始文档被称为 Document,切分之后,每个块变成一个 Node。一个 Node 包含:
text:文本内容metadata:元数据node_id:节点唯一标识relationships:节点之间的关系
关键就在 relationships 上。LlamaIndex 的 Node 会主动维护和其他节点之间的关系,比如:
SOURCE:这个节点的来源文档PREVIOUS/NEXT:前一个节点 / 后一个节点PARENT/CHILD:父子节点关系(用于分层切分)
这个设计看起来只是多了一个字段,但它带来的能力是完全不一样的。Node 是 LlamaIndex 的一等公民,它不仅存文本,还存“上下文关系”。 这为后续的高级检索策略(比如 ParentDocumentRetriever、SentenceWindowRetriever)提供了基础。
这个差异带来的实际影响
我们用一句话总结这个差异:
LangChain 切分后的块是“死”的,LlamaIndex 切分后的节点是“活”的。
“死”的意思是:块与块之间没有关联,你拿到一个块,就只有一个块。“活”的意思是:节点与节点之间存在关系图谱,你拿到一个节点,可以顺着关系找到它的父节点、相邻节点、来源文档。
这个差异在简单问答场景下可能感知不明显。但一旦你面对的是复杂文档、长文档、或者需要多级检索的场景,这个差异就会被急剧放大。
2.2 切分器的实现差异:RecursiveCharacterTextSplitter vs SentenceSplitter
LangChain 的默认切分器:按字符优先级“暴力切分”
LangChain 默认推荐的切分器是 RecursiveCharacterTextSplitter。它的工作原理是:给定一个字符列表 ["\n\n", "\n", " ", ""],从第一个分隔符开始,尝试按它切分;如果切出来的块仍然大于 chunk_size,就换下一个分隔符继续切;直到所有块都小于等于 chunk_size。
这个策略的优点是通用、简单、不挑文档类型。但它的本质是“按字符优先级”的暴力切分,完全不感知语义边界。
举一个典型的例子。假设你有一段这样的文本:
# 第三章 系统架构本系统采用微服务架构,包含以下核心组件:1. 网关服务:负责请求路由和鉴权2. 用户服务:负责用户管理和权限控制3. 订单服务:负责订单处理和状态流转## 3.1 网关服务网关服务基于 Spring Cloud Gateway 实现...
如果用 RecursiveCharacterTextSplitter(chunk_size=100, chunk_overlap=20) 来切,它可能会把“3. 订单服务”和“## 3.1 网关服务”切到同一个块里,或者把“1. 网关服务”和“2. 用户服务”拆到两个块里。标题层级、列表结构、语义边界,它统统不关心。
这在早期 demo 阶段问题不大,因为你的测试问题可能恰好避开了这些边界。但一旦文档复杂起来,这种“暴力切分”就会频繁产生语义残缺的块,检索质量自然就崩了。
LlamaIndex 的默认切分器:按句子边界“语义切分”
LlamaIndex 默认推荐的切分器是 SentenceSplitter。它的工作原理是:先把文档按句子边界(句号、问号、感叹号等)切分成一个个句子,然后按 chunk_size 和 chunk_overlap 把相邻的句子聚合成块。
这个策略的本质是“按语义单元”切分。它保证了一个块内部是完整的句子,不会出现“一句话被拦腰截断”的情况。
同样用上面那段文本,SentenceSplitter(chunk_size=100, chunk_overlap=20) 会尽量把完整的句子聚合在一起,保持语义的连贯性。
代码对比:同一个文本,两种切分器的输出差异
我们直接上代码,看两种切分器对同一段文本的输出差异。
# LangChain 方式from langchain.text_splitter import RecursiveCharacterTextSplittertext = """# 第三章 系统架构本系统采用微服务架构,包含以下核心组件:1. 网关服务:负责请求路由和鉴权2. 用户服务:负责用户管理和权限控制3. 订单服务:负责订单处理和状态流转## 3.1 网关服务网关服务基于 Spring Cloud Gateway 实现,支持动态路由和 OAuth2 鉴权。"""splitter = RecursiveCharacterTextSplitter(chunk_size=100, chunk_overlap=20)chunks = splitter.split_text(text)for i, chunk in enumerate(chunks):print(f"--- Chunk {i+1} ---")print(chunk)print()
# LlamaIndex 方式from llama_index.core.node_parser import SentenceSplittertext = """# 第三章 系统架构本系统采用微服务架构,包含以下核心组件:1. 网关服务:负责请求路由和鉴权2. 用户服务:负责用户管理和权限控制3. 订单服务:负责订单处理和状态流转## 3.1 网关服务网关服务基于 Spring Cloud Gateway 实现,支持动态路由和 OAuth2 鉴权。"""splitter = SentenceSplitter(chunk_size=100, chunk_overlap=20)nodes = splitter.get_nodes_from_documents([Document(text=text)])for i, node in enumerate(nodes):print(f"--- Node {i+1} ---")print(node.text)print()
你运行这段代码,会看到明显的输出差异。LangChain 的切分可能把“1. 网关服务”和“2. 用户服务”拆到两个块里,或者把“## 3.1 网关服务”和下面的正文拆开;而 LlamaIndex 的切分则会尽量保持句子的完整性。
一个最容易踩的坑:chunk_size 的含义不同
这里必须强调一个迁移时最容易踩的坑:chunk_size 在 LangChain 中表示“字符数”,在 LlamaIndex 中表示“token 数”(默认)。
同一个参数名,含义完全不同。这意味着什么?意味着如果你在 LangChain 里用 chunk_size=512 调好了效果,直接搬到 LlamaIndex 里用 chunk_size=512,实际切出来的块会大得多——因为 512 个 token 大约相当于 700-800 个英文字符,或者 400-500 个中文字符。
迁移到 LlamaIndex 后,你必须重新调参,不能直接沿用 LangChain 的参数。
2.3 索引结构的默认行为差异
LangChain:平铺的“文档块列表 + 向量”
LangChain 的向量存储索引,本质上是“文档块列表 + 向量”的平铺结构。每个切分后的 Document 被向量化后存入向量库,检索时按向量相似度取 TopK。
这个结构简单直接,但它的局限性在于:检索到的块之间没有任何关系。你拿到 TopK 个块,就是 TopK 个孤立的文本片段。如果你需要跨块理解(比如一个问题的答案分散在三个块里),你得自己做后处理。
LlamaIndex:带关系图谱的索引结构
LlamaIndex 的 VectorStoreIndex 在构建时,会额外维护 Node 之间的关系。这意味着你可以从检索到的叶子节点,回溯到它的父节点或相邻节点。
更重要的是,LlamaIndex 内置了多种索引类型,每种索引适合不同的文档结构和检索需求:
SummaryIndex:适合“摘要型”问答,把整个文档压缩成一个摘要TreeIndex:适合层级结构的文档,按树形结构组织节点KeywordTableIndex:适合“关键词匹配”场景,按关键词建立索引PropertyGraphIndex:适合知识图谱场景,提取实体和关系
而 LangChain 主要依赖向量检索 + 外部编排。你要实现类似的能力,需要自己组装多个组件。不是说 LangChain 做不到,而是它没有把这些能力内置成默认行为。
第三节:落地做法——不同场景下的切分策略选型与参数配置
理解了原理,我们来看落地。这一节给出可操作的切分策略选型和参数配置指南。
3.1 按文档类型选择切分策略
结构化文档(Markdown、HTML):用标题感知的切分器
如果你的文档是 Markdown 或 HTML 格式,强烈建议使用标题感知的切分器,保留层级结构。
LangChain 提供了 MarkdownHeaderTextSplitter,可以按标题层级切分:
from langchain.text_splitter import MarkdownHeaderTextSplitterheaders_to_split_on = [("#", "H1"),("##", "H2"),("###", "H3"),]splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers_to_split_on)chunks = splitter.split_text(markdown_document)
LlamaIndex 提供了 MarkdownNodeParser:
from llama_index.core.node_parser import MarkdownNodeParserparser = MarkdownNodeParser()nodes = parser.get_nodes_from_documents([document])
决策点: 如果你的文档有清晰的标题层级,务必使用标题感知的切分器。这能保证每个块都带有“上下文锚点”(比如“第三章 > 3.1 网关服务”),检索时更容易匹配到语义相关的块。
非结构化长文(报告、论文):用句子级别的切分器
对于没有明显结构的长文,建议用句子级别的切分器。chunk_size 建议 300-500 token,chunk_overlap 建议 50-100 token。
决策点:chunk_size 不是越大越好,也不是越小越好,取决于你的检索单位与回答粒度的匹配。
如果你的问答是“摘要型”(需要全局理解),用较大的 chunk(500-800 token)或 ParentDocument 策略; 如果是“事实型”问答(精确查找某个事实),用较小的 chunk(200-300 token),提高检索精度。
混合型文档(含表格、代码块):需要自定义切分规则
混合型文档是最难处理的。表格和代码块有自己特殊的结构,用普通的文本切分器很容易切坏。
推荐做法有两种:
- 先按类型拆分,再分别切分: 用文档解析工具把表格、代码块、正文分别提取出来,各自用合适的切分器处理;
- 用分层切分: 使用 LlamaIndex 的
HierarchicalNodeParser做多层级切分,把文档切成“父节点 → 子节点”的树形结构。
3.2 实战参数配置指南
两个框架在不同场景下的推荐参数表
场景 | LangChain | LangChain | LlamaIndex | LlamaIndex |
事实型问答 | 300-500(字符) | 30-50 | 200-300(token) | 20-30 |
摘要型问答 | 800-1200(字符) | 100-200 | 500-800(token) | 50-100 |
代码文档 | 200-400(字符) | 20-40 | 150-300(token) | 15-30 |
混合型文档 | 自定义切分 | 自定义 | 分层切分(2048/512/128) | 20 |
注意: 这张表是经验值,不是标准答案。每个项目的文档类型、问题类型、embedding 模型都不同,你需要在自己的数据上做实验调参。
一个容易被忽视的点:chunk_size 与回答粒度的匹配
我们反复强调“chunk_size 不是越大越好,也不是越小越好”,用案例来说明。
假设你有一个 100 页的企业制度文档,用户问“年假天数是怎么规定的”。这个问题的答案可能只在一个段落里,用 200 token 的小 chunk 就能精确命中。但如果你用 2000 token 的大 chunk,检索命中的块可能包含大量无关信息,LLM 容易被噪声干扰。
反过来,如果用户问“公司的人力资源制度整体框架是什么”,这个问题需要全局理解。用 200 token 的小 chunk,你可能需要检索很多个块才能拼出全貌,而且拼出来的内容可能是碎片化的。这时候用大 chunk 或 ParentDocument 策略更合适。
一句话总结:让 chunk 的大小匹配你的回答粒度。
用 LlamaIndex 的 HierarchicalNodeParser 实现父子节点切分
LlamaIndex 的 HierarchicalNodeParser 是一个非常有用的工具,它可以把文档切成多层级结构:
from llama_index.core.node_parser import HierarchicalNodeParsernode_parser = HierarchicalNodeParser.from_defaults(chunk_sizes=[2048, 512, 128], # 三层:父-中-子chunk_overlap=20)nodes = node_parser.get_nodes_from_documents([document])
这个切分策略的价值在于:检索时先命中小的子节点(精度高),然后通过关系回溯到父节点(上下文全)。 这就是所谓的“ParentDocument 策略”。
LangChain 中对应的方案是 ParentDocumentRetriever:
from langchain.retrievers import ParentDocumentRetrieverfrom langchain.storage import InMemoryStorefrom langchain.vectorstores import Chromaparent_splitter = RecursiveCharacterTextSplitter(chunk_size=2000)child_splitter = RecursiveCharacterTextSplitter(chunk_size=400)retriever = ParentDocumentRetriever(vectorstore=vectorstore,docstore=store,child_splitter=child_splitter,parent_splitter=parent_splitter,)
对比总结: LlamaIndex 把父子节点关系内建在 Node 里,开箱即用;LangChain 需要你手动组装多个组件。两者都能实现类似效果,但 LlamaIndex 的默认行为更合理,LangChain 需要你理解原理后自己搭。
3.3 检索增强:切分之后还能做什么
切分只是第一步,检索质量还取决于检索策略。这一节对比两个框架在“检索后处理”上的能力差异。
LlamaIndex 内置的检索策略
- SentenceWindowRetriever: 检索句子级节点后,自动扩展到整个窗口上下文。适合“事实型”问答。
- AutoMergingRetriever: 检索叶子节点后,自动合并到父节点。适合“摘要型”问答。
from llama_index.core.indices.query.query_transform import HyDEQueryTransformfrom llama_index.core.query_engine import TransformQueryEngine# 使用 SentenceWindowRetrieverfrom llama_index.core.indices.postprocessor import SentenceTransformerRerankfrom llama_index.core.retrievers import AutoMergingRetriever
LangChain 的检索策略
LangChain 需要借助外部组件实现类似能力:
ParentDocumentRetriever:类似 AutoMergingRetriever,但需要手动组装;EnsembleRetriever:组合多个检索器,适合多路召回;ContextualCompressionRetriever:检索后做上下文压缩,去除无关信息。
对比总结: LlamaIndex 把高级检索策略内置为默认能力,LangChain 需要你理解原理后自行组装。如果你的核心诉求是“文档问答”,LlamaIndex 的默认行为更合理。
第四节:对比总结与踩坑清单——选型建议和迁移指南
4.1 框架定位的本质差异
我们用一句话总结两个框架的本质差异:
LangChain 是“应用编排框架”,文档处理只是其中一环,核心优势在 Chain/Agent 的灵活组装。
LlamaIndex 是“数据框架”,文档处理和索引是核心能力,核心优势在数据接入、索引类型和检索策略的丰富度。
这个定位差异决定了它们的默认行为。LangChain 的默认行为是“通用”,适合各种应用场景,但每个场景都不是最优的;LlamaIndex 的默认行为是“数据优先”,适合文档问答,但在其他场景(比如多轮对话、Agent)需要额外组装。
选型建议:
如果你的 RAG 是“文档问答”为主,选 LlamaIndex,它的默认行为更合理; 如果你的 RAG 是“对话流程”中的一环,选 LangChain,它的编排能力更契合; 如果你的项目需要知识图谱、复杂索引,选 LlamaIndex; 如果你的项目需要 Agent、工具调用、多轮对话,选 LangChain。
4.2 迁移踩坑清单
如果你正打算从 LangChain 迁到 LlamaIndex,这份清单请收好。
坑 1:chunk_size 的语义不同(字符 vs token)
这是最大的坑。LangChain 的 chunk_size 是字符数,LlamaIndex 的是 token 数。迁移时必须重新调参,不能直接沿用。
坑 2:LangChain 切分后丢失了文档结构信息
LangChain 切分后的 Document 是“死”的,没有关系图谱。迁移到 LlamaIndex 后,你需要重新设计 Node 解析策略,充分利用 Node 的关系能力。
坑 3:metadata 处理方式不同
两个框架的 metadata 处理方式不同,影响过滤和检索。LangChain 的 metadata 是 Document 的属性,LlamaIndex 的 metadata 是 Node 的属性。迁移时需要重新设计 metadata 的保留和传递策略。
坑 4:向量库的索引结构不兼容
两个框架的向量库索引结构不兼容,迁移需要重新 embedding 和入库。这不是改几行代码就能解决的。
坑 5:切分器的默认行为不同
即使你用了相同的 chunk_size 和 chunk_overlap,两个框架的切分结果也可能完全不同。因为它们的切分逻辑不同(字符优先级 vs 句子边界)。迁移后必须重新评估切分质量。
4.3 结论:没有“哪个框架更好”,只有“哪个框架的默认行为更匹配你的场景”
最后,我们给出一个决策表,按项目类型推荐框架:
项目类型 | 推荐框架 | 理由 |
纯文档问答 | LlamaIndex | 默认行为更合理,Node 关系图谱和高级检索策略开箱即用 |
多轮对话 + RAG | LangChain | 编排能力更强,容易集成到对话流程中 |
知识图谱 | LlamaIndex | 内置 PropertyGraphIndex,支持知识图谱构建 |
Agent 应用 | LangChain | Agent 生态更成熟,工具调用更灵活 |
混合型(对话 + 文档问答) | 两者结合 | 用 LlamaIndex 处理文档和索引,用 LangChain 做编排 |
最终建议:不要把框架当信仰,把文档处理当黑盒。理解切分和索引的原理,比选哪个框架更重要。
实例:用 LlamaIndex 构建一个文档问答系统(含分层切分)
这一节,我们做一个可落地的小项目:用 LlamaIndex 构建一个基于分层切分的文档问答系统,并用 LangChain 的 ParentDocumentRetriever 做对比,让你直观感受两个框架在切分和检索上的差异。
项目目标
输入:一份 Markdown 格式的技术文档(含标题层级、列表、代码块) 输出:一个命令行问答工具,支持“事实型”和“摘要型”两类问题
步骤 1:准备环境
pip install llama-index langchain chromadb openai步骤 2:准备测试文档
我们用一个简化的技术文档作为测试数据,保存为 test_doc.md:
# 系统架构文档## 1. 系统概述本系统采用微服务架构,包含网关服务、用户服务、订单服务三个核心组件。## 2. 网关服务网关服务基于 Spring Cloud Gateway 实现,支持动态路由和 OAuth2 鉴权。### 2.1 动态路由动态路由支持基于路径和请求头的路由规则。### 2.2 鉴权方式支持 OAuth2 和 JWT 两种鉴权方式。## 3. 用户服务用户服务负责用户管理和权限控制,支持 RBAC 权限模型。## 4. 订单服务订单服务负责订单处理和状态流转,支持分布式事务。## 5. 部署说明推荐使用 Docker Compose 部署,最低配置为 2 核 4G 内存。
步骤 3:用 LlamaIndex 构建分层切分 + 问答系统
from llama_index.core import Document, VectorStoreIndexfrom llama_index.core.node_parser import HierarchicalNodeParser, get_leaf_nodesfrom llama_index.core.retrievers import AutoMergingRetrieverfrom llama_index.core.query_engine import RetrieverQueryEnginefrom llama_index.core.storage.docstore import SimpleDocumentStorefrom llama_index.core.storage import StorageContext# 1. 加载文档with open("test_doc.md", "r") as f:text = f.read()document = Document(text=text)# 2. 分层切分:三层结构(父-中-子)node_parser = HierarchicalNodeParser.from_defaults(chunk_sizes=[2048, 512, 128],chunk_overlap=20)nodes = node_parser.get_nodes_from_documents([document])# 3. 构建索引(使用叶子节点)leaf_nodes = get_leaf_nodes(nodes)storage_context = StorageContext.from_defaults()storage_context.docstore.add_documents(nodes) # 存储所有节点,包括父子关系index = VectorStoreIndex(leaf_nodes,storage_context=storage_context)# 4. 使用 AutoMergingRetriever(自动合并到父节点)retriever = AutoMergingRetriever(index.as_retriever(similarity_top_k=3),storage_context=storage_context)# 5. 构建查询引擎query_engine = RetrieverQueryEngine.from_args(retriever=retriever)# 6. 测试queries = ["网关服务支持哪些鉴权方式?", # 事实型"系统的核心组件有哪些?", # 摘要型]for q in queries:response = query_engine.query(q)print(f"Q: {q}")print(f"A: {response}")print("---")
关键输出:
事实型问题“网关服务支持哪些鉴权方式”能精确命中“### 2.2 鉴权方式”节点; 摘要型问题“系统的核心组件有哪些”能通过 AutoMergingRetriever 自动合并到父节点,拿到完整上下文。
步骤 4:用 LangChain 实现对比
from langchain.text_splitter import RecursiveCharacterTextSplitterfrom langchain.vectorstores import Chromafrom langchain.embeddings import OpenAIEmbeddingsfrom langchain.retrievers import ParentDocumentRetrieverfrom langchain.storage import InMemoryStore# 1. 加载文档with open("test_doc.md", "r") as f:text = f.read()# 2. 父子切分parent_splitter = RecursiveCharacterTextSplitter(chunk_size=2000, chunk_overlap=100)child_splitter = RecursiveCharacterTextSplitter(chunk_size=400, chunk_overlap=50)# 3. 构建向量库和文档存储vectorstore = Chroma(collection_name="test_docs",embedding_function=OpenAIEmbeddings())store = InMemoryStore()# 4. 构建 ParentDocumentRetrieverretriever = ParentDocumentRetriever(vectorstore=vectorstore,docstore=store,child_splitter=child_splitter,parent_splitter=parent_splitter,)# 5. 切分并入库retriever.add_documents([Document(page_content=text)])# 6. 测试from langchain.chains import RetrievalQAfrom langchain.llms import OpenAIqa = RetrievalQA.from_chain_type(llm=OpenAI(),retriever=retriever,return_source_documents=True)result = qa.invoke("网关服务支持哪些鉴权方式?")print(result["result"])
对比结论:
对于这份简单的测试文档,两个框架的表现差异不大; 但当你把文档复杂度提升(增加更多层级、表格、代码块),LlamaIndex 的 Node 关系图谱和 AutoMergingRetriever 的优势就会显现; LangChain 需要你手动组装多个组件,理解原理后也能实现类似效果,但默认行为不如 LlamaIndex 合理。
步骤 5:调试与调参
决策点 1: 如果事实型问题的检索精度不够,尝试减小 chunk_sizes 中的叶子节点大小(比如从 128 降到 64)。
决策点 2: 如果摘要型问题的上下文不够完整,尝试增大父节点的大小(比如从 2048 提到 4096),并检查 AutoMergingRetriever 是否正确合并。
决策点 3: 如果两个框架的效果差异不明显,检查你的文档是否足够复杂。简单文档上,两个框架的差异会被掩盖。
小结与延伸阅读
写到这里,我们把整篇文章的核心观点做一个小结:
- RAG 的检索质量上限由文档处理和切分策略决定,而不是由模型决定。
- LangChain 的 Document 抽象是“平铺”的,LlamaIndex 的 Node 抽象是“带关系图谱”的。
- LangChain 的默认切分器按字符优先级暴力切分,LlamaIndex 的默认切分器按句子边界语义切分。
- chunk_size 在 LangChain 中表示字符数,在 LlamaIndex 中表示 token 数——迁移时必须重新调参。
- 没有“哪个框架更好”,只有“哪个框架的默认行为更匹配你的场景”。
如果你想继续深入,推荐阅读以下方向:
- GraphRAG: 在文档切分的基础上,进一步提取实体和关系,构建知识图谱;
- RAPTOR: 递归式地压缩和抽象文本,实现多层级检索;
- Self-RAG: 让 LLM 自己判断是否需要检索、检索什么内容。
写在最后
回到文章开头那个让团队吵翻天的场景。后来我们做了什么?
我们并没有放弃 LangChain,也没有全面转向 LlamaIndex。我们把文档处理这一层单独拆出来,用 LlamaIndex 做切分和索引,然后通过 LangChain 的 LCEL(LangChain Expression Language)把检索结果接入到对话流程中。两个框架各取所长,效果反而最好。
所以,我的最终建议是:不要把框架当信仰,把文档处理当黑盒。理解切分和索引的原理,比选哪个框架更重要。
你在做 RAG 项目时,有没有遇到过“文档切分导致检索效果差”的坑?你是用什么方法解决的?欢迎在评论区分享你的经验,或者说说你在 LangChain 和 LlamaIndex 之间做选型时的纠结——我们一起聊聊。
如果这篇文章对你有帮助,欢迎点赞、在看、转发给团队里正在做 RAG 的同事。你的转发,可能帮他们省掉两周的踩坑时间。
夜雨聆风