一、问题背景:为什么我的RAG答非所问

去年底我接手了一个内部技术文档问答系统,知识库大约有 1.2 万篇 Markdown 和 PDF 文档,总量约 800MB 纯文本。用户提问后,系统经常出现两种典型失败:

  1. 召回了错误段落:问“如何配置 Kafka 的 acks 参数”,检索出来的却是“Kafka 消费者组重平衡”相关段落。
  2. 答案不完整:明明文档里写了三步操作,模型只答出第一步,因为它只看到被切断的 chunk。

最初方案是业界最常见的“朴素 RAG”:

  • 分块:RecursiveCharacterTextSplitterchunk_size=512chunk_overlap=50
  • Embedding:OpenAI text-embedding-ada-002
  • 向量库:ChromaDB 0.4.24
  • 检索:余弦相似度 Top-5,直接拼进 prompt 给 gpt-3.5-turbo

我搭建了一个包含 200 条人工标注问答对的评测集,用两个指标衡量:

  • Hit Rate@5:正确段落是否出现在前 5 个结果中。
  • Answer Accuracy:人工判断最终答案是否正确(完全正确才算)。

基线数据(2024-03 测得):

指标 数值
Hit Rate@5 61.0%
Answer Accuracy 54.5%
平均检索延迟 180ms
平均端到端延迟 2.4s

61% 的召回率意味着近四成问题根本找不到正确材料,后面 LLM 再强也没用。于是我开始按“分块 → embedding → rerank”的顺序逐层优化。

二、环境与版本

先固定环境,避免版本漂移影响对比:

Python            3.10.13
langchain         0.1.16
chromadb          0.4.24
sentence-transformers 2.7.0
FlagEmbedding     1.2.10
torch             2.2.2 (CUDA 12.1)
openai            1.23.2
rank-bm25         0.2.2

硬件:一台 24GB 显存的 4090 用于本地 embedding 和 rerank,向量库单机部署。

三、方案设计:三层递进优化

我没有一次性全换,而是分层做 A/B,每层单独评估,这样才能知道收益来自哪里。

第一层:分块策略
- 方案 A(基线):512 固定字符 + 50 overlap
- 方案 B:按 Markdown 标题层级 + 语义段落切分,chunk_size 动态在 256~1024 之间
- 方案 C:在 B 基础上给每个 chunk 加上“文档标题 + 所属章节路径”作为上下文前缀

第二层:Embedding 模型
- text-embedding-ada-002(1536 维,闭源 API)
- BGE-M3(1024 维,本地,支持中英混检)
- bge-large-zh-v1.5(1024 维,纯中文)

第三层:Rerank
- 无 rerank:向量 Top-5 直接使用
- 引入 BGE-Reranker-Large:向量召回 Top-20,rerank 后取 Top-5

四、核心实现

4.1 语义分块 + 上下文前缀

固定分块最大的问题是把语义切断。我改用基于 Markdown 标题的层级切分,再对超长段落做二次切分,并给每个 chunk 拼上章节路径。

import re
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.schema import Document

def semantic_chunk(md_text: str, doc_title: str, max_size: int = 1024, min_size: int = 256):
    """按 Markdown 标题层级切分,并附加章节路径前缀"""
    # 按标题切分,保留标题作为上下文
    sections = re.split(r'\n(?=#{1,4}\s)', md_text)
    splitter = RecursiveCharacterTextSplitter(
        chunk_size=max_size,
        chunk_overlap=80,
        separators=["\n\n", "\n", "。", "!", "?", ";", " ", ""],
    )
    chunks = []
    current_path = [doc_title]
    for sec in sections:
        header_match = re.match(r'^(#{1,4})\s+(.*)', sec)
        if header_match:
            level = len(header_match.group(1))
            title = header_match.group(2).strip()
            # 维护章节路径栈
            current_path = current_path[:level - 1] + [title]
        path_str = " > ".join(current_path)
        for piece in splitter.split_text(sec):
            if len(piece) < min_size and chunks:
                # 过短的片段并入前一个 chunk
                chunks[-1] += "\n" + piece
            else:
                chunks.append(f"[{path_str}]\n{piece}")
    return [Document(page_content=c, metadata={"source": doc_title}) for c in chunks]

这一步把 Hit Rate@5 从 61% 拉到 72%。上下文前缀贡献明显,因为很多问题问的是“XX 模块的配置”,而 chunk 本身没出现模块名。

4.2 切换 BGE-M3 + Reranker

M3 支持多粒度、多语言,且本地推理没有 API 限速。关键是要批量编码 + 归一化,否则相似度计算会偏。

