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

去年底我们给公司内部搭了一个知识库问答系统,文档来源包括Confluence导出的Markdown、产品手册PDF和一堆历史工单。数据量不大,约1.2万篇文档,切完chunk大概8万多条。技术栈是最朴素的组合:LangChain 0.1.x + OpenAI text-embedding-ada-002 + FAISS + GPT-3.5-turbo。

上线第一周就被业务方吐槽:"问它A功能怎么配置,它给我答B功能的参数。"我拉了一批badcase看,问题非常集中:

  1. 答案明明在库里,但top5检索结果里根本没有那个chunk——召回失败。
  2. 召回里有正确chunk,但排在第4、第5位,前面的噪声把LLM带偏了——排序失败。
  3. 有些chunk本身就是"半句话",因为被硬切断了——切分失败。

这三个问题基本对应RAG优化的三个经典抓手:chunk策略、embedding模型、rerank。我花了大概三周时间逐层替换并做了A/B对比,下面把过程和数字都摊开讲。

二、环境与版本

先把环境固定下来,避免"我本地好的"这种扯皮:

Python 3.10.13
langchain 0.1.20
langchain-community 0.0.38
faiss-cpu 1.8.0
sentence-transformers 2.7.0
FlagEmbedding 1.2.10
openai 1.30.1
torch 2.2.1 (CUDA 12.1)

评测集:人工从真实工单里抽了200个问题,每个问题标注了"标准答案所在的文档ID"。指标用Recall@5(正确文档是否进前5)、MRR@10、以及最终的端到端准确率(人工判定答案是否正确,二分类)。

基线数据(优化前):

  • Recall@5 = 0.71
  • MRR@10 = 0.63
  • 端到端准确率 = 62%
  • P95延迟 = 2.1s

三、方案设计

整体思路是"分层解耦":把召回和排序拆开,召回负责"别漏",排序负责"别错"。三个阶段独立替换、独立评测,这样每一步的增益都能归因。

  • 阶段1:chunk策略从固定长度改为递归+语义边界混合。
  • 阶段2:embedding从ada-002换成bge-large-zh-v1.5(中文场景)。
  • 阶段3:召回top20后接bge-reranker-large重排,取top5给LLM。

四、核心实现

4.1 Chunk策略调整

原来的切分是RecursiveCharacterTextSplitter(chunk_size=512, chunk_overlap=50),纯按字符数。问题在于Markdown里的表格、代码块、列表经常被从中间劈开。

我改成两级策略:先用Markdown结构切,再对超长块做递归切。

from langchain.text_splitter import MarkdownHeaderTextSplitter, RecursiveCharacterTextSplitter

# 第一级:按Markdown标题切
headers_to_split_on = [
    ("#", "h1"),
    ("##", "h2"),
    ("###", "h3"),
]
md_splitter = MarkdownHeaderTextSplitter(
    headers_to_split_on=headers_to_split_on,
    strip_headers=False,
)

# 第二级:对超长section做递归切,保留语义分隔符优先级
recursive_splitter = RecursiveCharacterTextSplitter(
    chunk_size=800,
    chunk_overlap=120,
    separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""],
    length_function=len,
)

def hybrid_chunk(md_text: str):
    chunks = []
    for section in md_splitter.split_text(md_text):
        content = section.page_content
        if len(content) <= 800:
            chunks.append({
                "text": content,
                "meta": section.metadata,
            })
        else:
            for sub in recursive_splitter.split_text(content):
                chunks.append({
                    "text": sub,
                    "meta": section.metadata,
                })
    return chunks

关键参数选择理由:chunk_size从512提到800,是因为中文技术文档一个完整"操作步骤"通常600-900字;overlap从50提到120,是为了避免跨chunk的指代丢失。separators里把中文标点放在英文空格前面,是因为我们文档以中文为主,按句号切比按空格切更符合语义。

4.2 Embedding模型切换

ada-002是通用多语言模型,在中文技术语料上其实不算强。我选了BAAI的bge-large-zh-v1.5,1024维,中文MTEB榜单当时排前列。用sentence-transformers加载,加query instruction前缀(bge官方推荐)。

from sentence_transformers import SentenceTransformer
import numpy as np

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

QUERY_INSTRUCTION = "为这个句子生成表示以用于检索相关文章:"

