使用 L昂Graph 构建 RAG 应用:用 LangChain + DeepSeek 打造"黑神话:悟空"智能问答
本文将带你一步步实现一个完整的检索增强生成(RAG)系统,从网页文档抓取、文本分割、向量化存储,到用大模型生成回答。我们将以百度百科"黑神话:悟空"词条为例,结合 LangChain、LangGraph、HuggingFace 本地嵌入模型和 DeepSeek API,构建一个能回答"黑悟空有哪些游戏场景?"的应用。教程不仅涵盖代码实现,还会深入解析每一步的原理、常见陷阱及解决方案,非常适合正在入门 RAG 的开发者。
1. 什么是 RAG?为什么要用 RAG?
检索增强生成(Retrieval-Augmented Generation, RAG)是一种将信息检索 与文本生成相结合的技术。它让大语言模型在回答问题前,先从一个外部知识库中查找相关文档,再把检索到的内容作为上下文提供给模型,从而生成更准确、更具事实依据的答案。
相比单纯依靠模型自身的参数化知识,RAG 有以下优势:
- 知识实时更新:只需更新外部文档,无需重新训练模型。
- 减少幻觉:回答基于真实的检索片段,降低模型编造事实的概率。
- 领域专精:可接入企业私有知识库、产品手册、学术论文等。
我们将要构建的系统正是典型的 RAG 流程:加载文档 → 文本分块 → 嵌入向量化 → 相似性检索 → 提示词注入 → 生成回答。
2. 技术栈一览
- LangChain:编排各个组件(文档加载器、文本分割器、向量存储、提示模板等)。
- LangGraph :用有向图定义检索和生成的执行流程(这是本文用到的方法)。
- HuggingFace Embeddings :本地加载
bge-small-zh-v1.5模型,将中文文本转换为向量。 - InMemoryVectorStore:基于内存的向量存储,适合快速原型和教学。
- DeepSeek:作为生成模型,通过 API 提供高质量中文回答。
- WebBaseLoader:直接从网页抓取文档内容。
整个应用代码量不足 60 行,却展示了 RAG 的核心骨架。
3. 环境准备
3.1 安装依赖
在 Python 3.10+ 环境中执行:
bash
pip install langchain langchain-community langchain-core langgraph
pip install langchain-huggingface langchain-text-splitters
pip install langchain-deepseek # DeepSeek 集成
pip install sentence-transformers # 嵌入模型依赖
pip install faiss-cpu # 可选,若改用 FAISS 向量库
版本冲突提醒 :如果你同时使用了
langchain-ollama等包,可能会遇到ImportError: cannot import name 'is_data_content_block'之类的错误。这通常是因为langchain-core版本过低。建议先升级langchain-core,或者采用猴子补丁临时绕过(后文会给出详细解决方案)。
3.2 准备本地嵌入模型
示例中使用了 BAAI/bge-small-zh-v1.5,这是一个轻量且高效的中文嵌入模型。你需要提前下载到本地文件夹(如 ./bge-small-zh-v1.5):
bash
# 安装 huggingface_hub
pip install huggingface_hub
# 下载模型
huggingface-cli download BAAI/bge-small-zh-v1.5 --local-dir ./bge-small-zh-v1.5
如果无法直接连接 HuggingFace,也可使用镜像站点(设置环境变量 HF_ENDPOINT=https://hf-mirror.com)。
3.3 获取 DeepSeek API Key
-
前往 DeepSeek 开放平台 注册并获取 API Key。
-
在项目根目录创建
.env文件,写入:DEEPSEEK_API_KEY=你的密钥
代码中会通过 dotenv 加载这个变量。
4. 分步详解
下面我们拆解每一段代码,不仅说明"怎么做",更解释"为什么这么做"。
4.1 加载文档:WebBaseLoader
python
from langchain_community.document_loaders import WebBaseLoader
loader = WebBaseLoader(
web_paths=("https://baike.baidu.com/item/%E9%BB%91%E7%A5%9E%E8%AF%9D%EF%BC%9A%E6%82%9F%E7%A9%BA/53303078",)
)
docs = loader.load()
- 作用 :自动抓取指定 URL 的页面内容,解析为纯文本
Document对象。 - 为什么用这个 :
WebBaseLoader封装了 HTTP 请求和 HTML 解析,适合快速获取网络百科、文档页面。 - 注意:百度百科页面结构复杂,可能会包含一些无关元素(如导航栏、广告)。实际生产中可能需要自定义清洗逻辑,但本例中不影响问答效果。
4.2 文档分割:RecursiveCharacterTextSplitter
python
from langchain_text_splitters import RecursiveCharacterTextSplitter
text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
all_splits = text_splitter.split_documents(docs)
- 参数解释 :
chunk_size=1000:每个文本块最多包含 1000 个字符。chunk_overlap=200:相邻块之间重叠 200 字符,避免关键信息被切断。
- 分割策略 :递归地按照
\n\n→\n→。→!等分隔符进行切割,尽可能保持语义完整。 - 为什么需要分块:大型语言模型的上下文窗口有限,且长文本会稀释注意力机制。分块后检索更精准,生成的答案也更聚焦。
4.3 嵌入模型:HuggingFaceEmbeddings
python
from langchain_huggingface import HuggingFaceEmbeddings
embeddings = HuggingFaceEmbeddings(
model_name="./bge-small-zh-v1.5",
model_kwargs={'device': 'cuda'},
encode_kwargs={'normalize_embeddings': True}
)
- 本地模型路径 :
./bge-small-zh-v1.5是我们提前下载好的文件夹。你也可以直接使用model_name="BAAI/bge-small-zh-v1.5"让库自动从 HuggingFace 下载,但建议本地化以提高加载速度和稳定性。 - 设备 :
'cuda'表示使用 GPU 加速;如果没有 GPU,改为'cpu'即可。 - 归一化 :
normalize_embeddings=True会将向量转换为单位长度,这样相似度计算可以使用内积,提高检索一致性。 - 模型选择 :
bge-small-zh-v1.5专为中文优化,在语义相似度任务上表现优秀,模型体积小,非常适合嵌入式环境或快速实验。
4.4 创建向量存储:InMemoryVectorStore
python
from langchain_core.vectorstores import InMemoryVectorStore
vector_store = InMemoryVectorStore(embeddings)
vector_store.add_documents(all_splits)
- InMemoryVectorStore:一个简单的内存向量库,适合演示和小规模数据。它会在内存中存储所有文档的向量和原始文本。
- 添加文档 :
add_documents会自动将文本块通过嵌入模型转为向量,并保存到内部索引。 - 生产环境替代:可切换为 FAISS、Chroma、Milvus 等持久化向量数据库,以支持大规模数据和快速检索。
4.5 定义提示词模板
python
from langchain import hub
prompt = hub.pull("rlm/rag-prompt")
-
hub.pull:从 LangChain Hub 拉取预置的提示词模板。rlm/rag-prompt是一个经典的 RAG 模板,格式类似:You are an assistant for question-answering tasks. Use the following pieces of retrieved context to answer the question. If you don't know the answer, just say that you don't know. Use three sentences maximum and keep the answer concise. Question: {question} Context: {context} Answer: -
自定义提示词 :如果不想依赖网络,也可以直接定义一个
ChatPromptTemplate:pythonfrom langchain_core.prompts import ChatPromptTemplate prompt = ChatPromptTemplate.from_template(""" 使用以下检索到的上下文回答问题。如果不知道答案,就说不知道。 问题:{question} 上下文:{context} 答案: """)这样更可控。
4.6 定义应用状态 State
python
from typing import List
from typing_extensions import TypedDict
from langchain_core.documents import Document
class State(TypedDict):
question: str
context: List[Document]
answer: str
TypedDict:定义了一个字典类型的结构,包含三个字段:用户问题question,检索到的文档列表context,最终生成的答案answer。- 作用:在 LangGraph 中,各个节点(函数)通过这个 State 进行数据传递和更新。每个节点可以读取 State 中的内容,并返回一个部分 State 用于合并。
4.7 检索步骤 retrieve
python
def retrieve(state: State):
retrieved_docs = vector_store.similarity_search(state["question"])
return {"context": retrieved_docs}
similarity_search:根据输入问题,在向量存储中计算余弦相似度,返回最相关的k个文档(默认k=4,可在函数参数中调整)。- 返回格式 :函数必须返回一个字典,其键对应 State 中要更新的字段。这里我们返回
{"context": ...},LangGraph 会自动将返回值合并到 State 中。
4.8 生成步骤 generate
python
def generate(state: State):
from langchain_deepseek import ChatDeepSeek
import os
from dotenv import load_dotenv
load_dotenv() # 加载 .env 中的环境变量
llm = ChatDeepSeek(
model="deepseek-chat",
temperature=0.7,
max_tokens=2048,
api_key=os.getenv("DEEPSEEK_API_KEY")
)
docs_content = "\n\n".join(doc.page_content for doc in state["context"])
messages = prompt.invoke({"question": state["question"], "context": docs_content})
response = llm.invoke(messages)
return {"answer": response.content}
- 模型初始化 :
ChatDeepSeek封装了 DeepSeek 的聊天接口。参数temperature=0.7控制随机性,max_tokens限制回答长度。 - 上下文拼接 :将检索到的多个文档的
page_content用两个换行符连接,形成一个完整的上下文字符串。 - 提示词调用 :
prompt.invoke会填充模板中的{question}和{context}占位符,生成最终的提示消息。 - 生成回答 :
llm.invoke(messages)调用远程 API 并获取回复,从response.content提取纯文本答案。
4.9 构建和编译工作流 StateGraph
python
from langgraph.graph import START, StateGraph
graph = (
StateGraph(State)
.add_sequence([retrieve, generate])
.add_edge(START, "retrieve")
.compile()
)
StateGraph(State):以我们定义的State为数据结构创建工作流图。add_sequence:添加一个线性节点序列[retrieve, generate],内部自动为这些节点建立顺序边。add_edge(START, "retrieve"):明确起始节点为retrieve。compile():编译图,生成可运行的应用。
实际上,add_sequence 已经隐含了顺序关系,但显式添加 START 边使意图更清晰。编译后的 graph 是一个可调用对象,接受初始状态并执行整个流程。
4.10 运行查询
python
question = "黑悟空有哪些游戏场景?"
response = graph.invoke({"question": question})
print(f"\n问题: {question}")
print(f"答案: {response['answer']}")
graph.invoke:传入初始状态(只包含question),按图执行检索和生成,最终返回包含answer的完整状态。- 打印结果,你会看到类似如下的答案: 黑神话:悟空的游戏场景包括黑风山、黄风岭、小西天、盘丝岭、火焰山等,每个场景都有独特的风格和敌人设计......
5. 完整代码整合
将以上步骤合并为一个完整脚本,并添加了一些必要的注释和错误处理建议:
python
# rag_blackmyth.py
import os
from typing import List
from typing_extensions import TypedDict
from dotenv import load_dotenv
from langchain_community.document_loaders import WebBaseLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_huggingface import HuggingFaceEmbeddings
from langchain_core.vectorstores import InMemoryVectorStore
from langchain import hub
from langchain_core.documents import Document
from langgraph.graph import START, StateGraph
from langchain_deepseek import ChatDeepSeek
# 1. 加载文档
loader = WebBaseLoader(
web_paths=("https://baike.baidu.com/item/%E9%BB%91%E7%A5%9E%E8%AF%9D%EF%BC%9A%E6%82%9F%E7%A9%BA/53303078",)
)
docs = loader.load()
# 2. 文档分块
text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
all_splits = text_splitter.split_documents(docs)
# 3. 设置嵌入模型(确保本地模型已下载)
embeddings = HuggingFaceEmbeddings(
model_name="./bge-small-zh-v1.5",
model_kwargs={'device': 'cuda'},
encode_kwargs={'normalize_embeddings': True}
)
# 4. 创建向量存储
vector_store = InMemoryVectorStore(embeddings)
vector_store.add_documents(all_splits)
# 5. 定义RAG提示词(可替换为自定义模板)
prompt = hub.pull("rlm/rag-prompt")
# 6. 定义状态
class State(TypedDict):
question: str
context: List[Document]
answer: str
# 7. 检索节点
def retrieve(state: State):
retrieved_docs = vector_store.similarity_search(state["question"])
return {"context": retrieved_docs}
# 8. 生成节点
def generate(state: State):
load_dotenv()
llm = ChatDeepSeek(
model="deepseek-chat",
temperature=0.7,
max_tokens=2048,
api_key=os.getenv("DEEPSEEK_API_KEY")
)
docs_content = "\n\n".join(doc.page_content for doc in state["context"])
messages = prompt.invoke({"question": state["question"], "context": docs_content})
response = llm.invoke(messages)
return {"answer": response.content}
# 9. 构建工作流
graph = (
StateGraph(State)
.add_sequence([retrieve, generate])
.add_edge(START, "retrieve")
.compile()
)
# 10. 运行查询
if __name__ == "__main__":
question = "黑悟空有哪些游戏场景?"
response = graph.invoke({"question": question})
print(f"\n问题: {question}")
print(f"答案: {response['answer']}")
6. 常见问题与调试
6.1 导入错误:cannot import name 'is_data_content_block'
现象 :在安装 langchain-ollama 或其他集成包后,运行时报错 ImportError: cannot import name 'is_data_content_block' from 'langchain_core.messages'。
原因 :langchain-core 版本过低,缺少高版本集成包所需的函数。
解决方案(按优先级):
-
升级
langchain-core:bashpip install --upgrade langchain-core -
若出现依赖冲突 (如
langchain-ollama 1.1.0 depends on langchain-core<2.0.0 and >=1.2.21),先卸载再重装:bashpip uninstall langchain-core langchain-ollama -y pip install langchain-ollama -
临时猴子补丁 (不推荐生产使用):
在导入
langchain_ollama之前,手动注入缺失函数:pythonimport langchain_core.messages if not hasattr(langchain_core.messages, 'is_data_content_block'): def _is_data_content_block(block): return isinstance(block, dict) and block.get("type") in ("image_url", "image") langchain_core.messages.is_data_content_block = _is_data_content_block
6.2 嵌入模型加载失败
- 确保已执行
huggingface-cli download ...下载模型到正确路径。 - 如果使用 CPU,将
device改为'cpu'。 - 如果网络受限,设置
HF_ENDPOINT镜像。
6.3 DeepSeek API 调用失败
- 检查
.env文件中是否包含正确的DEEPSEEK_API_KEY。 - 确认模型名称是否有效(当前为
deepseek-chat)。 - 检查网络能否访问
api.deepseek.com。
6.4 检索效果不佳
- 调整
chunk_size和chunk_overlap,观察对答案完整性的影响。 - 增加
similarity_search的k值(例如vector_store.similarity_search(query, k=6)),召回更多上下文。 - 尝试其他嵌入模型,如
BAAI/bge-large-zh-v1.5或text2vec-large-chinese。
7. 总结与扩展
通过这个教程,我们完整地构建了一个 RAG 应用,它从网页获取知识,用本地嵌入模型进行向量检索,并调用 DeepSeek 大模型生成回答。这套流程可以轻松扩展到任意文档来源(PDF、TXT、Notion、数据库等)和任意 LLM(Ollama 本地模型、OpenAI、文心一言等)。
下一步可以尝试的改进:
- 替换向量存储:使用 FAISS 或 Chroma 实现持久化和更高效的检索。
- 添加对话记忆 :在 State 中增加
chat_history,让应用支持多轮对话。 - 流式输出 :LangChain 支持
stream方法,可以逐 token 返回答案。 - 可观测性:接入 LangSmith 或本地日志,监控检索和生成性能。
- 自定义提示词:针对特定领域优化系统指令,提升回答质量。
RAG 是当前大模型应用落地的核心范式,掌握它的原理和实现,能让你在构建智能助手、知识库问答、文档分析等场景中游刃有余。希望本文能为你打开一扇门,快去动手试试吧!
参考链接
- LangChain 官方文档:https://python.langchain.com
- LangGraph 文档:https://langchain-ai.github.io/langgraph/
- BGE 嵌入模型:https://huggingface.co/BAAI/bge-small-zh-v1.5
- DeepSeek API:https://platform.deepseek.com/
8. 关于 graph 的创建
着重解析下面的代码:
python
from langgraph.graph import START, StateGraph # pip install langgraph
graph = (
StateGraph(State)
.add_sequence([retrieve, generate])
.add_edge(START, "retrieve")
.compile()
)
这段代码使用 LangGraph 构建了一个有向图(Graph) ,其中 节点(Node) 就是处理步骤(函数),边(Edge) 决定了数据如何在节点之间流动,而 状态(State) 则像一个"公共记事本",每个节点都可以读、写它。
下面我会用生活中的比喻,再配上代码拆解,帮你彻底看懂这 5 行代码。
1. 先理解三个核心概念
| 概念 | 比喻 | 对应代码 |
|---|---|---|
| 状态(State) | 一张共享的便签纸 | class State(TypedDict): ... |
| 节点(Node) | 一个处理任务的人 | retrieve 函数、generate 函数 |
| 边(Edge) | 任务传递的顺序:"A 做完给 B" | .add_edge(START, "retrieve") / .add_sequence(...) |
2. 状态(State)是什么?
你之前定义了这个类:
python
class State(TypedDict):
question: str # 用户问题
context: List[Document] # 检索到的文档
answer: str # 最终答案
它规定了"共享便签纸"上可以写哪些字段:问题、上下文、答案。
在整个图运行过程中,这张便签纸会经过每个节点,节点可以读取 它上面的内容,也可以往上面写新内容(返回一个字典,LangGraph 会自动合并进去)。
3. 节点(Node)是怎么创建的?
只要一个函数接收 State 并返回一个字典(用来更新 State),它就可以成为一个节点。
比如你的 retrieve 函数:
python
def retrieve(state: State):
retrieved_docs = vector_store.similarity_search(state["question"])
return {"context": retrieved_docs}
- 它从便签纸上读了
state["question"], - 做完检索后,返回
{"context": ...},相当于在便签纸上贴上检索到的文档。
generate 函数同理:
python
def generate(state: State):
# ... 从 state 中取 context 和 question 生成答案
return {"answer": response.content}
- 它读了
state["question"]和state["context"], - 生成答案后,返回
{"answer": ...},把答案写到便签纸上。
LangGraph 会把这些函数注册为节点,节点的名字就是函数名 ("retrieve" 和 "generate")。
4. 边(Edge)是怎么创建的?线(流程)如何形成?
看回这段核心代码:
python
graph = (
StateGraph(State) # 创建一个图,指定状态类型
.add_sequence([retrieve, generate]) # 添加一个节点序列
.add_edge(START, "retrieve") # 添加从开始到 retrieve 的边
.compile() # 编译成可运行的应用
)
我们一句句拆解:
① StateGraph(State)
创建一张"空的流程图",并告诉它:"我们将来在各个节点之间传递的状态对象,必须符合 State 这个格式"。
② .add_sequence([retrieve, generate])
这行代码同时做了两件事:
- 创建了两个节点 :
"retrieve"和"generate"(名字就是函数名)。 - 自动创建了一条边 :从
"retrieve"指向"generate",即 retrieve → generate。
也就是说,执行完这行后,图里已经有了:
[retrieve] ────→ [generate]
③ .add_edge(START, "retrieve")
START 是 LangGraph 内置的特殊节点,代表图的入口。
这行代码创建了一条边:START → retrieve。
于是现在的流程图变成:
(START) ──→ [retrieve] ──→ [generate]
④ .compile()
把上面定义的逻辑"编译"成一个可执行的 Runnable 对象。之后你才能用 graph.invoke(...) 来运行它。
5. 运行时状态如何流动?
当你运行:
python
response = graph.invoke({"question": "黑悟空有哪些游戏场景?"})
LangGraph 会按以下步骤自动执行:
-
初始化状态 :便签纸上现在写着
{"question": "...", "context": [], "answer": ""}(后两个字段暂时为空)。 -
从 START 出发 ,进入
retrieve节点:-
retrieve函数被调用,参数就是当前的整个状态字典。 -
函数读取
state["question"],执行相似度搜索。 -
返回
{"context": [Document1, Document2, ...]}。 -
LangGraph 将这个返回值合并进状态 ,现在便签纸变成了:
python{ "question": "...", "context": [Document1, Document2, ...], "answer": "" }
-
-
沿着边 retrieve → generate ,自动进入
generate节点:-
generate函数被调用,接收合并后的新状态。 -
它用
context和question构造提示词,调用 DeepSeek。 -
返回
{"answer": "黑神话:悟空的游戏场景包括..."}。 -
再次合并,状态最终变为:
python{ "question": "...", "context": [Document1, Document2, ...], "answer": "黑神话:悟空的游戏场景包括..." }
-
-
图执行完毕,最终状态作为返回值给你。
6. 总结:一张图看清一切
add_edge(START, "retrieve") add_sequence 自动创建
┌─────────────────────────┐ ┌──────────────────────┐
↓ │ ↓
╔════════╗ 状态传递 ╔══════════╗ 状态传递 ╔══════════╗
║ START ║ ───────────→ ║ retrieve ║ ───────────→ ║ generate ║
╚════════╝ ╚══════════╝ ╚══════════╝
│ │
│ 读 question │ 读 question + context
│ 写 context │ 写 answer
└────────────┬────────────┘
↓
共享状态 State
{ question, context, answer }
- 节点 :
retrieve和generate是处理步骤,由函数自动注册。 - 边 :
START → retrieve用add_edge显式添加;retrieve → generate由add_sequence自动生成。 - 状态:在每个节点之间传递,每个节点只返回它想要更新的字段,LangGraph 自动合并。