RAG 系列(二):用 LangChain 搭建你的第一个 RAG Pipeline

从 100 行代码到生产级 Pipeline

上一篇我们用手写 Python 搭了一个最小 RAG,100 行代码跑通了核心逻辑。但如果你想把那套代码搬到生产环境,很快就会撞上一堵墙。

要加载 PDF? 你需要 PyPDF2pdfplumber,然后发现表格、页眉页脚的解析是一场噩梦。

要切分文本? 你那个朴素的 text.split("\n\n") 会把句子拦腰截断、破坏代码块,或者切出超长的块直接把 Token 上限撑爆。

想换个向量数据库? 祝你下午愉快------每个数据库的 API 都不一样,距离度量不一样,元数据处理也不一样。

想换个 LLM 提供商? OpenAI 的客户端、Anthropic 的客户端、本地 llama.cpp......每个都有自己的消息格式、Token 计算方式、错误处理逻辑。

这正是 LangChain 存在的意义。它不搞什么魔法,也不替代底层的模型或数据库。它只做一件简单但有价值的事:给所有组件提供一个统一的接口,让你专注于 RAG 系统的业务逻辑,而不是 plumbing( plumbing 指的是那些连接管道的脏活累活)。

这篇文章,我们用 LangChain 的现代 API 重建 RAG Pipeline。读到最后,你会拥有一个完整的、可运行的项目:它能读取 PDF、智能切分、存入 ChromaDB、用多 Provider LLM 回答问题------而真正的 Pipeline 代码只有大约 30 行。

LangChain 版本说明: 本文代码基于 langchain 1.2.x(当前主流稳定版)。langchain 1.x 对 0.3.x 做了破坏性重组,部分高层 API(如 create_retrieval_chain)被移除。代码改用 LCEL 原生语法| 管道符)组合 Chain,功能完全等价,且不依赖版本。完整源码:github.com/chendongqi/...


RAG Pipeline 的六大组件

LangChain 把 RAG 拆解成六个组件。理解每个组件是做什么的、质量风险藏在哪里------这是以后调试 RAG 系统的关键。

组件 职责 质量风险
Document Loader 读取原始文件(PDF、Word、Markdown、HTML)并提取文本 表格、图片、奇怪格式会被搞砸
Text Splitter 把长文档切成语义连贯的小块 块太大 = 精度低;块太小 = 丢失上下文
Embedding Model 把文本块转换成高维向量 模型选错 = 语义不相关的文本聚到一起
Vector Store 持久化向量,支持快速相似度检索 距离度量选错、没有元数据过滤 = 检索质量差
Retriever 接收查询,检索向量库,返回相关块 Top-K 太小 = 漏信息;太大 = 引入噪声
Chain 编排完整流程:查询 → 检索 → 组装 Prompt → LLM → 答案 Prompt 设计和上下文组装决定回答质量

把六个组件想象成工厂里的一条流水线:Document Loader 是原材料进料口,Text Splitter 是精密切割站,Embedding Model 和 Vector Store 是仓库和库存系统,Retriever 是拣货员,Chain 是车间主任------协调一切并交付最终产品。

流水线上任何一个工位配置错了,最终产品都会受影响------而棘手的是,失败看起来经常是 LLM 的问题,实际上是检索的问题


实战:完整的 LangChain RAG 项目

我们来动手搭建。这个项目会读取一个目录下的 PDF 文件,建立索引,然后让你用自然语言提问。

项目结构

bash 复制代码
rag-project/
├── requirements.txt
├── data/
│   └── sample.pdf          # 把你的 PDF 文档放这里
└── rag_pipeline.py

Step 0:安装依赖

text 复制代码
langchain>=0.3.0
langchain-text-splitters>=0.3.0
langchain-openai>=0.2.0
langchain-chroma>=0.1.0
langchain-community>=0.3.0
pypdf>=4.0.0
python-dotenv>=1.0.0

完整源码(可直接运行): github.com/chendongqi/... 支持智谱 AI / OpenAI / Ollama 多 LLM Provider,Embedding 支持 SiliconFlow 和本地 Ollama。

安装:

bash 复制代码
pip install -r requirements.txt

还需要配置 API Key(复制 .env.example.env 后填入):

bash 复制代码
cp .env.example .env
# 编辑 .env,填入 LLM_API_KEY 和 EMBEDDING_API_KEY

