1. 问题背景:答案“看起来对”,但总差一口气

上个月我们内部知识库问答机器人上线,基于LangChain + OpenAI接口搭的RAG流程。业务方反馈“能用,但经常答非所问”,尤其在查技术手册和故障处理文档时,经常只召回无关段落。

我拉了一周日志,抽样200条query人工评估,发现两个致命问题:
1. 召回缺失:答案引用的原文片段,有40%以上不是用户问题的真正答案所在段落。
2. 语义漂移:检索返回的chunk文本与用户问题在字面上高度重合,但语义上完全偏了。

当时的管线是:文档切分(固定512字符) → text2vec-large-chinese embedding → FAISS向量检索(返回top5) → LLM生成。没有任何重排,也没有对chunk做结构化处理。

2. 环境与版本:锁定基线

所有实验均在以下环境进行,避免版本干扰:

组件 版本/参数
Python 3.10.12
langchain 0.1.16
faiss-cpu 1.8.0
sentence-transformers 2.6.1
text2vec-large-chinese 1024维, 来自HuggingFace
bge-m3 1024维, 来自BAAI
bge-reranker-v2-m3 交叉编码器
LLM gpt-4o-mini (仅用于生成,不参与检索)
测试集 2000条QA对,来自内部运维手册

评估指标用Hit Rate@5(正确答案片段是否在top5内)和MRR(倒数排名均值)。

3. 方案设计:三步走,逐步替换

我一开始就没指望一步到位,分三个独立实验,每步跑完整评估:

  1. Chunk策略重构:从固定512字符 → 基于Markdown标题的动态切片(保留标题层级作为上下文前缀)。
  2. Embedding模型升级:从text2vec-large(中文特化但语义粒度粗) → bge-m3(多语言、支持长文本、带dense+sparse混合)。
  3. 引入Rerank精排:在FAISS粗排top20基础上,用交叉编码器bge-reranker-v2-m3重排取top5。

每个阶段跑同一份2000条测试集,保证可对比。

4. 核心实现:关键代码与配置

4.1 Chunk重构:按标题切片,保留上下文

我写了一个MarkdownHeaderSplitter,先按######层级拆分,再把标题路径拼回chunk内容前面。这样每个chunk自带“章节上下文”,检索时即使命中子段落,LLM也能看懂所属主题。

from langchain.text_splitter import MarkdownHeaderTextSplitter

def create_structured_chunks(md_doc: str, max_chunk_size: int = 800):
    headers_to_split_on = [
        ("#", "H1"),
        ("##", "H2"),
        ("###", "H3"),
    ]
    splitter = MarkdownHeaderTextSplitter(
        headers_to_split_on=headers_to_split_on,
        strip_headers=False,
    )
    chunks_with_metadata = splitter.split_text(md_doc)

    final_chunks = []
    for chunk in chunks_with_metadata:
        # 将标题路径拼入content,增强语义
        header_path = " > ".join(
            [f"{k}:{v}" for k, v in chunk.metadata.items()]
        )
        content = f"[{header_path}]\n{chunk.page_content}"
        if len(content) > max_chunk_size:
            # 超长段落再按句号切
            content = content[:max_chunk_size]
        final_chunks.append(content)
    return final_chunks

踩坑:一开始我保留strip_headers=True,结果chunk内容丢失了标题,检索时完全不知道这段属于“故障排查”还是“安装指南”。改回False并手动拼前缀,Hit Rate立刻涨了5个点。

4.2 Embedding切换:bge-m3的dense+sparse混合检索

bge-m3支持同时输出dense向量和稀疏权重。FAISS只能存dense,我额外用BM25存sparse部分,两者分数做加权融合。这是bge-m3对比text2vec最大的优势——能捕捉精确词匹配。

from FlagEmbedding import BGEM3FlagModel

# 加载模型(首次会自动下载)
model = BGEM3FlagModel("BAAI/bge-m3", use_fp16=True)

def encode_texts(texts: list[str]):
    # 输出dense向量 + lexical稀疏权重
    output = model.encode(
        texts, 
        return_dense=True, 
        return_sparse=True,
        max_length=2048
    )
    return output["dense_vecs"], output["lexical_weights"]

# 使用时:dense部分存FAISS,sparse部分存BM25索引
# 检索时:dense_score * 0.7 + sparse_score * 0.3 加权取top20

