链能拼起来了,但是如果不给模型额外的资料和文档,它依赖的主要还是已有知识和当前上下文。公司的规章制度、会议纪要、产品手册,这些模型训练时没见过,它一个也答不上来。
把外部资料引入模型,有两种方式。一种是直接把资料拼进提示词,它受上下文窗口的容量限制,文档一长就超出上限,而且每次提问都需要人工筛选。
另一种是先按问题检索出相关的若干块,再把这些内容和问题一起给到模型。这就是 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 问答链就有了:能查资料、也能根据资料回答问题。
但它还缺两个常见能力:**记忆和工具调用。**上一轮对话不会自动进入下一轮上下文,模型也不能主动调用外部接口获取实时数据。