一、问题背景:一个"能用但不好用"的RAG

去年底我给公司内部知识库搭了一套RAG问答系统,文档量约1.8万篇,涵盖产品手册、运维SOP、合同模板和会议纪要。第一版跑通得很快,LangChain + FAISS + bge-large-zh-v1.5 + Qwen2.5-7B-Instruct,两周上线。

上线后反馈很典型:

  • 用户问"退款流程要几天到账",召回的却是"退款政策适用范围"这种标题相似但内容不相关的段落;
  • 问"XX接口的限流阈值是多少",正确段落确实在Top10里,但排在Top6,而LLM只拿到Top5,直接答"文档未提及";
  • 长文档(合同类,动辄两三万字)切出来的chunk语义断裂,一个条款被切成三段,每段都缺主语。

我拉了一个1200条的真实query测试集(人工标注gold chunk),基线数据如下:

指标 基线值
Recall@5 0.71
Recall@10 0.83
Top-1 命中率 0.54
端到端答案准确率(人工评估) 62%
P95 延迟 1.9s

问题很清楚:召回质量是瓶颈,不是LLM。于是定了三轮优化:chunk策略 → embedding模型 → rerank。

二、环境与版本

Python 3.11.9
torch 2.3.1 (cu121)
transformers 4.44.2
langchain 0.2.16
langchain-community 0.2.16
faiss-cpu 1.8.0
sentence-transformers 3.0.1
FlagEmbedding 1.2.11
Qwen2.5-7B-Instruct (vllm 0.6.1 部署, tensor-parallel=1, A10 24G)

嵌入与重排模型:

  • 基线:BAAI/bge-large-zh-v1.5(1024维,512 max_length)
  • 切换后:BAAI/bge-m3(1024维,8192 max_length,支持dense/sparse/colbert)
  • 重排:BAAI/bge-reranker-v2-m3(cross-encoder,568M参数)

三、方案设计

整体链路改成三段式:

离线:文档解析 → 语义chunk → bge-m3 dense向量 → FAISS IndexFlatIP
在线:query → 向量召回 Top20 → bge-reranker 重排 → 取Top4 → 拼prompt → Qwen2.5

三个决策点说明一下:

  1. chunk不用固定长度。改为"标题感知 + 递归分隔 + 语义合并":先按 Markdown 标题层级切大块,块内按 \n\n→\n→。 递归切,目标 400 token,重叠 80 token;若某块切完 list[dict]:
    """标题感知 + 递归切分 + 小块合并。token 用字符数近似(中文≈1:1)。"""
    base_splitter = RecursiveCharacterTextSplitter(
    chunk_size=target,
    chunk_overlap=overlap,
    separators=["\n\n", "\n", "。", ";", ",", ""],
    length_function=len,
    )

    1. 按标题切大节,保留 heading 作为元数据前缀

    sections, last_pos, last_title = [], 0, ""
    for m in HEADING_RE.finditer(text):
    if m.start() > last_pos:
    sections.append((last_title, text[last_pos:m.start()]))
    last_title, last_pos = m.group(2).strip(), m.end()
    sections.append((last_title, text[last_pos:]))

    chunks = []
    for title, body in sections:
    if doc_type == "contract":
    # 合同按"第X条"强制边界,避免条款被切断
    parts = [p for p in CLAUSE_RE.split(body) if p and p.strip()]
    units = ["".join(parts[i:i+2]) for i in range(0, len(parts), 2)]
    else:
    units = [body]

    for unit in units:
        for piece in base_splitter.split_text(unit):
            piece = piece.strip()
            if not piece:
                continue
            # 小块向前合并
            if chunks and len(piece)  600 的候选截断到前 600 字符再送 rerank(cross-encoder 对尾部信息不敏感,实测分数变化 < 0.02)。
    

    最终 rerank 环节耗时从 2.1s 降到 0.5s。

坑3:chunk overlap 导致重复召回。 80 token 重叠让相邻 chunk 同时命中,Top4 里出现两段高度重复内容,浪费 context。加了一步简单去重:按 title + 前50字符 做 key,命中则保留 rerank 分数高的。

坑4:prompt 里 chunk 顺序。 早期按 rerank 分数降序拼 prompt,模型容易只盯第一条。改成按原文出现顺序(用 chunk 的 id 排序)拼接,并在每条前加 [文档i],答案准确率涨了约 3 个点——LLM 对"上下文连贯性"比"相关性排序"更敏感。

六、效果数据

同一测试集(1200 query,人工标注 gold chunk + 人工评估答案),三轮对比:

配置 Recall@5 Recall@10 Top-1命中 答案准确率 P95延迟
基线:512定长 + bge-large-zh-v1.5,无rerank 0.71 0.83 0.54 62% 1.9s
+语义chunk(400/80,标题前缀) 0.76 0.86 0.59 68% 1.9s
+切换 bge-m3(dense) 0.81 0.90 0.66 74% 2.0s
+bge-reranker-v2-m3(recall20→top4) 0.89 0.94 0.78 84% 2.6s

几点解读:

  • chunk 优化的收益被低估了。只改切分就 +5 个点 Recall@5,成本几乎为零,应该最先做。
  • bge-m3 的增益主要来自长文档。按文档类型拆分看,合同类 Recall@5 从 0.62 → 0.79,产品手册只从 0.78 → 0.82。符合预期:长文档截断问题被 1024 长度缓解了。
  • rerank 是性价比最高的一步:Recall@10 已有 0.90,reranker 把"在候选里但排不上"的那部分捞了回来,Top-1 命中 +12 个点。代价是 P95 +0.6s,业务方接受。
  • 端到端准确率 84% 仍不是终点,剩余 badcase 里约一半是"文档本身没写",属于知识覆盖问题,不是检索问题。

七、总结

三条经验,按投入产出排序:

  1. 先动 chunk,再动模型。切分策略是零成本优化,固定长度切分在结构化文档上几乎必然吃亏。标题前缀、条款边界、小块合并这三招值得默认开启。
  2. rerank 不是银弹,前提是召回池够好。如果 Recall@20 只有 0.7,reranker 也救不回来。我们的顺序是先把 Recall@10 做到 0.90,再上 rerank,收益才明显。
  3. 延迟要算总账。embedding 换大模型、rerank 加 cross-encoder,每一步都加延迟。我们的做法是给每步设预算(召回 300ms / rerank 600ms / LLM 1.5s),超了就降 recall_k 或截断候选,而不是无脑加算力。

后续计划:把 bge-m3 的 sparse 分支接进来做 hybrid 检索(FAISS 不支持稀疏,考虑 Milvus 或 Qdrant),以及针对"文档没写"的 query 做拒答阈值调优。这两块有进展再写一篇。