一个普遍的问题
RAG几乎是每个AI项目的标配:企业知识库、智能客服、文档问答……都要用RAG。
但大部分人做的RAG是这样的:
# Demo级别的RAG
docs = load_documents("知识库.pdf")
chunks = split_documents(docs, chunk_size=500)
embeddings = embed(chunks)
vector_store.add(chunks, embeddings)
def query(question):
relevant_chunks = vector_store.search(question, top_k=3)
prompt = f"根据以下信息回答问题:\n{relevant_chunks}\n\n问题:{question}"
return llm.generate(prompt)
这个Demo能跑,但上不了生产。
为什么?因为真实场景比Demo复杂得多:
- 文档格式混乱(PDF、Word、PPT、网页混合)
- 知识库有上万条数据
- 用户问题很模糊("帮我查一下那个东西")
- 检索回来的内容不相关
- 模型回答不准确
Demo到生产,差的是工程细节。
RAG的5个关键问题
问题1:文档解析——脏数据进,脏数据出
RAG的第一步是把文档转成文本。但真实世界的文档很脏:
PDF的问题:
- 表格被解析成乱码
- 图片里的文字丢失
- 多栏排版被错误拼接
- 页眉页脚混入正文
Word/PPT的问题:
- 样式丢失(标题、列表、表格)
- 嵌入的图片无法提取
- 宏和复杂格式无法解析
网页的问题:
- 导航栏、广告、页脚混入正文
- 动态内容(JavaScript渲染)抓不到
- 编码问题(乱码)
错误做法:
# 直接用pdfplumber解析,表格会变成乱码
text = pdfplumber.extract_text("document.pdf")
正确做法:
# 根据文档类型选择合适的解析器
def parse_document(file_path: str) -> str:
if file_path.endswith(".pdf"):
# PDF用专业解析器,支持表格识别
return parse_pdf_with_table_detection(file_path)
elif file_path.endswith(".docx"):
# Word用python-docx,保留结构信息
return parse_docx_with_structure(file_path)
elif file_path.startswith("http"):
# 网页用Readability算法提取正文
return parse_webpage_with_readability(file_path)
else:
raise ValueError(f"不支持的格式: {file_path}")
def parse_pdf_with_table_detection(pdf_path: str) -> str:
"""PDF解析:支持表格识别"""
import pdfplumber
text_parts = []
with pdfplumber.open(pdf_path) as pdf:
for page in pdf.pages:
# 提取表格
tables = page.extract_tables()
for table in tables:
# 把表格转成Markdown格式
table_md = convert_table_to_markdown(table)
text_parts.append(table_md)
# 提取文本(排除表格区域)
text = page.extract_text(x_tolerance=3, y_tolerance=3)
text_parts.append(text)
return "\n\n".join(text_parts)
经验: 文档解析质量决定了RAG的上限。花时间在解析上,比调模型更有效。
问题2:文档切分——切错了,检索就废了
RAG的核心是"检索相关片段"。但如果切分策略不对,检索回来的内容是不完整的。
错误做法:
# 固定500字切分,一个句子可能被切断
chunks = split_by_fixed_size(text, chunk_size=500)
问题:
- 一个完整的段落被切成两半
- 表格被切断
- 上下文丢失("这个产品"指的是什么?)
正确做法:
# 按语义切分:先按段落,再按句子,最后按字数限制
def split_by_semantic(text: str, max_chunk_size: int = 500) -> list[str]:
"""按语义切分文档"""
# 第一步:按段落切分
paragraphs = text.split("\n\n")
chunks = []
current_chunk = []
current_size = 0
for para in paragraphs:
para_size = len(para)
# 如果单个段落超过限制,按句子切分
if para_size > max_chunk_size:
sentences = split_by_sentences(para)
for sent in sentences:
if current_size + len(sent) > max_chunk_size:
chunks.append(" ".join(current_chunk))
current_chunk = [sent]
current_size = len(sent)
else:
current_chunk.append(sent)
current_size += len(sent)
else:
if current_size + para_size > max_chunk_size:
chunks.append(" ".join(current_chunk))
current_chunk = [para]
current_size = para_size
else:
current_chunk.append(para)
current_size += para_size
if current_chunk:
chunks.append(" ".join(current_chunk))
return chunks
def split_by_sentences(text: str) -> list[str]:
"""按句子切分,保留标点"""
import re
# 匹配中文句子结尾
sentences = re.split(r'(?<=[。!?.!?])\s+', text)
return [s.strip() for s in sentences if s.strip()]
进阶:重叠切分
为了防止上下文丢失,切分时让相邻chunk有重叠:
def split_with_overlap(text: str, chunk_size: int = 500, overlap: int = 50) -> list[str]:
"""带重叠的切分,保留上下文"""
chunks = []
start = 0
while start < len(text):
end = start + chunk_size
chunk = text[start:end]
chunks.append(chunk)
start = end - overlap # 下一个chunk从当前chunk的末尾回退50字开始
return chunks
经验: 切分策略要根据文档类型调整。表格、代码、对话记录都有特殊的切分方式。
问题3:向量化——Embedding模型选错了,检索就不准
RAG的检索依赖向量相似度。但不同的Embedding模型,效果差距很大。
错误做法:
# 随便选一个Embedding模型
embeddings = openai.embed("text-embedding-ada-002")
问题:
- 英文模型对中文效果差
- 通用模型对专业领域效果差
- 没有针对你的数据微调
正确做法:
1. 选择合适的Embedding模型
# 中文场景推荐的Embedding模型
EMBEDDING_MODELS = {
"bge-large-zh": "智源BGE,中文效果最好",
"m3e-base": "MokaAI,中文效果好,开源",
"text2vec-large-chinese": "shibing624,开源,中文优化",
"openai-text-embedding-3": "OpenAI,多语言,效果好但贵"
}
def choose_embedding_model(language: str = "zh", domain: str = "general") -> str:
"""根据语言和领域选择Embedding模型"""
if language == "zh":
if domain == "legal":
return "bge-large-zh" # 法律领域测试效果好
elif domain == "medical":
return "m3e-base" # 医疗领域测试效果好
else:
return "bge-large-zh" # 通用中文
else:
return "openai-text-embedding-3"
2. 评估Embedding效果
def evaluate_embedding(test_questions: list[dict], embedding_model: str) -> dict:
"""评估Embedding模型的检索效果"""
correct = 0
total = len(test_questions)
for item in test_questions:
question = item["question"]
expected_doc = item["expected_doc"]
# 检索
question_embedding = embed(question, model=embedding_model)
retrieved_docs = vector_store.search(question_embedding, top_k=5)
# 检查是否检索到正确文档
if expected_doc in retrieved_docs:
correct += 1
accuracy = correct / total
return {"accuracy": accuracy, "model": embedding_model}
经验: Embedding模型对最终效果影响很大。花时间测试不同模型,找到最适合你数据的。
问题4:检索策略——只靠向量相似度不够
RAG的检索不能只用向量相似度。因为:
- 用户问题很模糊("那个东西怎么弄")
- 关键词匹配有时候比语义匹配更准
- 需要结合元数据过滤(时间、作者、文档类型)
错误做法:
# 只用向量相似度检索
results = vector_store.search(query_embedding, top_k=3)
正确做法:混合检索(Hybrid Search)
def hybrid_search(query: str, top_k: int = 5) -> list[dict]:
"""混合检索:向量检索 + BM25关键词检索"""
# 1. 向量检索(语义相似度)
query_embedding = embed(query)
vector_results = vector_store.search(query_embedding, top_k=top_k * 2)
# 2. BM25检索(关键词匹配)
bm25_results = bm25_index.search(query, top_k=top_k * 2)
# 3. 融合排序(Reciprocal Rank Fusion)
fused_scores = {}
for rank, doc in enumerate(vector_results):
doc_id = doc["id"]
fused_scores[doc_id] = fused_scores.get(doc_id, 0) + 1.0 / (rank + 1)
for rank, doc in enumerate(bm25_results):
doc_id = doc["id"]
fused_scores[doc_id] = fused_scores.get(doc_id, 0) + 1.0 / (rank + 1)
# 4. 按融合分数排序
sorted_docs = sorted(fused_scores.items(), key=lambda x: x[1], reverse=True)
return [get_doc(doc_id) for doc_id, score in sorted_docs[:top_k]]
进阶:元数据过滤
def search_with_metadata(query: str, filters: dict, top_k: int = 5) -> list[dict]:
"""带元数据过滤的检索"""
query_embedding = embed(query)
# 向量数据库支持元数据过滤
results = vector_store.search(
embedding=query_embedding,
filter={
"document_type": filters.get("doc_type"), # 只检索PDF
"date": {"$gte": "2024-01-01"}, # 只检索2024年后的文档
"author": filters.get("author") # 只检索特定作者
},
top_k=top_k
)
return results
经验: 混合检索比纯向量检索效果好很多,尤其是用户问题很模糊的时候。
问题5:生成质量——检索到了,但模型回答不准
RAG的最后一步是"根据检索到的内容生成回答"。但模型可能:
- 忽略检索到的内容,自己瞎编
- 检索到的内容太多,模型处理不过来
- 检索到的内容互相矛盾
错误做法:
# 直接把检索结果拼到prompt里
prompt = f"根据以下信息回答问题:\n{retrieved_chunks}\n\n问题:{question}"
正确做法:
1. 压缩检索结果
def compress_context(chunks: list[str], max_tokens: int = 2000) -> str:
"""压缩检索结果,避免超过模型上下文限制"""
# 按相关性排序(第一个最相关)
# 逐步添加chunk,直到达到token限制
compressed = []
current_tokens = 0
for chunk in chunks:
chunk_tokens = count_tokens(chunk)
if current_tokens + chunk_tokens > max_tokens:
break
compressed.append(chunk)
current_tokens += chunk_tokens
return "\n\n".join(compressed)
2. 引用来源
def generate_with_citation(question: str, chunks: list[dict]) -> str:
"""生成回答时引用来源"""
# 给每个chunk编号
context_with_ids = "\n\n".join(
f"[来源{i+1}] {chunk['content']}"
for i, chunk in enumerate(chunks)
)
prompt = f"""根据以下信息回答问题。如果信息不足以回答,请说"信息不足"。
回答时请引用来源编号,如[来源1]。
信息:
{context_with_ids}
问题:{question}
回答:"""
response = llm.generate(prompt)
return response
3. 处理矛盾信息
def handle_contradiction(question: str, chunks: list[dict]) -> str:
"""处理矛盾信息"""
# 先让模型判断是否有矛盾
check_prompt = f"""以下信息是否有矛盾?如果有,指出矛盾点。
信息:
{chunks}
是否有矛盾?"""
contradiction_check = llm.generate(check_prompt)
if "有矛盾" in contradiction_check:
# 有矛盾,让模型说明
prompt = f"""以下信息有矛盾,请指出不同观点:
{chunks}
问题:{question}
请列出不同观点,并说明来源。"""
else:
# 无矛盾,正常回答
prompt = f"""根据以下信息回答问题:
{chunks}
问题:{question}"""
return llm.generate(prompt)
经验: 生成质量的关键是prompt设计。明确告诉模型"根据检索到的内容回答",并要求引用来源。
生产级RAG系统架构
把上面的优化整合起来,一个生产级RAG系统的架构:
[文档接入]
↓
[文档解析](PDF/Word/网页,保留结构)
↓
[语义切分](按段落/句子,带重叠)
↓
[向量化](选择合适的Embedding模型)
↓
[存储](向量数据库 + 元数据)
↓
[查询]
↓
[混合检索](向量 + BM25 + 元数据过滤)
↓
[结果压缩](控制token数量)
↓
[生成](带引用,处理矛盾)
↓
[回答]
完整代码:
class ProductionRAG:
"""生产级RAG系统"""
def __init__(self, embedding_model: str = "bge-large-zh"):
self.embedding_model = embedding_model
self.vector_store = VectorStore()
self.bm25_index = BM25Index()
self.llm = LLMClient()
def ingest(self, file_path: str):
"""文档接入"""
# 1. 解析文档
text = parse_document(file_path)
# 2. 语义切分
chunks = split_by_semantic(text, max_chunk_size=500)
# 3. 向量化
embeddings = [embed(chunk, model=self.embedding_model) for chunk in chunks]
# 4. 存储
for i, (chunk, embedding) in enumerate(zip(chunks, embeddings)):
doc_id = f"{file_path}_{i}"
self.vector_store.add(doc_id, chunk, embedding, metadata={
"source": file_path,
"chunk_index": i
})
self.bm25_index.add(doc_id, chunk)
def query(self, question: str, filters: dict = None) -> str:
"""查询"""
# 1. 混合检索
results = self.hybrid_search(question, filters, top_k=5)
# 2. 压缩上下文
context = self.compress_context(results, max_tokens=2000)
# 3. 生成回答(带引用)
answer = self.generate_with_citation(question, results)
return answer
def hybrid_search(self, query: str, filters: dict = None, top_k: int = 5) -> list[dict]:
"""混合检索"""
# 向量检索
query_embedding = embed(query, model=self.embedding_model)
vector_results = self.vector_store.search(query_embedding, filter=filters, top_k=top_k * 2)
# BM25检索
bm25_results = self.bm25_index.search(query, top_k=top_k * 2)
# 融合排序
fused = self.reciprocal_rank_fusion(vector_results, bm25_results)
return fused[:top_k]
def reciprocal_rank_fusion(self, *result_lists, k=60):
"""RRF融合排序"""
scores = {}
for results in result_lists:
for rank, doc in enumerate(results):
doc_id = doc["id"]
scores[doc_id] = scores.get(doc_id, 0) + 1.0 / (rank + k)
sorted_docs = sorted(scores.items(), key=lambda x: x[1], reverse=True)
return [self.get_doc(doc_id) for doc_id, score in sorted_docs]
def compress_context(self, chunks: list[dict], max_tokens: int = 2000) -> str:
"""压缩上下文"""
compressed = []
current_tokens = 0
for chunk in chunks:
chunk_tokens = count_tokens(chunk["content"])
if current_tokens + chunk_tokens > max_tokens:
break
compressed.append(chunk["content"])
current_tokens += chunk_tokens
return "\n\n".join(compressed)
def generate_with_citation(self, question: str, chunks: list[dict]) -> str:
"""带引用的生成"""
context_with_ids = "\n\n".join(
f"[来源{i+1}] {chunk['content']}"
for i, chunk in enumerate(chunks)
)
prompt = f"""根据以下信息回答问题。如果信息不足,请说"信息不足"。
回答时请引用来源编号,如[来源1]。
信息:
{context_with_ids}
问题:{question}
回答:"""
return self.llm.generate(prompt)
总结
RAG从Demo到生产,差的是工程细节:
- 文档解析:根据格式选择解析器,保留结构信息
- 文档切分:按语义切分,带重叠,保留上下文
- 向量化:选择合适的Embedding模型,评估效果
- 检索策略:混合检索(向量+BM25+元数据过滤)
- 生成质量:压缩上下文,带引用,处理矛盾
Demo级别的RAG只是"切块→向量化→检索→生成"。生产级RAG要在每个环节做优化。
RAG的效果,80%取决于工程细节,20%取决于模型。
花时间打磨工程细节,比换更大的模型更有效。