支持的 Provider:

  • LLM:智谱 AI(默认)、OpenAI、SiliconFlow、Ollama、Azure
  • Embedding:SiliconFlow(默认)、OpenAI、Ollama

Step 1:加载文档

PyPDFLoader 帮我们处理 PDF 解析。它按页提取文本,返回一个 Document 对象列表,每个对象包含页面内容和元数据(页码、来源文件等)。

python 复制代码
from langchain_community.document_loaders import PyPDFLoader
from pathlib import Path

def load_pdfs(data_dir: str = "./data"):
    """从数据目录加载所有 PDF 文件"""
    documents = []
    pdf_paths = list(Path(data_dir).glob("*.pdf"))

    for pdf_path in pdf_paths:
        loader = PyPDFLoader(str(pdf_path))
        pages = loader.load()
        documents.extend(pages)
        print(f"已加载 '{pdf_path.name}':{len(pages)} 页")

    print(f"共加载 {len(documents)} 个文档片段")
    return documents

真实世界提示: PDF 是文档格式里的"狂野西部"。如果你的 PDF 是扫描件(图片),你需要 OCR(通过 pdfplumber + Tesseract 或 Azure Document Intelligence)。如果 PDF 里有很多表格,考虑用 UnstructuredPDFLoader,它对表格结构的保留比纯文本提取好得多。

Step 2:切分文档

还记得第一篇里的分块问题吗?LangChain 的 RecursiveCharacterTextSplitter 能成为行业默认不是没道理的。它会尝试按自然边界切分------先按段落、再按换行、再按句子、再按单词------尽量避免在句子中间切断。

python 复制代码
from langchain_text_splitters import RecursiveCharacterTextSplitter

def split_documents(documents, chunk_size=200, chunk_overlap=30):
    """
    将文档切分为有重叠的块
    chunk_overlap 确保相邻块之间有上下文连续性
    """
    splitter = RecursiveCharacterTextSplitter(
        chunk_size=chunk_size,
        chunk_overlap=chunk_overlap,
        length_function=len,
        separators=["\n\n", "\n", ". ", " ", ""]
    )

    chunks = splitter.split_documents(documents)
    print(f"切分为 {len(chunks)} 个块(chunk_size={chunk_size},overlap={chunk_overlap})")
    return chunks

为什么 chunk_overlap 很重要: 如果一个关键概念横跨两个块------比如"API 速率限制是每分钟 100 次请求。超过限制会返回 429 状态码"------50 个字符的重叠能确保第二个块里还保留着"每分钟 100 次请求"的上下文。没有重叠的话,Retriever 可能只召回一个块,漏掉因果关系。

Chunk size 的权衡:

Chunk 大小 精度 上下文 适合场景
256 tokens 最少 事实查询、结构化文档问答
512 tokens 平衡 中等 通用 RAG(推荐默认值)
1024 tokens 较低 丰富 长文摘要、叙事类文档
2048+ tokens 非常丰富 仅当 LLM 上下文窗口很大且查询范围很广时

大多数场景下,512 tokens + 50 token 重叠 是一个稳妥的起点。

Step 3:Embedding 并存入 ChromaDB

现在把每个块转成向量并存起来。这里选 ChromaDB,因为它支持持久化(数据不会随重启丢失)、支持元数据过滤、而且零配置------作为嵌入式数据库本地运行。

python 复制代码
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma
import os

persist_directory = "./chroma_db"

def build_vector_store(chunks):
    """创建 Embedding 并存入 ChromaDB"""
    embeddings = OpenAIEmbeddings(
        model="BAAI/bge-large-zh-v1.5",  # SiliconFlow 中文模型
        api_key=os.getenv("EMBEDDING_API_KEY"),
        base_url="https://api.siliconflow.cn/v1",
        dimensions=1024,
        chunk_size=32  # SiliconFlow 限制每批最多 32 条
    )

    if os.path.exists(persist_directory):
        import shutil
        shutil.rmtree(persist_directory)

    vector_store = Chroma.from_documents(
        documents=chunks,
        embedding=embeddings,
        persist_directory=persist_directory
    )
    print(f"向量库构建完成:{vector_store._collection.count()} 个向量已持久化")
    return vector_store

