一、问题背景:一个“看起来能用”的RAG系统

去年底我接手了一个企业内部知识库问答系统,底层是典型的RAG(检索增强生成)架构。文档量不大,约1.2万篇Markdown和PDF,覆盖产品手册、运维SOP、API文档。用户是内部工程师,问题偏具体,比如“网关超时重试次数怎么配置”“订单服务降级开关在哪”。

上线初期用的是最朴素的方案:

  • 分块:按字符数512定长切分,重叠64
  • 嵌入:OpenAI text-embedding-ada-002,1536维
  • 向量库:Milvus 2.3.1,HNSW索引
  • 检索:纯向量Top-5
  • 生成:GPT-3.5-turbo,temperature=0

业务方反馈“有时答得对,有时答非所问”。我搭了一个1200条问题的评测集(人工标注标准答案段落),跑出来几个关键指标:

  • Recall@5 = 0.71
  • MRR = 0.58
  • 答案准确率(人工判定)= 0.63
  • 端到端P95延迟 = 1.2s

问题很明确:召回不够,且召回的段落里噪声多。生成模型本身没问题,是喂给它的上下文不对。于是我开始按“分块 → 嵌入 → 重排”的顺序逐层优化。

二、环境与版本

先固定环境,避免版本漂移导致对比失真:

Python 3.10.13
torch 2.1.2 + cu121
transformers 4.36.2
sentence-transformers 2.3.1
FlagEmbedding 1.2.10
Milvus 2.3.1
langchain 0.1.0
openai 1.6.1

硬件:单卡A10 24GB,Milvus单机。评测脚本固定随机种子,所有对比在同一评测集上跑。

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

我的优化思路是分层定位瓶颈,而不是一次性全换:

  1. 分块层:定长切分把语义单元切碎了,先换语义分块
  2. 嵌入层:ada-002对中文技术文档的语义区分度一般,换中文强化的bge
  3. 重排层:向量检索是“粗排”,Top-20里混入噪声,引入cross-encoder重排

每层单独做A/B,记录指标,最后叠加。这样能清楚知道每一层贡献了多少。

四、核心实现

4.1 语义分块

定长512字符的问题在于:一个配置说明可能被从中间切断,或者把两个不相关的小节拼在一起。我改用基于标点和标题的递归分块,目标块大小256-512 token,重叠50 token。

from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.document_loaders import UnstructuredMarkdownLoader

def build_chunks(file_path: str):
    loader = UnstructuredMarkdownLoader(file_path, mode="single")
    docs = loader.load()

    splitter = RecursiveCharacterTextSplitter(
        chunk_size=400,          # 约400 token
        chunk_overlap=50,
        length_function=lambda x: len(x),  # 中文按字符近似
        separators=["\n## ", "\n### ", "\n\n", "\n", "。", ";", ",", ""],
        keep_separator=True,
    )
    chunks = splitter.split_documents(docs)
    # 过滤过短块,避免噪声
    chunks = [c for c in chunks if len(c.page_content.strip()) >= 80]
    return chunks

关键点:separators里把Markdown标题放在最前,保证标题层级不被破坏;过滤掉小于80字符的块,这类块在检索时几乎全是噪声。

4.2 嵌入模型切换

从ada-002切到BAAI/bge-large-zh-v1.5。bge在中文语义相似度上表现更好,且支持指令前缀。注意:查询要加指令前缀,文档不加,这是bge的用法约定。

from FlagEmbedding import FlagModel
import numpy as np

class BGEEmbedder:
    def __init__(self, model_name="BAAI/bge-large-zh-v1.5", device="cuda"):
        self.model = FlagModel(
            model_name,
            query_instruction_for_retrieval="为这个句子生成表示以用于检索相关文章:",
            use_fp16=True,
            device=device,
        )

    def encode_docs(self, texts):
        # 文档不加前缀
        return self.model.encode(texts, batch_size=64, max_length=512)

    def encode_query(self, text):
        # 查询加前缀
        return self.model.encode_queries([text], max_length=128)[0]

