一、问题背景:为什么召回率虚高,答案却答非所问
我们的RAG系统服务于一个包含3.2万份技术文档的内部知识库。初期版本采用了最朴素的架构:bge-large-zh-v1.5做embedding,固定512字符切分chunk,Faiss存储向量,通过余弦相似度召回Top-20后直接拼接给LLM。
上线后用户反馈两极分化:简单问题(“如何重启服务”)表现尚可,但复杂问题(“对比Redis和Memcached在缓存穿透场景下的表现”)经常答非所问。我们追踪了线上日志,发现一个诡异现象:召回文档的相关性评分普遍在0.82以上,但最终答案的BLEU分数只有0.31。
拆解后发现两个致命问题:
1. 固定512字符切分导致大量语义断裂——比如“Redis持久化”的完整段落被截断,召回时只匹配到“Redis”关键词,却丢失了“持久化”上下文。
2. 向量召回对短文本不敏感,top-20结果中往往混入5-6个无关文档,直接污染了LLM的生成上下文。
二、环境与版本基线
我们统一在以下环境中进行实验:
- Python 3.10.12
- langchain 0.1.16(早期版本,后续升级到0.2.x)
- faiss-cpu 1.7.4
- sentence-transformers 2.2.2
- 千问qwen-max API(temperature=0.1)
- 评估集:从CMRC2018中抽取800个问答对,人工标注了标准答案段落
基线配置:
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.embeddings import HuggingFaceEmbeddings
# 基线:固定512字符切分
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=512,
chunk_overlap=50,
separators=["\n\n", "\n", "。", "!", "?", ";", " ", ""]
)
embeddings = HuggingFaceEmbeddings(
model_name="BAAI/bge-large-zh-v1.5",
encode_kwargs={'normalize_embeddings': True} # 注意:bge系列必须加这个
)
三、方案设计:三步渐进式改造
我们决定不一次性推翻重来,而是分三步验证效果:
Step 1:chunk策略调整 —— 从固定字符改为 语义段落感知 + 动态长度。使用中文标点(句号、问号、感叹号)作为硬边界,同时用滑动窗口保证最大长度不超过600。
Step 2:embedding模型切换 —— 将bge-large(1024维)换成bge-m3(1024维,但支持稀疏检索)。这里有个认知误区:bge-m3不是单纯提升向量质量,而是引入了稀疏向量,可以和BM25形成互补。
Step 3:引入rerank重排序 —— 使用bge-reranker-large对召回结果精排。核心思路:向量召回Top-50 → rerank取Top-10 → 拼入LLM。
设计原则:每一步改造必须独立可回滚,并且用同一份评估集验证。
四、核心实现与关键代码
4.1 语义感知的chunk切分器
我们放弃了langchain的默认splitter,改用正则+递归逻辑:
import re
from typing import List
def semantic_chunk_split(text: str, max_chunk: int = 600, min_chunk: int = 150) -> List[str]:
"""
按语义边界切分:优先保持段落完整,其次按句号切分。
如果单段超过max_chunk,则按句号递归切分。
"""
# 先按段落切分(双换行或制表符)
paragraphs = re.split(r'\n\s*\n', text)
chunks = []
for para in paragraphs:
para = para.strip()
if not para:
continue
# 如果段落本身很短,直接作为chunk
if len(para) = min_chunk:
chunks.append(para)
elif len(para) List[str]:
pairs = [[query, doc] for doc in docs]
scores = reranker.compute_score(pairs, normalize=True) # 返回0-1的分数
# 按分数降序排列,取top_k
sorted_idx = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:top_k]
return [docs[i] for i in sorted_idx]
五、踩坑记录:三个隐蔽的坑
坑1:bge-m3的稀疏权重必须归一化
第一次混合检索时,我们直接把稀疏分数和稠密分数相加,结果稀疏分数完全主导(因为数值范围在0-20,稠密相似度只有0.7左右)。后来发现bge-m3官方建议对稀疏权重做L2归一化,否则必须手动min-max归一化。
坑2:chunk切分导致的内存爆炸
语义切分后,chunk数量从原来的1.1万暴增到1.8万。faiss的index尺寸没变,但构建索引时间从40秒涨到2分钟。后来发现是长段落递归切分时产生的短chunk碎片太多(比如“。”单独一个chunk),增加了一个min_chunk=150的过滤条件,过滤掉无效碎片后chunk数量稳定在1.5万。
坑3:rerank模型对长文本的截断
bge-reranker-large的最大输入长度是512 token。当query和文档拼接后超长时,模型直接截断尾部,导致重排序效果下降。我们临时方案是:先按字符数粗过滤(>500字符的文档直接不参与rerank),但后来发现更好的做法是使用bge-reranker-v2-m3,它支持8192长度。
六、效果数据与对比
我们采用四个指标:Top-20召回率(标准答案是否在召回结果中)、最终答案准确率(人工评分,1-5分)、端到端延迟、chunk数量。
| 阶段 | Top-20召回率 | 准确率(3分以上占比) | P95延迟 | chunk数量 |
|---|---|---|---|---|
| 基线(bge-large+固定512) | 68.2% | 41.3% | 1.2s | 11,230 |
| +语义chunk切分 | 74.5% | 46.8% | 1.1s | 15,478 |
| +bge-m3混合检索 | 82.1% | 53.2% | 1.4s | 15,478 |
| +bge-reranker-large | 91.3% | 67.8% | 2.8s | 15,478 |
关键发现:
- 单看召回率,embeding切换只提升了约8%,但rerank后召回率跳升9.2%。原因在于:bge-m3的混合检索解决了“同义词”问题,而rerank解决了“语义匹配但不相关”的问题。
- 延迟从1.2s涨到2.8s,主要开销在rerank阶段。我们后来把向量召回从50压缩到30,rerank的top_k从10降到8,延迟降到2.1s,准确率仅损失2%。
- 最终线上我们保留了语义chunk + bge-m3混合检索 + rerank,但将rerank改为异步模式——先返回Top-5结果给用户,后台悄悄rerank全部结果用于日志分析。
七、总结与建议
这次优化让我深刻体会到:RAG的瓶颈往往不在模型,而在数据预处理。chunk切分方式直接决定了检索的上限,embedding模型只是在这个上限内做筛选,rerank则是最后的兜底。对于生产环境:
- 如果预算有限,优先做chunk优化(成本最低,效果提升明显)
- 不要盲目追求大模型,bge-m3的混合检索能力远超bge-large,但需要配套归一化处理
- rerank不是必须的,但如果你的文档库超过1万篇,建议引入。它可以有效过滤“语义相似但实际无关”的噪声。
目前我们正在实验Late Chunking方案(先切分再合并向量),以及尝试用小模型蒸馏rerank来降低延迟。如果效果稳定,后续会再写一篇对比文章。