Embedding 模型说明: 这里用 SiliconFlow 上的 BAAI/bge-large-zh-v1.5(中文效果优秀),通过 langchain_openai 的 OpenAI 兼容接口接入。如果用 OpenAI 官方,换成 text-embedding-3-small 即可。chunk_size=32 是 SiliconFlow 的批次限制(每批最多 32 条),其他 Provider 通常默认 1000 条。

Step 4:构建 Retriever

Retriever 是向量库的一个薄封装,负责处理搜索逻辑。默认情况下,它做相似度搜索,返回最相关的 Top-K 个块。

python 复制代码
def get_retriever(vector_store, search_k=4):
    """配置相似度检索的 Retriever"""
    retriever = vector_store.as_retriever(
        search_type="similarity",
        search_kwargs={"k": search_k}
    )
    return retriever

Step 5:构建 RAG Chain

这里是 LangChain 现代 API 最亮眼的地方。不用老旧的 RetrievalQA 类,我们用 LCEL(LangChain Expression Language,LangChain 表达式语言)来组合 Chain------代码更直观、更容易调试、也更容易修改。

python 复制代码
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough

def build_rag_chain(retriever):
    """
    构建完整 RAG Chain(langchain 1.x 兼容写法,不依赖 create_retrieval_chain)
    检索 → format_docs → 塞进 Prompt → LLM → StrOutputParser
    """
    llm = ChatOpenAI(
        model="glm-4-flash",  # 智谱 AI 模型,通过 SiliconFlow 或直连
        api_key=os.getenv("LLM_API_KEY"),
        base_url="https://open.bigmodel.cn/api/paas/v4",
        temperature=0
    )

    # System Prompt,{context} 由 format_docs 填充,{question} 是用户原始问题
    system_prompt = (
        "你是一个精准的知识助手。请仅根据下方提供的参考内容回答用户问题。"
        "如果参考内容中没有答案,请明确说明------不要编造。\n\n"
        "参考内容:\n{context}"
    )

    prompt = ChatPromptTemplate.from_messages([
        ("system", system_prompt),
        ("human", "{question}")
    ])

    # 辅助函数:把 Document 列表转成字符串
    def format_docs(docs: list) -> str:
        return "\n\n".join(doc.page_content for doc in docs)

    # LCEL Chain:用管道符 | 组合各个组件
    # 1. {"context": retriever | format_docs, "question": RunnablePassthrough()}
    #    → retriever 检索文档,format_docs 把 Document 对象列表转成字符串
    # 2. | prompt → 组装成完整的 Prompt
    # 3. | llm → LLM 生成答案
    # 4. | StrOutputParser() → 输出纯文本(而不是 AIMessage 对象)
    rag_chain = (
        {
            "context": retriever | format_docs,
            "question": RunnablePassthrough()
        }
        | prompt
        | llm
        | StrOutputParser()
    )

    return rag_chain

这里发生了什么? 我们用的是 LCEL (LangChain Expression Language)原生语法,用 | 管道符把各个组件串联起来,而不是用高层的 create_retrieval_chain(该函数在 langchain 1.x 中已被移除)。

关键在于 retriever | format_docs:Retriever 返回的是 Document 对象列表,format_docs 把它转成字符串填入 {context} 占位符。RunnablePassthrough() 把用户的原始问题透传到 {question} 占位符。这三行代码等价于第一篇里的手写检索 + 组装 + 生成逻辑。

Step 6:查询 Pipeline

python 复制代码
def query(rag_chain, question: str, retriever):
    """把问题丢进 RAG Pipeline 跑一遍,打印答案和来源"""
    print(f"\n问题:{question}")

    answer = rag_chain.invoke(question)  # LCEL Chain 直接返回纯文本
    print(f"\n答案:\n{answer}")

    # 单独检索一次来源(rag_chain 不直接暴露 retrieved docs)
    docs = retriever.invoke(question)
    print("\n检索到的来源:")
    for i, doc in enumerate(docs, 1):
        source = doc.metadata.get("source", "未知")
        page = doc.metadata.get("page", "?")
        preview = doc.page_content[:120].replace("\n", " ")
        print(f"  [{i}] {source}(第 {page} 页):{preview}...")

    return answer

组装起来

