一、问题背景:为什么召回率虚高,答案却答非所问

我们的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则是最后的兜底。对于生产环境:

  1. 如果预算有限,优先做chunk优化(成本最低,效果提升明显)
  2. 不要盲目追求大模型,bge-m3的混合检索能力远超bge-large,但需要配套归一化处理
  3. rerank不是必须的,但如果你的文档库超过1万篇,建议引入。它可以有效过滤“语义相似但实际无关”的噪声。

目前我们正在实验Late Chunking方案(先切分再合并向量),以及尝试用小模型蒸馏rerank来降低延迟。如果效果稳定,后续会再写一篇对比文章。