RAG(检索增强生成)系统设计与实现
引言
想象一下,你正在开发一个企业级知识问答系统。用户问:“我们公司去年Q3的营收数据在哪个报表里?”这个问题看似简单,但背后隐藏着巨大的挑战:大语言模型(LLM)虽然博闻强识,却对你的企业内部数据一无所知。它可能自信满满地编造一个答案,然后你的老板就会收到一份“我们Q3营收是10个亿,在财务报表第42页”的虚假信息——而实际数据是8.5个亿,在第17页。
这就是LLM的“幻觉”问题。RAG(Retrieval-Augmented Generation,检索增强生成)正是为了解决这个问题而生。它不是让模型记住所有数据,而是教会模型“查资料”的能力:在回答问题前,先从知识库中检索相关文档,然后基于这些文档生成答案。
在本文中,我将从一个资深架构师的视角,带你深入RAG系统的设计细节,从理论到实践,从简单到复杂,最终构建一个生产级别的RAG系统。
核心概念
生活类比:图书馆里的学霸
想象一个学霸在图书馆复习考试。他有两种学习方式:
- 纯背诵(LLM预训练):把整本书的内容都背下来,然后直接回答。但问题在于:书会更新,他背的可能是旧版本;而且他可能会记混细节,把A书的内容安到B书上。
- 查资料再回答(RAG):他先看题目,然后去书架找到相关书籍,翻开特定章节,根据找到的内容组织答案。这样答案准确、可溯源、且能应对新知识。
RAG的核心流程就是:理解问题 → 检索相关文档 → 基于文档生成答案。
技术定义
RAG系统由三个核心组件构成:
- 文档处理管道:将原始文档切分成合适的片段,并转化为向量表示
- 检索引擎:根据用户查询找到最相关的文档片段
- 生成引擎:将检索到的文档片段作为上下文,驱动LLM生成答案
源码/原理深度分析
为什么朴素RAG不够好?
大多数初学者会写出这样的伪代码:
def naive_rag(query, vector_db, llm):
# 1. 检索最相似的3个文档
docs = vector_db.similarity_search(query, k=3)
# 2. 拼接成上下文
context = "\n".join([doc.text for doc in docs])
# 3. 生成答案
prompt = f"基于以下内容回答问题:\n{context}\n问题:{query}"
return llm.generate(prompt)这个实现有三个致命缺陷:
问题1:检索精度不足
- 向量检索只考虑语义相似度,不考虑文档的时效性、权威性
- 关键词精确匹配的能力很弱(比如“苹果”和“Apple”语义相同但向量可能不同)
问题2:上下文窗口浪费
- 3个文档可能有大量重复信息
- 关键信息可能被埋没在冗余内容中
问题3:生成质量不可控
- LLM可能忽略上下文,依然产生幻觉
- 无法处理“没有找到相关信息”的情况
进阶RAG架构
一个生产级别的RAG系统需要引入多个优化组件:
实战代码
示例1:完整的RAG管道实现
"""
完整RAG管道实现
包含:文档加载、切分、向量化、检索、重排序、生成
依赖:pip install langchain chromadb sentence-transformers rank_bm25
"""
from typing import List, Dict, Any
import hashlib
import json
from dataclasses import dataclass
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.embeddings import HuggingFaceEmbeddings
from langchain.vectorstores import Chroma
from langchain.llms import OpenAI
from langchain.prompts import ChatPromptTemplate
from rank_bm25 import BM25Okapi
import numpy as np
@dataclass
class Document:
"""文档数据类"""
text: str
metadata: Dict[str, Any]
doc_id: str = None
def __post_init__(self):
if self.doc_id is None:
# 用内容哈希生成唯一ID,便于去重
self.doc_id = hashlib.md5(self.text.encode()).hexdigest()[:16]
class RAGPipeline:
"""
RAG管道核心类
实现了混合检索(向量+BM25)+ 重排序的完整流程
"""
def __init__(self, embedding_model: str = "BAAI/bge-small-zh-v1.5"):
# 文本切分器:递归切分,保持段落完整性
self.text_splitter = RecursiveCharacterTextSplitter(
chunk_size=512, # 每个片段约512字符
chunk_overlap=128, # 重叠128字符,避免关键信息被截断
separators=["\n\n", "\n", "。", "!", "?", ",", " ", ""],
length_function=len
)
# 向量化模型:使用中文优化的bge模型
self.embeddings = HuggingFaceEmbeddings(
model_name=embedding_model,
model_kwargs={'device': 'cpu'},
encode_kwargs={'normalize_embeddings': True}
)
# 向量数据库:持久化存储
self.vector_store = None
self.documents = [] # 存储原始文档
self.bm25_index = None # BM25索引
self.bm25_corpus = [] # BM25语料库
def add_documents(self, docs: List[Dict[str, str]]):
"""
添加文档到知识库
Args:
docs: [{"text": "...", "metadata": {...}}, ...]
"""
# 1. 文本切分
chunks = []
for doc in docs:
text_chunks = self.text_splitter.split_text(doc["text"])
for chunk in text_chunks:
doc_obj = Document(
text=chunk,
metadata=doc.get("metadata", {})
)
chunks.append(doc_obj)
# 2. 构建向量索引
texts = [chunk.text for chunk in chunks]
metadatas = [chunk.metadata for chunk in chunks]
if self.vector_store is None:
self.vector_store = Chroma.from_texts(
texts=texts,
embedding=self.embeddings,
metadatas=metadatas,
persist_directory="./chroma_db"
)
else:
self.vector_store.add_texts(texts=texts, metadatas=metadatas)
# 3. 构建BM25索引(用于关键词检索)
self.documents.extend(chunks)
self.bm25_corpus = [doc.text for doc in self.documents]
self.bm25_index = BM25Okapi(self.bm25_corpus)
print(f"已添加 {len(chunks)} 个文档片段到知识库")
def hybrid_search(self, query: str, k: int = 5) -> List[Document]:
"""
混合检索:向量检索 + BM25关键词检索
Args:
query: 用户查询
k: 返回结果数量
"""
# 1. 向量检索:捕获语义相似度
vector_results = self.vector_store.similarity_search_with_score(
query, k=k*2 # 多召回一些,给重排序留空间
)
# 2. BM25检索:捕获关键词匹配
tokenized_query = query.split() # 简单分词,生产环境用jieba
bm25_scores = self.bm25_index.get_scores(tokenized_query)
bm25_top_k = np.argsort(bm25_scores)[-k*2:][::-1]
# 3. 融合结果(加权平均)
combined_scores = {}
# 向量检索得分(归一化到0-1)
for doc, score in vector_results:
doc_id = hashlib.md5(doc.page_content.encode()).hexdigest()[:16]
# 余弦距离转相似度(1 - 距离)
norm_score = 1.0 / (1.0 + score)
combined_scores[doc_id] = {
'doc': Document(text=doc.page_content, metadata=doc.metadata),
'vector_score': norm_score,
'bm25_score': 0.0
}
# BM25得分(归一化)
for idx in bm25_top_k:
doc = self.documents[idx]
doc_id = doc.doc_id
norm_score = bm25_scores[idx] / (max(bm25_scores) + 1e-6)
if doc_id in combined_scores:
combined_scores[doc_id]['bm25_score'] = norm_score
else:
combined_scores[doc_id] = {
'doc': doc,
'vector_score': 0.0,
'bm25_score': norm_score
}
# 加权融合:向量0.7,BM25 0.3
alpha = 0.7
for doc_id in combined_scores:
scores = combined_scores[doc_id]
combined_scores[doc_id]['final_score'] = (
alpha * scores['vector_score'] +
(1 - alpha) * scores['bm25_score']
)
# 4. 排序并返回Top-K
sorted_results = sorted(
combined_scores.values(),
key=lambda x: x['final_score'],
reverse=True
)[:k]
return [result['doc'] for result in sorted_results]
def generate_answer(self, query: str, context_docs: List[Document]) -> str:
"""
基于检索到的文档生成答案
使用结构化提示词,强制LLM遵循约束
"""
# 组装上下文
context = ""
for i, doc in enumerate(context_docs, 1):
source = doc.metadata.get('source', '未知来源')
context += f"[文档{i}](来源:{source})\n{doc.text}\n\n"
# 结构化提示词模板
prompt_template = ChatPromptTemplate.from_template("""
你是一个知识问答助手。请基于提供的文档内容回答问题。
要求:
1. 如果文档中有相关信息,请用中文回答,并标注信息来源
2. 如果文档中没有相关信息,请明确说"无法从提供的资料中找到答案"
3. 不要编造任何文档中没有的信息
4. 回答要简洁、准确,控制在200字以内
文档内容:
{context}
问题:{query}
回答:
""")
prompt = prompt_template.format_messages(
context=context,
query=query
)
# 使用LLM生成(这里用OpenAI示例,实际可替换为其他模型)
llm = OpenAI(temperature=0.1, max_tokens=500)
answer = llm(prompt)
return answer
def query(self, query: str) -> Dict[str, Any]:
"""
完整查询流程
"""
# 1. 检索相关文档
retrieved_docs = self.hybrid_search(query, k=5)
# 2. 生成答案
answer = self.generate_answer(query, retrieved_docs)
return {
'query': query,
'answer': answer,
'source_docs': [
{
'text': doc.text[:100] + "...", # 截取前100字符
'metadata': doc.metadata
}
for doc in retrieved_docs
]
}
# 使用示例
if __name__ == "__main__":
rag = RAGPipeline()
# 添加知识库文档
documents = [
{
"text": """
2025年Q3财务报告
营收:8.5亿元,同比增长12.3%
净利润:1.2亿元,同比增长8.7%
主要增长驱动:云计算业务增长35%,企业服务增长20%
这份报告由财务部于2025年10月15日发布。
""",
"metadata": {"source": "财务报告", "date": "2025-10-15"}
},
{
"text": """
公司组织架构调整通知(2025年)
经董事会决议,自2025年11月1日起:
1. 原技术部拆分为云技术部和应用技术部
2. 成立AI事业部,由张明担任负责人
3. 销售团队合并为统一的大客户部
""",
"metadata": {"source": "内部公告", "date": "2025-10-20"}
}
]
rag.add_documents(documents)
# 测试查询
result = rag.query("公司Q3营收是多少?")
print(f"问题:{result['query']}")
print(f"答案:{result['answer']}")
print("参考来源:")
for doc in result['source_docs']:
print(f" - {doc['text']}")示例2:查询重写与分解
"""
查询优化模块:提升检索准确率
包含查询重写、查询分解、伪文档生成
"""
from typing import List, Tuple
import re
class QueryOptimizer:
"""
查询优化器:在检索之前对用户查询进行优化
"""
def __init__(self, llm=None):
self.llm = llm # 可选,用于复杂重写
def expand_abbreviations(self, query: str) -> str:
"""
缩略词扩展:Q3 -> 第三季度
"""
replacements = {
r'\bQ1\b': '第一季度',
r'\bQ2\b': '第二季度',
r'\bQ3\b': '第三季度',
r'\bQ4\b': '第四季度',
r'\bCEO\b': '首席执行官',
r'\bCTO\b': '首席技术官',
r'\b营收\b': '营业收入',
r'\b净利\b': '净利润',
}
expanded = query
for pattern, replacement in replacements.items():
expanded = re.sub(pattern, replacement, expanded)
return expanded
def generate_sub_queries(self, query: str) -> List[str]:
"""
将复杂查询分解为子查询
例如:"对比Q2和Q3的营收变化"
-> ["Q2营收数据", "Q3营收数据"]
"""
# 简单规则分解
sub_queries = []
# 处理"和"、"与"、"对比"等连接词
if '对比' in query or '比较' in query:
# 提取对比的两方
parts = re.split(r'[和与]', query)
for part in parts:
# 提取关键实体
cleaned = re.sub(r'对比|比较|的|变化|差异', '', part).strip()
if cleaned:
sub_queries.append(cleaned)
# 如果没有分解成功,返回原查询
return sub_queries if sub_queries else [query]
def rewrite_query(self, query: str, history: List[str] = None) -> str:
"""
查询重写:补充上下文,消除歧义
例如:
用户问:"去年营收是多少?"
然后问:"今年呢?"
-> 重写为:"今年的营收是多少?"
"""
if not history:
return self.expand_abbreviations(query)
# 简单规则:如果当前查询很短,可能依赖上文
if len(query) < 10 and history:
# 提取历史查询中的关键实体
last_query = history[-1]
# 提取可能的实体(如"营收"、"利润"等)
entities = re.findall(r'[\u4e00-\u9fa5]{2,}', last_query)
if entities:
# 将当前查询与历史实体组合
rewritten = f"{query} {' '.join(entities[-3:])}"
return rewritten
return self.expand_abbreviations(query)
def optimize(self, query: str, history: List[str] = None) -> Tuple[str, List[str]]:
"""
完整优化流程
Returns:
(优化后的主查询, 子查询列表)
"""
# 1. 查询重写
rewritten = self.rewrite_query(query, history)
# 2. 查询分解
sub_queries = self.generate_sub_queries(rewritten)
# 3. 缩略词扩展
expanded = self.expand_abbreviations(rewritten)
sub_queries = [self.expand_abbreviations(sq) for sq in sub_queries]
return expanded, sub_queries
# 使用示例
if __name__ == "__main__":
optimizer = QueryOptimizer()
# 测试缩略词扩展
query = "Q3营收和净利分别是多少?"
optimized, sub_queries = optimizer.optimize(query)
print(f"原始查询:{query}")
print(f"优化后:{optimized}")
print(f"子查询:{sub_queries}")
# 测试上下文补全
history = ["2025年Q2的营收是多少?"]
query2 = "Q3呢?"
optimized2, _ = optimizer.optimize(query2, history)
print(f"\n历史:{history[-1]}")
print(f"当前查询:{query2}")
print(f"重写后:{optimized2}")示例3:多轮对话与上下文管理
"""
多轮对话RAG:维护对话历史,实现上下文感知的问答
"""
from typing import List, Dict, Optional
from dataclasses import dataclass, field
from datetime import datetime
import uuid
@dataclass
class Message:
"""对话消息"""
role: str # 'user' 或 'assistant'
content: str
timestamp: datetime = field(default_factory=datetime.now)
message_id: str = field(default_factory=lambda: uuid.uuid4().hex[:8])
class ConversationManager:
"""
对话管理器:维护对话状态,实现上下文感知
"""
def __init__(self, max_history: int = 10):
self.sessions: Dict[str, List[Message]] = {}
self.max_history = max_history
def create_session(self) -> str:
"""创建新会话"""
session_id = uuid.uuid4().hex[:12]
self.sessions[session_id] = []
return session_id
def add_message(self, session_id: str, role: str, content: str):
"""添加消息到会话"""
if session_id not in self.sessions:
self.sessions[session_id] = []
message = Message(role=role, content=content)
self.sessions[session_id].append(message)
# 限制历史长度
if len(self.sessions[session_id]) > self.max_history:
self.sessions[session_id] = self.sessions[session_id][-self.max_history:]
def get_context(self, session_id: str, max_messages: int = 5) -> str:
"""
获取对话上下文,用于生成回答
策略:
1. 只保留最近的N轮对话
2. 提取关键信息(如上次提到的实体)
3. 压缩过长的历史
"""
if session_id not in self.sessions:
return ""
messages = self.sessions[session_id][-max_messages*2:] # 保留N轮对话
context_parts = []
for msg in messages:
prefix = "用户" if msg.role == "user" else "助手"
# 截取过长消息
content = msg.content[:200] if len(msg.content) > 200 else msg.content
context_parts.append(f"{prefix}: {content}")
return "\n".join(context_parts)
class ConversationalRAG:
"""
多轮对话RAG系统
结合对话历史和检索结果生成回答
"""
def __init__(self, rag_pipeline: RAGPipeline):
self.rag = rag_pipeline
self.conversation = ConversationManager()
self.query_optimizer = QueryOptimizer()
def chat(self, session_id: str, user_message: str) -> str:
"""
处理用户消息,返回回答
Args:
session_id: 会话ID,用于跟踪对话历史
user_message: 用户输入
"""
# 1. 保存用户消息
self.conversation.add_message(session_id, "user", user_message)
# 2. 获取对话上下文
history = self.conversation.get_context(session_id, max_messages=3)
# 3. 优化查询(上下文感知)
optimized_query, sub_queries = self.query_optimizer.optimize(
user_message,
history=[msg.content for msg in self.conversation.sessions[session_id][-3:]]
)
# 4. 检索文档(对每个子查询分别检索)
all_docs = []
seen_ids = set()
for sq in [optimized_query] + sub_queries:
docs = self.rag.hybrid_search(sq, k=3)
for doc in docs:
if doc.doc_id not in seen_ids:
all_docs.append(doc)
seen_ids.add(doc.doc_id)
# 5. 生成回答(带对话上下文)
answer = self._generate_with_context(
user_message,
all_docs[:5], # 最多5个文档
history
)
# 6. 保存助手消息
self.conversation.add_message(session_id, "assistant", answer)
return answer
def _generate_with_context(self, query: str, docs: List, history: str) -> str:
"""
带上下文的生成
"""
# 组装上下文
context = f"对话历史:\n{history}\n\n"
context += "参考文档:\n"
for i, doc in enumerate(docs, 1):
context += f"[{i}] {doc.text}\n\n"
prompt = f"""
你是一个智能助手,请基于对话历史和参考文档回答问题。
要求:
1. 如果问题可以通过对话历史回答,优先使用历史信息
2. 如果需要最新信息,使用参考文档
3. 如果两者都没有相关信息,请明确说明
4. 回答要自然流畅,像正常对话一样
{context}
用户问题:{query}
回答:
"""
llm = OpenAI(temperature=0.3, max_tokens=500)
return llm(prompt)
# 使用示例
if __name__ == "__main__":
# 初始化RAG
rag = RAGPipeline()
# 添加知识库
rag.add_documents([
{
"text": "公司2025年Q3营收8.5亿元,同比增长12.3%",
"metadata": {"source": "财务报告"}
},
{
"text": "2025年10月公司进行了组织架构调整,成立了AI事业部",
"metadata": {"source": "内部公告"}
}
])
# 创建对话式RAG
chat_rag = ConversationalRAG(rag)
session = chat_rag.conversation.create_session()
# 模拟多轮对话
questions = [
"公司最近有什么重大变动吗?",
"那财务表现怎么样?",
"Q3营收具体是多少?"
]
for q in questions:
print(f"\n用户: {q}")
answer = chat_rag.chat(session, q)
print(f"助手: {answer}")方案对比
主流RAG方案对比
| 特性 | 朴素RAG | 进阶RAG(本文方案) | 微调模型 | 知识图谱增强 |
|------|---------|-------------------|----------|-------------|
| 实现复杂度 | 低 | 中 | 高 | 高 |
| 检索精度 | 低 | 高 | N/A | 高(关系准确) |
| 更新成本 | 低(改文档即可) | 低 | 高(需重新训练) | 中(需维护图谱) |
| 可解释性 | 中 | 高(可溯源) | 低(黑盒) | 高 |
| 处理长尾知识 | 好 | 好 | 差(数据稀疏) | 中 |
| 对话连贯性 | 差 | 好(带上下文) | 好 | 中 |
| 适用场景 | 简单QA | 企业知识库 | 特定风格生成 | 关系推理 |
为什么选择进阶RAG?
- 成本效益:不需要昂贵的模型训练,只需更新文档即可更新知识
- 可控性强:可以精确控制信息来源,避免幻觉
- 可扩展性好:支持动态添加新知识,无需重新部署模型
- 透明度高:用户可以查看答案的来源文档
最佳实践与避坑指南
文档处理最佳实践
- 切分策略
- ✅ 按语义边界切分(段落、句子)
- ❌ 固定字符切分(会切断关键信息)
- 建议:chunk_size=512,overlap=128,使用递归切分器
- 向量模型选择
- 中文场景:BAAI/bge-* 系列
- 英文场景:text-embedding-ada-002
- 多语言场景:multilingual-e5-*
- 元数据管理
- 必须包含:来源、时间戳、文档类型
- 建议包含:作者、权限级别、版本号
检索优化技巧
- 混合检索权重:向量检索0.7 + BM25 0.3
- 重排序:使用cross-encoder模型对候选结果重新排序
- 查询重写:补充上下文、扩展缩略词、分解复杂查询
- 多路召回:同时使用向量检索、关键词检索、知识图谱检索
常见坑
- 检索不到关键信息
- 原因:chunk_size过大,关键信息被稀释
- 解决:减小chunk_size,增加重叠度
- 生成内容与文档矛盾
- 原因:LLM忽略上下文,依赖预训练知识
- 解决:使用更强约束的提示词,降低temperature值
- 回答过于冗长
- 原因:上下文包含太多无关信息
- 解决:增加重排序步骤,限制上下文长度
- 多轮对话上下文丢失
- 原因:未正确维护对话历史
- 解决:使用会话管理器,实现上下文感知的查询重写
- 性能问题
- 原因:每次查询都检索整个向量数据库
- 解决:使用HNSW索引、分片存储、缓存热门查询
总结
RAG系统不是简单的"检索+生成"拼接,而是一个需要精心设计的系统工程。从文档处理、检索优化到生成控制,每个环节都会影响最终效果。
回顾本文的核心要点:
- 文档处理是基础:合理的切分策略和元数据管理决定了检索质量的上限
- 混合检索是核心:向量检索+BM25的结合能兼顾语义和关键词匹配
- 查询优化是关键:查询重写、分解、上下文补全能显著提升检索精度
- 对话管理是进阶:维护对话历史,实现上下文感知的问答
延伸思考:RAG的未来方向包括:
- 多模态RAG:不仅能检索文本,还能检索图片、表格、视频
- Agent RAG:让LLM自主决定何时检索、如何检索、检索什么
- 流式RAG:边检索边生成,实现实时知识更新
最后,记住一个原则:好的RAG系统应该让LLM学会"查资料",而不是"背资料"。当你看到你的系统能准确回答"我们公司去年Q3的营收数据在哪个报表里?"时,那种成就感是无与伦比的。