python 复制代码
if __name__ == "__main__":
    # 1. 加载
    docs = load_pdfs("./data")

    # 2. 切分(PDF 每页内容短,用较小的 chunk_size)
    chunks = split_documents(docs, chunk_size=200, chunk_overlap=30)

    # 3. Embedding & 存储
    vector_store = build_vector_store(chunks)

    # 4. 检索器
    retriever = get_retriever(vector_store, search_k=4)

    # 5. 构建 Chain(LCEL 方式)
    rag_chain = build_rag_chain(retriever)

    # 6. 交互式提问
    while True:
        user_input = input("\n你的问题(输入 quit 退出):").strip()
        if user_input.lower() in ("quit", "exit", "q"):
            break
        if user_input:
            query(rag_chain, user_input, retriever)

运行 Pipeline

把 PDF 放到 data/sample.pdf,然后运行:

bash 复制代码
python rag_pipeline.py

示例输出:

bash 复制代码
RAG Pipeline 启动
  LLM Provider    : zhipu
  LLM Model       : glm-4-flash
  Embedding       : openai / BAAI/bge-large-zh-v1.5
  数据目录        : ./data
  向量库          : ./chroma_db
==================================================
已加载 'Automotive-SPICE-PAM-v40.pdf':153 页
共加载 153 个文档片段
切分为 2000 个块(chunk_size=200,overlap=30)
[Embedding] Provider: openai | Model: BAAI/bge-large-zh-v1.5 | Base: https://api.siliconflow.cn/v1
已清除旧向量库:./chroma_db
向量库构建完成:2000 个向量已持久化
==================================================
RAG Pipeline 构建完成!输入问题开始问答(输入 'quit' 退出)
==================================================

你的问题:什么是 Automotive SPICE?

==================================================
问题:什么是 Automotive SPICE?
==================================================

答案:
Automotive SPICE(Automotive Software Process Improvement and
Capability Determination)是一种用于评估和改进汽车软件
开发过程能力的框架。它定义了软件开发生命周期中的关键
过程域,并建立了过程能力的等级评估标准...

检索到的来源:
  [1] ./data/Automotive-SPICE-PAM-v40.pdf(第 5 页):Automotive
      SPICE Process Assessment Model The Process Assessment Model
      (PAM) defines the processes...(正文来自 RAG 实际运行)

注意答案里带有来源引用------我们清楚知道信息来自哪几页。这种可追溯性对生产级 RAG 系统至关重要,因为用户需要验证答案的可靠性。


和 100 行手写版相比,变了什么?

对比一下第一篇的手写 RAG 和现在的 LangChain Pipeline:

| 对比项 | 手写版(第一篇) | LangChain 版(第二篇) |
|:-------------------|:----------------------------|:--------------------------------------|----------|
| PDF 加载 | 不支持 | 一行 PyPDFLoader |
| 文本切分 | 没有(整篇传入) | RecursiveCharacterTextSplitter 智能边界 |
| 向量持久化 | 仅存内存,重启丢失 | ChromaDB 落盘持久化 |
| 换 Embedding 模型 | 重写 API 调用 | 改一个参数 |
| 换 LLM | 重写客户端代码 | 改一个参数 |
| 换向量数据库 | 重写存储 + 检索 | ChromaQdrant / Pinecone |
| Prompt 工程 | 原始字符串拼接 | ChatPromptTemplate 模板化 |
| 来源引用 | 手动维护 | 元数据自动传递 |
| Chain 构建 | 手写 retrieve + generate 逻辑 | LCEL ` | ` 管道符组合 |
| Pipeline 代码量 | ~80 行 | ~25 行 |

这些抽象没有隐藏复杂性------而是隔离了它。当你需要调试检索质量时,你知道该调哪个组件。当你想换更便宜的 Embedding 模型时,改一行代码。当你的数据量超出 ChromaDB 的能力时,切到 Qdrant 不用动 Pipeline 的其他部分。

关于 LangChain 版本兼容性: 本文的代码基于 langchain 1.x(当前稳定版)。langchain 1.x 对 0.3.x 做了破坏性重组,create_retrieval_chaincreate_stuff_documents_chain 在 1.x 中已被移除。代码用的是 LCEL 原生语法(|> 管道符组合),功能完全等价,且不依赖特定版本的高层 API。


每个阶段的常见坑

