🐸 青蛙编程网
首页 / AI / 检索增强 RAG / 11. 上框架:LangChain 版 RAG

11. 上框架:LangChain 版 RAG

这是什么:用 LangChain 把前十章手写的 RAG 重新做一遍——同样的知识库助手,同样的兜底与出处标注,换框架的零件。

为什么用:第 6.7 节说过「先手搓再上框架」——现在每个零件你都亲手摸过了,正是看框架省了哪一步、又藏了哪一步的时候。

如何用:本章按「模型 → 切分 → 入库 → 检索 → 链」的顺序逐个替换,最后给一张全章对照表。

11.1 装包与环境

pip install -U langchain langchain-openai langchain-chroma langchain-text-splitters

Key 和模型名沿用本教程的环境变量约定(第 2 章):

$env:LLM_API_KEY = "你的key"
$env:LLM_BASE_URL = "https://api.openai.com/v1"   # 可换成任意兼容服务
$env:EMBED_MODEL = "text-embedding-3-small"
$env:CHAT_MODEL = "gpt-4o-mini"

版本说明:LangChain 1.4.x · langchain-core 1.6.x · langchain-openai 1.7.x · langchain-chroma 1.1.0 要求 Python ≥ 3.10,站点基线 3.15 直接可用。写法与本站 Agent 教程第 5 章同口径:v1 里 LLMChain、RetrievalQA、langchain.retrievers 已整体迁出到 langchain-classic,网上大量 0.x 教程的 import 会直接报错,抄代码前先看版本。

11.2 模型与嵌入:两个客户端

手写时是同一个 OpenAI 客户端干两件事;框架里拆成两个对象——对话模型和嵌入各自独立:

import os
from langchain.chat_models import init_chat_model
from langchain_openai import OpenAIEmbeddings

model = init_chat_model(
    os.environ.get("CHAT_MODEL", "gpt-4o-mini"),
    api_key=os.environ["LLM_API_KEY"],
    base_url=os.environ.get("LLM_BASE_URL"),      # 任意 OpenAI 兼容服务
    temperature=0,
)

embeddings = OpenAIEmbeddings(
    model=os.environ.get("EMBED_MODEL", "text-embedding-3-small"),
    api_key=os.environ["LLM_API_KEY"],
    base_url=os.environ.get("LLM_BASE_URL"),
    check_embedding_ctx_length=False,             # 兼容端点必加,见 11.7
)

vec = embeddings.embed_query("怎么申请报销?")
print(len(vec))        # 1536,等价于第 2 章的 embed([text])[0]

第 4 章的结论原样适用:换嵌入模型必须重建索引,所以这个对象要在建库和查询两边共用同一个。

11.3 切分:递归切分器有了现成的

第 3 章手写的 split_text,生命周期到了——LangChain 的递归切分器想法完全相同,但把「同级语义边界内滚动保留 overlap」也做了(手写版是简单拼接尾部,切口处反而更干净):

from langchain_text_splitters import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=400,
    chunk_overlap=60,
    separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""],
)

docs = splitter.create_documents(
    [open("handbook.md", encoding="utf-8").read()],
    metadatas=[{"source": "handbook.md"}],        # 元数据跟着 Document 走
)
print(f"共 {len(docs)} 块,最长 {max(len(d.page_content) for d in docs)} 字")

切出来的每块是一个 Document 对象:page_content 是原文,metadata 是标签——正是第 3.5 节那个 dict 的对象化。

11.4 入库与检索

from langchain_chroma import Chroma

vectorstore = Chroma(
    collection_name="rag_kb",   # Chroma 1.x 要求 ≥3 字符
    embedding_function=embeddings,
    persist_directory="./chroma_data",            # 目录即库,等价 PersistentClient
    collection_configuration={"hnsw": {"space": "cosine"}},
)
ids = [f"{d.metadata['source']}#{i}" for i, d in enumerate(docs)]
vectorstore.add_documents(docs, ids=ids)          # 第 3 章切好的块直接进库

# ① 检索器:拿相似块
retriever = vectorstore.as_retriever(search_kwargs={"k": 3})
for d in retriever.invoke("差旅住宿的报销上限是多少?"):
    print(d.metadata["source"], d.page_content[:30])

# ② 带相似度:score 已换算成相关度,越大越相关
results = vectorstore.similarity_search_with_relevance_scores(
    "差旅住宿的报销上限是多少?", k=3,
)
for doc, score in results:
    print(f"{score:.3f}  {doc.page_content[:20]}")

# ③ 元数据过滤:等价第 5.5 节的 where
by_source = vectorstore.as_retriever(
    search_kwargs={"k": 3, "filter": {"source": "handbook.md"}},
)

距离换算那条坑(5.4 节)不用再记了——similarity_search_with_relevance_scores 返回的就是换算好的相关度。但它换算得对不对,取决于建库时的 space 配置有没有定成 cosine(见 11.7 翻车表)。

11.5 整条链:LCEL 串起来

新建 langchain_rag.py,前面 11.2–11.4 的准备就位后,问答就是一条链:

from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser

prompt = ChatPromptTemplate.from_template(
    "你是一个严谨的知识库助手。只依据下面的资料回答问题,"
    "不要补充资料里没有的信息;资料不足时明确说明缺什么。"
    "回答末尾用 [编号] 标出依据。\n\n"
    "资料:\n{context}\n\n问题:{question}"
)

