LangChain 系列 · 第四篇:RAG 基础------给大模型装上"外脑"
🎯 适合人群:已了解 LangChain 基本概念与 LCEL 用法,想构建基于私有数据问答系统的工程师
⏱️ 阅读时间:约 30 分钟
💬 本文讲解 RAG(检索增强生成)的完整工作原理与工程实现,涵盖文档加载、文本分割、向量化、存储与检索全链路
一、为什么需要 RAG
大语言模型的知识来自训练数据,存在两个根本性局限:
知识截止日期:模型不知道训练结束后发生的事情。
私有数据盲区:模型对企业内部文档、代码库、产品手册一无所知。
解决这两个问题有两条路:
| 方案 | 原理 | 适用场景 | 代价 |
|---|---|---|---|
| Fine-tuning(微调) | 用私有数据重新训练模型,将知识"烧入"参数 | 固定领域、风格迁移 | 训练成本高、数据更新困难 |
| RAG(检索增强生成) | 每次回答前先从外部知识库检索相关内容,注入上下文 | 知识频繁更新、文档量大 | 实现相对简单、知识可实时更新 |
绝大多数"基于私有文档的问答"场景,RAG 都是更合适的选择。
RAG 的完整工作流分为两个阶段:
=== Indexing (offline) ===
Raw Documents
|
v
[Document Loader] --> Document objects (page_content + metadata)
|
v
[Text Splitter] --> Smaller chunks
|
v
[Embedding Model] --> Vectors
|
v
[Vector Store] --> Stored index
=== Retrieval & Generation (online) ===
User Question
|
v
[Embedding Model] --> Question vector
|
v
[Vector Store] --> Top-K relevant chunks (similarity search)
|
v
[LLM + Prompt] --> Answer grounded in retrieved context
二、Document Loader:加载原始数据
Document Loader 负责将各种格式的原始数据统一转换为 LangChain 的 Document 对象(包含 page_content 文本和 metadata 元数据)。
2.1 常用 Loader 一览
langchain_community.document_loaders 提供了数十种 Loader:
| 类 | 用途 | 安装依赖 |
|---|---|---|
TextLoader |
纯文本文件(.txt、.md 等) |
无 |
PyPDFLoader |
PDF 文件,按页分割为 Document | pip install pypdf |
PyMuPDFLoader |
PDF 文件,速度更快,保留更丰富的元数据 | pip install pymupdf |
WebBaseLoader |
网页内容,基于 BeautifulSoup 提取正文 | pip install bs4 |
DirectoryLoader |
批量加载目录下多个文件,支持 glob 过滤 | 无 |
CSVLoader |
CSV 文件,每行转为一个 Document | 无 |
UnstructuredMarkdownLoader |
Markdown 文件,保留文档结构 | pip install unstructured |
GitLoader |
Git 仓库中的代码文件 | pip install gitpython |
WikipediaLoader |
Wikipedia 文章 | pip install wikipedia |
ArxivLoader |
arXiv 学术论文 | pip install arxiv |
2.2 常用加载示例
python
from langchain_community.document_loaders import (
TextLoader,
PyPDFLoader,
WebBaseLoader,
DirectoryLoader,
)
# 加载单个文本文件
text_loader = TextLoader("./docs/readme.txt", encoding="utf-8")
docs = text_loader.load()
print(f"加载了 {len(docs)} 个 Document")
print(docs[0].page_content[:200]) # 文本内容
print(docs[0].metadata) # {'source': './docs/readme.txt'}
# 加载 PDF(按页分割)
pdf_loader = PyPDFLoader("./docs/report.pdf")
pages = pdf_loader.load()
print(f"PDF 共 {len(pages)} 页")
print(pages[0].metadata) # {'source': '...', 'page': 0}
# 加载网页
web_loader = WebBaseLoader(
web_paths=["https://python.langchain.com/docs/introduction/"],
bs_kwargs={
"parse_only": bs4.SoupStrainer(class_=("article", "main")) # 只提取正文
}
)
web_docs = web_loader.load()
# 批量加载目录(加载所有 .md 文件)
dir_loader = DirectoryLoader(
path="./docs/",
glob="**/*.md",
loader_cls=TextLoader,
loader_kwargs={"encoding": "utf-8"},
show_progress=True, # 显示进度条
)
all_docs = dir_loader.load()
print(f"加载了 {len(all_docs)} 个文档")
💡
metadata字段非常重要。它记录了每个 Document 的来源(文件路径、URL、页码等),RAG 系统在返回答案时可以附带来源引用,显著提升可信度。
三、Text Splitter:切分文档
加载后的文档通常过长,无法直接放入模型上下文窗口,需要切分为更小的 chunk(语义片段)。
3.1 常用 Text Splitter 一览
| 类 | 分割策略 | 适用场景 |
|---|---|---|
RecursiveCharacterTextSplitter |
按字符列表递归分割,优先在段落/句子边界切分 | 通用首选,绝大多数场景 |
CharacterTextSplitter |
按单一分隔符(如 \n\n)分割 |
结构简单、段落清晰的文本 |
TokenTextSplitter |
按 token 数分割,精确控制 token 消耗 | 需要严格控制 token 预算时 |
MarkdownTextSplitter |
按 Markdown 标题层级(#、##)分割 |
Markdown 文档 |
PythonCodeTextSplitter |
按 Python 函数/类语法结构分割 | Python 代码库 |
HTMLHeaderTextSplitter |
按 HTML 标题层级分割,保留语义结构 | HTML 网页文档 |
SemanticChunker |
基于 Embedding 相似度分割,保证语义完整性 | 质量要求高、不在乎速度的场景 |
3.2 chunk_size 与 chunk_overlap 的选择原则
这是 RAG 效果调优中最关键的参数,直接影响检索质量。
chunk_size(每个 chunk 的最大字符数):
chunk 过小 --> 单个 chunk 缺少上下文,模型难以理解 --> 回答片面
chunk 过大 --> 一个 chunk 包含太多无关信息 --> 检索噪声增加
实践中的参考值:
| 文档类型 | 建议 chunk_size | 原因 |
|---|---|---|
| 通用文章、FAQ | 512 ~ 1024 字符 | 平衡语义完整性与检索精度 |
| 技术文档、教程 | 1024 ~ 2048 字符 | 技术概念需要更多上下文 |
| 代码文件 | 1500 ~ 3000 字符 | 函数/类需要完整呈现 |
| 短问答、条目 | 128 ~ 256 字符 | 每条信息本身就是独立单元 |
chunk_overlap(相邻 chunk 的重叠字符数):
重叠的目的是防止关键信息恰好被切断在两个 chunk 的边界处。通常设为 chunk_size 的 10%~20%。
python
from langchain_text_splitters import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=1000, # 每个 chunk 最大 1000 字符
chunk_overlap=100, # 相邻 chunk 重叠 100 字符(10%)
length_function=len, # 用字符数计算长度;也可传入 tiktoken 的 token 计数函数
add_start_index=True, # 在 metadata 中记录 chunk 在原文中的起始位置
)
# 分割文档列表
chunks = splitter.split_documents(docs)
print(f"原始文档 {len(docs)} 篇 --> 切分后 {len(chunks)} 个 chunk")
# 查看第一个 chunk
print(chunks[0].page_content)
print(chunks[0].metadata) # {'source': '...', 'start_index': 0}
3.3 按 token 精确控制
当下游使用的模型有严格的 context window 限制时,按字符数估算 token 不够精确。使用 TokenTextSplitter 或在 RecursiveCharacterTextSplitter 中替换 length_function:
python
import tiktoken
from langchain_text_splitters import RecursiveCharacterTextSplitter
enc = tiktoken.encoding_for_model("gpt-4o-mini")
def token_len(text: str) -> int:
return len(enc.encode(text))
splitter = RecursiveCharacterTextSplitter(
chunk_size=512, # 每个 chunk 最多 512 tokens
chunk_overlap=50,
length_function=token_len,
)
四、Embedding:将文本转为向量
Embedding 是将文本映射到高维向量空间的技术。语义相近的文本,其向量在空间中的距离也更近------这正是向量检索能够找到相关内容的数学基础。
可以把 Embedding 想象成"语义坐标":每段文本对应空间中的一个点,"向量检索"就是找距离用户问题最近的若干个点。
4.1 常用 Embedding 模型一览
| 类 | 模型 | 特点 |
|---|---|---|
OpenAIEmbeddings |
text-embedding-3-small / text-embedding-3-large |
质量高,按 token 计费 |
HuggingFaceEmbeddings |
BAAI/bge-m3、sentence-transformers/paraphrase-multilingual 等 |
开源免费,支持本地运行 |
OllamaEmbeddings |
nomic-embed-text、mxbai-embed-large 等 |
本地 Ollama 部署,完全离线 |
CohereEmbeddings |
embed-multilingual-v3.0 |
多语言优化,按调用次数计费 |
FastEmbedEmbeddings |
BAAI/bge-small-zh 等 |
轻量快速,适合资源受限环境 |
4.2 使用示例
python
from langchain_openai import OpenAIEmbeddings
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
# 对单条文本生成向量
vector = embeddings.embed_query("什么是向量数据库?")
print(f"向量维度:{len(vector)}") # text-embedding-3-small 输出 1536 维
# 对多条文本批量生成向量(索引阶段)
texts = ["文本一", "文本二", "文本三"]
vectors = embeddings.embed_documents(texts)
print(f"生成了 {len(vectors)} 个向量")
💡 选型建议:
- 开发/原型阶段:
OpenAIEmbeddings+text-embedding-3-small,性价比高- 生产阶段且数据敏感:
HuggingFaceEmbeddings+BAAI/bge-m3,本地运行,数据不出境- 中文场景:优先考虑
BAAI/bge-m3或text-embedding-3-large(对中文支持更好)
五、Vector Store:存储与检索向量
向量数据库(Vector Store) 负责存储文本对应的向量,并在接收到查询向量时,快速找出最相似的若干条记录。
5.1 常用 Vector Store 一览
| 类 | 定位 | 适用场景 |
|---|---|---|
FAISS |
Meta 开源,纯内存索引 | 本地开发、快速原型,不需要持久化 |
Chroma |
轻量级本地向量数据库 | 开发/小规模生产,支持持久化到磁盘 |
Milvus |
分布式向量数据库 | 亿级向量、高并发生产场景 |
Qdrant |
开源,支持复杂元数据过滤 | 需要混合检索(向量 + 关键词过滤)的场景 |
Pinecone |
全托管云服务 | 不想运维、快速上云 |
PGVector |
PostgreSQL 扩展 | 已有 PG 基础设施,不想引入新组件 |
Weaviate |
开源,支持多模态 | 图文混合检索场景 |
5.2 Chroma:持久化本地向量库
python
from langchain_chroma import Chroma
from langchain_openai import OpenAIEmbeddings
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
# 从 chunks 创建向量库(首次运行,自动调用 Embedding API)
vectorstore = Chroma.from_documents(
documents=chunks,
embedding=embeddings,
persist_directory="./chroma_db", # 持久化到本地目录
collection_name="my_docs",
)
# 下次运行直接加载,无需重新 Embedding
vectorstore = Chroma(
persist_directory="./chroma_db",
embedding_function=embeddings,
collection_name="my_docs",
)
5.3 FAISS:快速内存索引
python
from langchain_community.vectorstores import FAISS
from langchain_openai import OpenAIEmbeddings
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
# 创建 FAISS 索引
vectorstore = FAISS.from_documents(chunks, embeddings)
# 保存到本地(可选)
vectorstore.save_local("./faiss_index")
# 从本地加载
vectorstore = FAISS.load_local(
"./faiss_index",
embeddings,
allow_dangerous_deserialization=True,
)
5.4 检索参数调优
向量库提供了多种检索方式,参数选择直接影响 RAG 的回答质量:
python
# 方式一:相似度搜索(最常用)
# k 控制返回的 chunk 数量
results = vectorstore.similarity_search(
query="什么是 LCEL?",
k=4, # 返回最相似的 4 个 chunk
)
# 方式二:带相似度分数
results_with_score = vectorstore.similarity_search_with_score(
query="什么是 LCEL?",
k=4,
)
for doc, score in results_with_score:
print(f"score: {score:.4f} | {doc.page_content[:80]}")
# 方式三:MMR(最大边际相关性)------平衡相关性与多样性,避免检索到内容重复的 chunk
results_mmr = vectorstore.max_marginal_relevance_search(
query="什么是 LCEL?",
k=4,
fetch_k=20, # 先取 20 个候选,再从中挑选多样性最高的 4 个
lambda_mult=0.5, # 0=最大多样性,1=最大相关性
)
🔬
k值的选择:k=3~5适合大多数场景。k过小会遗漏关键信息,k过大会引入噪声并增加 Token 消耗。当检索效果不理想时,优先考虑改善 chunk 策略,而非无限增大k。
六、构建完整 RAG Chain
将以上组件用 LCEL 串联,构建一个端到端的 RAG 问答系统:
python
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_chroma import Chroma
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough
load_dotenv()
# --------- 索引阶段(离线执行一次)---------
from langchain_community.document_loaders import DirectoryLoader, TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
loader = DirectoryLoader("./docs/", glob="**/*.md", loader_cls=TextLoader)
docs = loader.load()
splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=100)
chunks = splitter.split_documents(docs)
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
vectorstore = Chroma.from_documents(
chunks, embeddings, persist_directory="./chroma_db"
)
# --------- 检索+生成阶段(在线执行)---------
retriever = vectorstore.as_retriever(
search_type="similarity",
search_kwargs={"k": 4},
)
# RAG Prompt:要求模型严格基于检索内容回答
rag_prompt = ChatPromptTemplate.from_messages([
(
"system",
"你是一个问答助手,严格根据以下检索到的上下文内容回答用户问题。\n\n"
"规则:\n"
"- 只使用上下文中明确提到的信息作答\n"
"- 如果上下文中没有相关信息,回复:'根据现有文档,无法回答该问题。'\n"
"- 不要捏造或推测上下文中未提及的信息\n\n"
"上下文:\n{context}"
),
("human", "{question}"),
])
def format_docs(docs):
"""将检索到的 Document 列表格式化为字符串"""
return "\n\n---\n\n".join(
f"[来源: {doc.metadata.get('source', 'unknown')}]\n{doc.page_content}"
for doc in docs
)
model = ChatOpenAI(model="gpt-4o-mini", temperature=0)
parser = StrOutputParser()
# 完整 RAG Chain
rag_chain = (
{
"context": retriever | format_docs, # 检索并格式化上下文
"question": RunnablePassthrough(), # 原样传递用户问题
}
| rag_prompt
| model
| parser
)
# 调用
answer = rag_chain.invoke("什么是 LCEL?它和旧版 LLMChain 有什么区别?")
print(answer)
6.1 带来源引用的 RAG
生产环境中,用户通常需要知道答案来自哪个文档:
python
from langchain_core.runnables import RunnableParallel
# 同时返回答案和检索到的来源文档
rag_chain_with_source = RunnableParallel(
{
"answer": rag_chain,
"sources": retriever, # 单独保留检索结果
}
)
result = rag_chain_with_source.invoke("什么是 LCEL?")
print("回答:", result["answer"])
print("\n来源文档:")
for doc in result["sources"]:
print(f" - {doc.metadata['source']}")
七、常见坑与最佳实践
坑一:chunk_size 设置不合理导致检索效果差
# ❌ chunk_size=100,切得太碎
# 每个 chunk 只有一两句话,缺乏上下文,模型无法理解
# ❌ chunk_size=5000,切得太大
# 一个 chunk 包含多个主题,检索时引入大量无关内容
# ✅ 根据文档类型调整:通用文章 512~1024,技术文档 1024~2048
# ✅ 实验时打印几个 chunk,人工判断语义完整性
坑二:向量库未持久化,每次启动重新 Embedding
python
# ❌ 每次运行都调用 from_documents,重复消耗 Embedding API
vectorstore = Chroma.from_documents(chunks, embeddings)
# ✅ 检查是否已有索引,有则直接加载
import os
if os.path.exists("./chroma_db"):
vectorstore = Chroma(
persist_directory="./chroma_db",
embedding_function=embeddings,
)
else:
vectorstore = Chroma.from_documents(
chunks, embeddings, persist_directory="./chroma_db"
)
坑三:RAG Prompt 没有约束模型,导致"幻觉"
python
# ❌ 没有约束,模型会混入自身知识
system = "根据以下内容回答问题:{context}"
# 当上下文信息不足时,模型会用训练数据补充,输出难以验证
# ✅ 明确约束:上下文中没有的信息不能回答
system = (
"严格根据以下上下文回答问题,不得使用上下文以外的信息。\n"
"若上下文不足以回答,输出:'文档中未找到相关信息。'\n\n"
"上下文:{context}"
)
坑四:中文文档检索效果差
python
# ❌ 使用默认的 text-embedding-ada-002(对中文支持有限)
embeddings = OpenAIEmbeddings() # 默认 ada-002
# ✅ 中文场景优先选择:
# 方案一:OpenAI text-embedding-3-large(中文效果显著优于 ada-002)
embeddings = OpenAIEmbeddings(model="text-embedding-3-large")
# 方案二:本地 BGE 模型(专门针对中文优化,免费)
from langchain_community.embeddings import HuggingFaceEmbeddings
embeddings = HuggingFaceEmbeddings(model_name="BAAI/bge-m3")
坑五:忽略 metadata 过滤,检索结果来源混乱
python
# ✅ 利用 metadata 过滤,只在特定文档集合中检索
results = vectorstore.similarity_search(
query="退款政策是什么?",
k=4,
filter={"source": "policy_2024.pdf"}, # 只检索指定文档
)
八、总结
| 组件 | 核心作用 | 关键参数/选择 |
|---|---|---|
| Document Loader | 将原始文件转为 Document 对象 | 按文件格式选择对应 Loader |
| Text Splitter | 将长文档切分为可检索的 chunk | chunk_size(5122048)、`chunk_overlap`(10%20%) |
| Embedding Model | 将文本映射为高维向量 | 中文场景选 BGE 或 text-embedding-3-large |
| Vector Store | 存储向量并支持相似度检索 | 开发用 FAISS/Chroma,生产用 Milvus/Qdrant |
| Retriever | 执行相似度搜索 | k=3~5,质量差时优先调整 chunk 策略 |
| RAG Prompt | 约束模型基于上下文回答 | 必须明确禁止使用上下文以外的信息 |
🎯 RAG 的效果上限由检索质量决定,而检索质量由 chunk 策略和 Embedding 模型决定。调优时的顺序:先确认 chunk 质量(打印几个 chunk 人工检查),再评估 Embedding 模型,最后才调整
k值。
参考资料
下期预告
基础 RAG 的检索准确率往往只有 60%~70%------用户的提问方式与文档的表述方式不一致时,相似度搜索就会失效。
第五篇《RAG 进阶:让检索真的准》 将介绍提升检索质量的四种核心技术:Multi-query(多角度提问)、Contextual Compression(压缩冗余上下文)、HyDE(假设文档嵌入)和 Reranking(交叉编码器重排),并给出各方案的量化对比数据。