【Agent 实战】Phase 2:从零搭建 RAG 文档问答系统(LangGraph + 通义千问 + Chroma)
摘要:本文记录 Agent 学习路线 Phase 2 的完整实战过程——如何用 Python 搭建一套 RAG(检索增强生成)文档问答系统,让大模型基于私有文档回答问题,而不是「凭空编造」。技术栈:LangGraph、通义千问 qwen-plus、DashScope text-embedding-v3、Chroma 向量库。
关键词:RAG、Agent、LangGraph、通义千问、Chroma、向量检索、Embedding、大模型应用
一、前言:为什么要学 RAG?
在 Phase 1 中,我学会了工具 Agent——让 LLM 调用天气 API、计算器等外部工具「动手做事」。但有一类问题工具解决不了:
「我们公司的年假有多少天?」「报销最晚什么时候提交?」
这些信息在内部 PDF、Markdown 文档里,不在 API 里,也不在 LLM 的训练数据中(或者 LLM 会瞎编)。
RAG(Retrieval-Augmented Generation,检索增强生成) 就是解决这个问题的主流方案:
- 先把文档切小块、转成向量,存进向量数据库(离线建库)
- 用户提问时,先检索最相关的文档片段(在线检索)
- 把检索结果作为「参考资料」交给 LLM 生成回答(增强生成)
一句话:给 Agent 装一个「图书馆」,让它查资料再回答。
二、RAG 是什么?和 Phase 1 有什么区别?
2.1 RAG 核心流程
文档 → 加载 → 切分 → 向量化 → 存入向量库
↓
用户提问 → 问题向量化 → 相似度检索 Top-K → 拼上下文 → LLM 生成回答
2.2 Phase 1 vs Phase 2 对比
| 维度 | Phase 1 工具 Agent | Phase 2 RAG |
|---|---|---|
| 解决什么问题 | 执行实时动作(查天气、算数) | 基于私有文档问答 |
| 数据来源 | 外部 API | 本地向量库 |
| 核心能力 | Function Calling | 向量相似度检索 |
| 工作流 | agent ↔ tools 循环 | retrieve → generate |
| 典型提问 | 「北京天气怎么样?」 | 「入职满 1 年年假几天?」 |
2.3 架构图
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ ingest.py │ ──→ │ chroma_db/ │ ──→ │ agent.py │
│ 离线建库 │ │ 向量知识库 │ │ 在线问答 │
└─────────────┘ └──────────────┘ └─────────────┘
↑ ↑
data/ 文档 用户提问
三、项目结构
agent-rag/
├── ingest.py # 文档入库:加载 → 切分 → 向量化 → 存 Chroma
├── retriever.py # 检索模块:加载向量库 + similarity_search
├── agent.py # RAG 问答:retrieve → generate 工作流
├── config.py # 配置(API Key、Chunk 参数、Top-K 等)
├── trace.py # Trace 日志
├── data/ # 放文档(样例:company_handbook.md)
├── chroma_db/ # 向量库(运行 ingest 后自动生成)
├── requirements.txt
├── .env # API Key(不要提交 Git)
└── run.ps1 # 一键启动脚本
四、环境准备
4.1 安装依赖
cd agent-rag
python -m venv .venv
.venv\Scripts\activate
pip install -r requirements.txt
主要依赖:
langgraph/langchain-core/langchain-openai—— 编排与 LLM 调用langchain-community—— 文档加载器、DashScope Embedding、Chromachromadb—— 向量数据库dashscope—— 阿里云 DashScope SDKpypdf—— PDF 解析
4.2 配置 API Key
copy .env.example .env
# 编辑 .env,填入 DASHSCOPE_API_KEY
.env 示例:
DASHSCOPE_API_KEY=sk-xxxxxxxx
DASHSCOPE_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
MODEL_NAME=qwen-plus
EMBEDDING_MODEL=text-embedding-v3
如果 Phase 1 已配置过,直接复制
FirstAgent/.env即可。
4.3 两个大模型,各司其职
| 模型 | 作用 | 调用时机 |
|---|---|---|
text-embedding-v3 |
文本 → 向量 | ingest 入库 + 每次检索提问 |
qwen-plus |
理解 + 生成回答 | generate 节点 |
同一个 API Key,两次不同能力的调用。
五、ingest.py 详解:离线建库
5.1 整体流程
documents = load_documents(DATA_DIR) # 1. 加载
chunks = split_documents(documents) # 2. 切分
build_vectorstore(chunks) # 3. 向量化 + 存入 Chroma
运行命令:
python ingest.py --rebuild
5.2 第一步:加载文档
支持三种格式:
| 格式 | 加载器 | 元数据 |
|---|---|---|
.md / .txt |
TextLoader |
source = 文件名 |
.pdf |
PyPDFLoader |
source + page(页码) |
元数据很重要——后面回答要引用「来源:xxx 第 N 页」。
5.3 第二步:文档切分(Chunking)
splitter = RecursiveCharacterTextSplitter(
chunk_size=600, # 每块最大 600 字符
chunk_overlap=80, # 相邻块重叠 80 字符
separators=["\n## ", "\n### ", "\n", "。", " ", ""],
)
为什么要切分?
- LLM 上下文窗口有限,塞不进整本书
- 向量检索需要「小块」才能精准命中
为什么要 overlap(重叠)?
- 避免关键信息被切在两块边界上,导致检索时只命中一半
参数调优经验:
chunk_size太大 → 检索粒度粗,噪音多chunk_size太小 → 上下文不完整- 本项目 600/80 是入门友好的默认值
5.4 第三步:向量化 + 入库
embeddings = DashScopeEmbeddings(
model="text-embedding-v3",
dashscope_api_key=DASHSCOPE_API_KEY,
)
store = Chroma.from_documents(
documents=chunks,
embedding=embeddings,
collection_name="knowledge_base",
persist_directory="./chroma_db",
)
这里发生了两件事:
- 每个 Chunk 调用
text-embedding-v3转成高维向量 - 向量 + 原文 + 元数据一起写入
chroma_db/
--rebuild 参数:先删除旧向量库再重建,适合文档更新后使用。
六、retriever.py 详解:在线检索
6.1 检索流程
def retrieve(question: str, top_k: int = 4):
store = get_vectorstore() # 加载 Chroma
docs = store.similarity_search(question, k=top_k) # 相似度检索
context, sources = format_context(docs) # 格式化 + 提取来源
return context, sources
6.2 similarity_search 背后发生了什么?
用户问题「入职满1年年假几天?」
→ text-embedding-v3 把问题转成 query 向量
→ 与 chroma_db 中所有 chunk 向量计算相似度
→ 返回 Top-4 最相似的文档片段
注意:检索阶段也会调用 embedding 模型,不只是 ingest 时才用。
6.3 格式化上下文
检索到的片段会被包装成 LLM 可读的格式:
【片段1 | 来源: company_handbook.md】
入职满 1 年的员工享有 15 天带薪年假...
【片段2 | 来源: company_handbook.md】
入职满 3 年的员工享有 18 天带薪年假...
6.4 向量库不存在时的处理
if not CHROMA_DIR.exists():
raise FileNotFoundError("向量库不存在,请先运行: python ingest.py")
七、agent.py 详解:RAG 问答工作流
7.1 LangGraph 两节点工作流
workflow.add_node("retrieve", retrieve_node) # 检索
workflow.add_node("generate", generate_node) # 生成
workflow.add_edge(START, "retrieve")
workflow.add_edge("retrieve", "generate")
workflow.add_edge("generate", END)
与 Phase 1 的 agent ↔ tools 循环不同,RAG 是单向流水线:
用户提问 → retrieve(检索资料)→ generate(LLM 生成回答)→ 输出
7.2 retrieve 节点
@trace_node("retrieve")
def retrieve_node(state):
question = get_latest_question(state["messages"]) # 取最新用户问题
context, sources = retrieve(question) # 调用检索
return {"context": context, "sources": sources}
7.3 generate 节点
@trace_node("generate")
def generate_node(state):
context = state.get("context", "")
if not context.strip():
context = "(未检索到任何相关资料)"
system = RAG_PROMPT.format(context=context)
messages = [SystemMessage(content=system)] + state["messages"]
response = llm.invoke(messages)
return {"messages": [response]}
核心设计:把检索结果塞进 System Prompt 的 {context} 占位符,LLM 只能「开卷考试」。
7.4 RAG Prompt 设计
你是企业知识库问答助手,只能根据「参考资料」回答问题。
规则:
1. 只使用参考资料中的信息,不要编造。
2. 如果参考资料不足以回答,明确说「知识库中没有找到相关信息」。
3. 回答末尾用「引用来源:」列出你用到的来源。
4. 用简洁的中文回答。
参考资料:
{context}
Prompt 设计是 RAG 质量的关键之一——约束 LLM 不要脱离资料胡编。
7.5 会话记忆
memory = MemorySaver()
config = {"configurable": {"thread_id": "1"}}
- 多轮对话保留历史(如「那病假呢?」能接上文)
- 输入
clear切换thread_id,清空记忆
八、实战测试
8.1 入库
python ingest.py --rebuild
预期输出:
已加载: company_handbook.md
切分完成: N 个文档块
向量库已保存: chroma_db
入库完成!
8.2 问答
python agent.py
8.3 测试用例
| 提问 | 预期回答 | 考察点 |
|---|---|---|
| 入职满 1 年的员工有多少天年假? | 15 天 | 基本检索 |
| 报销最晚多少天内提交? | 30 日 | 跨章节检索 |
| 公司上市代码是多少? | 知识库中没有找到 | 防幻觉 |
8.4 Trace 日志解读
[输入] 用户: 入职满1年的员工有多少天年假?
>> 进入节点: retrieve
[检索] 命中 2 个片段,来源: company_handbook.md
[检索] 片段1: 入职满 1 年的员工享有 15 天带薪年假...
<< 完成节点: retrieve (320 ms)
>> 进入节点: generate
<< 完成节点: generate (850 ms)
🤖 Agent:入职满 1 年的员工享有 15 天带薪年假。
引用来源:company_handbook.md
日志对应关系:
| 日志 | 含义 |
|---|---|
>> 进入节点: retrieve |
开始检索 |
[检索] 命中 N 个片段 |
向量搜索成功 |
>> 进入节点: generate |
开始 LLM 生成 |
引用来源 |
Prompt 约束生效 |
九、核心问题深入理解
9.1 为什么检索不到时要说「不知道」?
这是 RAG 和纯聊天的本质区别:
| 纯 LLM 聊天 | RAG | |
|---|---|---|
| 知识来源 | 模型训练数据(可能过时/错误) | 仅检索到的文档片段 |
| 不知道时 | 可能编造一个看起来合理的答案 | 必须说「知识库中没有」 |
本项目有三层保障:
- 检索层:没命中 →
context为空 → 填入「未检索到任何相关资料」 - Prompt 层:规则 1「只使用参考资料」+ 规则 2「不足时说不知道」
- temperature=0:降低 LLM 随机性,减少编造
即使 LLM「知道」答案,只要资料里没有,也应该拒绝回答——这是 RAG 的核心原则。
9.2 RAG 完整数据流(一图总结)
┌─────────────────────────────────────────────────────────┐
│ 离线阶段(ingest.py) │
│ │
│ data/文档 → 加载 → 切分(600/80) → embedding-v3 向量化 │
│ ↓ │
│ chroma_db/ 持久化 │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 在线阶段(agent.py) │
│ │
│ 用户提问 → HumanMessage │
│ ↓ │
│ retrieve: 问题 → embedding-v3 → similarity_search(Top4) │
│ ↓ │
│ context + sources │
│ ↓ │
│ generate: RAG_PROMPT + qwen-plus → 带引用的回答 │
└─────────────────────────────────────────────────────────┘
十、踩坑记录
10.1 模块找不到
ModuleNotFoundError: No module named 'langchain_openai'
原因:没激活虚拟环境,用了系统 Python。
解决:.venv\Scripts\activate 后再运行,或用 .\run.ps1。
10.2 API Key 未配置
ValueError: 未配置有效的 DASHSCOPE_API_KEY
解决:复制 .env.example 为 .env,填入真实 Key。
10.3 向量库不存在
FileNotFoundError: 向量库不存在,请先运行: python ingest.py
解决:先 python ingest.py --rebuild 建库,再 python agent.py。
10.4 检索不准
- 调小
CHUNK_SIZE或增大TOP_K - 检查文档编码(中文用 UTF-8)
- 换更好的 Embedding 模型
10.5 回答幻觉(编造)
- 加强 Prompt 约束
- 设置
temperature=0 - 检查检索结果是否真的包含答案
十一、学习总结
11.1 Phase 2 通关清单
- 理解 RAG 四步:加载 → 切分 → 向量化 → 检索生成
- 跑通
ingest.py和agent.py - 看懂
[检索]Trace 日志 - 往
data/添加自己的文档并重新入库 - 解释「检索不到时为什么要说不知道」
- 区分
qwen-plus(问答)和text-embedding-v3(向量化)的分工
11.2 关键收获
- RAG = 检索 + 生成,不是纯 LLM 聊天
- 离线建库 + 在线检索 是工业 RAG 的标准分工
- Prompt 约束 和 检索质量 同样重要
- 两个模型 各干各的:Embedding 负责「找」,LLM 负责「说」
- Trace 日志 是理解 RAG 工作流的最佳工具
11.3 与 Phase 1 的统一视角
Phase 1:给 Agent 装「手」(工具调用)
Phase 2:给 Agent 装「图书馆」(RAG 检索)
Phase 2.5(下一步):把 RAG 封装成工具,与 Phase 1 合体
合体后的 Agent 可以同时:
- 问天气 →
get_weather - 算税费 →
calculator - 查制度 →
search_knowledge_base(RAG)
这才是工业级 Agent 的常见形态。
十二、参考资料
系列文章导航
- Phase 1:工具 Agent(LangGraph + Function Calling)
- Phase 2:RAG 文档问答(本文)
- Phase 2.5:RAG + 工具 Agent 合体 —— 待发布
- Phase 3+:复杂工作流、多 Agent 协作、生产化部署 —— 规划中
如果这篇文章对你有帮助,欢迎点赞收藏。有问题欢迎在评论区交流。
更多推荐

所有评论(0)