LangChain 实践笔记:LangChain 与 RAG 实战

从本地文档出发,拆解加载器、切割器、Document、嵌入模型、向量库、检索器的职责,用两个 create_ 函数串成问答链,再改造成网页实现。

链能拼起来了,但是如果不给模型额外的资料和文档,它依赖的主要还是已有知识和当前上下文。公司的规章制度、会议纪要、产品手册,这些模型训练时没见过,它一个也答不上来。

把外部资料引入模型,有两种方式。一种是直接把资料拼进提示词,它受上下文窗口的容量限制,文档一长就超出上限,而且每次提问都需要人工筛选。

另一种是先按问题检索出相关的若干块,再把这些内容和问题一起给到模型。这就是 RAG(检索增强生成)。

RAG 解决的问题很具体:让模型在回答之前,先取回我们自己资料里真正相关的那几段。 LangChain 把这条链路上的每个环节都做成了现成的组件。这一篇看的是这些组件分别叫什么、怎么装配。

#一、基础RAG 的六个环节

一个基础 RAG 流程通常包括:文档加载、切分、嵌入、存储、检索和生成。

LangChain 为这些环节都提供了对应的组件:

环节用到的类做什么
加载TextLoader/PyPDFLoader/CSVLoader/JSONLoader 等把外部资料读成 Document
切割CharacterTextSplitter/RecursiveCharacterTextSplitter 等把长文档切成小块
嵌入Embeddings把文本转成向量
存储Chroma/FAISS 等向量库保存文档和向量
检索Retriever找出和问题最相关的文档块
生成Chat Model根据检索到的内容和问题生成答案

加载器按数据来源区分:文本、PDF、CSV、JSON、网页都有对应的 Loader,读取后统一变成 Document。

切分器负责把长文档拆成 chunk。常用的是 RecursiveCharacterTextSplitter,chunk_size 控制块大小,chunk_overlap 控制相邻块的重叠。

Embedding 把文本转换成向量,VectorStore 负责保存和检索这些向量。真正提问时,Retriever 先取回相关文档,再把它们和问题一起交给模型生成答案。

整个流程可以简单分成两段:

建库:Load → Split → Embed → Store
问答:Question → Retrieve → Generate

所以 RAG 可以理解成两段:先把资料建成可检索的索引,再根据问题提取资料生成答案。

#二、Document:数据在 RAG 里流通的形式

前面表格提到的 Document,也就是文档对象 。

最常用的两个字短:

  • page_content:原始文本内容;
  • metadata:这段内容的描述信息,比如来源文件、页码等。

Document 很重要,因为 RAG 的很多环节都是围绕它展开:

  • Loader 读取数据后,返回 Document 列表;
  • Spliter 把长文档切开,切出来的每一块仍然是 Document;
  • VectorStore 会读取 page_content 生成向量,同时保存 metadata;
  • Retriever 检索后,返回的仍然是 Document。

所以从加载、切分到最后检索,文本内容和来源信息一直是绑在一起走的。

其中 page_content 保存真正参与检索和生成的文本,metadata 则负责记录“这段内容来自哪里”,后面做引用、溯源、过滤时都会用到。

#三、手写一条 RAG 链

先看第一步到第五步的装配。下面这段把一份 Word 文档处理成可检索的状态:

import os
from langchain_community.document_loaders import Docx2txtLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.embeddings import DashScopeEmbeddings
from langchain_community.vectorstores import Chroma
from models import ALI_TONGYI_EMBEDDING_MODEL, ALI_TONGYI_API_KEY_OS_VAR_NAME

# 1. 加载:一个加载器对应一种来源,load() 返回 Document 列表
loader = Docx2txtLoader("人事管理流程.docx")
documents = loader.load()

# 2. 切割:切成 500 字一块、相邻块重叠 50 字,每块仍是一个 Document
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=500,       # 切块大小
    chunk_overlap=50,     # 切块重叠大小
)
split_documents = text_splitter.split_documents(documents)

# 3. 嵌入模型包装器:把文本转成向量
llm_embeddings = DashScopeEmbeddings(
    model=ALI_TONGYI_EMBEDDING_MODEL,
    dashscope_api_key=os.getenv(ALI_TONGYI_API_KEY_OS_VAR_NAME),
)

# 4. 向量库:入库时自动调用嵌入模型,把每一块转成向量存起来
vector_store = Chroma.from_documents(documents=split_documents, embedding=llm_embeddings)

# 5. 检索器:统一取数接口,检索时同样要经过嵌入模型
retriever = vector_store.as_retriever()

接着把它接进链。这里只有一处需要解释:

from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough

message = """
仅使用提供的上下文回答下面的问题:
{question}
上下文:
{context}
"""
prompt_template = ChatPromptTemplate.from_messages([('human', message)])