from FlagEmbedding import BGEM3FlagModel, FlagReranker
import numpy as np

# 向量模型
embed_model = BGEM3FlagModel('BAAI/bge-m3', use_fp16=True)

def encode(texts, batch_size=16):
    out = embed_model.encode(
        texts,
        batch_size=batch_size,
        max_length=1024,
        return_dense=True,
        return_sparse=False,
        return_colbert_vecs=False,
    )
    dense = out['dense_vecs']
    # 归一化,保证余弦相似度等价于点积
    dense = dense / np.linalg.norm(dense, axis=1, keepdims=True)
    return dense

# 重排序模型
reranker = FlagReranker('BAAI/bge-reranker-large', use_fp16=True)

def retrieve_and_rerank(query, collection, top_k=5, recall_k=20):
    q_vec = encode([query])[0]
    # 向量粗排召回 recall_k 条
    res = collection.query(
        query_embeddings=[q_vec.tolist()],
        n_results=recall_k,
        include=["documents", "metadatas"],
    )
    docs = res['documents'][0]
    # 精排
    pairs = [[query, d] for d in docs]
    scores = reranker.compute_score(pairs, normalize=True)
    ranked = sorted(zip(docs, scores), key=lambda x: x[1], reverse=True)
    return ranked[:top_k]

这里 recall_k=20 是调出来的:再大延迟涨得明显,再小 rerank 没有足够候选。bge-reranker-large 在 4090 上对 20 条候选打分约 90ms。

引入 rerank 后,Hit Rate@5 直接跳到 89%,Answer Accuracy 从 76%(仅换 embedding 后)提升到 82%

五、踩坑与优化

坑 1:embedding 归一化漏了。 BGE-M3 输出未归一化,直接用点积算相似度,导致长文档分数虚高,召回质量反而下降。必须显式 L2 归一化。

坑 2:rerank 的 normalize=True 影响阈值判断。 不归一化时分数范围在 -10~10,归一化后是 0~1。我一开始用固定阈值 0.5 过滤,结果大量正确段落被误删。后来改成不做阈值过滤,只做 Top-K 截断,效果更稳。

坑 3:chunk 太碎导致上下文丢失。 语义分块后有些 chunk 只有 100 多字符,虽然精准但信息不全。解决办法是设置 min_size=256 并向上合并,同时保留章节路径。

坑 4:延迟上升。 引入 rerank 后单次检索延迟从 180ms 涨到约 320ms(其中向量召回 210ms + rerank 90ms + 开销)。为了压延迟,我把 M3 的 max_length 从 8192 降到 1024,并在应用层对 query 做缓存,高频问题命中缓存后端到端降到 1.3s。

坑 5:混合检索。 纯向量对“错误码 0x80070005”这类精确 token 不敏感。我加了 BM25(rank_bm25)做混合召回,用 RRF 融合,Hit Rate 再涨了约 3 个百分点,达到 89%。这部分代码略,核心是 RRF = Σ 1/(60 + rank)

六、效果数据汇总

最终对比(200 条评测集,2024-05 复测):

阶段 Hit Rate@5 Answer Accuracy 检索延迟
基线(512 固定块 + ada-002) 61.0% 54.5% 180ms
+ 语义分块 & 章节前缀 72.0% 63.0% 195ms
+ BGE-M3 embedding 76.5% 70.5% 205ms
+ BGE-Reranker-Large 87.0% 79.5% 300ms
+ BM25 混合召回(RRF) 89.0% 82.0% 320ms

端到端答案准确率从 54.5% 提升到 82%,提升 27.5 个百分点。代价是检索延迟增加约 140ms,但通过缓存和 max_length 调优,用户感知延迟基本可接受。

七、总结

这次优化给我最大的三点体会:

  1. 分块决定上限。embedding 和 rerank 再强,也救不回被切碎的语义。语义分块 + 章节路径前缀是性价比最高的一步,几乎零成本就把召回率提了 11 个点。
  2. rerank 是召回率的放大器。它把“正确段落排在前 20”转化为“排在前 5”,是 Hit Rate@5 从 76% 到 87% 的关键。但要注意候选数 recall_k 和延迟的权衡。
  3. 混合检索补短板。向量擅长语义,BM25 擅长精确匹配,RRF 融合几乎无脑涨点。

如果你的 RAG 系统召回率卡在 60% 左右,建议按“分块 → embedding → rerank → 混合检索”的顺序逐层验证,每层单独评估,别一次性全换,否则出了问题根本不知道是哪一层导致的。