一、为什么召回率卡在61%?先看系统现状

先说背景:我们做的是保险条款问答系统,语料是几十份PDF格式的产品条款和理赔细则,总大小约120MB。用户问“甲状腺结节投保重疾险会被除外吗”,系统需要从知识库中捞出最相关的3-5个段落。

最初的架构很常规:langchain 0.1.0 + FAISS + text2vec-large-chinese + ChatGLM2-6B。chunk策略就是最朴素的按字符切分,chunk_size=256, chunk_overlap=20。当时觉得没问题,直到我们拉出500条真实用户问题做离线评估,结果吓一跳:

  • Hit@3:47.2%
  • Hit@5:61.5%
  • MRR:0.384

也就是说,有将近40%的问题,系统压根没把正确答案捞进前5个文档。再往下游走,LLM再能编也没用——召回阶段决定上限

进一步分析错误案例,发现了三个典型问题:

  1. 语义被切碎:一句话被拦腰切断,比如“等待期内出险”被拆成“等待期内”和“出险”分别进入两个chunk,导致Embedding向量语义漂移。
  2. 长尾专有名词匹配弱:“原位癌”这种词,在老Embedding模型里跟“癌症”的相似度极低,模型根本没学过保险领域语义。
  3. Top5内顺序错乱:正确的chunk其实在Top10里,但被大量泛化文本挤到后面去了。

二、环境与版本:尽量锁定依赖,避免玄学问题

先列一下当前优化的硬件和软件环境,方便你复现:

  • GPU:单卡A100 80G(训练/推理Embedding和Rerank),CPU 32核做chunk预处理
  • Python:3.10.12
  • langchain:0.1.0(后来升到0.1.7,API有微调,注意)
  • faiss-cpu:1.7.4(不涉及训练,CPU版足够)
  • text2vec-large-chinese:由text2vec库加载,底层是transformers 4.36.2
  • bge-large-zh-v1.5FlagEmbedding 1.2.8,维度1024
  • bge-reranker-v2-m3:同样是FlagEmbedding加载,输入max_length=512

这里有一个坑:text2vec库和FlagEmbedding库都依赖transformers,如果同时装,版本冲突会导致模型加载后推理结果随机。我最后是建了两个虚拟环境,一个跑Embedding+检索,一个跑Rerank,通过Redis队列通信。

三、chunk重构:从固定256字到语义段落树

方案设计:直接按字符切分是最偷懒的做法,但保险条款这种结构化文档,存在天然的分隔符——条款编号(第X条)、章节标题、表格。我们的思路是先做结构解析,再做二次切分

具体做法:

  1. pdfplumber提取PDF文本,保留段落结构。
  2. 按“第X条”或三级标题(如“6.2.1”)作为一级边界切分,得到段落级块(平均长度约400字)。
  3. 对超过512字的段落,用句号/分号做边界做二次切分,min_chunk_size=128, max_chunk_size=512,重叠量设为50字(为了保住跨chunk的指代关系)。
  4. 每个chunk保存两层元数据:parent_title(所属条款标题)和chunk_index(段落内序号)。

核心实现代码(chunk切分部分):

import re
from typing import List, Dict

def parse_and_chunk(text: str, max_chunk: int = 512, overlap: int = 50) -> List[Dict]:
    # 先用条款编号切一级块
    parts = re.split(r'(第[一二三四五六七八九十百千]+条)', text)
    chunks = []
    buffer = ""
    for part in parts:
        if re.match(r'第[一二三四五六七八九十百千]+条', part):
            if buffer:
                chunks.append(buffer)
            buffer = part
        else:
            buffer += part
    if buffer:
        chunks.append(buffer)

    # 二级切分:超过max_chunk的段落按句子再切
    final_chunks = []
    for chunk in chunks:
        if len(chunk)  max_chunk:
                    if temp:
                        final_chunks.append({"text": temp, "meta": {"parent": chunk[:30]}})
                    temp = sent
                else:
                    temp += sent
            if temp:
                final_chunks.append({"text": temp, "meta": {"parent": chunk[:30]}})
    return final_chunks

踩坑记录:PDF提取时,表格数据会乱序。比如“等待期180天”在表格里是“等待期 | 180天”,提取后变成“等待期 180天”但列顺序可能颠倒。我最后对表格区域单独处理,用pdfplumberextract_table接口转成Markdown格式再并入chunk,召回率又涨了2个点。

四、Embedding换代:从text2vec到bge-large-zh-v1.5

方案设计text2vec-large-chinese是2022年的老模型,在通用语义上OK,但在垂直领域表现一般。我们对比了三个候选:

  • text2vec-large-chinese(768维)
  • BAAI/bge-large-zh-v1.5(1024维)
  • moka-ai/m3e-large(1024维)