def embed_docs(texts):
    # 文档侧不加instruction
    return model.encode(
        texts,
        batch_size=64,
        normalize_embeddings=True,
        show_progress_bar=True,
    )

def embed_query(query: str):
    return model.encode(
        [QUERY_INSTRUCTION + query],
        normalize_embeddings=True,
    )[0]

# 建FAISS索引,用内积等价于cosine(因为已归一化)
import faiss
dim = 1024
index = faiss.IndexFlatIP(dim)
doc_embs = embed_docs([c["text"] for c in chunks]).astype("float32")
index.add(doc_embs)

注意:bge系列文档侧不加instruction,query侧加,这个不对称如果搞反了,召回会掉好几个点,我第一版就踩了这个坑。

4.3 Rerank引入

召回top20,用bge-reranker-large做cross-encoder重排。reranker是query和doc拼一起过模型,精度高但慢,所以只对候选集做。

from FlagEmbedding import FlagReranker

reranker = FlagReranker(
    "BAAI/bge-reranker-large",
    use_fp16=True,   # 显存不够就开fp16,精度损失可忽略
)

def retrieve_and_rerank(query: str, top_k_recall=20, top_k_final=5):
    q_emb = embed_query(query).astype("float32").reshape(1, -1)
    scores, indices = index.search(q_emb, top_k_recall)
    candidates = [chunks[i] for i in indices[0]]

    pairs = [[query, c["text"]] for c in candidates]
    rerank_scores = reranker.compute_score(pairs, normalize=True)

    ranked = sorted(
        zip(candidates, rerank_scores),
        key=lambda x: x[1],
        reverse=True,
    )
    return ranked[:top_k_final]

use_fp16=True在我们这张卡上把rerank耗时从约420ms压到约180ms,分数差异在0.002以内,值得。

五、踩坑与优化

坑1:语义切分反而变差。 我一开始想上SemanticChunker,用embedding相似度找断点。结果在工单类短文本上切得稀碎,一个工单被切成七八块。后来放弃,回到Markdown结构+递归的组合。结论:语义切分适合长散文,不适合结构化文档。

坑2:reranker的batch size。 默认batch下20个pair一次算,显存直接OOM(我们卡是16G)。改成batch_size=8后稳定,耗时只增加约15%。

坑3:归一化不一致。 换模型时忘了改FAISS的metric,从L2换IP,导致分数排序全乱。这个bug藏了两天才被发现,因为"看起来还能返回结果"。建议索引构建时把metric和normalize写进配置并断言。

坑4:chunk元数据丢失。 MarkdownHeaderTextSplitter切完metadata里有h1/h2,但递归二次切分后要手动继承,否则过滤和引用溯源全废。这个在代码里已经补上。

六、效果数据

每一步单独评测(同一评测集、同一LLM、同一prompt):

阶段 Recall@5 MRR@10 端到端准确率 P95延迟
基线(512固定切+ada-002) 0.71 0.63 62% 2.1s
+混合chunk 0.76 0.67 68% 2.2s
+bge-large-zh-v1.5 0.84 0.76 77% 2.3s
+bge-reranker-large 0.89 0.83 84% 2.9s

几个观察:

  • 召回提升最猛的是embedding切换,单步+8个点,说明中文场景下模型选择比什么都重要。
  • rerank主要提升的是MRR和端到端准确率,Recall@5只涨了5个点,因为它本质是"把对的往前排",不是"把对的捞出来"。
  • 延迟从2.1s到2.9s,主要来自reranker的180ms和embedding模型比ada-002慢的部分。对内部工具来说可接受,对C端可能要再压缩。

badcase复看:剩下的16%错误里,约一半是多跳问题(需要跨文档推理),这已经不是检索层能解决的了,得上query rewrite或者agent式多轮检索。

七、总结

这次优化最大的体会是:RAG不是一个模型问题,是一个系统工程问题。 chunk决定上限,embedding决定召回,rerank决定精度,三者各司其职。不要指望换个更强的LLM就能救RAG,检索层烂,GPT-4也答不对。

如果只让我保留一条经验:先建评测集,再动手优化。 没有那200条标注,我根本分不清是chunk的锅还是模型的锅,所有"感觉变好了"都是自欺欺人。

后续计划:把query rewrite加进来处理多跳问题,另外试试bge-m3做稀疏+稠密混合检索,看看能不能把Recall@5再推一推。有进展再写一篇。