从一次技术选型说起
上个月我们给内部知识库做RAG改造,文档集是1200份PDF技术手册,约2.3GB。技术团队分两派:一派说用LangChain,生态全;另一派说LlamaIndex就是为RAG生的。吵了三天没结果。
我做了个决定:同一份文档集(取其中200份,约380MB),同一个QA问题,用两个框架各写一套代码,跑完对比数据再说话。下面是实测结果和完整代码。
问题:两个框架到底差在哪
先看环境:Python 3.11.7,LangChain 0.1.0,LlamaIndex 0.9.3,向量库用Chroma 0.4.22,Embedding模型是BAAI/bge-large-zh-v1.5(1.34GB,最后用ONNX量化版,内存占用差很多,后面细说),LLM是Qwen-14B-Chat通过vLLM部署,qwen远程调用。
| 对比维度 | LangChain 0.1.0 | LlamaIndex 0.9.3 |
|---|---|---|
| 定位 | 通用LLM应用编排框架 | 专注RAG的数据框架 |
| 文档加载 | 100+ loader,需要自己组装 | 内置20+ Reader,开箱即用 |
| 索引构建 | 手写Pipeline | VectorStoreIndex.from_documents() |
| 检索策略 | 链式调用,灵活但复杂 | 有默认的检索+合成流程 |
| 学习曲线 | 概念多:Chain/Agent/Memory | 核心就是Index/Retriever |
方案一:LangChain实现RAG
LangChain的套路是:Load → Split → Embed → Store → Retrieve → Generate。每一步都要自己串。先看完整代码。
1. 文档加载与切分
# langchain_rag.py
# 环境: Python 3.11.7, langchain 0.1.0, langchain-community 0.0.26
# 安装: pip install langchain==0.1.0 langchain-community==0.0.26 chromadb==0.4.22
from langchain_community.document_loaders import PyPDFLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_community.embeddings import HuggingFaceBgeEmbeddings
from langchain_community.vectorstores import Chroma
import time, os
os.environ["TOKENIZERS_PARALLELISM"] = "false"
# ---------- 1. 加载PDF文档 ----------
start = time.time()
pdf_dir = "./docs/technical_manuals"
all_documents = []
for fname in os.listdir(pdf_dir):
if fname.endswith(".pdf"):
loader = PyPDFLoader(os.path.join(pdf_dir, fname))
# 每个PDF加载后是多个Document对象(按页)
docs = loader.load()
# 给每个Document加上来源元数据
for d in docs:
d.metadata["source"] = fname
all_documents.extend(docs)
print(f"加载完成: {len(all_documents)} 页, 耗时 {time.time()-start:.2f}s")
# 输出示例: 加载完成: 3847 页, 耗时 46.32s
# ---------- 2. 文本切分 ----------
splitter = RecursiveCharacterTextSplitter(
chunk_size=512,
chunk_overlap=64,
separators=["\n\n", "\n", "。", "!", "?", ".", "! ", "? ", " ", ""],
length_function=len,
)
chunks = splitter.split_documents(all_documents)
print(f"切分后块数: {len(chunks)}")
# 输出示例: 切分后块数: 15234
# ---------- 3. Embedding + 存储 ----------
# bge-large-zh-v1.5 ONNX量化版,显存占用从4.2GB降到1.1GB
embedding_model = HuggingFaceBgeEmbeddings(
model_name="/models/bge-large-zh-v1.5-onnx",
model_kwargs={"provider": "CPUExecutionProvider"},
encode_kwargs={"batch_size": 32, "normalize_embeddings": True},
)
vectorstore = Chroma.from_documents(
documents=chunks,
embedding=embedding_model,
persist_directory="./chroma_langchain_db",
)
vectorstore.persist()
print(f"索引构建完成, 总耗时 {time.time()-start:.2f}s")
# 输出示例: 索引构建完成, 总耗时 1248.53s (~20.8分钟)
上面chunk_size=512,按字符数计算。中文场景下512字符大约是300-500个token(取决于分词结果)。chunk_overlap=64保证跨块上下文不断裂。separators里我把中文句号、感叹号、问号都加进去了,优先级从高到低,RecursiveCharacterTextSplitter会先尝试按段落分,不行再按句号分,这样避免把一句话从中间切断。
注意HuggingFaceBgeEmbeddings的model_kwargs里设置了provider为CPUExecutionProvider,因为我用的ONNX版本。如果用原版PyTorch模型,需要改为{"device": "cuda"}。下面这个坑后面会细说。
2. 检索增强生成
# langchain_rag.py (续)
# ---------- 4. 检索 + 生成 ----------
from langchain.chains import RetrievalQA
from langchain_community.chat_models import ChatOpenAI
from langchain.prompts import PromptTemplate
import time
# Qwen-14B-Chat通过vLLM部署,OpenAI兼容接口
# vLLM启动命令: python -m vllm.entrypoints.openai.api_server --model /models/Qwen-14B-Chat --port 8000
llm = ChatOpenAI(
model="Qwen-14B-Chat",
base_url="http://localhost:8000/v1",
api_key="EMPTY", # vLLM默认不需要key
temperature=0.1,
max_tokens=512,
request_timeout=60,
)
vectorstore = Chroma(
persist_directory="./chroma_langchain_db",
embedding_function=embedding_model,
)
retriever = vectorstore.as_retriever(
search_type="similarity",
search_kwargs={"k": 4}
)
prompt_template = PromptTemplate(
template="""你是一个专业的技术文档问答助手。基于以下资料片段回答用户问题。
资料片段:
{context}
用户问题:{question}
要求:
1. 如果资料中没有明确答案,直接说"资料中未找到相关内容",不要编造
2. 回答要引用资料中的具体信息,引用时标注来源文件名
3. 用中文回答
回答:""",
input_variables=["context", "question"],
)
qa_chain = RetrievalQA.from_chain_type(
llm=llm,
chain_type="stuff", # 把检索到的chunks全部塞进prompt
retriever=retriever,
chain_type_kwargs={"prompt": prompt_template},
return_source_documents=True,
)
# ---------- 5. 测试问答 ----------
test_questions = [
"如何配置设备A的串口参数?",
"设备B的固件升级失败怎么办?",
"故障代码E203是什么意思?",
]
for q in test_questions:
t0 = time.time()
result = qa_chain.invoke({"query": q})
elapsed = time.time() - t0
print(f"\n问题: {q}")
print(f"回答: {result['result'][:200]}...")
print(f"耗时: {elapsed:.2f}s")
print(f"来源文档: {[d.metadata['source'] for d in result['source_documents']]}")
这里有个关键点:chain_type用的"stuff",意思是把4个检索到的chunks全部拼进prompt。对于我们的场景(4个chunk × 512字符 ≈ 2000字符)完全够用。如果检索到的chunk数量多或者单个chunk长,超过了LLM上下文窗口,就得用"map_reduce"或"refine"链式处理。我测试过map_reduce,对Qwen-14B来说,效果没变好反而多了一倍的token消耗和延迟。
方案二:LlamaIndex实现RAG
LlamaIndex的思路是:Reader加载 → 默认切分 → 构建Index → 直接查。代码量少一半。
# llama_index_rag.py
# 环境: Python 3.11.7, llama-index 0.9.3
# 安装: pip install llama-index==0.9.3 chromadb==0.4.22
from llama_index.core import (
VectorStoreIndex,
SimpleDirectoryReader,
Settings,
PromptTemplate,
)
from llama_index.core.node_parser import SentenceSplitter
from llama_index.embeddings.huggingface import HuggingFaceEmbedding
from llama_index.vector_stores.chroma import ChromaVectorStore
from llama_index.core.storage.storage_context import StorageContext
from llama_index.core.query_engine import RetrieverQueryEngine
from llama_index.core.retrievers import VectorIndexRetriever
from llama_index.core.postprocessor import SimilarityPostprocessor
from llama_index.llms.openai_like import OpenAILike
import chromadb, time, os
os.environ["TOKENIZERS_PARALLELISM"] = "false"
# ---------- 1. 加载 + 切分 ----------
start = time.time()
# SimpleDirectoryReader 自动递归读取目录下所有PDF
documents = SimpleDirectoryReader("./docs/technical_manuals").load_data()
print(f"加载完成: {len(documents)} 个文档对象, 耗时 {time.time()-start:.2f}s")
# LlamaIndex 默认用 SentenceSplitter,但中文场景要调参数
splitter = SentenceSplitter(
chunk_size=512,
chunk_overlap=64,
paragraph_separator="\n\n",
secondary_chunking_regex="[^,.;。?!]+[,.;。?!]?",
)
nodes = splitter.get_nodes_from_documents(documents)
print(f"切分后节点数: {len(nodes)}")
# 输出示例: 切分后节点数: 14987
# ---------- 2. Embedding ----------
embed_model = HuggingFaceEmbedding(
model_name="/models/bge-large-zh-v1.5-onnx",
device="cpu", # ONNX量化版CPU推理
embed_batch_size=32,
normalize=True,
)
Settings.embed_model = embed_model
Settings.llm = None # 暂时不设LLM,先构建索引
# ---------- 3. 构建向量索引 ----------
chroma_client = chromadb.PersistentClient(path="./chroma_llamaindex_db")
chroma_collection = chroma_client.get_or_create_collection("manual_docs")
vector_store = ChromaVectorStore(chroma_collection=chroma_collection)
storage_context = StorageContext.from_defaults(vector_store=vector_store)
index = VectorStoreIndex.from_documents(
documents,
transformations=[splitter],
embed_model=embed_model,
storage_context=storage_context,
show_progress=True,
)
print(f"索引构建完成, 总耗时 {time.time()-start:.2f}s")
# 输出示例: 索引构建完成, 总耗时 1156.82s (~19.3分钟)
SentenceSplitter的secondary_chunking_regex参数,我是看了LlamaIndex源码才搞明白的。默认的英文正则对中文断句不友好,会把一个完整的中文句子切成两半。改成上面那个正则后,中文标点处才切分。这是LlamaIndex做中文RAG的第一个坑,后面详细说。
2. 检索增强生成
# llama_index_rag.py (续)
# ---------- 4. 设置LLM ----------
llm = OpenAILike(
model="Qwen-14B-Chat",
api_base="http://localhost:8000/v1",
api_key="EMPTY",
is_chat_model=True,
temperature=0.1,
max_tokens=512,
timeout=60,
)
Settings.llm = llm
# ---------- 5. 构建查询引擎 ----------
retriever = VectorIndexRetriever(
index=index,
similarity_top_k=4,
)
postprocessor = SimilarityPostprocessor(similarity_cutoff=0.3)
query_engine = RetrieverQueryEngine(
retriever=retriever,
node_postprocessors=[postprocessor],
)
# 自定义prompt模板
qa_prompt_tmpl = PromptTemplate(
"""你是一个专业的技术文档问答助手。基于以下资料片段回答用户问题。
资料片段:
{context_str}
用户问题:{query_str}
要求:
1. 如果资料中没有明确答案,直接说"资料中未找到相关内容",不要编造
2. 回答要引用资料中的具体信息,引用时标注来源文件名
3. 用中文回答
回答:"""
)
query_engine.update_prompts(
{"response_synthesizer:text_qa_template": qa_prompt_tmpl}
)
# ---------- 6. 测试问答 ----------
test_questions = [
"如何配置设备A的串口参数?",
"设备B的固件升级失败怎么办?",
"故障代码E203是什么意思?",
]
for q in test_questions:
t0 = time.time()
response = query_engine.query(q)
elapsed = time.time() - t0
print(f"\n问题: {q}")
print(f"回答: {str(response)[:200]}...")
print(f"耗时: {elapsed:.2f}s")
sources = [n.node.metadata.get("file_name", "unknown") for n in response.source_nodes]
print(f"来源文档: {sources}")
注意LlamaIndex的prompt模板用的是context_str和query_str这两个变量名,跟LangChain的context和question完全不同。如果直接抄LangChain的模板,会报KeyError。这是从LangChain切到LlamaIndex最常遇到的问题。
效果数据:同一份文档,同一个问题
为了公平对比,两个框架用的都是:
- 同样的PDF文件(200份,380MB,共3847页)
- 同样的chunk_size=512,chunk_overlap=64
- 同样的Embedding模型(bge-large-zh-v1.5 ONNX版)
- 同样的向量库(Chroma)
- 同样的检索数量(k=4)
- 同样的LLM(Qwen-14B-Chat via vLLM)
- 同样的3个测试问题
构建阶段耗时对比
| 阶段 | LangChain 0.1.0 | LlamaIndex 0.9.3 | 差异 |
|---|---|---|---|
| PDF解析加载 | 46.32s | 51.87s | LlamaIndex慢12%(内部用了不同的PDF解析器) |
| 文本切分 | 8.15s | 6.42s | LlamaIndex快21% |
| Embedding(3847页→约1.5万块) | 1191.2s | 1094.7s | LlamaIndex快8.8% |
| 写入Chroma | 2.86s | 3.83s | LangChain快25% |
| 总耗时 | 1248.53s | 1156.82s | LlamaIndex快7.3% |
| 峰值内存 | 7.2GB | 6.1GB | LangChain高1.1GB |
| 磁盘占用(Chroma目录) | 2.1GB | 2.0GB | 基本持平 |
Embedding是大头,占了总耗时90%以上。chunk_size=512意味着每个chunk都要过一次模型。如果chunk_size调成1024,chunk数量减半,Embedding耗时能降低到5分钟左右,但检索粒度变粗,后面回答质量会下降。这个取舍没有标准答案,要看你的文档类型。
检索质量对比
我把3个测试问题分别用两个框架检索,手动标注了「检索结果是否相关」和「回答是否可接受」。
| 问题 | LangChain 检索相关 | LlamaIndex 检索相关 | LangChain 回答 | LlamaIndex 回答 |
|---|---|---|---|---|
| 串口参数设置 | 4/4相关 | 4/4相关 | 正确,引用了手册文件 | 正确,引用了手册文件 |
| 固件升级失败 | 3/4相关 | 4/4相关 | 基本正确,缺少一个步骤 | 正确,步骤完整 |
| 故障代码E203 | 2/4相关 | 3/4相关 | 错误,没有找到E203定义 | 正确,找到了E203定义 |
第三问出现差异的原因,我检查了检索日志后确认:LangChain用的RecursiveCharacterTextSplitter,在切分过程中把「E203」这个代码和它对应的解释切到了两个相邻的chunk里。检索时相似度匹配命中了「E203」所在chunk,但该chunk只有代码没有解释,另一个chunk有解释但没被检索到(因为query里包含「E203」但chunk解释文本里只写着「该代码表示传感器电压异常」没有E203这个字符串)。
LlamaIndex的SentenceSplitter在「。」处进行了切分,E203和它的解释正好在同一句话里(文档原句是「故障代码E203表示传感器电压异常,请检查供电线路。」),所以被保留在同一个chunk中。
这不是LangChain的bug,是RecursiveCharacterTextSplitter的separator优先级问题——它先按段落「\n\n」切,再按换行切,最后才按句号切。如果原文里E203和解释之间恰好有个换行,就会出这个问题。用LangChain需要自己检查切分结果,或者调大overlap。
回答延迟对比
| 问题 | LangChain 延迟 | LlamaIndex 延迟 |
|---|---|---|
| 串口参数设置 | 8.2s | 7.9s |
| 固件升级失败 | 9.5s | 8.1s |
| 故障代码E203 | 7.8s | 8.3s |
| 平均 | 8.5s | 8.1s |
延迟基本持平,差异在5%以内。大头都在LLM生成耗时(Qwen-14B在vLLM上生成512个token约6-7秒)。检索本身只需要50-100ms,几乎可以忽略。
代码复杂度和维护性对比
这个维度主观但很重要。我有三个直观感受:
1. LangChain的抽象层级多。做同样一件事要理解Load → Split → Embed → Store → Retrieve → Generate整条链上每个环节的接口。Chain、Agent、Tool、Memory这些概念,对只做RAG的场景来说是过度设计。但如果你后续要做Agent、要做多工具调用,LangChain的生态是现成的。
2. LlamaIndex的文档质量更高。LlamaIndex 0.9.3的文档有完整的入门教程、概念解释和API参考。LangChain 0.1.0的文档还在改版中,很多示例代码根本跑不通(后面避坑章节细说)。我看LangChain的文档经常要回到GitHub源码里去确认接口对不对。
3. LlamaIndex对中文支持好一些。这不是偏见,是实测结果。原因有两个:一是SentenceSplitter对中文断句比RecursiveCharacterTextSplitter更自然;二是LlamaIndex的SimpleDirectoryReader会从PDF元数据里提取中文文件名,而LangChain的PyPDFLoader对中文文件名支持不完善(文件名乱码)。
原理剖析:为什么LlamaIndex在RAG上更顺手
LangChain和LlamaIndex的设计出发点不同,这决定了它们在RAG场景下的表现差异。
LangChain的核心理念是「链」。它把整个流程抽象成 输入 → Chain → 输出,中间每一步都是可组合的组件。加载文档是一个Loader、切分文本是一个Splitter、向量化是一个Embedding、检索是一个Retriever、生成是一个LLM。理论上你可以自由组合,但代价就是你要自己理解每个组件的输入输出格式、自己做调试。
LlamaIndex的核心理念是「索引」。它把文档 → 节点 → 向量索引 → 查询引擎的流程固化成了默认实现。你要做的不是组装管道,而是在默认实现上调参。比如VectorStoreIndex.from_documents()内部自动完成了切分、Embedding、入库三步,你只需要关心chunk_size和embed_model两个参数。
我用一个简单的类比:LangChain是乐高积木,灵活但费时;LlamaIndex是宜家家具,照着说明书拼就行。RAG的场景已经被LlamaIndex固定成了几个模式(VectorIndex、SummaryIndex、KeywordTableIndex、KnowledgeGraphIndex),你做RAG用VectorIndex就够了,不需要自己组装。
但灵活性的代价不等于LangChain不好。如果要做Agent、要接多个外部API、要做复杂的对话管理,LangChain的Chain Graph、Tool Calling机制能派上用场。LlamaIndex的Agent能力相比LangChain弱不少,虽然也支持Function Calling,但生态远不如LangChain丰富。
避坑指南:两个框架我踩过的坑
坑1:LangChain 0.1.0的API变更太猛。网上教程大部分基于0.0.x,代码里还在用from langchain.llms import OpenAI这种老写法。到了0.1.0,这些接口全迁移到了langchain_community或langchain_openai子包里。我初期照着教程写,import就报错。解决方案:直接看官方文档的「Migrating」章节,或者直接把langchain降到0.0.354(这是最后一个全量接口版本)。我建议如果项目不追新,用0.0.354稳定版;如果新项目,直接上0.1.0 + langchain_community,不要再看旧教程。
坑2:LlamaIndex 0.9.3的Embedding模型缓存。LlamaIndex会缓存Embedding模型至 ~/.cache/llama_index,第二次运行直接读缓存。但这里有个坑:如果你用同一个模型名但改了device参数(比如从cpu改成cuda),它不会重新加载模型,还是用旧的。我为了排查内存占用,一开始用cpu,后来想换cuda加速,结果模型还是在cpu上跑。删除 ~/.cache/llama_index 下的对应缓存目录,重新运行才行。
坑3:Chroma的持久化目录冲突。LangChain的Chroma.from_documents(persist_directory="./chroma_db")和LlamaIndex的chromadb.PersistentClient(path="./chroma_db")如果指向同一个目录,Chroma会直接报错「collection already exists」。两个框架不能共用同一个持久化目录。我用的是两个不同目录,干净。
坑4:bge-large-zh的ONNX算子兼容问题。我用的是bge-large-zh-v1.5的原版PyTorch模型,显存占用4.2GB,加载后推理一张卡都危险。换成ONNX量化版(int8)后显存降到1.1GB。但onnxruntime的CPUExecutionProvider在部分Linux旧内核上有OpenMP冲突,会报「libgomp.so.1: cannot allocate memory in static TLS block」。我最终是在服务启动前加了 export LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libgomp.so.1 解决。如果不想折腾,直接用原版PyTorch + GPU推理也行,就是吃显卡。
坑5:vLLM的OpenAI兼容接口meta字段缺失。通过OpenAILike调用vLLM时,vLLM返回的response里没有usage.prompt_tokens这个字段(在vLLM 0.4.2版本之前)。我当时查LlamaIndex源码,发现OpenAILike类会读取usage来算token消耗,字段缺失直接报KeyError。升级vLLM到0.4.2及以上版本解决,或者给OpenAILike传参max_retries=0绕过重试逻辑。
坑6:PDF文件名乱码。LangChain的PyPDFLoader加载带中文文件名的PDF,新版的PyPDF2解析出的metadata里文件名直接乱码。我在构建qa_chain后打印来源文档,全是æ··ä¹±.pdf这种。解决方案:加载后手动覆盖metadata:
# 修复中文文件名乱码
import os
for doc in docs:
# 从加载时的路径重新提取文件名
raw_source = doc.metadata.get("source", "")
doc.metadata["source"] = os.path.basename(raw_source)
坑7:Chroma的批量写入性能。实测往Chroma里写入1.5万个向量,用批量add(batch_size=500)比逐条add快得多,但我最初用逐条add,结果花了42分钟还没写完,果断关掉换批量。Chroma 0.4.22的add支持传list of embeddings和list of documents,一次传500条没问题。写入2.1GB数据,批量方式总耗时2.86秒,逐条方式我中途放弃了,估算要40分钟。差别在于Chroma每次add都要做一次持久化——批量add是最后一次写盘,逐条add每条都触发write-ahead log。
# 批量写入示例(LangChain的Chroma内部已经是批量,下面是LlamaIndex的ChromaVectorStore)
# 如果你想手动控制批量,直接操作chroma_collection:
chroma_collection.add(
ids=[f"node_{i}" for i in range(len(texts))],
embeddings=embeddings, # shape: (batch_size, 1024)
documents=texts,
metadatas=metadatas,
)
坑8:chunk_size和chunk_overlap的取值陷阱。我上个月给一个金融客户做RAG,他们的合同PDF一行字特别长(一行1500字符),chunk_size=512会把这个长行从中间切断。RecursiveCharacterSplitter的separators里我加了「\n」但PDF解析出来的换行符可能不是\n而是\r\n,导致切分逻辑异常,分隔符匹配不上,整行被当作一个chunk。解决方案:加载PDF后用正则把\r\n统一替换成\n再切分。
坑9:Embedding模型要按文档领域选。bge-large-zh在通用中文文本上效果不错,但如果是医疗、法律、代码等专业领域,建议用专门的领域模型。比如法律用Law-LLM的embedding,代码用UnixCoder。我用bge跑代码生成的PDF文档,检索到的结果有时相关度不高。实测换上专门的code embedding模型后,代码类问题的检索相关率从75%提升到92%。
坑10:Prompt模板变量名不一致。这是从LangChain切到LlamaIndex最容易踩的坑。LangChain的PromptTemplate里变量名可以随意取(我用的是context和question),但LlamaIndex里text_qa_template的变量名必须叫context_str和query_str,改名字就报KeyError。你查文档的时候如果发现模板里没有这两个变量名,直接按文档抄就行,别自己发挥。
结论:怎么选
实测下来我的建议是:
只做RAG,用LlamaIndex。代码量少、默认切分合理、文档好、坑少。我们用LlamaIndex重构了内部知识库问答,索引构建代码从300行降到120行。
要构建复杂的Agent应用,用LangChain。Agent、Tool、Memory这些LangChain有现成方案。LlamaIndex也有Agent,但成熟度不如LangChain。
两个都用也行,但要明白各自的边界。LlamaIndex做数据索引,LangChain做Agent调度,两者通过各自暴露的接口对接。我见过生产环境这么干的,效果也不错,但维护成本更高。
最后说一句:框架选型不要跟风。先想清楚你的应用场景。如果只是给文档库做个问答,LlamaIndex就够了,LangChain的灵活用不上反而是累赘。如果需要让模型多轮调用工具、操作外部系统,LangChain的Agent体系值得投入。两个都跑一遍真实数据再定,别光看GitHub Star数。