本系列所有博客均配套Gihub开源代码,开箱即用,仅需配置API_KEY。
如果该Agent教学系列帮到了你,欢迎给个Star⭐!
知识点:RAG 概念|文本加载与分块 (Load & Split)|向量化 (Embedding)|向量存储 (FAISS)|LCEL RAG 链
前言:
回顾前文,我们已能用Langchain构建一个功能足够多的智能体。它可以靠封装好的工具函数查询api,也可以链接数据库获取相关信息,但它还是有个比较大的缺陷:它所能访问的知识公开且过时。假设我们有个本地知识库想给它让他帮我们检索出其中想要的数据,它做不到。
所以本期的rag基础篇,我们就来解决这个问题:让它智能检索出我们想要的某个数据。
一、Rag概念与它的核心流程
- Rag概念
Rag(Retrieval-Augumented Generation):检索增强生成。
它的最大功能就是让LLM能 “开卷考试” 。
举个例子,问:
“今年公司财报Q3是什么?” Agent能查内部文档并回答。
把Rag定义的三个词拆解出来看:
检索(Retrieval) :从外部知识库中寻找与问题相关的信息。
增强(Augmented):将检索到的信息作为上下文,提供给大模型(LLM)。
生成(Generation) :LLM结合检索到的信息,生成更准确、更可靠的回答。
更通俗点理解就是
查资料 - 把资料塞给LLM - 让LLM看着资料写答案
而我们本篇,最核心的关注点就是这个检索(查资料部分)。
有了合理的检索,才能增强(丢给LLM看),并生成(LLM给回答)。
- Rag核心流程
综上,我们可以拆分出RAG两条线的运行流程:
两个完全不同的“生命周期” :
-
模块 A:RAG 的“离线准备” (Offline Indexing)
- 目标: 把你的“书”(文档)全部读一遍,拆成 “知识卡片”(Chunks) ,并为每张卡片贴上 “语义标签” ,再把这些有“语义标签”的卡片上架到 “智能图书馆”(向量数据库) 。
- 特点: 这个阶段没有 LLM 参与,纯数据处理。它可能很慢,但只需做一次。
-
模块 B:RAG 的“在线运行” (Online R-A-G Flow)
- 目标: 当用户提问时,去“智能图书馆”里检索,找出相关的“知识卡片”,然后把“卡片”和“问题”一起交给 LLM(生成)。
- 特点: 这个阶段LLM 登场。它非常快,每次提问时都会执行。
模块A的“离线操作”是R-A-G中的R(检索)前提,完成这个离线操作才能进行检索;
模块B的“在线运行”才是完整的R-A-G流程。
所以我们会先讲这个“离线操作”的模块A的三大核心组件:
加载与分块 & 向量化 & 存储
学会这三步后才能做好这个“智能图书馆”,模块B的“在线运行”的R-A-G才能围绕着这个图书馆运行起来。
同时,我们的检索精准与否,完全与这个图书馆,即内部的这三大组件息息相关。
所以我们会接着深入到这三组件的内部,理解使用它们的原因与好处。
二、核心组件(一):加载与分块(Load&split)- (准备“知识卡片”)
(一):加载与分块的的必要性
1. 加载(Load):
- 场景:我们“私有知识”的来源五花八门,它可能是.txt文本、.pdf报告、.csv表格,甚至可能是网页或者Notion笔记。
- 必要性:所以,确定一套统一的阅读器(loader)来读懂这些格式,并最终转化为Langchain认识的标准格式(Document对象)。这就是“加载”的价值。
2. 分块(Split):
-
场景:加载进来的Document可能非常大,但LLM的“桌子”(上下文长度)是有限的,比如4k,8k,128tokens。我们不可能将整本书都塞给他,必然要对其进行优化处理。
-
必要性:因此,我们做法就是:将整本书切成一块块的“知识卡片” (Chunks)。
-
关键参数 - chunk_size:切好的“知识卡片”的大小上限
- 权衡:如果size太小,会使得语义割裂,答案被切割成两半;太大则会出现上下文噪点(llm找不出重点)。
- 硬约束:必须小于你Embedding模型(如bge-small-zh-v1.5)的512token上限。1中文字符越为1-2token,所以设chunk_size=250-300是一个很安全的上限。
- 数据约束:最好大于你文档中“一个完整语义单元”的长度(比如一个完整的段落)。
-
关键参数 - chunk_overlap:不同“知识卡片”可重叠的字符大小
- 目的:如果分块造成“语义割裂”,chunk_overlap可以允许一定字符进行“知识卡片”间的拼接,确保语义不丢失。
- 通常设定:一般为chunk_size的10%-20%。
这俩关键参数务必要留意,强烈建议根据具体情况来配置。同时这里俩参数调优的性价比与其他模块相比非常高,所以单独讲一下。Rag的开头找数据这里如果没有配置好,那么只能是“垃圾进,垃圾出” ,后面的检索也没有任何意义了。
如果看着还是比较头晕,可以接着看下文实战部分来加强理解。
(二):上工具
对于“加载”与“分块”,Langchain都提供了专业的加载器与分割器:
-
Document Loaders (加载器):
- TextLoader:(基础篇首选) 对应 .txt 文件。
- PyPDFLoder:对应
.pdf文件(需要 pypdf 库)。 - CSVloader:对应.csv文件,它会将每一行视为一个独立的 Document。
-
Text Splitters (分割器):
-
RecursiveCharacterTextSplitter:最推荐、最智能的默认分割器。
-
它就是“造轮子”的“最终版”。它会自动按
["\n\n", "\n", " ", ""]的优先级列表来分割,最大限度地保留语义的完整性。 -
实战配置(针对我们的演示txt文件):
-
分析:我们的txt文本是高度结构化的。以\n作为模块的自然分割,最长文本“模块05”约为190字符。
-
chunk_size = 250:
- 它小于Embedding设置的512token限制,非常安全。
- 它大于任何一个单独模块(如190字符) ,确保了RecursiveCharacterTextSpliter会优先在\n处切割,保持每个模块的语义完整。
-
chunk_overlap = 40
- 设置其为chunk_size的15%-20%,作为“安全垫”,防止未来出现某个模块超过250字符。(即“知识卡片”最高可有290个字符)
-
-
以下是具体加载与分割的代码:
import os
from langchain_community.document_loaders import TextLoader # 加载
from langchain_text_splitters import RecursiveCharacterTextSplitter # 分割
# 创建一个演示txt文件
knowledge_base_content = """
### 模块 01 — Agent 入门 & 环境搭建
- **目标**:理解 Agent 概念,完成环境配置与首次调用。
- **内容**:环境依赖|API Key 配置|最小可运行 Agent
### 模块 02 — LLM 基础调用
- **目标**:掌握模型调用逻辑,初步构建智能体能力。
- **内容**:LLM了解与调用|Prompt编写与逻辑构思|多轮对话记忆|独立搭建一个智能体
### 模块 03 — Function Calling 与工具调用
- **目标**:实现 LLM 调用外部函数,赋予模型“执行力”。
- **内容**:Function calling原理|工具函数封装|API接入实践|多轮调用流程|Agent能力扩展
### 模块 04 — LangChain 基础篇
- **目标**:认识Langchain六大模块,学会用Langchain构建智能体。
- **内容**:LLM 调用|Prompt 设计|Chain 构建|Memory 记忆|实战练习
### 模块 05 — LangChain 进阶篇
- **目标**:掌握Langchain Agents的核心机制,构建能调用工具、持续思考、具备记忆的智能体。
- **内容**:Function Calling|@tool 工具封装|ReAct 循环|Agent 构建|SQL Agent|记忆+流式|开发优化
"""
with open('knowledge_base.txt','w',encoding='utf8') as f:
f.write(knowledge_base_content)
# 1.加载
# TextLoader 读取.txt文件,并将其转换为Document对象
loader = TextLoader('knowledge_base.txt',encoding='utf8')
docs = loader.load()
print(f'{docs}已加载完成!')
# 2.分割
text_splitter = RecursiveCharacterTextSplitter(
# 本节重点
chunk_size=250, # 设定的chunk块大小(字符数),
chunk_overlap=40 # 设定的重叠大小(字符数)
)# 创建分割器的配置模板
splits = text_splitter.split_documents(docs) # 切割成chunk块
print(f'分块结果:{len(splits)}')
for i,doc in enumerate(splits):
print(f'片段{i+1}(长度:{len(doc.page_content)})')
print(doc.page_content)
print('-'*100+'\n')
# 观察:
# 我们的文本被分割成了四块,没有一块的长度超过250.
# 所以没有用到overlap(重叠),这是最好的结果
运行结果:
四个片段,长度最大也是236,没有碰到250,自然也不需要用到overlap(重叠)。
这就是最好的情况。
chunk_overlap本来就是用于兜底,没有用到才证明“分块”策略的成功。理想情况就应该闲置。
综上,我们完成了最初的文件加载与chunk块的切割。接下来就得使用这些切割好的chunk块。
三、核心组件(二):向量化(Embedding)-(贴上语义标签)
1. 理论:什么是Embedding?
我们已经把知识切分成了小卡片(chunk),接下来我们还要为它贴上“语义标签”,这样计算机才能看得懂它是什么意思。
传统搜索: 如果用关键词匹配,搜“小狗”就永远也找不到“金毛犬”的卡片。
语义搜索: 我们希望计算机理解“小狗”和“金毛”是相似的概念。
Embedding(向量化)的作用:Embedding模型在此刻就能作为一个“翻译官”,把任何一段文字(知识卡片)转换为一串独特的数字,这些数字就是“向量”。
“向量”就是“语义坐标” 。小狗 和 金毛在语义坐标上是非常接近的,所以计算机能认出来它们是相似概念。
2. “上工具”:Langchain的Embedding方案
所谓Embedding方案,说简单点就是如何将文本转换为向量的解决方法。
对应社区已经有的云/本地方案,我们可以自行选择:
-
方案 1 (在线 API): 如
BaichuanTextEmbeddings或OpenAIEmbeddings。- 优点: 效果好,速度快,不占本地资源。
- 缺点: 需要 API Key,并且按 Token 收费。
-
方案 2 (本地模型):
HuggingFaceEmbeddings。- 优点: 完全免费,数据 100% 在本地,保护隐私。
- 缺点: 第一次运行需要下载模型,并且会消耗本地 CPU/GPU 资源。
本篇我们首选开源本地模型HuggingFaceEmbeddings使用。
用一段代码来感受文本向量化:
from langchain_huggingface import HuggingFaceEmbeddings
print('---正在首次加载本地嵌入模型(bge-small-zh-v1.5)...---')
# 第一次运行可能时间较久
embeddings_model = HuggingFaceEmbeddings(
model_name="BAAI/bge-small-zh-v1.5" # 一个中英双语开源模型
)
print('嵌入模型载入完毕')
# 演示:将文本转换为向量
text = "模块05的目标是什么"
query_embedding = embeddings_model.embed_query(text)
print(f'文本:{text}')
print(f'向量(前五维):{query_embedding[:5]}')
print(f'向量维度:{len(query_embedding)}')
运行结果:
如上。我们成功用工具来完成了小段文本的向量化工作。后续我们再想要完成类似操作,也这样做即可。
四、核心组件(三):存储(Store)-(建造“智能图书馆”)
如果说上步的Embedding目标是如何把文字变成向量,那么这一步存储的目标则是
有了向量后,如何快速找到最相似的那个。
我们已经有了文档切片(splits)和向量化(Embedding),接着就需要为这些向量构建一个高效的搜索索引。
⚠️澄清:本部分不涉及检索。 检索是用户提问时发生的在线、实时的操作,我们现在做的,是为未来的检索做铺路。相当于为一本没有目录的厚书,加入一个智能的快速查阅目录。
1. 理论:向量搜索的魔法
在向量化的世界里,一切的核心都是向量 (Vector) 。
- 每一个 split(知识片段)都被 embeddings_model 转换成了一个高维知识向量。
- 未来用户的 query(问题)也会被转换成一个查询向量。
我们的目标是:在由成千上万个“知识向量”构成的空间中,找到与“查询向量”语义最相似的那几个。
很明显,如果用暴力搜索去存储并检索,for循环检索速度能极慢。
所以我们肯定还是要借助工具:向量索引(Vector Index)
(离线阶段)建索引:向量索引工具会分析所有知识向量的分布,并构建一个高效的内部结构(如图、树或哈希表)。这个过程可能耗时,但只需做一次。
(在线阶段)检索:当一个新的查询向量到来时,它会利用索引结构进行“跳跃式”搜索,直接定位到最可能相关的区域,将复杂度降低到非常小的级别。
(具体算法如近似最近邻、余弦相似度等本篇不做过多讲解,感兴趣可自行搜索相关资料)
2. 上框架:选择你的向量索引工具:FAISS&Chroma
FAISS (Facebook AI Similarity Search)
-
定位:一个极致轻量的向量搜索库,专注于高性能的索引构建与搜索。
-
优点:
- 速度快:专为高性能向量搜索设计。
- 纯本地:数据和索引完全在您自己的机器上,安全且无需网络。
- 简单高效:API 直观,非常适合学习和快速原型开发。
-
缺点:
- 功能相对基础,主要用于“建索引 + 搜索”,不擅长复杂的文档生命周期管理(如动态增删改)。
-
适合场景:适合基础使用,让我们快速体验 RAG 的核心离线流程(基础篇)。
Chroma
-
定位:一个功能完整的向量数据库,内置了索引能力。
-
优点:
- 支持文档的增、删、改、查,API 更友好。
- 支持持久化存储(重启后数据不丢失)。
- 可以作为独立服务运行。
-
适合场景:当您的项目需要更复杂的文档管理时(进阶篇)。
本章,我们选用 FAISS 来构建并保存一个本地的向量索引,为后续的实时检索做好准备。
以下是完整的初始化向量数据库构建的代码:
# 仅负责: 切片 -> 向量化 -> 构建索引 -> 保存到磁盘
# 运行一次即可,无需每次检索都运行
import os
from langchain_community.document_loaders import TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_huggingface import HuggingFaceEmbeddings
from langchain_community.vectorstores import FAISS
# 准备知识库内容
knowledge_base_content = """
### 模块 01 — Agent 入门 & 环境搭建
- **目标**:理解 Agent 概念,完成环境配置与首次调用。
- **内容**:环境依赖|API Key 配置|最小可运行 Agent
...
...(同前文)...
"""
with open("knowledge_base.txt",'w',encoding='utf8') as f:
f.write(knowledge_base_content)
# 1. 加载并切分文档 (Load&Split)
loader = TextLoader("knowledge_base.txt",encoding='utf8')
docs = loader.load()
text_splitter = RecursiveCharacterTextSplitter(chunk_size=250,chunk_overlap=40) # 载入切分器模板
splits = text_splitter.split_documents(docs) # 运行切分器
print(f'p1完成,文档已切分成{len(splits)}个片段\n')
# 2. 向量化(Embedding)
embeddings_model = HuggingFaceEmbeddings(model_name="BAAI/bge-small-zh-v1.5") # 载入向量化模型
print(f'p2完成,Embedding模型已准备\n') #
# 3. 存储(Store)
db = FAISS.from_documents(splits,embeddings_model) # 将分割块与向量化的模型传递给FAISS,FAISS会使用它们并完成最终向量数据库的构建。
db.save_local("faiss_index")
print(f'p3完成,向量数据库{db}已构建')
# 清理临时文件
os.remove("knowledge_base.txt")
print('---所有阶段已经完成!---')
(运行后构建的向量数据库:faiss_index)
看起来很复杂,真反应到代码上是不是几乎没有了?向量化与存储俩部分甚至只分别占一行代码。
而且要知道,这个“初始化”只需要运行一次!
所以这个构建过程,我们可以容许它运行时间较长一点,毕竟只要向量数据库构建好了,后面当我们需要进行检索时候,就是立刻返回对应数据,高效便捷。而生成的这个向量数据库的质量自然也是非常重要。
前三步的初始化(离线模块)完成后,我们终于能正式进入RAG了。
五、让Agent“开卷考试”:检索+生成的完美闭环
拥有向量数据库后,我们就可以正式开始RAG 的 “在线运行” (Online R-A-G Flow) 。
1. R-A-G流程拆解
回顾04篇的Chain思想,我们学过LCEL的 | 管道符。而这个链条正好完美对应了R-A-G三个词:
-
R (Retrieval - 检索): retriever = db.as_retriever()
- 我们把 P3 中构建的 db 变成一个 retriever(检索器)对象。
-
A (Augmented - 增强): prompt = ChatPromptTemplate.from_template(...)
- 我们定义一个 Prompt 模板,它包含两个变量:
{context}(来自 R) 和{question}(来自用户)。
- 我们定义一个 Prompt 模板,它包含两个变量:
-
G (Generation - 生成): llm = ChatOpenAI(...)
- 最后,把“增强后”的 Prompt 交给 LLM 去“生成”答案。
2.format-docs:数据的适配器
按上述做法,我们会遇到数据格式冲突的问题:
- R (Retriever) 的输出是:List[Document] (一个 Document 对象的列表,即一叠文档)。
- A (Prompt) 的 {context} 槽位需要的是:str (一个字符串,即一段连续的文本)。
format-docs函数就是用来解决这个冲突的“适配器”: 输入 List[Document] -> 输出 str。
# 4. 辅助函数:将检索到的Doc原始文档对象格式化为字符串
def format_docs(docs):
# 遍历 List[Document],取出每个 doc 的 page_content,用换行符 \n 拼成一个大字符串
return "\n".join(doc.page_content for doc in docs)
3. 用一个输入,驱动整个RAG流程:
在构建prompt中,我们常会写出类似这样的结构:
system = """
根据以下上下文回答问题:
[上下文]:
{context}
[问题]:
{question}
"""
这个prompt需要两个信息(context与question),但我们调用链条时能给一个参数:
rag_chain.invoke("模块05的目标是什么?")
如何让这一个参数,能满足两个输入的需求?
这就需要一个机制,让同一个输入既能用于检索,又能作为问题本身传递下去。
这就是这个结构的作用:
{"context": retrieve | format_docs, "question": RunnablePassthrough()}
"context":用问题去检索,并格式化结果。"question":用RunnablePassthrough()将原始问题原样传递。
这样,我们就能用一个输入,生成一个包含两个字段的字典,完美匹配 Prompt 的需求。
RunnablePassthrough()的本质是:在数据流中保留原始输入,解决“输入被消耗后无法复用”的问题。
综上,我们最终构建出完整的RAG链条:
from langchain_community.document_loaders import TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_huggingface import HuggingFaceEmbeddings
from langchain_community.vectorstores import FAISS
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough
# --- 模块A (离线操作) ---
# 1. 加载&分割 2. 向量化 3. 存储
KNOWLEDGE_BASE_CONTENT = """
### 模块 01 - Agent 入门 & 环境搭建
- **目标**: 理解 Agent 概念, 完成环境配置与首次调用。
- **内容**: 环境依赖 | API Key 配置 | 最小可运行 Agent
### 模块 02 - LLM 基础调用
- **目标**: 掌握模型调用逻辑, 初步构建智能体能力。
- **内容**: LLM 了解与调用 | Prompt 编写与逻辑构思 | 多轮对话记忆 | 独立构建一个智能体
### 模块 03 - Function Calling 与工具调用
- **目标**: 实现 LLM 调用外部函数, 赋予模型“执行力”。
- **内容**: Function calling 原理 | 工具函数封装 | API 接入实践 | 多轮调用流程 | Agent 能力扩展
### 模块 04 - LangChain 基础篇
- **目标**: 认识 LangChain 六大模块, 学会用 LangChain 构建智能体。
- **内容**: LLM 调用 | Prompt 设计 | Chain 构建 | Memory 记忆 | 实战练习
### 模块 05 - LangChain 进阶篇
- **目标**: 掌握 LangChain Agents 的核心机制, 构建能调用工具、持续思考、具备记忆的智能体。
- **内容**: Function Calling | @tool 工具封装 | ReAct 循环 | Agent 构建 | SQL Agent | 记忆+流式 | 开发优化
"""
with open("knowledge_base.txt", "w", encoding="utf-8") as f:
f.write(KNOWLEDGE_BASE_CONTENT)
loader = TextLoader('knowledge_base.txt',encoding='utf8')
docs = loader.load()
text_splitter = RecursiveCharacterTextSplitter(chunk_size=250,chunk_overlap=40)
splits = text_splitter.split_documents(docs)
embedding_model = HuggingFaceEmbeddings(model_name="BAAI/bge-small-zh-v1.5")
db = FAISS.from_documents(splits,embedding_model)
print('---模块A(Indexing)完成---\n')
# --- 模块B (在线运行:Online R-A-G Flow) ---
print('--- 模块B R-A-G正在构建---\n')
# 1. R (Retrieval - 检索)
retrieve = db.as_retriever(search_kwarg={"k":1}) # 只返回最相关的1个
# 2. A (Augmented - 增强)
system = """
请你扮演一个 Ai Agent 教学助手。
请你只根据下面提供的“上下文”来回答问题。
如果上下文中没有提到,请回答“对不起,我不知道”。
[上下文]:
{context}
[问题]:
{question}
"""
prompt = ChatPromptTemplate.from_messages([
('system',system),
('human','{question}')
])
# 3. G (Generation - 生成)
llm = ChatOpenAI(
model="deepseek-chat",
api_key=api_key,
base_url="https://api.deepseek.com"
)
# 4. 辅助函数:将检索到的Doc原始文档对象格式化为字符串,让llm能读懂
def format_docs(docs):
return "\n".join(doc.page_content for doc in docs)
# 5. 组装 RAG 链条(LCEL)
rag_chain = (
{"context":retrieve | format_docs,"question":RunnablePassthrough()}
| prompt
| llm
| StrOutputParser()
)
# --- 运行 RAG 链 ---
question = '模块05的目标是什么?'
response = rag_chain.invoke(question)
print(f'提问:{question}')
print(f'回答:{response}\n')
question = 'Langchain进阶篇讲了什么?'
response = rag_chain.invoke(question)
print(f'提问:{question}')
print(f'回答:{response}\n')
question = '今天天气怎么样?' # 知识库中没有
response = rag_chain.invoke(question)
print(f'提问:{question}')
print(f'回答:{response}\n')
# 清理临时文件
os.remove("knowledge_base.txt")
如上,它真正做到了rag,从检索到增强到最后生成的步骤。
六、总结
知识点概括:RAG 概念|文本加载与分块 (Load & Split)|向量化 (Embedding)|向量存储 (FAISS)|LCEL RAG 链
RAG基础篇可谓是看着简单,但理解起来很复杂,而且写起来感觉也很复杂,费时间。
很多知识点概念必须得一点点吃才能理解透。
但有个好消息:这篇我们为了讲透原理,用的是LCEL(手动拼装),所以代码看着比较繁琐且量大。等进入Rag进阶篇后,我们会用Langchain的高级抽象来自动封装这个模块,在提高Rag性能的同时,真正降低代码量并提升解耦程度。
同时上述代码的RAG链条也的确仍然脆弱:
- 痛点 1:
FAISS只是个“玩具”(内存索引,无法增删改)。如何换成Chroma这样的专业数据库? - 痛点 2: 如果搜索结果不准怎么办?(“垃圾进,垃圾出”)
- 痛点 3: 它没有“记忆”,听不懂“它是什么意思?”(无法处理多轮对话)
- 痛点 4: 它和 05 篇的 Agent 还是割裂的。
下篇07 Rag进阶篇 将解决所有这些问题。我们会将Rag封装成一个Tool,打造成一个能自主决策“该查天气”还是“该查文本”的终极Agent。
📌 项目代码 + 后续案例合集 全部发布在 Github 仓库 agent-craft ,持续更新中,欢迎Star⭐!