# 定义链时还不知道用户会问什么,用 RunnablePassthrough 把问题动态传进来
# context 直接挂检索器:它也是 Runnable,链会用它把问题换成检索结果
chain = {"question": RunnablePassthrough(), "context": retriever} | prompt_template | client

resp = chain.invoke("晋升")
print(resp.content)

retriever 能直接接在链里,是因为它本身就是一个 Runnable。给它一个问题,它会返回一组相关的 Document,所以可以和提示词模板、聊天模型一样直接参与 LCEL 组合。

同一个位置也可以直接调用向量库的检索方法:

from langchain_core.runnables import RunnableLambda

# bind(k=1) 表示只取相似度最高的第一块
docs_find = RunnableLambda(vector_store.similarity_search).bind(k=1)

不过更常见的做法还是把向量库转成 Retriever。

Retriever 的价值在于统一检索接口。底层可以是向量检索,也可以是关键词检索、混合检索;只要保持相同的输入输出约定,后面的链通常不用改。

#四、用预制链把这几步固定下来

前边是自己拼提示词、接 Retriever。LangChain 也提供了两个 create_ 函数,把这套常见流程封装好了。

create_stuff_documents_chain 负责文档生成:把检索到的 Document 格式化后放进 {context},再交给模型回答。它适合检索结果能够放进模型上下文窗口的场景。

create_retrieval_chain 再把 Retriever 和文档链接起来,组成完整的检索、生成流程。

from langchain_classic.chains.combine_documents import create_stuff_documents_chain
from langchain_classic.chains.retrieval import create_retrieval_chain

# 提示词里必须同时出现 {context} 和 {input}:前者由检索结果填,后者由调用方填
system_prompt = """
您是问答任务的助理。使用以下的上下文来回答问题,
上下文:<{context}>
如果你不知道答案,不要其他渠道去获得答案,就说你不知道。
"""
prompt_template = ChatPromptTemplate.from_messages(
    [
        ("system", system_prompt),
        ("human", "{input}"),
    ]
)

chain1 = create_stuff_documents_chain(client, prompt_template)
chain2 = create_retrieval_chain(retriever, chain1)

resp = chain2.invoke({"input": "会议说了什么?"})
print(resp["answer"])    # 模型给出的答案
print(resp["context"])   # 这次回答实际用到的文档列表

{context} 接收检索到的文档,{input} 接收用户问题。

相比前面的手写版本,这两个函数只是把常见的 RAG 组装方式提前封装好了:Retriever 负责找资料,Document Chain 负责把资料交给模型回答。

返回结果里还会保留 context,后面要做引用、溯源或者检查模型依据时,可以直接使用。

#五、网页 RAG:把加载器换掉

前面处理的是本地文件。换成网页,改动集中在加载这一步:

import bs4
from langchain_community.document_loaders import WebBaseLoader

loader = WebBaseLoader(
    web_path="https://www.gov.cn/yaowen/liebiao/202512/content_7050416.htm",
    # 只取正文所在的容器,导航栏、页脚不进 Document
    bs_kwargs={"parse_only": bs4.SoupStrainer(id="UCAP-CONTENT")},
)
docs = loader.load()

splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=100)
documents = splitter.split_documents(docs)

db = Chroma.from_documents(documents=documents, embedding=llm_embeddings)
retriever = db.as_retriever()

接上同样的提示词模板和 create_retrieval_chain,就构成了一条网页问答链。

这里唯一需要额外注意的是 bs_kwargs:网页正文通常应按 HTML 结构筛选,不建议用正则直接匹配页面文本。SoupStrainer 接收的是 CSS 选择器,它在解析阶段就过滤掉无关节点,加载器得到的只有正文。

选择器写错不会报错:load 仍然正常返回,但内容可能是空的,或者混着导航文字。这类问题要到问答结果出来才暴露。

#六、总结

这一篇把六个环节的类名理清之后,有两点值得记住:

  • Document 是 RAG 系统中最重要的数据载体。Loader 产出它,Spliter 切分它,VectorStore 保存对应的文本、向量和元数据,Retriever 最后再把相关 Document 取回来。
  • **Retriever 是统一的检索接口。**底层可以是向量检索、关键词检索或者混合检索,后边的链只需要知道,给它一个问题,它会返回相关文档。

在这条基础流程里,网页和本地文件主要只差在加载阶段,后面的切分、嵌入、存储和检索基本可以复用。


到这里,一条完整的 RAG 问答链就有了:能查资料、也能根据资料回答问题。

但它还缺两个常见能力:**记忆和工具调用。**上一轮对话不会自动进入下一轮上下文,模型也不能主动调用外部接口获取实时数据。