def ask(question, top_k=3):
    results = vectorstore.similarity_search_with_relevance_scores(question, k=top_k)
    if not results or results[0][1] < 0.35:       # 兜底:相似度太低 = 没找到
        return "知识库里没有找到相关资料。可以换个说法,或把资料补进知识库再问。", results

    context = "\n\n".join(
        f"[{i + 1}](来源:{doc.metadata.get('source', '?')})\n{doc.page_content}"
        for i, (doc, _) in enumerate(results)
    )
    chain = prompt | model | StrOutputParser()    # LCEL:竖线从左流到右
    return chain.invoke({"context": context, "question": question}), results

answer, hits = ask("差旅住宿的报销上限是多少?")
print(answer)
for doc, score in hits:
    print(f"  {score:.3f}  {doc.metadata.get('source')}")

prompt | model | parser 读作「提示词 → 模型 → 只取文本」,跑起来和第 10.5 的手写版行为一致:拒答、[1] 出处、相似度列表全在。

11.6 全系列对照:手写 → 框架零件

章节手写的 LangChain 对应零件 省了什么
第 2 章 embed() / chat.completions OpenAIEmbeddings / init_chat_model 批量、重试、缓存全是现成的
第 3 章递归切分器 RecursiveCharacterTextSplitter overlap 语义滚动,切分更稳
第 4 章手写余弦 建库时 space: "cosine" 相似度换算交给库
第 5 章 Chroma 落地 langchain_chroma.Chroma Document 进库一步到位
第 6 章 BM25 + RRF langchain_classic.retrievers.EnsembleRetriever RRF 融合就是它的默认行为
第 6.4 查询改写 langchain_classic.retrievers.MultiQueryRetriever 自动改写多路检索
第 7 章 评估 没有——照旧手写 Hit Rate / MRR 框架不替你定「什么是好」
第 10 章三件套 上面这条 LCEL 链 编排代码只剩十来行

再说一遍第 6 章那句话:框架只是打包零件,不替你解决「切分不对」和「检索不准」。评测集(第 7 章)不装进框架,依然是你自己的账本。

11.7 常见翻车现场

报错 / 现象 原因 修法
ImportError 提到 RetrievalQA / LLMChain v1 把经典链移到了 langchain-classic pip install langchain-classic,改 from langchain_classic.chains import ...;或直接用 11.5 的 LCEL 写法
兼容端点嵌入报 404 / tiktoken 相关错 默认先按 OpenAI 的分词器本地切分再请求 OpenAIEmbeddings(check_embedding_ctx_length=False)
score 出现 0~2 的怪数值且越大越不相关 建库落在默认 l2 空间 换集合名,重建时显式指定 space: "cosine"
过滤不生效 metadata 里塞了 dict / list / None Chroma 只认 str / int / float / bool
换嵌入模型后检索全乱 忘了删库 删掉 ./chroma_data 重建(第 4.5 节铁律)
照抄旧教程 create_react_agent v1 已废弃 from langchain.agents import create_agent(Agent 教程 5.7)
Unable to infer model provider for model='glm-…' init_chat_model 按模型名猜 provider,国产模型名猜不出 显式传 model_provider="openai"(OpenAI 兼容端点都走它)

11.8 动手改

  1. 把第 10 章的三件套用 LangChain 重写:build_kb.py 走 from_documents,ask.py 走 11.5 的链,对比代码行数
  2. pip install langchain-classic,接入 EnsembleRetriever(向量 + BM25Retriever 配 jieba 分词),感受 RRF 不用再手写
  3. 跑第 7 章的评测集,把手写版 vs 框架版的 Hit Rate@3 / MRR 记成一张表——框架快了,效果有没有变差?

11.9 实战扩展:让 .pdf 和扫描件入库

配套项目 rag-kb 把第 8、9 章的解析路由接进了框架版,核心仍是第 9.7 节那个「路由器」,只是块从 dict 换成了 Document:

输入 判断 走哪条路
.md / .txt — 直读 + RecursiveCharacterTextSplitter(11.3)
.pdf 有文字层 pypdf 抽样判断 MinerU CLI(独立 3.12 环境,第 8.3)→ 输出 Markdown → 按标题分节(第 8.6)
.pdf 无文字层 同上 pypdfium2 渲染 → PaddleOCR 三道工序(第 9.3)
.png / .jpg — PaddleOCR

三个落地要点恰好串联本章全部知识点:

  1. MinerU 不装进主环境——它要求 Python >=3.10,<3.15,通过 subprocess 调独立环境的 mineru CLI,主项目只合并结果
  2. PaddleOCR 是重依赖(PaddlePaddle 约 1 GB),做成独立 requirements-parsers.txt、代码里惰性 import——不用扫描件的场合不付这笔安装成本
  3. OCR 块带 page 元数据,LCEL 链里把出处拼成「来源 + 第 N 页」,答案可以直接定位到页

11.10 小结

  • 框架版 = 同样的流程,换封装好的零件:嵌入、切分、检索、链四步对应四个包
  • LCEL 一条竖线:prompt | model | StrOutputParser,编排不再手拼
  • similarity_search_with_relevance_scores 把距离换算包掉了,但空间仍要建库时定对
  • v1 的搬家风向:经典链 / 老 Retrievers 进了 langchain-classic,agent 新标准是 create_agent
  • 兜底拒答、出处标注、评测集这三样框架给不了——手搓过的经验才是 RAG 的真本事
  • 解析路由也不归框架管:.md / PDF / 扫描件分流(第 8、9 章)照样手写,框架只收 Document

RAG 教程完结:你已经会两条路——手搓和框架。下一站:智能体 Agent,让会查资料的 LLM 学会自己调工具 🐸

📥 下载本章代码

ZIP 内含本章全部代码文件与说明,解压即用,与教程内容同步生成。