RAG 系统上线三个月后的 7 个教训
从实战角度总结 RAG 系统开发和优化过程中的 7 个关键教训,包括 Chunk 策略、Hybrid Search、Reranker 等
# RAG 系统上线三个月后的 7 个教训
我们团队的 RAG(检索增强生成)系统从去年 11 月开始上线,到现在跑了三个月。期间经历了三次大版本的迭代,从最开始的"能跑就行"到现在的"勉强能用在生产环境"。这些踩过的坑,值得记录下来,让后来人少走点弯路。
背景
我们的场景是给内部技术文档做问答系统。数据源包括:
技术方案:Python + LangChain + ChromaDB + OpenAI API(后来切换到自部署的 Mixtral 8x7B)。
教训一:Chunk Size 不是越大越好
最开始的版本,我们设置的 chunk_size 是 2000,chunk_overlap 是 200。理由是:"越大包含的信息越完整"。
结果上线后用户反馈:"回答太啰嗦,而且经常偏离主题"。
问题出在哪?我们用的 embedding 模型是 text-embedding-3-small,它的训练数据是按句子和段落组织的。当 chunk 太大时,一个 chunk 里可能包含多个主题,导致:
2. **LLM 的上下文窗口被浪费**。如果检索出 5 个 chunk,每个 2000 tokens,光上下文就占了 10000 tokens,留给"真正相关内容"的空间反而小了。
优化方案:
# 最开始的版本
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=2000,
chunk_overlap=200
)
# 优化后的版本:根据文档类型动态调整
def get_text_splitter(doc_type: str):
if doc_type == 'api_doc':
# API 文档按函数/类分割
return RecursiveCharacterTextSplitter(
chunk_size=800,
chunk_overlap=100,
separators=["\\n## ", "\\n### ", "\\n```", "\\n"]
)
elif doc_type == 'tutorial':
# 教程类文档保持较大的 chunk
return RecursiveCharacterTextSplitter(
chunk_size=1200,
chunk_overlap=150
)
else:
# 默认配置
return RecursiveCharacterTextSplitter(
chunk_size=1000,
chunk_overlap=100
)
做了这个优化后,检索准确率从 62% 提升到了 78%(人工评估 100 个 query)。
教训二:Hybrid Search 真的有用,但要做好权重平衡
最开始我们只用了向量检索(Dense Retrieval),后来加上了 BM25(Sparse Retrieval),做 Hybrid Search。
实现方式:
from langchain.retrievers import EnsembleRetriever
from langchain_community.retrievers import BM25Retriever
from langchain_community.vectorstores import Chroma
# 向量检索器
vectorstore = Chroma.from_documents(docs, embeddings)
vector_retriever = vectorstore.as_retriever(search_kwargs={"k": 10})
# BM25 检索器
bm25_retriever = BM25Retriever.from_documents(docs)
bm25_retriever.k = 10
# Hybrid Search
ensemble_retriever = EnsembleRetriever(
retrievers=[vector_retriever, bm25_retriever],
weights=[0.5, 0.5] # 这里很重要!
)
**坑点:权重不是固定的**。
我们最开始设置的是 [0.7, 0.3](向量检索权重更高),因为"语义理解更重要"。但实际测试发现:
解决方案是做**查询分类**,根据 query 的类型动态调整权重:
def classify_query(query: str) -> str:
# 简单规则:如果包含代码、命令、专业术语,认为是技术查询
if re.search(r'{1,3}.*|sudo|apt|npm|git', query):
return 'technical'
elif len(query.split()) < 5:
return 'keyword'
else:
return 'natural_language'
def get_retriever_weights(query_type: str):
if query_type == 'technical':
return [0.3, 0.7] # BM25 权重更高
elif query_type == 'keyword':
return [0.4, 0.6]
else:
return [0.7, 0.3] # 向量检索权重更高
这个优化让检索准确率又提升了 8%。
教训三:Reranker 是性价比最高的优化
在 Retrieval 和 Generation 之间加一个 Reranker,效果提升明显。
我们用的中是 bge-reranker-large,部署在本地(用 Hugging Face Transformers)。
from transformers import AutoModelForSequenceClassification, AutoTokenizer
import torch
class Reranker:
def __init__(self, model_name='BAAI/bge-reranker-large'):
self.tokenizer = AutoTokenizer.from_pretrained(model_name)
self.model = AutoModelForSequenceClassification.from_pretrained(model_name)
self.model.eval()
def rerank(self, query: str, docs: List[str], top_k: int = 5) -> List[int]:
pairs = [[query, doc] for doc in docs]
with torch.no_grad():
inputs = self.tokenizer(
pairs,
padding=True,
truncation=True,
max_length=512,
return_tensors='pt'
)
scores = self.model(**inputs).logits.squeeze(-1)
# 返回 top_k 的索引
top_indices = torch.topk(scores, top_k).indices.tolist()
return top_indices
**效果**:检索阶段取 20 个 chunk,rerank 后取前 5 个给 LLM。这样既能保证召回率,又能提升 precision。
**成本**:bge-reranker-large 在 CPU 上处理 20 个 chunk 大约需要 200ms,完全可以接受。
教训四:Prompt 工程比换模型更有用
我们最开始用的 prompt 是这样的(直接从 LangChain 的模板复制的):
根据以下上下文回答问题:
{context}
问题:{question}
这个 prompt 的问题:
2. 没有约束回答格式(用户想要列表,它给了段落)
3. 没有利用历史对话(多轮问答体验差)
优化后的 prompt:
你是一个技术文档助手。根据提供的上下文回答问题。
规则:
2. 回答要简洁,用列表格式(如果适用)
3. 如果上下文包含代码示例,一定要包含在回答中
4. 不要重复上下文,直接给答案
上下文:
{context}
当前问题:{question}
历史对话:
{history}
回答:
这个优化让"拒绝回答"的准确率从 45% 提升到了 92%(之前经常瞎编)。
教训五:文档预处理被严重低估了
RAG 的很多问题描述是"检索不准",但根因是"文档质量差"。
我们遇到的文档质量问题:
1. 代码块没有做特殊处理
最开始代码块和普通文本一样被切分,导致:
解决方案:切分前先提取代码块,单独处理:
import re
def extract_code_blocks(markdown_text: str) -> List[dict]:
"""提取 Markdown 中的代码块"""
pattern = r'``(\\w+)?\\n(.*?)``'
blocks = []
for match in re.finditer(pattern, markdown_text, re.DOTALL):
lang = match.group(1) or 'text'
code = match.group(2)
blocks.append({
'lang': lang,
'code': code,
'start': match.start(),
'end': match.end()
})
return blocks
def split_with_code_awareness(text: str, splitter):
"""切分时保护代码块不被截断"""
code_blocks = extract_code_blocks(text)
# 如果代码块很短(< 50 行),把它和上下文放在一起
# 如果很长,单独作为一个 chunk
chunks = []
for block in code_blocks:
if block['code'].count('\\n') < 50:
# 保留在原文位置
pass
else:
# 单独处理
chunks.append(block['code'])
# 非代码部分正常切分
non_code_text = remove_code_blocks(text)
non_code_chunks = splitter.split_text(non_code_text)
return chunks + non_code_chunks
2. 图片和表格丢失
Confluence 文档里有很多图片(架构图、流程图)和表格,直接用 API 拉取后会丢失。
解决方案:
def convert_table_to_markdown(html_table: str) -> str:
"""把 HTML 表格转成 Markdown"""
from bs4 import BeautifulSoup
soup = BeautifulSoup(html_table, 'html.parser')
table = soup.find('table')
# 提取表头
headers = [th.get_text().strip() for th in table.find_all('th')]
markdown = '| ' + ' | '.join(headers) + ' |\\n'
markdown += '| ' + ' | '.join(['---'] * len(headers)) + ' |\\n'
# 提取行
for row in table.find_all('tr'):
cells = [td.get_text().strip() for td in row.find_all('td')]
if cells:
markdown += '| ' + ' | '.join(cells) + ' |\\n'
return markdown
教训六:评估体系要从第一天开始建
我们最开始评估 RAG 系统的方式是"跑几个 case 看看效果",这显然不行。
后来搭建了一个评估 pipeline,核心指标:
2. **答案准确率**(Answer Accuracy):生成的答案是否正确
3. **拒绝率**(Abstain Rate):对于无法回答的问题,系统是否拒绝回答
4. **延迟**(Latency):端到端响应时间
评估数据集:人工标注了 500 个 query,每个 query 标注:
def evaluate_retrieval(query: str, expected_doc_ids: List[str], retriever):
retrieved_docs = retriever.get_relevant_documents(query)
retrieved_ids = [doc.metadata['doc_id'] for doc in retrieved_docs]
# Recall@5
hits = len(set(expected_doc_ids) & set(retrieved_ids[:5]))
recall = hits / len(expected_doc_ids)
# MRR (Mean Reciprocal Rank)
mrr = 0
for i, doc_id in enumerate(retrieved_ids):
if doc_id in expected_doc_ids:
mrr = 1 / (i + 1)
break
return {'recall@5': recall, 'mrr': mrr}
这个评估体系帮我们快速定位了问题。比如我们发现"概念解释"类问题的检索准确率只有 40%,原因是这类问题通常需要跨多个文档才能回答,而我们的 chunk 太小了。
教训七:别忽视负向反馈数据
用户每次点"这个回答没有帮助"时,我们都会记录下 query 和返回的 answer。这些数据是优化系统的金矿。
我们做了一个简单的反馈 UI:
# 在回答后面加上反馈按钮
feedback_ui = """
[👍 有帮助] [👎 没帮助]
{如果用户点"没帮助",展开一个表单}
请选择原因:
"""
收集到 200 多条负向反馈后,我们分析发现:
总结
RAG 系统不是"接个 LLM 就能用"的东西。它更像是一个需要持续优化的产品,而不是一个一次性的技术方案。
如果让我重新开始,我会这样做:
2. **第二周**:建评估体系,标注 200 个测试 case
3. **第三周**:优化文档预处理和 chunk 策略
4. **第四周**:加 hybrid search 和 reranker
5. **后续**:根据负向反馈数据持续迭代
别想着一步到位。RAG 系统的优化是个持续的过程,重要的是先上线,再快速迭代。
VkingAI