维度从1536降到1024,Milvus集合需要重建。索引参数:M=16, efConstruction=200,检索时ef=128

4.3 重排引入

向量检索是双塔结构,query和doc各自编码,交互不足。引入BAAI/bge-reranker-large做cross-encoder重排:先向量召回Top-20,再用reranker精排取Top-5。

from FlagEmbedding import FlagReranker

class Reranker:
    def __init__(self, model_name="BAAI/bge-reranker-large", device="cuda"):
        self.reranker = FlagReranker(model_name, use_fp16=True, device=device)

    def rerank(self, query, docs, top_k=5):
        pairs = [[query, d] for d in docs]
        scores = self.reranker.compute_score(pairs, normalize=True)
        ranked = sorted(zip(docs, scores), key=lambda x: x[1], reverse=True)
        return ranked[:top_k]

在线检索流程变成:

def retrieve(query, top_k=5, recall_k=20):
    q_vec = embedder.encode_query(query)
    hits = milvus.search(q_vec, limit=recall_k)  # 粗排20
    docs = [h.entity.get("text") for h in hits[0]]
    reranked = reranker.rerank(query, docs, top_k=top_k)  # 精排5
    return reranked

五、踩坑与优化

坑1:分块重叠导致重复召回。语义分块后,相邻块有50 token重叠,Top-5里出现两块内容高度相似,浪费上下文窗口。解决:在重排后做一次去重,按内容前100字符做哈希,重复的只保留分数高的。

坑2:bge查询前缀漏加。一开始文档和查询都没加前缀,Recall只从0.71涨到0.74,几乎没效果。后来发现查询必须加指令前缀,加上后直接到0.82。这个细节FlagEmbedding文档里有,但很容易忽略。

坑3:reranker拖慢延迟。Top-20重排,单次约300ms(A10)。P95从1.2s涨到2.1s。优化:把recall_k从20降到15,reranker batch推理,延迟降到1.7s,Recall只掉0.01。最终取recall_k=15。

坑4:Milvus重建集合时忘了改维度。ada-002是1536维,bge是1024维,直接往旧集合插会报错。必须新建集合并重新灌数据。灌1.2万篇文档约8分钟。

六、效果数据

在1200条评测集上的对比(相同生成模型GPT-3.5-turbo):

方案 Recall@5 MRR 答案准确率 P95延迟
基线(512定长+ada+无重排) 0.71 0.58 0.63 1.2s
+语义分块 0.75 0.62 0.67 1.2s
+bge-large-zh-v1.5 0.82 0.71 0.75 1.3s
+bge-reranker-large 0.93 0.85 0.86 2.1s
+recall_k调至15 0.92 0.84 0.86 1.7s

贡献拆解:分块+0.04,嵌入+0.07,重排+0.11。重排贡献最大,但前提是召回池里得有正确文档——如果嵌入层没做好,重排也救不回来。这三层是有序依赖的。

七、总结

这次优化最大的体会是:RAG的瓶颈几乎永远在检索,不在生成。很多人一上来就换更大的LLM,但上下文里没有正确答案,模型再强也只能胡编。

具体到可复用的经验:

  1. 分块要尊重文档结构,Markdown标题、代码块边界不能破坏
  2. 中文场景优先用bge系列,注意查询指令前缀这个细节
  3. 重排是性价比最高的一步,但召回池质量是前提,recall_k取15-20比较平衡
  4. 每层单独A/B,别一次性全换,否则出问题不知道是哪层的锅

当前系统还有优化空间:比如用混合检索(BM25+向量)补足关键词匹配,或者对长文档做层级摘要。但就目前业务反馈,准确率0.86已经能覆盖大部分内部问答场景了。下一步打算试试bge-m3的多向量检索,看看能不能把Recall再往上推一推。