1. 问题背景:为什么我的RAG系统答非所问?
几个月前我接手了一个内部知识库问答系统,基于LangChain + OpenAI兼容接口构建。用户反馈很直接:“问一个具体参数,它给我一堆无关文字”“有时候回答完全不对”。经过简单压测,发现两个核心问题:
- 召回率低:标准问题集的召回率(recall@5)只有62%,意味着近四成相关文档根本没被检索到。
- 排序不准:即使召回了正确文档,top-1准确率仅35%,正确片段往往排在第三、第四位。
当时使用的组件如下:
- 文档切分:RecursiveCharacterTextSplitter,chunk_size=512,chunk_overlap=128
- Embedding模型:text2vec-large-chinese(moka-ai/m3e-base之前的流行选项)
- 向量库:Chroma(单机测试,hnsw:space=cosine)
- 检索参数:similarity_top_k=5
系统版本:Python 3.10.12,LangChain 0.1.16,chromadb 0.4.22。
2. 环境与版本:本次优化使用的技术栈
优化过程分三阶段进行,为了结果可复现,所有测试使用同一台机器(Mac M2 Pro,16GB内存)和同一份测试集(200个问答对,来自技术手册和FAQ)。主要依赖如下:
# 核心依赖版本
langchain==0.2.10
langchain-community==0.2.9
chromadb==0.5.5
sentence-transformers==3.0.1
FlagEmbedding==1.3.0 # 用于BGE模型
torch==2.3.0 # MPS后端(Mac专用)
测试集说明:200个问题中,约30%需要多段落推理(如“A模块的B参数在C场景下的限值是多少”),40%为单段落事实性查询,30%为模糊描述(如“那个连接器有几种规格”)。
3. 方案设计:三步走,先查全再排准
优化策略分三个阶段,每个阶段只改变一个变量:
| 阶段 | 改动点 | 预期目标 |
|---|---|---|
| 1 | chunk策略调整 | 提升recall@5到75%以上 |
| 2 | embedding模型替换 | 提升recall@5到85%+,top-3准确率提升 |
| 3 | 引入rerank | 提升top-1准确率至50%+,不降低召回 |
核心思想:先保证相关文档能被检索到,再保证最相关的结果排在第一位。
4. 核心实现:三阶段代码与关键参数
4.1 阶段一:chunk策略调整
原方案使用固定的512字符切分,遇到长技术文档时,一段关键信息可能被切到两个chunk中,导致任何一个单独chunk都缺乏完整上下文。新方案使用语义感知的递归切分,并针对中文技术文档调整分隔符优先级。
from langchain.text_splitter import RecursiveCharacterTextSplitter
# 新策略:基于句子和段落边界,加大chunk_size允许更多上下文
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=768, # 从512提升到768,适配中文技术文档
chunk_overlap=256, # 重叠从128提升到256,减少边界信息丢失
separators=[
"\n\n", # 段落分隔(最高优先级)
"\n", # 行分隔
"。", # 中文句号(新增)
";", # 中文分号(新增)
",", # 中文逗号
" ", # 空格
"" # 字符级别
],
length_function=len,
)
改动要点:
- 增加chunk_size(512→768),让技术文档中的完整段落尽量不被切断
- 增加中文标点分隔符(句号、分号),使切分更符合中文语义
- 加大重叠(128→256),保证跨chunk的关键信息至少有一个完整版本
4.2 阶段二:embedding模型替换
text2vec-large-chinese(768维)对技术术语的语义理解偏弱,比如“过流保护阈值”和“电流限值”在它的向量空间中距离较远。替换为BAAI的bge-m3模型(1024维),它在多语言和密集检索任务上表现更好。
from langchain_community.embeddings import HuggingFaceBgeEmbeddings
model_name = "BAAI/bge-m3"
model_kwargs = {
'device': 'mps', # Mac M系列GPU加速
'torch_dtype': 'float16' # 半精度减少显存占用
}
encode_kwargs = {
'normalize_embeddings': True,
'batch_size': 32
}
embeddings = HuggingFaceBgeEmbeddings(
model_name=model_name,
model_kwargs=model_kwargs,
encode_kwargs=encode_kwargs,
query_instruction="为这个句子生成表示以用于检索相关文章:", # BGE的query前缀
)
关键参数:
- normalize_embeddings=True:余弦相似度计算前归一化,提高检索稳定性
- query_instruction:BGE模型要求对query添加特定前缀,否则检索效果下降10-15%
- 推理时间:embedding 200条query+850个chunk,bge-m3耗时约8秒(text2vec约5秒),可接受
4.3 阶段三:引入rerank
即使embedding模型再好,向量检索本质上是在“语义近似”空间找最近邻,容易把近义词但非正确答案排在前面。rerank模型通过交叉编码(cross-encoder)对query和每个候选文档进行深度匹配,重新打分排序。
from FlagEmbedding import FlagReranker
# 初始化reranker(加载到MPS)
reranker = FlagReranker(
'BAAI/bge-reranker-v2-m3',
use_fp16=True,
device='mps'
)
def rerank_documents(query: str, documents: list, top_k: int = 3) -> list:
"""
对向量检索结果进行重排序
:param query: 用户问题
:param documents: 向量检索返回的Document列表(score已排序)
:param top_k: 最终返回条数
:return: 重排序后的Document列表
"""
pairs = [[query, doc.page_content] for doc in documents]
scores = reranker.compute_score(pairs, normalize=True) # 返回0-1之间的分数
# 将分数附加到document对象上
scored_docs = []
for doc, score in zip(documents, scores):
doc.metadata['rerank_score'] = score
scored_docs.append((doc, score))
# 按rerank分数降序排列
scored_docs.sort(key=lambda x: x[1], reverse=True)
return [doc for doc, _ in scored_docs[:top_k]]
注意:normalize=True使rerank分数在0-1之间,方便与向量检索的相似度分数做对比或加权融合(本文未融合,直接使用rerank排序)。
5. 踩坑与优化:那些文档没告诉你的细节
踩坑1:chunk_size不是越大越好
我曾尝试chunk_size=1024,认为包含更多上下文。结果recall@5反而下降至73%,因为大chunk包含的信息太杂,embedding向量无法聚焦到问题相关的具体段落。768是当前知识库的最优值。
踩坑2:BGE模型的query指令必须加
不加query_instruction时,bge-m3的召回率仅71%,比text2vec还低。官方文档要求对query使用"为这个句子生成表示以用于检索相关文章:"前缀,对文档不加。我在测试中漏加了query前缀,浪费了半天排查。
踩坑3:rerank的推理延迟
bge-reranker-v2-m3对每个query-document对需要约15ms(MPS下),top-5检索结果rerank一次约75ms。如果QPS要求大于10,需要将rerank改为异步批处理或使用更轻量的reranker(如bge-reranker-v2-small)。我的场景是内部工具,延迟容忍度高,所以直接用full model。
踩坑4:chunk重叠导致的重复答案
chunk_overlap=256后,同一段信息可能出现在两个chunk中。向量检索时两个chunk都被召回,rerank后可能占据top-2。解决方案:在rerank后去重(根据文档hash),保留rerank分数最高的副本。
6. 效果数据:每个阶段提升了什么?
测试集200个问题,评估指标:
- recall@5:top-5结果中是否包含正确答案(正确性由人工标注)
- top-3准确率:正确答案是否出现在top-3中
- top-1准确率:第一个结果是否为正确答案
| 阶段 | recall@5 | top-3准确率 | top-1准确率 | 平均检索延迟 |
|---|---|---|---|---|
| 初始(512+text2vec) | 62% | 69% | 35% | ~120ms |
| 阶段1(chunk优化) | 78% | 81% | 38% | ~110ms |
| 阶段2(bge-m3) | 89% | 86% | 43% | ~135ms |
| 阶段3(+rerank) | 89% | 88% | 58% | ~210ms |
关键发现:
1. chunk优化贡献最大的是recall@5(+16%),对top-1提升有限
2. bge-m3相比text2vec,在语义区分上更优,top-3准确率提升5%
3. rerank是top-1准确率的最大功臣(+15%),且不影响召回率
4. 三阶段总效果:top-1准确率从35%→58%,用户反馈“答非所问”减少了约70%
7. 总结:RAG优化的优先级建议
基于本次实践,我的建议优先级是:
- 先调chunk策略:这是成本最低的优化,只需改几行参数,往往能直接提升召回。中文技术文档一定要加中文标点分隔符。
- 再换embedding模型:从m3e/text2vec升级到bge-m3或gte-large,收益明确,代价是模型加载和推理时间增加。
- 最后加rerank:当召回率足够高(>85%)但top-1不理想时,rerank是终结方案。注意推理延迟和模型选型。
最终系统上线后,用户满意度从62%(调研得分)提升至84%。后续计划引入HyDE(假设文档嵌入)和query重写,针对模糊查询做进一步优化。
代码及测试数据已上传至GitHub(链接见评论区),欢迎Star和PR。有疑问可以在评论区交流,我会尽量回复。