RAG(Retrieval-Augmented Generation)大概是2024到2026年落地最广的大模型应用架构了。原因很简单:它解决了大模型最致命的两个问题——知识过时和幻觉。不改模型、不微调、不碰权重,只在推理阶段加一层检索,就能让模型基于你自己的数据回答问题。这买卖太划算了。
我从2024年初开始搭RAG系统,踩了不少坑,也积累了一些经验。今天把整个流程整理出来,从文档加载到最终部署,尽量写得足够实操。
先搞清楚RAG到底在做什么
很多人对RAG的理解停留在"先搜索,再让模型回答",这个理解大方向没错,但缺了关键细节;
一个完整的RAG系统处理一个用户问题的流程是这样的:用户输入一个问题;系统把问题转换成向量(embedding);用这个向量去向量数据库中做相似度检索,找到和问题最相关的K个文档片段;把检索到的文档片段和用户问题一起组装成一个提示词(prompt);把提示词发送给大模型,大模型基于提供的文档片段生成回答。
注意关键点:大模型看到的不是完整的原始文档,而是被切分成小块的文档片段。所以分块策略直接决定了检索质量,检索质量直接决定了最终回答的质量。
项目结构
先把代码结构定下来,后面按模块讲解:
rag-system/
├── ingest.py # 文档摄取:加载、分块、向量化
├── query.py # 查询:检索、组装prompt、生成回答
├── config.py # 配置
├── loaders/ # 文档加载器
│ ├── pdf_loader.py
│ ├── markdown_loader.py
│ └── web_loader.py
├── splitters/ # 分块策略
│ └── text_splitter.py
└── retrievers/ # 检索器
└── vector_retriever.py
第一步:文档加载
文档格式千奇百怪,PDF、Word、Markdown、HTML、数据库记录、API响应……加载器要做的是把这些不同格式统一转换成纯文本,并保留元数据(来源、页码等);
from langchain_community.document_loaders import (
PyPDFLoader,
DirectoryLoader,
TextLoader,
UnstructuredMarkdownLoader
)
import os
def load_documents(doc_dir: str):
"""递归加载目录下所有支持格式的文档"""
all_docs = []
# PDF文件
pdf_loader = DirectoryLoader(
doc_dir, glob="**/*.pdf",
loader_cls=PyPDFLoader, show_progress=True
)
all_docs.extend(pdf_loader.load())
# Markdown文件
md_loader = DirectoryLoader(
doc_dir, glob="**/*.md",
loader_cls=UnstructuredMarkdownLoader, show_progress=True
)
all_docs.extend(md_loader.load())
# 纯文本文件
txt_loader = DirectoryLoader(
doc_dir, glob="**/*.txt",
loader_cls=TextLoader,
loader_kwargs={"encoding": "utf-8"}, show_progress=True
)
all_docs.extend(txt_loader.load())
print(f"共加载 {len(all_docs)} 个文档片段")
return all_docs
一个常见的坑是PDF加载。PyPDFLoader按页提取文本,如果PDF是扫描件(图片形式),它提取出来是空的。这种情况需要先做OCR,或者用Unstructured库的auto模式。
第二步:文本分块
分块是RAG系统中最影响效果的环节,没有之一;
块太大(比如2000字以上),检索到的块里可能只有一小部分和问题相关,其余的都是噪音,会干扰模型判断。块太小(比如100字以下),检索到的片段可能丢失了必要的上下文,模型没法理解。块之间没有重叠,关键信息如果恰好在分块边界处,会被切断;
LangChain的RecursiveCharacterTextSplitter是最常用的选择:
from langchain.text_splitter import RecursiveCharacterTextSplitter
def split_documents(documents, chunk_size=500, chunk_overlap=80):
"""将文档分割成适合检索的小块"""
# 中文文本的分隔符优先级
separators = [
"\n\n", # 段落分隔
"\n", # 换行
"。", # 句号
"!", # 感叹号
"?", # 问号
";", # 分号
",", # 逗号
" ", # 空格
"" # 字符级(兜底)
]
splitter = RecursiveCharacterTextSplitter(
chunk_size=chunk_size,
chunk_overlap=chunk_overlap,
length_function=len,
separators=separators,
is_separator_regex=False
)
chunks = splitter.split_documents(documents)
print(f"分割成 {len(chunks)} 个文本块")
print(f"平均块大小: {sum(len(c.page_content) for c in chunks) / len(chunks):.0f} 字符")
return chunks
chunk_size和chunk_overlap需要根据你的文档类型调整。技术文档信息密度高,chunk_size可以设小一些(300-500),chunk_overlap设50-100。文章/报告类内容逻辑连贯性强,chunk_size建议600-1000,chunk_overlap设100-200。
第三步:向量化和存储
把文本块转换成向量,存入向量数据库。这里用Chroma做演示,它是最轻量的选择,适合快速原型:
from langchain_community.embeddings import HuggingFaceEmbeddings
from langchain_community.vectorstores import Chroma
def create_vectorstore(chunks, persist_dir="./chroma_db"):
"""创建向量数据库"""
# 选用中文优化的嵌入模型
embeddings = HuggingFaceEmbeddings(
model_name="BAAI/bge-small-zh-v1.5",
model_kwargs={"device": "cpu"},
encode_kwargs={
"normalize_embeddings": True,
"batch_size": 64
}
)
# 批量创建向量并存储
vectorstore = Chroma.from_documents(
documents=chunks,
embedding=embeddings,
persist_directory=persist_dir,
collection_metadata={"hnsw:space": "cosine"}
)
print(f"向量数据库已持久化到 {persist_dir}")
print(f"共 {vectorstore._collection.count()} 条向量")
return vectorstore
嵌入模型的选择很重要。中文场景推荐BAAI的bge-small-zh-v1.5或bge-large-zh-v1.5。small版本只有120MB,速度快效果够用;large版本400MB,效果更好但速度慢一些。
第四步:检索和生成
最后把检索和生成串起来:
from langchain_community.chat_models import ChatOpenAI
from langchain.chains import RetrievalQA
from langchain.prompts import PromptTemplate
def build_qa_chain(vectorstore):
"""构建RAG问答链"""
llm = ChatOpenAI(
base_url="http://localhost:11434/v1",
api_key="ollama",
model="deepseek-r1:7b",
temperature=0.1, # 低温度减少随机性
max_tokens=2048
)
# 自定义提示词——这是效果的关键
prompt = PromptTemplate(
template="""你是一个严谨的技术文档问答助手。请严格基于以下参考文档回答用户的问题。
## 参考文档
{context}
## 用户问题
{question}
## 回答规则
1. 只根据参考文档的内容回答,绝对不要编造信息
2. 如果参考文档中没有相关信息,直接说"根据现有文档,我无法回答这个问题"
3. 引用文档中的具体段落来支撑你的回答
4. 如果文档信息不足以完整回答,说明哪些部分有依据、哪些部分文档未提及""",
input_variables=["context", "question"]
)
qa_chain = RetrievalQA.from_chain_type(
llm=llm,
chain_type="stuff",
retriever=vectorstore.as_retriever(
search_type="similarity",
search_kwargs={"k": 4} # 检索最相关的4个片段
),
chain_type_kwargs={"prompt": prompt},
return_source_documents=True,
verbose=False
)
return qa_chain
prompt的写法直接影响回答质量。要点是:明确要求模型只基于文档回答(减少幻觉);要求引用原文(方便用户验证);给模型"说不知道"的权利(不要逼模型编造)。
完整调用
# 摄取阶段(一次性)
documents = load_documents("./docs")
chunks = split_documents(documents, chunk_size=500, chunk_overlap=80)
vectorstore = create_vectorstore(chunks)
# 查询阶段(日常使用)
qa_chain = build_qa_chain(vectorstore)
result = qa_chain({"query": "我们的退款政策是什么?"})
print(result["result"])
print("\n--- 引用来源 ---")
for doc in result["source_documents"]:
print(f"文件: {doc.metadata.get('source', '未知')}")
print(f"内容: {doc.page_content[:150]}...")
效果优化方向
基础版本搭好后,可以从几个方向优化。混合检索方面,把向量检索和关键词检索(BM25)结合,召回率会明显提升。查询改写方面,用户的问题可能表达不清楚,先用模型改写成更适合检索的形式。重排序方面,检索到的候选文档用Cross-Encoder重新打分排序,比单纯的向量相似度更准。
写在最后
RAG系统不复杂,但细节决定效果。分块策略和提示词设计是两个最容易出效果的优化点,建议优先在这两处下功夫。别急着上花哨的框架,先把最基础的流程跑通、跑好。