Loader 坑:"我的 PDF 里有表格,解析出来是一团糟"

原始 PDF 文本提取会把表格拍扁成一串数字。表格多的文档,用 UnstructuredPDFLoaderAzureAIDocumentIntelligenceLoader,它们能保留结构关系。

Splitter 坑:"答案被切成了两半,模型只看到了一半"

chunk_overlap 提高到 100-150,或者减小 chunk_size 让关键概念能放进一个块。更好的方案是 Parent-Document Retrieval(后续文章会讲)------用小块做检索,但返回完整的父文档作为上下文。

Embedding 坑:"问题和文档明明相关,就是匹配不上"

这是"非对称检索"问题。用户问"怎么重置密码?",文档写的是"要重置密码,请前往 设置 → 安全"。问题和答案的表面文本差异大,向量距离也远。解法:用针对 Q&A 检索微调的模型(如 BGE-M3),或者生成假设答案来做检索(HyDE------后续也会讲)。

Retriever 坑:"Top-K=4 不够用,复杂问题漏信息"

如果一个问题需要综合文档里五个不同部分的信息,k=4 就会漏掉一个。但盲目增大 k 会引入噪声。更好的做法:Multi-Query Retrieval (生成 3 个问题的变体,分别检索,去重)或者 Reranking(先召回 20 个,再用 Cross-Encoder 挑出最好的 5 个)。

Chain 坑:"模型无视上下文,开始 hallucinate"

Prompt 设计很重要。系统 Prompt 必须明确指示模型只用提供的上下文回答。加上"如果参考内容中没有答案,请明确说明------不要编造"能显著提升忠实度。我们会在评估篇里用 RAGAS 定量测量这个指标。


小结

这篇文章把第一篇的裸奔 RAG 概念,包上了一个生产级框架。我们讲了:

  1. LangChain RAG Pipeline 的六大组件------Loader、Splitter、Embedding、Vector Store、Retriever、Chain,每个组件藏了什么质量风险。
  2. 一个完整可运行的项目 ------加载 PDF、用 RecursiveCharacterTextSplitter 切分、OpenAI Embedding、ChromaDB 存储、LangChain LCEL Chain 问答。
  3. Chunk size 的权衡------实际项目中 PDF 每页内容可能很短(200 字符),这时候 512 的默认值会产生 0 个块。200 + 30 重叠是实测可用的安全值。
  4. 每个阶段的常见坑------从 PDF 表格解析失败,到非对称检索失配。

这篇文章的代码是一个扎实的基础。它能处理真实 PDF、持久化数据、还能给出来源引用。但它仍然是一个朴素 RAG(Naive RAG)------一次查询、一次检索、一次回答。后面的文章会逐步加入区分玩具 Demo 和生产系统的组件:混合检索、Reranking、查询优化、评估框架。


参考资料

相关推荐
学习论之费曼学习法1 小时前
多模态大模型实战:用 GPT-4o API 打造 AI 助手,能看、能听、能说!
人工智能
昨夜见军贴06161 小时前
IACheck与AI报告审核,开启供应商资质核验报告审核新篇章
人工智能
m0_726365832 小时前
Ai漫剧系统 几分钟,让AI 把一篇小说变成了一部漫剧成片:从剧本到视频的全流程系统实现
人工智能·语言模型·ai作画·音视频
AIwenIPgeolocation2 小时前
出海应用合规与风控平衡术:可信ID的全球安全实践
人工智能·安全
WordPress学习笔记2 小时前
镌刻中式美学的高端WordPress主题
大数据·人工智能·wordpress
直奔標竿2 小时前
Java开发者AI转型第二十七课!Spring AI 个人知识库实战(六)——全栈闭环收官,解锁前端流式渲染终极技巧
java·开发语言·前端·人工智能·后端·spring
科技社2 小时前
咪咕互娱亮相数字中国峰会:“精品游戏+轻量终端”组合,打开数字娱乐新想象
人工智能
数智化精益手记局3 小时前
拆解物料管理erp系统的核心功能,看物料管理erp系统如何解决库存积压与缺料难题
大数据·网络·人工智能·安全·信息可视化·精益工程
Flying pigs~~3 小时前
RAG 完整面试指南:原理、优化、幻觉解决方案
人工智能·prompt·rag·智能体·检索增强生成·rag优化