一、问题背景:上线两周,用户投诉不断

我们做的是一套面向内部运维团队的知识库问答系统,文档来源包括Confluence、GitLab Wiki和一些PDF手册,总量约1.2万篇,平均每篇800字。技术栈是LangChain + Chroma + GPT-3.5-turbo,典型的RAG架构。

上线第一周,我就收到了十几条投诉。典型问题包括:

  • 问“Nginx 502排查步骤”,返回的却是“Nginx安装配置”
  • 问“K8s Pod OOM怎么处理”,检索到的片段只包含“OOM”这个词但完全不相关
  • 多轮追问时,上下文经常丢失关键信息

我搭了一个简单的评测集:200个问题,每个问题标注了标准答案和应召回的文档ID。跑下来数据很难看:

指标 数值
Recall@5 62%
MRR 0.48
答案准确率(人工评估) 54%

这个水平根本没法交付。于是开始了为期三周的优化。

二、环境与版本

先交代一下基础环境,避免版本差异导致复现问题:

Python: 3.10.12
langchain: 0.1.16
chromadb: 0.4.24
openai: 1.23.2
sentence-transformers: 2.7.0
torch: 2.2.1 (CUDA 12.1)
FlagEmbedding: 1.2.10

硬件:一台A10 GPU服务器(24G显存),用于本地embedding和rerank推理。LLM继续用GPT-3.5-turbo,这块不是本文优化重点。

三、方案设计:三步走

我没有一次性全改,而是分三步,每步单独评测,这样才能知道哪个改动真正有效:

  1. Chunk策略调整:从固定512字符改为256字符+50重叠,并保留标题层级
  2. Embedding模型切换:text-embedding-ada-002 → bge-large-zh-v1.5
  3. 引入Rerank:bge-reranker-large,对top-20召回结果重排取top-5

评测方法保持一致:同样的200题评测集,同样的LLM,只改检索环节。

四、核心实现

4.1 Chunk策略调整

原来的做法很粗暴:

from langchain.text_splitter import CharacterTextSplitter

splitter = CharacterTextSplitter(
    chunk_size=512,
    chunk_overlap=0,
    separator="\n"
)

问题很明显:512字符对中文来说太长了,一个chunk里可能混了好几个主题,embedding被“平均”掉,语义焦点模糊。而且没有重叠,跨chunk的信息直接被切断。

改成按标题层级切分 + 递归字符切分:

from langchain.text_splitter import RecursiveCharacterTextSplitter, MarkdownHeaderTextSplitter

# 先按Markdown标题切,保留层级信息
headers_to_split_on = [
    ("#", "h1"),
    ("##", "h2"),
    ("###", "h3"),
]
md_splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers_to_split_on)

# 再按字符递归切,控制粒度
char_splitter = RecursiveCharacterTextSplitter(
    chunk_size=256,
    chunk_overlap=50,
    separators=["\n\n", "\n", "。", "!", "?", ";", ",", ""],
    length_function=len,
)

def split_doc(text):
    md_chunks = md_splitter.split_text(text)
    final_chunks = []
    for c in md_chunks:
        # 把标题拼回内容,保证chunk自带上下文
        header = " > ".join([v for k, v in c.metadata.items() if v])
        for sub in char_splitter.split_text(c.page_content):
            final_chunks.append({
                "text": f"{header}\n{sub}" if header else sub,
                "metadata": c.metadata
            })
    return final_chunks

关键点有两个:一是把标题拼进chunk内容,这样embedding时能感知到“这是Nginx下的502排查”;二是overlap=50,保证跨chunk的句子不被硬切。

4.2 Embedding模型切换

原来用OpenAI的text-embedding-ada-002,1536维,英文强但中文一般,而且每次调用都要走网络,成本和延迟都高。换成BGE:

from sentence_transformers import SentenceTransformer
import torch

device = "cuda" if torch.cuda.is_available() else "cpu"
model = SentenceTransformer("BAAI/bge-large-zh-v1.5", device=device)
model.max_seq_length = 512