在同样的500条测试集上,先用旧chunk跑了一轮,bge-large-zh-v1.5的Hit@5是68%,比text2vec高6.5个点。但注意,bge官方建议在检索时对query加上指令前缀“为这个句子生成表示以用于检索相关文章:”,不加的话效果会下降3-4个点。

实现代码:

from FlagEmbedding import BGEM3FlagModel

model = BGEM3FlagModel('BAAI/bge-large-zh-v1.5', use_fp16=True)

def embed_texts(texts: List[str], is_query: bool = False) -> List[list]:
    if is_query:
        texts = [f"为这个句子生成表示以用于检索相关文章:{t}" for t in texts]
    embeddings = model.encode(texts, batch_size=32)['dense_vecs']
    return embeddings.tolist()

# 存量文档向量化
doc_embeddings = embed_texts([c["text"] for c in chunks], is_query=False)
# 用户query向量化
query_emb = embed_texts([user_query], is_query=True)[0]

踩坑记录bge-large-zh-v1.5的向量维度是1024,如果你之前用768维的向量建了FAISS索引,不能直接append,必须重建索引。另外,该模型对文本长度敏感,超过512字会自动截断,所以前面chunk的最大长度必须控制在512以内——这也是为什么我们在chunk策略里把max_chunk设为512。

五、Rerank引入:精排模型修正Top20的顺序

方案设计:即使换了更好的Embedding,Top5里依然有噪声。我们的做法是:先召回Top20(粗排),再用Rerank模型精排取Top5。这相当于把Embedding的“快”和Rerank的“准”结合起来。

实现代码:

from FlagEmbedding import FlagReranker

reranker = FlagReranker('BAAI/bge-reranker-v2-m3', use_fp16=True)

def rerank(query: str, candidates: List[str], top_k: int = 5):
    pairs = [[query, cand] for cand in candidates]
    scores = reranker.compute_score(pairs, normalize=True)
    scored = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
    return [cand for cand, score in scored[:top_k]]

注意:bge-reranker-v2-m3是跨编码器,不能把query和文档分开编码,必须组成pair输入。另外,它的max_length是512,超长会截断,但Rerank阶段一般只处理粗排后的Top20,速度可控。

踩坑记录:Rerank模型的分数分布不是0-1均匀分布,而是集中在0.7-0.9之间,不能直接拿分数做阈值过滤。我们最后改成:只做排序,不做过滤。另外,Rerank推理速度比Embedding慢一个数量级,batch_size调到16才能勉强跟上,如果并发高,建议用vLLM部署或异步处理。

六、效果对比:数据说话,别靠感觉

最终我们做了四组消融实验,分别对应不同阶段的优化组合:

配置 Embedding Chunk策略 Rerank Hit@3 Hit@5 MRR
A(基线) text2vec 固定256字 47.2% 61.5% 0.384
B text2vec 语义段落树 53.8% 66.9% 0.431
C bge-large-zh-v1.5 语义段落树 62.4% 74.7% 0.502
D(最终) bge-large-zh-v1.5 语义段落树 bge-reranker-v2-m3 71.6% 89.2% 0.612

从A到D,每一步提升都能归因:

  • chunk重构(A→B):Hit@5提升5.4%,主要解决了语义切碎问题,特别是长条款中的指代词。
  • Embedding换代(B→C):Hit@5提升7.8%,这是最大收益点,垂直领域专用模型优势明显,尤其在“原位癌”“等待期”这类术语上。
  • Rerank引入(C→D):Hit@5提升14.5%,MRR提升0.11,说明Top20里确实存在正确答案,但被噪声淹没,Rerank能有效排掉不相关文本。

七、总结与后续优化方向

这次优化的核心结论其实就三点:

  1. chunk是地基。固定长度切分在高噪声场景下就是灾难,先做结构解析再切,成本低收益高。
  2. Embedding值得换bge-large-zh-v1.5在中文垂直领域确实比老的text2vec强,但记得加指令前缀。
  3. Rerank是兜底。它不能解决“召回没召回”的问题,但能把排序质量拉高一个档次。

目前线上系统已经跑了三周,用户反馈“答非所问”的比例明显下降。后续打算做两件事:一是测试bge-m3多向量模型(支持稀疏+稠密混合检索),二是把Rerank换成更重的Rerank-tiny模型跑线上推理,进一步压缩延迟。如果你也在做RAG优化,建议先从chunk入手,别一上来就调模型——地基不牢,换什么模型都是白搭。