一、问题背景:为什么召回率只有61%
上个月我们团队上线了一个面向企业合同库的RAG问答系统。上线一周后,用户反馈“查询‘违约金上限’时,模型总回答‘未找到相关条款’”。查看日志发现,系统确实检索到了相关段落,但排名靠后,或者chunk被截断在关键信息之前。
我们统计了100条人工标注的测试集,初始召回率(top-5内包含正确答案的比例)为61.3%,首条命中率仅为28%。问题集中在:
- chunk边界不合理,一个完整的法律条款被切碎
- embedding模型对专业术语区分度不够
- 没有rerank,导致噪声段落挤占了有效结果
于是我们开始了一次“阶梯式”优化,目标是召回率≥85%,首条命中率≥60%。
二、环境与版本
项目环境如下:
- Python 3.10.12
- langchain 0.2.10
- chromadb 0.5.0(向量数据库)
- embedding模型:text2vec-base-chinese → bge-large-zh-v1.5 (BAAI)
- reranker模型:bge-reranker-v2-m3 (BAAI)
- 文档解析:unstructured 0.14.0 (PDF/Word)
- 硬件:1×RTX 4090 (24GB),CPU: AMD EPYC 7K62
所有模型通过 sentence-transformers 加载,推理使用FP16。
三、方案设计:三阶段递进优化
我们将优化拆为三个独立阶段,每个阶段只改一个变量,其他保持不变,以便量化效果。
- 阶段一:chunk策略从“固定字符”改为“语义段落分割+重叠切片”
- 阶段二:embedding模型从text2vec-base切到bge-large-zh-v1.5
- 阶段三:引入bge-reranker-v2-m3进行重排序,rerank top-20 → top-5
评估指标:
- Recall@5:top-5内是否包含正确答案
- Hit@1:首条是否为正确答案
- 平均检索延迟(ms/query)
四、核心实现(含代码)
4.1 语义chunk实现
原始方案使用 RecursiveCharacterTextSplitter 按256字符切分。优化后改用 SemanticChunker(基于句子嵌入相似度合并段落),并设置 breakpoint_threshold 为0.4。
# chunk_optimizer.py
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_experimental.text_splitter import SemanticChunker
from sentence_transformers import SentenceTransformer
# 旧方案:固定字符
old_splitter = RecursiveCharacterTextSplitter(
chunk_size=256,
chunk_overlap=32,
separators=["\n\n", "\n", "。", "!", "?", ","]
)
# 新方案:语义段落分割
embedding_model_for_chunk = SentenceTransformer("BAAI/bge-large-zh-v1.5")
new_splitter = SemanticChunker(
embedding_model_for_chunk,
breakpoint_threshold_type="percentile",
breakpoint_threshold=0.4, # 相似度低于40%的句子视为新段落
min_chunk_size=100,
max_chunk_size=800
)
# 示例:处理一段合同文本
text = "甲方应于合同生效后30日内支付首期款...若逾期超过15日,乙方有权解除合同..."
chunks = new_splitter.split_text(text)
print(f"新方案生成 {len(chunks)} 个chunk,平均长度 {sum(len(c) for c in chunks)//len(chunks)} 字符")
4.2 Embedding模型切换与rerank接入
模型切换后,我们使用 BAAI/bge-large-zh-v1.5 作为默认embedding。reranker在检索的top-20结果上进行二次排序。
# retrieval_with_rerank.py
from sentence_transformers import CrossEncoder
import numpy as np
# 加载embedding模型(已切换)
from langchain_community.embeddings import HuggingFaceBgeEmbeddings
embedder = HuggingFaceBgeEmbeddings(
model_name="BAAI/bge-large-zh-v1.5",
model_kwargs={"device": "cuda"},
encode_kwargs={"normalize_embeddings": True}
)
# 加载reranker
reranker = CrossEncoder(
"BAAI/bge-reranker-v2-m3",
max_length=512,
device="cuda"
)
def retrieve_and_rerank(query, top_k_raw=20, top_k_final=5):
# 1. 向量检索原始top-20
raw_results = vector_store.similarity_search_with_score(query, k=top_k_raw)
docs = [doc[0] for doc in raw_results]
scores_raw = [doc[1] for doc in raw_results]
# 2. rerank二次排序
pairs = [[query, doc.page_content] for doc in docs]
rerank_scores = reranker.predict(pairs)
# 3. 按rerank分数重新排序
sorted_indices = np.argsort(rerank_scores)[::-1][:top_k_final]
final_docs = [docs[i] for i in sorted_indices]
final_scores = [rerank_scores[i] for i in sorted_indices]
return final_docs, final_scores
# 测试
query = "违约金上限不超过合同总金额的多少"
docs, scores = retrieve_and_rerank(query)
for i, doc in enumerate(docs):
print(f"Rank {i+1} (score={scores[i]:.3f}): {doc.page_content[:50]}...")
五、踩坑与优化
5.1 chunk重叠与漏切问题
语义chunk第一次跑完后,发现部分长段落被错误分割成两段(如“违约责任”条款被从中间切开)。我们把 breakpoint_threshold 从0.3调整到0.4,并增加 min_chunk_size=100 避免切出过短片段。
5.2 模型加载显存爆炸
bge-large-zh-v1.5 参数量约1.3B,FP16推理占用约2.8GB显存。如果同时加载embedding和reranker(约2.1GB),总显存约5GB。我们原计划在一张2080Ti(11GB)上跑,后来换成4090确保有余量做batch推理。
关键参数:
- encode_kwargs={"batch_size": 32} 降低峰值显存
- max_length=512 对reranker的输入长度限制,避免OOM
5.3 reranker打分区间不稳定
bge-reranker-v2-m3的打分范围是[-10, +10],不同query的分数分布差异大。我们最终只使用相对排序,不依赖阈值过滤。
六、效果数据对比
| 阶段 | Recall@5 | Hit@1 | 平均检索延迟(ms) | 备注 |
|---|---|---|---|---|
| 初始(固定chunk + text2vec) | 61.3% | 28.0% | 42 | chunk碎片多 |
| 阶段一(语义chunk) | 73.5% | 41.0% | 48 | chunk质量提高,但embedding区分度不足 |
| 阶段二(切换bge-large) | 79.1% | 51.3% | 62 | 向量召回明显改善 |
| 阶段三(加入rerank) | 89.2% | 66.7% | 98 | rerank增加约36ms,但首条命中率提升15% |
延迟变化:总流程从42ms增加到98ms,但用户可接受(<200ms阈值)。
资源消耗:embedding库大小从0.5GB(text2vec)→1.8GB(bge-large),reranker占用2.1GB。
七、总结与建议
- chunk策略是基础:语义chunk比固定字符切分提升12%的Recall@5,这是成本最低的优化。推荐
SemanticChunker配合bge模型做分割。 - embedding模型决定上限:bge-large-zh-v1.5在中文法律文本上的区分度明显优于text2vec-base,切换后Recall@5再提升5.6%。
- rerank是“临门一脚”:对top-20做rerank,在不改变向量库的前提下,Recall@5再提升10.1%,Hit@1提升15.4%。
- 权衡延迟与精度:如果对延迟敏感(<50ms),可跳过rerank;如果追求首条命中率,rerank几乎是必选项。
后续我们计划尝试 Qwen2-7B 作为生成模型,并测试 ColBERT 的延迟优势。欢迎留言交流。
本文所有代码已在Python 3.10 + langchain 0.2.10 + chromadb 0.5.0 环境下验证通过。