def embed(texts, is_query=False):
    # BGE检索时query需要加instruction前缀
    if is_query:
        texts = [f"为这个句子生成表示以用于检索相关文章:{t}" for t in texts]
    with torch.no_grad():
        emb = model.encode(
            texts,
            batch_size=32,
            normalize_embeddings=True,  # 归一化后用内积=余弦
            show_progress_bar=False
        )
    return emb

这里有个坑我后面会细说:BGE的query和passage处理方式不一样,query要加instruction前缀,passage不加。我一开始两边都加了,效果反而掉了。

4.3 Rerank引入

Rerank的思路是:先用embedding粗排召回top-20,再用cross-encoder精排。Cross-encoder把query和doc拼在一起过模型,能捕捉细粒度交互,但计算量大,所以只用于小候选集。

from FlagEmbedding import FlagReranker

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

def rerank(query, candidates, top_k=5):
    pairs = [[query, c["text"]] for c in candidates]
    scores = reranker.compute_score(pairs, normalize=True)
    for c, s in zip(candidates, scores):
        c["rerank_score"] = s
    candidates.sort(key=lambda x: x["rerank_score"], reverse=True)
    return candidates[:top_k]

use_fp16=True很关键,A10上fp16推理,20个pair耗时约180ms,可以接受。

五、踩坑与优化

坑1:BGE的instruction前缀。 BGE-large-zh-v1.5的官方说明是query加instruction,passage不加。我一开始图省事两边都加,Recall@5反而从68%掉到64%。改回来后正常。

坑2:Chunk太小导致召回碎片化。 256字符确实提升了语义聚焦,但有些答案需要跨2-3个chunk。解决办法是在最终组装context时,把相邻chunk(通过metadata里的doc_id和chunk_index)合并,最多合并3个,总长度不超过1500字符。

坑3:Rerank的top_k选择。 一开始粗排取top-10,精排取top-3,发现有些问题标准答案排在第4、5位。改成粗排top-20、精排top-5后,Recall@5明显提升。代价是rerank耗时从90ms涨到180ms,但值得。

坑4:Chroma的collection需要重建。 换embedding模型后维度从1536变成1024,必须删库重建。我写了脚本批量重新灌数据,1.2万篇文档约15分钟。

六、效果数据

200题评测集,每步改动的对比:

阶段 Recall@5 MRR 答案准确率 平均检索延迟
基线(512 chunk + ada-002) 62% 0.48 54% 320ms
+ Chunk调整(256+50重叠) 71% 0.56 63% 310ms
+ 切换bge-large-zh-v1.5 83% 0.71 74% 95ms
+ 引入bge-reranker-large 91% 0.84 84% 275ms

几个观察:

  • Chunk调整单独贡献了+9个点的Recall,说明长chunk对中文语义确实是灾难
  • Embedding切换贡献最大,+12个点,而且延迟从320ms降到95ms(本地推理 vs 网络调用),成本也省了
  • Rerank再贡献+8个点,虽然延迟回到275ms,但相比基线还是更快,且准确率提升显著
  • 答案准确率的提升幅度小于Recall,说明还有LLM生成环节的优化空间,这是下一步的事

线上灰度一周后,用户投诉从每周十几条降到2条,主要是些边界case。

七、总结

这次优化的核心体会:

  1. Chunk策略是RAG的地基,中文场景下256字符+重叠+标题保留,比512无重叠强太多
  2. Embedding模型要选对语言,中文场景bge-large-zh-v1.5性价比极高,本地部署还省成本
  3. Rerank是性价比最高的增量优化,在粗排召回够全的前提下,精排能把准确率拉上一个台阶
  4. 每步单独评测,别一次性全改,否则出了问题不知道是哪个环节的锅

下一步计划:一是试试bge-m3做多向量检索,二是针对LLM生成环节做prompt工程和few-shot,三是引入查询改写(HyDE)处理短query。

代码都贴在文里了,有需要的可以直接跑。如果你们的RAG系统也卡在60%多的召回率,建议按这个顺序试一遍。