关键参数max_length=2048,因为我们的chunk平均长度在600-900字符,原默认512会截断导致语义丢失。同时,use_fp16在A100上能提速40%。

4.3 Rerank引入:交叉编码器精排

粗排取top20后,用bge-reranker-v2-m3对query与20个chunk计算相关性分数。交叉编码器比bi-encoder(embedding)精度高,但速度慢,所以只对top20精排。

from FlagEmbedding import FlagReranker

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

def rerank_top_k(query: str, candidate_chunks: list[str], top_k: int = 5):
    # 构造 (query, chunk) 对
    pairs = [(query, chunk) for chunk in candidate_chunks]
    scores = reranker.compute_score(pairs, normalize=True)
    # 按分数降序取top_k
    sorted_idx = sorted(
        range(len(scores)), 
        key=lambda i: scores[i], 
        reverse=True
    )[:top_k]
    return [candidate_chunks[i] for i in sorted_idx]

注意normalize=True会把分数映射到0-1,方便设置阈值(我定为0.35,低于此值直接丢弃,避免LLM基于噪声生成)。

5. 踩坑与优化:那些文档没告诉你的

5.1 text2vec的“伪相关”陷阱

text2vec在中文短文本上表现不错,但对内部技术文档(大量中英混排、专有名词如“K8s”“etcd”)效果极差。它会把“Pod重启策略”与“容器启动命令”判为高相似,因为两段都含“容器”和“启动”。bge-m3的sparse检索能捕捉“Pod”和“重启策略”的精确共现,大大缓解了这个问题。

5.2 混合权重不是玄学,要调

我最初dense:sparse权重设为0.5:0.5,Hit Rate反而比纯dense低2%。后来用验证集网格搜索,发现0.7:0.3最优。原因是内部文档中精确术语更重要,但纯sparse会漏掉同义改写(如“重启” vs “restart”)。

5.3 rerank的batch size陷阱

compute_score默认单条处理,2000条测试集要跑很久。必须手动batch:

scores = reranker.compute_score(pairs, batch_size=64, normalize=True)

耗时从12分钟降到3分钟。另外,reranker对超长输入(>512 tokens)会截断,需要先对chunk做截断,我保留前450个字符。

5.4 关于chunk重叠

我试过overlap=100字符,发现对Hit Rate提升微乎其微(+0.8%),但增加了索引体积20%。最终放弃overlap,因为标题切片本身已经保证了语义连贯性。

6. 效果数据:每个阶段的变化一目了然

实验阶段 Hit Rate@5 MRR 平均检索延迟
基线(固定chunk + text2vec + 无rerank) 61.3% 0.42 85ms
+ 标题结构化chunk 68.9% 0.51 88ms
+ 切换bge-m3(dense+sparse混合) 79.6% 0.63 132ms
+ 引入rerank(top20→top5) 89.7% 0.71 310ms

解读
- Chunk重构贡献了7.6个点,说明数据形态比模型更重要。
- Embedding切换贡献了10.7个点,bge-m3的稀疏检索对专业术语命中极其有效。
- Rerank贡献了10.1个点,是性价比最高的步骤——只增加约180ms延迟,却换来了近10个点的提升。

业务方反馈“回答准确率明显提高”,尤其故障排查类问题,引用文档的精确度从“能看懂”变为“直接命中”。

7. 总结:RAG优化的优先级排序

如果重来一遍,我会按这个顺序做:

  1. 先修数据形态(chunk结构化) —— 免费且收益大,别用固定字符切。
  2. 再换embedding模型 —— 优先支持混合检索的(bge-m3、e5-mistral)。
  3. 最后加rerank —— 延迟换精度,值得,但别对全量库rerank,只精排top20。

另外,评估集一定要贴近真实query。我一开始用公开数据集测试,感觉提升不大,换成内部2000条QA对后,差距立刻显现。

最后提一句:RAG没有银弹,但chunk、embedding、rerank这三板斧砍下去,效果肉眼可见。如果你的系统还在用固定长度切分和单一向量检索,强烈建议试试这套组合拳。


版本备注:所有实验代码已放在内部GitLab,使用FlagEmbedding库版本为1.2.8,LangChain版本为0.1.16。如果有同学遇到类似问题,欢迎评论交流,我会补充更多细节。