RAG 系统实战:从零搭建检索增强生成应用
大模型会幻觉、知识有截止日期——这是 LLM 落地最头疼的两件事。 RAG(Retrieval-Augmented Generation,检索增强生成)用一套「先检索、再生成」的架构完美解决了这两个问题。 本教程手把手带你用 Python + LangChain + FAISS 从零构建一套完整的 RAG 系统,每一步都有可直接运行的代码。
一、RAG 是什么,为什么你需要它
在没有 RAG 的世界里,你问 ChatGPT「我们公司的报销政策是什么?」它会随机编造一个答案——因为它根本不知道你公司的内部文件。 更糟糕的是,它编造的时候往往信心十足,让人难以分辨真假。
RAG 的核心思想用一句话概括:先检索相关文档,再把文档内容交给 LLM 生成答案。 这样 LLM 就不需要「记住」所有知识,它只需要在给定上下文中找到答案——这正是语言模型最擅长的事情。
RAG vs 微调:怎么选?
| 对比维度 | RAG | 微调(Fine-tuning) |
|---|---|---|
| 知识更新成本 | 低,更新文档即可 | 高,需重新训练 |
| 答案可追溯 | ✅ 可标注来源 | ❌ 难以溯源 |
| 适合场景 | 企业知识库、文档问答 | 特定风格、专业术语 |
| 开发门槛 | 中等,工程化即可 | 高,需要 GPU 资源 |
| 幻觉风险 | 较低(有依据) | 仍然存在 |
完整系统架构
RAG 全流程数据流向
二、环境搭建与项目结构
我们将构建一个结构清晰、职责分明的项目。每个文件专注一件事,方便你后续扩展。
项目结构预览
rag-from-scratch/
├── data/ # 知识库文档(.txt / .pdf / .md)
│ └── company_policy.txt
├── app/
│ ├── __init__.py
│ ├── document_loader.py # 文档加载 + 分块
│ ├── vector_store.py # 向量索引(FAISS)
│ ├── retriever.py # 检索逻辑
│ └── rag_chain.py # RAG 主链路
├── main.py # 入口:交互式问答
└── requirements.txt
安装依赖
创建虚拟环境后安装以下包。faiss-cpu 是纯 CPU 版本的向量相似度搜索库,无需 GPU。
# 创建并激活虚拟环境
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
# 安装核心依赖
pip install langchain langchain-openai langchain-community \
faiss-cpu pymupdf tiktoken openai python-dotenv
如果你想完全本地运行(不依赖 OpenAI),可以改用 Ollama:
# 本地 LLM 方案
pip install langchain-ollama
# 然后本地启动:ollama run qwen2.5:7b
ImportError,检查你是否安装了对应的 langchain-xxx 子包。配置 API Key
在项目根目录创建 .env 文件,放置你的 OpenAI API Key:
# .env
OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxx
# 如果用国内中转服务
# OPENAI_BASE_URL=https://your-proxy.com/v1
.env 文件提交到 Git。确保 .gitignore 中已包含 .env。三、文档加载与智能分块
分块(Chunking)是 RAG 系统中最容易被忽视、但影响最大的环节。 块太大会超出 LLM 上下文窗口,块太小会丢失上下文。一个好的分块策略需要在文档语义边界处切割。
实现文档加载器
创建 app/document_loader.py,支持 TXT 和 PDF 两种格式:
# app/document_loader.py
from pathlib import Path
from langchain_community.document_loaders import TextLoader, PyMuPDFLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_core.documents import Document
from typing import List
def load_documents(data_dir: str = "data") -> List[Document]:
"""加载 data/ 目录下的所有文档"""
docs = []
data_path = Path(data_dir)
for file_path in data_path.rglob("*"):
if file_path.suffix == ".txt":
loader = TextLoader(str(file_path), encoding="utf-8")
docs.extend(loader.load())
elif file_path.suffix == ".pdf":
loader = PyMuPDFLoader(str(file_path))
docs.extend(loader.load())
print(f"✅ 共加载 {len(docs)} 个文档片段")
return docs
def split_documents(docs: List[Document]) -> List[Document]:
"""将文档分割为适合检索的小块"""
splitter = RecursiveCharacterTextSplitter(
chunk_size=800, # 每块最多 800 字符
chunk_overlap=100, # 相邻块重叠 100 字符(保留上下文)
separators=["\n\n", "\n", "。", "!", "?", ".", " ", ""],
)
chunks = splitter.split_documents(docs)
print(f"✅ 分割为 {len(chunks)} 个文本块")
return chunks
四、构建向量索引(FAISS)
向量索引是 RAG 的「大脑」。它把每个文本块转换为一个高维数字向量, 语义相似的文本在向量空间中距离更近。检索时,我们把用户问题也转换成向量, 然后找最近的 K 个块。
创建向量存储
创建 app/vector_store.py,封装 FAISS 索引的创建和持久化:
# app/vector_store.py
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import FAISS
from langchain_core.documents import Document
from typing import List
import os
def get_embeddings():
"""返回 Embedding 模型实例"""
return OpenAIEmbeddings(
model="text-embedding-3-small", # 比 ada-002 更便宜且更好
openai_api_key=os.environ.get("OPENAI_API_KEY"),
)
def build_vector_store(chunks: List[Document], index_path: str = "faiss_index"):
"""从文本块构建 FAISS 向量索引并持久化到磁盘"""
embeddings = get_embeddings()
print("⏳ 正在生成向量嵌入,请稍候...")
vector_store = FAISS.from_documents(chunks, embeddings)
vector_store.save_local(index_path)
print(f"✅ 向量索引已保存至 {index_path}/")
return vector_store
def load_vector_store(index_path: str = "faiss_index"):
"""从磁盘加载已有的 FAISS 索引"""
embeddings = get_embeddings()
vector_store = FAISS.load_local(
index_path,
embeddings,
allow_dangerous_deserialization=True, # 本地文件信任即可
)
print(f"✅ 已从 {index_path}/ 加载向量索引")
return vector_store
load_vector_store() 读取,不必重复调用 Embedding API。
对于 1000 个文本块,text-embedding-3-small 的费用不到 0.01 美元。
五、组装 RAG 链路,连接 LLM
这一步是整个系统的核心:将检索器和生成器用一个 Prompt 模板串联起来。 好的 Prompt 设计能显著降低幻觉——我们明确告诉模型「只能用给定的上下文回答,没有就说不知道」。
实现 RAG 主链路
创建 app/rag_chain.py,使用 LangChain Expression Language(LCEL)组合各组件:
# app/rag_chain.py
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough
from langchain_community.vectorstores import FAISS
import os
# 系统 Prompt:严格限制模型只使用检索到的上下文
RAG_PROMPT = ChatPromptTemplate.from_messages([
("system", """你是一个专业的知识库问答助手。请严格遵守以下规则:
1. 只根据下方提供的【参考文档】来回答问题
2. 如果参考文档中没有相关信息,直接说"根据现有文档,我无法回答这个问题"
3. 引用信息时注明来源(如"根据第X部分...")
4. 回答要简洁准确,不要过度解读文档内容
【参考文档】
{context}"""),
("human", "{question}"),
])
def format_docs(docs):
"""将检索到的文档列表格式化为字符串"""
formatted = []
for i, doc in enumerate(docs, 1):
source = doc.metadata.get("source", "未知来源")
formatted.append(f"[文档{i}] 来源:{source}\n{doc.page_content}")
return "\n\n---\n\n".join(formatted)
def create_rag_chain(vector_store: FAISS, k: int = 4):
"""
创建 RAG 链路
k: 检索最相似的 k 个文本块
"""
# 1. 配置检索器
retriever = vector_store.as_retriever(
search_type="mmr", # MMR 算法:兼顾相关性和多样性
search_kwargs={"k": k, "fetch_k": 20},
)
# 2. 配置语言模型
llm = ChatOpenAI(
model="gpt-4o-mini", # 性价比最高的选择
temperature=0, # 设为 0:减少随机性,提升一致性
openai_api_key=os.environ.get("OPENAI_API_KEY"),
)
# 3. 用 LCEL 组装链路
rag_chain = (
{
"context": retriever | format_docs, # 检索 → 格式化
"question": RunnablePassthrough(), # 用户问题直接传递
}
| RAG_PROMPT
| llm
| StrOutputParser()
)
return rag_chain, retriever
创建主程序入口
创建 main.py,整合所有模块:
# main.py
import os
from pathlib import Path
from dotenv import load_dotenv
from app.document_loader import load_documents, split_documents
from app.vector_store import build_vector_store, load_vector_store
from app.rag_chain import create_rag_chain
load_dotenv()
INDEX_PATH = "faiss_index"
def setup():
"""首次运行:构建向量索引"""
print("🚀 首次运行,开始构建知识库...")
docs = load_documents("data")
chunks = split_documents(docs)
vector_store = build_vector_store(chunks, INDEX_PATH)
return vector_store
def main():
# 判断是否已有索引
if Path(INDEX_PATH).exists():
vector_store = load_vector_store(INDEX_PATH)
else:
vector_store = setup()
rag_chain, retriever = create_rag_chain(vector_store, k=4)
print("\n✅ 知识库就绪!输入 'quit' 退出\n")
print("=" * 50)
while True:
question = input("\n❓ 你的问题:").strip()
if question.lower() in ("quit", "exit", "q"):
break
if not question:
continue
print("\n🔍 正在检索相关文档...")
# 也可以单独查看检索到了哪些文档
retrieved_docs = retriever.invoke(question)
print(f" 找到 {len(retrieved_docs)} 个相关文档块")
print("\n🤖 正在生成答案...\n")
answer = rag_chain.invoke(question)
print(f"📝 答案:\n{answer}")
print("\n" + "=" * 50)
if __name__ == "__main__":
main()
data/ 目录放一个 company_policy.txt,
内容是任意公司政策文本(哪怕几百字都行),然后运行 python main.py 测试效果。
六、进阶优化:提升检索质量
基础 RAG 跑通之后,下面这些优化手段能显著提升答案质量:
优化 1:混合检索(向量 + 关键词)
纯向量检索对精确词汇(如产品型号、人名、代码片段)效果较差。引入 BM25 关键词检索做补充:
pip install langchain-community rank_bm25
# 混合检索示例
from langchain_community.retrievers import BM25Retriever
from langchain.retrievers import EnsembleRetriever
# 关键词检索器(BM25)
bm25_retriever = BM25Retriever.from_documents(chunks)
bm25_retriever.k = 4
# 向量检索器
faiss_retriever = vector_store.as_retriever(search_kwargs={"k": 4})
# 混合:60% 向量 + 40% 关键词
ensemble_retriever = EnsembleRetriever(
retrievers=[faiss_retriever, bm25_retriever],
weights=[0.6, 0.4],
)
优化 2:查询重写(Query Rewriting)
用户问题往往口语化,不适合直接做向量检索。先用 LLM 改写成更适合检索的形式:
from langchain_core.prompts import PromptTemplate
rewrite_prompt = PromptTemplate.from_template("""
将以下用户问题改写为一个适合搜索文档的查询语句,保持核心意图不变,使用关键词更清晰的表达:
原始问题:{question}
改写后的查询:
""")
rewrite_chain = rewrite_prompt | llm | StrOutputParser()
# 使用时
rewritten = rewrite_chain.invoke({"question": "上个月我申请的那个事怎么样了"})
# → "员工申请审批状态查询"
优化 3:语义分块(Semantic Chunking)
比固定长度分块更智能——在语义边界处切割,而不是在字数到了就硬切:
from langchain_experimental.text_splitter import SemanticChunker
# SemanticChunker 会在相邻句子语义差异较大时切割
semantic_splitter = SemanticChunker(
embeddings=get_embeddings(),
breakpoint_threshold_type="percentile", # 按相似度百分位切
breakpoint_threshold_amount=95,
)
semantic_chunks = semantic_splitter.split_documents(docs)
七、完整运行演示
假设 data/company_policy.txt 内容包含公司报销政策,以下是完整的运行效果:
$ python main.py
✅ 已从 faiss_index/ 加载向量索引
✅ 知识库就绪!输入 'quit' 退出
==================================================
❓ 你的问题:出差报销的上限是多少?
🔍 正在检索相关文档...
找到 4 个相关文档块
🤖 正在生成答案...
📝 答案:
根据公司差旅报销政策(第3条),出差报销上限如下:
- **国内出差**:住宿费用不超过 500 元/天,餐饮补贴 100 元/天
- **跨省出差**:可申请高铁二等座全额报销,飞机需提前 3 天申请
- **海外出差**:需经部门负责人和 HR 双重审批,上限按当地标准执行
如有特殊情况超出限额,需填写《超标报销申请表》并附主管签字。
==================================================
八、常见问题与排查
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 检索结果不相关 | chunk_size 太大或分块策略不佳 | 减小 chunk_size(尝试 400~600),启用语义分块;检查原始文档质量 |
| 模型仍然幻觉 | 检索到的块确实不包含答案 | 加强 Prompt 约束;考虑增大 k 值;检查文档是否覆盖了该问题领域 |
| 响应很慢 | Embedding API 调用延迟 | 使用本地 Embedding 模型(如 mxbai-embed-large via Ollama) |
| 中文检索效果差 | 英文 Embedding 模型对中文支持弱 | 改用多语言模型:text-embedding-3-large 或 BGE-M3 |
| FAISS 加载报错 | allow_dangerous_deserialization 未设置 |
在 load_local() 调用中加上 allow_dangerous_deserialization=True |
| 文档更新后答案还是旧的 | 向量索引没有重建 | 删除 faiss_index/ 目录,重新运行 main.py 触发重建 |
九、总结与下一步
恭喜你!你已经从零构建了一套完整的 RAG 系统,涵盖了:
- 文档加载与智能分块:RecursiveCharacterTextSplitter + chunk_overlap
- 向量索引:OpenAI Embedding + FAISS 本地持久化
- 语义检索:MMR 算法保证相关性和多样性
- 答案生成:约束型 Prompt + GPT-4o-mini
- 进阶优化:混合检索、查询重写、语义分块
推荐的后续学习方向
- Agentic RAG:让 LLM 自主决定是否需要检索、检索什么,构建能主动思考的 AI Agent
- GraphRAG:在知识图谱上做检索,更适合处理实体关系复杂的场景
- 多模态 RAG:检索对象扩展到图片、表格、代码,构建真正的多模态知识库
- 评估框架:接入 RAGAS 或 TruLens,用 Precision / Recall / Faithfulness 指标量化 RAG 质量