1. 问题背景:为什么基线RAG系统“看起来聪明,实际拉胯”
我们的业务是一个医疗领域的智能问答系统,知识库为2000+篇经过清洗的医学指南PDF。基线版本上线后,用户反馈“答非所问”的情况非常普遍。
我分析了100条错误样本,发现主要问题集中在三类:
1. 检索召回不精准:用户问“高血压患者能否服用布洛芬”,系统返回的是“高血压饮食建议”之类的片段——相关性命中太差。
2. 上下文碎片化:答案拼接了多个不连贯的chunk,生成的回答逻辑混乱。
3. 答案深度不足:即使检索到了正确的段落,由于chunk过长,关键信息被无关内容稀释,LLM提取不到重点。
基线配置如下:
- 向量库:FAISS(IndexFlatIP)
- Embedding:shibing624/text2vec-base-chinese(768维)
- chunk策略:固定大小512字符,无重叠
- LLM:ChatGLM3-6B
- 评估指标:BERT-F1(答案片段级匹配)
基线测试集(200条人工标注问答对)结果:准确率61.3%,召回率70.5%。
2. 环境与版本:锁定依赖,避免“玄学”问题
先把版本固定下来,RAG调优最怕底层库悄悄升级导致结果不可复现。
langchain==0.1.0
langchain-community==0.0.10
faiss-cpu==1.7.4
sentence-transformers==2.2.2
FlagEmbedding==1.2.8
torch==2.1.0
transformers==4.36.2
注意:FlagEmbedding和sentence-transformers在加载BGE系列模型时存在兼容性坑,后面会详细说。
3. 方案设计:三步走策略
我决定分三步优化,每一步都单独做A/B测试,避免多个变量同时变化导致无法归因。
- chunk策略调整:从固定512字符改为256字符+32字符重叠,同时增加标题和段落分隔符的感知。
- Embedding模型切换:从
text2vec-base-chinese换成bge-large-zh-v1.5(1024维),原因是BGE在中文语义匹配上更优,且支持长文本(512 token)。 - 引入Rerank:在向量检索Top50的基础上,用
bge-reranker-base做二次排序,取Top5喂给LLM。
整体流程变为:
Query -> Embedding -> FAISS召回Top50 -> BGE Rerank取Top5 -> LLM生成
4. 核心实现:关键代码与踩坑记录
4.1 chunk策略调整:从“一刀切”到“结构感知”
最初用的是RecursiveCharacterTextSplitter,但发现它会把同一个医学句子拆成两半。我改用MarkdownHeaderTextSplitter+RecursiveCharacterTextSplitter的组合,先按##标题切分,再对每个大节内部做滑动窗口。
from langchain.text_splitter import MarkdownHeaderTextSplitter, RecursiveCharacterTextSplitter
# 先按标题切分,保留结构化信息
headers_to_split_on = [
("#", "H1"),
("##", "H2"),
("###", "H3"),
]
markdown_splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers_to_split_on)
sections = markdown_splitter.split_text(markdown_document)
# 再对每个section做滑动窗口切分
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=256, # 从512降到256
chunk_overlap=32, # 增加32字符重叠
separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""] # 中文标点优先
)
chunks = []
for section in sections:
sub_chunks = text_splitter.split_text(section.page_content)
for sub in sub_chunks:
chunks.append({
"content": sub,
"metadata": {"header": section.metadata}
})
踩坑记录:MarkdownHeaderTextSplitter只认#开头的行,如果PDF解析出来的markdown格式不规范(比如标题后没有换行),会导致切分失效。我的解决办法是在PDF转markdown后先做一次正则清洗:re.sub(r'\n(?=#{1,3}\s)', '\n\n', text)。
效果对比:chunk_size=256比512的召回率反而提升了5.2%,原因是短chunk的向量表达更聚焦,检索时和query的语义距离更近。但同时带来了chunk数量翻倍,FAISS索引大小从原本的80MB增加到160MB,查询耗时从15ms增加到28ms。
4.2 Embedding模型切换:从text2vec到bge-large-zh
切换模型时遇到两个坑:
坑1:sentence-transformers加载bge-large-zh-v1.5时,默认会使用mean池化,但BGE官方推荐使用cls池化,并且要在query前加指令前缀"为这个句子生成表示以用于检索相关文章:"。
from sentence_transformers import SentenceTransformer
import torch
class BGEZhEmbedding:
def __init__(self, model_name="BAAI/bge-large-zh-v1.5"):
self.model = SentenceTransformer(model_name, device="cuda" if torch.cuda.is_available() else "cpu")
# bge系列必须使用cls池化,默认的mean会损失语义
self.model[1].pooling_mode_mean_tokens = False
self.model[1].pooling_mode_cls_token = True
self.query_instruction = "为这个句子生成表示以用于检索相关文章:"
self.max_length = 512
def encode_query(self, text: str):
# query需要加指令前缀,doc不需要
text = self.query_instruction + text
return self.model.encode(text, max_length=self.max_length, normalize_embeddings=True)
def encode_document(self, text: str):
return self.model.encode(text, max_length=self.max_length, normalize_embeddings=True)
坑2:FlagEmbedding和sentence-transformers同时安装时,会互相覆盖torch的ABI版本。我的解决方式是:用FlagEmbedding加载reranker模型,用sentence-transformers加载embedding模型,两者互不干扰。具体做法是分开两个Python进程——检索服务用sentence-transformers,rerank服务用FlagEmbedding,通过HTTP或消息队列通信。
效果对比:
- text2vec-base:维度768,检索Top50命中率(即正确doc在Top50内的比例)为78.1%
- bge-large-zh-v1.5:维度1024,Top50命中率提升至89.4%
但代价是embedding速度从8ms/条降到25ms/条,全量知识库构建索引耗时从3分钟增加到9分钟。对于5000条以内的知识库,这个耗时可以接受。
5. Rerank引入:从“召回”到“精排”的关键一跃
引入Rerank是收益最大的一步。向量检索本质上是“粗排”,它只关注query和doc的语义相似度,但忽略了答案片段在文档中的位置、上下文连贯性等特征。
我用FlagEmbedding的bge-reranker-base(约400MB)来做精排。核心逻辑是:先向量召回Top50,再用reranker对每一对(query, doc)打分,取Top5。
from FlagEmbedding import FlagReranker
reranker = FlagReranker('BAAI/bge-reranker-base', use_fp16=True)
def rerank(query: str, docs: list[str], top_k: int = 5) -> list[str]:
pairs = [[query, doc] for doc in docs]
scores = reranker.compute_score(pairs)
# 按分数降序排序
sorted_idx = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)
return [docs[i] for i in sorted_idx[:top_k]]
关键参数:
- use_fp16=True:显存占用从1.6GB降到800MB,速度提升约30%
- reranker的max_length默认512,如果chunk超过512字符会被截断。我特意把chunk_size限制在256,就是为了保证reranker能看到完整内容。
踩坑记录:reranker对中文标点敏感,如果原文中“高血压”和“血压高”这种同义改写,reranker的分数反而会低于字面匹配。我的应对是:在rerank之前,先对query和doc做一次同义词替换(用synonyms库),比如“高血压”统一替换为“高血压病”。这个操作让最终准确率又提升了2.3%。
6. 效果数据总览:每一步都不白走
| 阶段 | 准确率 | 召回率 | 平均响应时间 | 索引大小 |
|---|---|---|---|---|
| 基线(512 chunk + text2vec) | 61.3% | 70.5% | 1.8s | 80MB |
| 调整chunk(256+重叠) | 66.7% | 75.8% | 2.1s | 160MB |
| 切换bge-large-zh-v1.5 | 74.2% | 83.6% | 2.5s | 320MB |
| 引入rerank(Top50->Top5) | 84.7% | 91.2% | 3.2s | 320MB |
最终效果:准确率提升23.4个百分点,召回率提升20.7个百分点。响应时间增加了1.4秒,但换来的答案质量提升是值得的。另外,我在200条测试集上做了人工评估,用户可接受度从52%提升到88%。
额外惊喜:切换bge模型后,系统对专业术语的鲁棒性明显增强。比如“心梗”和“急性心肌梗死”这种同义表达,在text2vec下基本匹配不到,在bge下能稳定召回。
7. 总结与建议:RAG优化不是玄学
整个优化过程耗时两周,最大的感悟是:RAG系统的性能瓶颈往往不在LLM,而在检索链路。
给后来者的建议:
1. 先调chunk,再换模型,最后加rerank。这个顺序能帮你一步步定位瓶颈。
2. 不要迷信大模型。text2vec-base-chinese在通用域不错,但在垂直领域(医疗、法律)明显不如bge-large-zh-v1.5。
3. rerank是性价比最高的优化手段。如果资源有限,优先加rerank,收益比换embedding模型更明显。
4. 一定要做A/B测试。每次只改一个变量,否则出了问题你根本不知道是哪个改动引起的。
最后,代码和配置都在公司的GitLab上,不方便公开,但核心逻辑和参数都写在上面了。有问题的同学可以在评论区留言,我会尽量回复。