一、问题背景:一个"能用但不好用"的RAG系统
我们内部有一个面向客服的知识库问答系统,底层是典型的RAG架构:用户提问 → 向量检索Top-K文档片段 → 拼进Prompt → LLM生成答案。LLM用的是GPT-3.5-turbo,向量库是Milvus 2.3.1,embedding用的是OpenAI的text-embedding-ada-002。
上线三个月,运营那边反馈:"答案经常答非所问,或者只答对一半。"
我拉了一批真实query做人工评测(200条,覆盖产品咨询、故障排查、退款政策三类),结果如下:
| 指标 | 数值 |
|---|---|
| Top-5召回率 | 0.63 |
| 答案准确率(人工判定) | 0.58 |
| P95响应时间 | 1.9s |
也就是说,近四成的query,正确文档根本没被检索出来。LLM再强也救不了。问题出在检索侧,不是生成侧。
这篇文章就是记录我怎么一步步把它从0.63拉到0.89的。
二、环境与版本
先把环境列清楚,避免有人说"你版本不一样":
- Python 3.10.13
- Milvus 2.3.1(standalone部署)
- sentence-transformers 2.2.2
- FlagEmbedding 1.2.5(用于bge-reranker)
- OpenAI API(text-embedding-ada-002,用于基线对比)
- LLM:gpt-3.5-turbo-0613
- 服务器:单卡A10 24G
三、方案设计:三个改动点
我没有一上来就换模型,而是按"成本从低到高"的顺序排查。最终确定的三个改动:
- Chunk策略:从固定512字符切分,改为基于标点+语义的递归分块,chunk大小256,overlap 50。
- Embedding模型:从text-embedding-ada-002切换到bge-large-zh-v1.5(中文场景,本地部署,零API成本)。
- Rerank:检索Top-20后,用bge-reranker-large做cross-encoder重排,取Top-5。
每个改动我都单独做了A/B,下面逐个说。
四、核心实现
4.1 Chunk策略调整
原来的切分代码很粗暴:
# 旧方案:固定长度切分
def split_fixed(text, size=512):
return [text[i:i+size] for i in range(0, len(text), size)]
问题是:一句话被拦腰截断,语义破碎。检索时向量表达的是"半句话",自然不准。
改成递归分块,优先按段落、句子切,保证语义完整:
# 新方案:递归语义分块
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=256,
chunk_overlap=50,
separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""],
length_function=len,
)
def split_semantic(text):
return splitter.split_text(text)
注意两点:
- chunk_size从512降到256。中文信息密度高,256字符已经能表达一个完整语义单元,太大反而稀释向量。
- separators里中文标点必须放前面。默认的LangChain分隔符是英文的,中文文档会退化成按字符切。
这一步单独做完,Top-5召回率从0.63 → 0.71。
4.2 Embedding模型切换
text-embedding-ada-002是通用模型,中文语义捕捉一般,而且每次调用都要钱、有网络延迟。
换成bge-large-zh-v1.5,本地部署:
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("BAAI/bge-large-zh-v1.5")
model.max_seq_length = 512
def embed(texts, is_query=False):
# bge模型检索时query需要加指令前缀
if is_query:
texts = [f"为这个句子生成表示以用于检索相关文章:{t}" for t in texts]
return model.encode(texts, normalize_embeddings=True, batch_size=32)
关键坑:bge系列做检索时,query必须加指令前缀,passage不用加。不加前缀,召回率会掉5个点左右。这个细节官方文档写了,但很容易被忽略。
另外normalize_embeddings=True必须开,配合Milvus的IP(内积)度量。
Milvus collection的索引参数:
index_params = {
"metric_type": "IP",
"index_type": "HNSW",
"params": {"M": 16, "efConstruction": 200}
}
search_params = {"metric_type": "IP", "params": {"ef": 128}}
换完embedding,Top-5召回率 0.71 → 0.82。
4.3 引入Rerank
向量检索是双塔(bi-encoder),query和doc分别编码,快但精度有限。Rerank是cross-encoder,query和doc拼一起过模型,精度高但慢。
所以策略是:向量检索粗召回Top-20,rerank精排取Top-5。
from FlagEmbedding import FlagReranker
reranker = FlagReranker("BAAI/bge-reranker-large", use_fp16=True)
def rerank(query, candidates, top_k=5):
# candidates: List[str]
pairs = [[query, doc] for doc in candidates]
scores = reranker.compute_score(pairs, normalize=True)
ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
return [doc for doc, _ in ranked[:top_k]]
use_fp16=True在A10上能省一半显存,速度也快,精度几乎无损。
Rerank引入后,Top-5召回率 0.82 → 0.89。代价是每条query多180ms(20个候选,A10上batch推理)。
五、踩坑与优化
坑1:bge query前缀。 上面说了,不加前缀掉5个点。我一开始以为模型加载错了,debug了两小时。
坑2:Milvus的IP vs L2。 bge输出已经normalize了,用IP等价于cosine。我一开始用L2,分数排序反了,召回一塌糊涂。
坑3:rerank的batch size。 一开始逐条推理,20个候选要1.2s。改成一次传20个pair,降到180ms。FlagReranker的compute_score支持list输入,别写for循环。
坑4:chunk overlap太大导致重复。 overlap设100时,相邻chunk重复内容多,rerank后Top-5里出现3条几乎一样的,浪费上下文窗口。降到50后正常。
坑5:LLM上下文超长。 换256 chunk后,Top-5总长度反而更短了,因为每条更精炼。Prompt从平均3200 token降到2100 token,API成本还降了。
六、效果数据
最终对比(200条评测集,人工标注):
| 阶段 | Top-5召回率 | 答案准确率 | P95延迟 |
|---|---|---|---|
| 基线(512固定+ada-002) | 0.63 | 0.58 | 1.9s |
| +语义分块 | 0.71 | 0.65 | 1.8s |
| +bge-large-zh | 0.82 | 0.76 | 1.6s |
| +bge-reranker | 0.89 | 0.85 | 1.78s |
分场景看,故障排查类提升最明显(0.55 → 0.88),因为这类query专业术语多,ada-002抓不住,bge中文模型优势大。退款政策类提升最小(0.78 → 0.91),因为原文本身结构清晰。
成本侧:embedding从API改为本地,每月省约$120;rerank增加GPU占用,但A10本来就有余量。整体是降本增效。
七、总结
这次优化给我的几个结论:
- RAG的瓶颈90%在检索,不在生成。 别急着换GPT-4,先把召回率拉上去。
- 中文场景,bge系列确实比ada-002强。 尤其是专业术语多的领域,本地部署还省钱。
- Rerank是性价比最高的精度提升手段。 180ms换7个点召回,值。
- Chunk策略是最容易被忽视的环节。 很多人直接默认512,中文场景256更合适,且分隔符一定要配中文标点。
- 细节决定成败。 query前缀、normalize、度量类型,任何一个错了都白干。
下一步我打算试试bge-m3(支持多向量+稀疏),以及用LLM做query改写,看能不能把0.89再往上推。有进展再写一篇。
代码都在上面了,环境版本也给了,直接能跑。有问题评论区聊。