一、问题背景:召回率0.63,答非所问是常态
我们做的是一套面向企业内部规章制度的问答系统,语料大概1.2万份文档,切完chunk后约38万条,底层用Milvus 2.3.3做向量检索,LLM用qwen-plus走阿里云百炼。上线两个月后,运营同事反馈最多的一句话是:“它答的条款跟我们问的不是同一件事。”
我拉了一批真实query做评测,选了200条有标准答案的问题,指标定义如下:
- Recall@5:前5条召回里是否包含正确chunk
- MRR:正确chunk排名的倒数均值
- 端到端准确率:人工判断回答是否可接受
第一版基线数据:
| 指标 | 数值 |
|---|---|
| Recall@5 | 0.63 |
| MRR | 0.58 |
| 端到端准确率 | 0.61 |
| 平均响应耗时 | 1.8s |
问题很明确:向量库里明明有正确内容,但就是没排进前5。这不是LLM的问题,是检索链路的问题。我决定从chunk、embedding、rerank三个环节逐个排查。
二、环境与版本
先把环境列清楚,避免大家复现时踩版本坑:
Python 3.10.13
milvus 2.3.3 (standalone, docker)
pymilvus 2.3.4
langchain 0.1.16
langchain-community 0.0.36
FlagEmbedding 1.2.10
transformers 4.39.3
torch 2.1.2 (CUDA 12.1)
sentence-transformers 2.7.0
Embedding服务单独部署在一台A10上,reranker同机,走FastAPI封装。Milvus索引用HNSW,参数M=16, efConstruction=200,检索时ef=128。
三、方案设计:三段式优化路线
我没有一次性全改,而是分三轮,每轮只动一个变量,保证能定位收益来源:
- 第一轮:只改chunk策略,embedding和检索参数不动
- 第二轮:chunk固定后,切换embedding模型
- 第三轮:前两轮固定,引入rerank
每轮结束跑同一套200条评测集,记录三个指标。这样做的代价是慢,但好处是任何一个环节翻车都能立刻回滚。
四、核心实现
4.1 第一轮:chunk策略从固定切分改为语义+递归混合
原来的切分是RecursiveCharacterTextSplitter(chunk_size=512, chunk_overlap=50),按字符硬切。问题在于中文规章里一个条款经常跨段落,512字符经常把“第X条”和它的补充说明切开。
我改成两级策略:先用语义分割按标题层级切出“条”,再用递归切分兜底超长条款。
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_experimental.text_splitter import SemanticChunker
from langchain_community.embeddings import HuggingFaceBgeEmbeddings
# 语义分割用的embedding,注意这里只是切分用,不是最终检索模型
split_embed = HuggingFaceBgeEmbeddings(
model_name="BAAI/bge-small-zh-v1.5",
model_kwargs={"device": "cuda:0"},
encode_kwargs={"normalize_embeddings": True},
)
semantic_splitter = SemanticChunker(
embeddings=split_embed,
breakpoint_threshold_type="percentile",
breakpoint_threshold_amount=92, # 试了90/92/95,92最稳
)
recursive_splitter = RecursiveCharacterTextSplitter(
chunk_size=800,
chunk_overlap=120,
separators=["\n第", "\n\n", "\n", "。", ";", ""],
)
def hybrid_chunk(text: str):
# 第一级:语义分割
coarse = semantic_splitter.split_text(text)
final = []
for c in coarse:
if len(c) <= 900:
final.append(c)
else:
# 第二级:超长块递归切
final.extend(recursive_splitter.split_text(c))
return final
这里有个坑:SemanticChunker默认按句子算embedding,中文句号分句如果语料里句号不规范(比如全角半角混用),会切得很碎。我在预处理里统一了标点,并把breakpoint_threshold_amount从默认的95调到92,因为95对中文太苛刻,一个条款内部会被切出好几个断点。
这一轮跑完:
| 指标 | 基线 | 第一轮后 |
|---|---|---|
| Recall@5 | 0.63 | 0.74 |
| MRR | 0.58 | 0.66 |
| 端到端准确率 | 0.61 | 0.70 |
chunk数从38万涨到约52万,Milvus存储和检索延迟略增,平均响应从1.8s到2.0s,可以接受。
4.2 第二轮:embedding从ada-002切到bge-large-zh-v1.5
原来用的text-embedding-ada-002,1536维,走API,有网络延迟和成本,而且中文语义上确实不如专门的中文模型。我换成BAAI/bge-large-zh-v1.5,1024维,本地部署。
bge系列有个关键点:query侧要加指令前缀,passage侧不加。官方推荐query前缀是"为这个句子生成表示以用于检索相关文章:"。很多人漏了这步,效果会掉一大截。
from FlagEmbedding import FlagModel
model = FlagModel(
"BAAI/bge-large-zh-v1.5",
query_instruction_for_retrieval="为这个句子生成表示以用于检索相关文章:",
use_fp16=True,
devices="cuda:0",
)
def encode_passages(texts):
# passage不加指令前缀
return model.encode(texts, batch_size=64, max_length=512)
def encode_query(q):
return model.encode_queries([q], max_length=128)[0]
Milvus的collection要重建,维度从1536改成1024,索引参数不变。重建38万→52万条数据,A10上batch=64跑了大概22分钟。
这一轮效果:
| 指标 | 第一轮后 | 第二轮后 |
|---|---|---|
| Recall@5 | 0.74 | 0.81 |
| MRR | 0.66 | 0.71 |
| 端到端准确率 | 0.70 | 0.78 |
顺带把embedding成本从每月约300元API费降到了0,延迟反而从平均180ms降到约35ms(本地A10,batch=1)。
踩坑提醒:FlagModel的max_length默认512,但bge-large-zh-v1.5最大支持512,如果你的chunk经常超过512 token,会被截断。我第一轮chunk最大到900字符,中文大概对应450-500 token,勉强没超,但如果你的chunk更大,建议换bge-m3或调小chunk。
4.3 第三轮:引入bge-reranker-large二阶段重排
前两轮把正确内容“召回来”了,但排名还不够靠前,MRR只有0.71,意味着正确chunk平均排在第2-3位,LLM看到的context里混了不少噪声。引入cross-encoder做精排是性价比最高的一步。
检索流程改成:Milvus召回top-20 → reranker精排 → 取top-3喂给LLM。
from FlagEmbedding import FlagReranker
reranker = FlagReranker(
"BAAI/bge-reranker-large",
use_fp16=True,
devices="cuda:0",
)
def retrieve_and_rerank(query, top_k_recall=20, top_k_final=3):
q_vec = encode_query(query)
# Milvus召回
hits = milvus_client.search(
collection_name="kb_chunks",
data=[q_vec],
limit=top_k_recall,
output_fields=["text", "doc_id"],
param={"metric_type": "IP", "params": {"ef": 128}},
)[0]
candidates = [(h.entity.get("text"), h.distance) for h in hits]
# 重排
pairs = [[query, c[0]] for c in candidates]
scores = reranker.compute_score(pairs, normalize=True)
ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
return [r[0][0] for r in ranked[:top_k_final]]
参数上我调过top_k_recall,从10试到50:
| top_k_recall | Recall@5 | MRR | 平均耗时 |
|---|---|---|---|
| 10 | 0.79 | 0.76 | 2.1s |
| 20 | 0.81 | 0.79 | 2.4s |
| 30 | 0.81 | 0.79 | 2.9s |
| 50 | 0.82 | 0.80 | 3.6s |
20是拐点,再往上收益极小,但reranker耗时线性增长。最终定20。
第三轮总效果:
| 指标 | 基线 | 三轮后 |
|---|---|---|
| Recall@5 | 0.63 | 0.81 |
| MRR | 0.58 | 0.79 |
| 端到端准确率 | 0.61 | 0.89 |
| 平均响应耗时 | 1.8s | 2.4s |
端到端准确率从0.61到0.89,提升最明显的是MRR,因为reranker本质就是在做精排。耗时增加了0.6s,主要是reranker对20个pair做推理,A10上fp16大概400ms。
五、踩坑与优化
坑1:reranker的normalize参数。compute_score默认不归一化,输出是logits,量纲很大,容易在阈值判断时翻车。我加了normalize=True,输出0-1之间,方便后续做“低于0.3就拒答”的逻辑。
坑2:Milvus的IP和L2。bge系列输出是归一化向量,用内积(IP)等价于余弦相似度。如果你从ada-002切过来忘了改metric_type,排序会完全错乱,这个坑我踩了半天。
坑3:chunk变大后LLM context超限。top-3的chunk最大可能到900字符×3=2700字符,加上prompt大概3500 token,qwen-plus完全够。但如果你的LLM context只有4k,建议top_k_final降到2,或者对chunk再做压缩。
坑4:reranker和embedding抢GPU。两个模型都在A10上,显存占用:bge-large-zh-v1.5约1.3GB,bge-reranker-large约2.2GB,加上中间激活,峰值到8GB左右。A10 24GB够用,但如果你用3090 24GB同时跑LLM就紧张了,建议reranker单独一张卡或者用ONNX量化版。
六、效果数据汇总
三轮对比一览:
| 阶段 | chunk策略 | embedding | rerank | Recall@5 | MRR | 端到端准确率 |
|---|---|---|---|---|---|---|
| 基线 | 固定512 | ada-002 | 无 | 0.63 | 0.58 | 0.61 |
| 第一轮 | 语义+递归 | ada-002 | 无 | 0.74 | 0.66 | 0.70 |
| 第二轮 | 语义+递归 | bge-large-zh | 无 | 0.81 | 0.71 | 0.78 |
| 第三轮 | 语义+递归 | bge-large-zh | bge-reranker-large | 0.81 | 0.79 | 0.89 |
注意第三轮Recall@5没变,因为召回阶段没动,变的是MRR和端到端准确率。这也说明一个道理:召回决定上限,rerank决定你能不能接近上限。
七、总结
这次优化给我最大的感受是,RAG系统的收益分布很不均匀。chunk和embedding是基础,改对了能拿到70%的收益;rerank是放大器,在前两步做好的前提下收益巨大,但如果召回本身就很差,reranker也救不回来。
几个可复用的结论:
- 中文场景下,bge-large-zh-v1.5 + 指令前缀,性价比远高于ada-002
- chunk不要迷信固定长度,语义分割+递归兜底对规章类文档特别有效
- reranker的top_k_recall选20是大多数场景的甜点区,再往上边际收益递减
- 每轮只改一个变量,不然出了问题根本不知道是哪一步的锅
如果你们也在做RAG优化,建议先跑评测集再动手,别凭感觉调参。我见过太多团队上来就换模型,结果chunk切得稀碎,换什么模型都白搭。