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

三个月前我接手了公司内部知识库问答系统。原始版本是典型的"周末项目":LangChain + OpenAI + Milvus,文档一股脑按512字符硬切,embedding用text-embedding-ada-002,检索Top-3直接丢给GPT-3.5-turbo生成答案。

上线第一周就翻车了。用户问"报销流程需要哪些材料",系统返回的是"年假申请流程"——因为两个文档在向量空间里距离太近。更离谱的是问"服务器重启步骤",模型一本正经地编了一段不存在的命令。

我拉了一个1200条真实问题的评测集,人工标注了标准答案所在的文档片段。跑出来的基线数据很难看:

指标 数值
Top-5召回率 62.3%
Top-1命中率 41.8%
端到端准确率 58.7%
平均检索延迟 42ms
平均总延迟 1.8s

问题定位很清晰:召回阶段就漏了,后面生成再强也白搭。于是开始了为期三周的优化。

二、环境与版本

先把环境固定下来,避免"优化完发现是版本问题"的尴尬:

Python 3.11.6
langchain 0.1.12
langchain-community 0.0.28
pymilvus 2.3.4
sentence-transformers 2.6.1
FlagEmbedding 1.2.10
torch 2.2.1 (CUDA 12.1)
openai 1.14.2

硬件:单卡A10 24G,Milvus单机部署(16C32G)。文档库规模:约4.2万篇技术文档与制度文件,切分后约38万个chunk。

三、方案设计:三阶段递进优化

我没有一次性全改,而是分三阶段,每阶段单独评测,这样才能知道到底是哪一步起了作用。

阶段一:Chunk策略调整
- 从固定512字符 → 递归字符切分(按\n\n\n优先级)
- 加入语义边界检测:用句向量相似度低于阈值处强制断开
- chunk size 512 → 384,overlap 50 → 80

阶段二:Embedding模型切换
- text-embedding-ada-002(1536维)→ bge-large-zh-v1.5(1024维)
- 中文场景下BGE明显更优,且本地部署省成本

阶段三:引入Rerank
- 检索Top-20 → bge-reranker-large重排 → 取Top-5
- 这是提升最明显的一步

四、核心实现

4.1 语义+递归混合切分

单纯递归切分对技术文档不友好,比如代码块会被切碎。我加了一层语义边界检测:

import re
import numpy as np
from sentence_transformers import SentenceTransformer
from langchain.text_splitter import RecursiveCharacterTextSplitter

class SemanticChunker:
    def __init__(self, model_name="BAAI/bge-large-zh-v1.5",
                 threshold=0.72, max_chunk=384, overlap=80):
        self.model = SentenceTransformer(model_name)
        self.threshold = threshold
        self.max_chunk = max_chunk
        self.overlap = overlap
        self.splitter = RecursiveCharacterTextSplitter(
            chunk_size=max_chunk,
            chunk_overlap=overlap,
            separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""],
            keep_separator=True,
        )

    def _split_sentences(self, text):
        # 保留代码块完整性
        parts = re.split(r'(\n\n+)', text)
        sents = []
        for p in parts:
            if p.strip():
                sents.extend(re.split(r'(? self.max_chunk:
                chunks.append(cur)
                # overlap:把上一块尾部拼到新块
                tail = cur[-self.overlap:] if len(cur) > self.overlap else cur
                cur = tail + nxt
            else:
                cur += nxt
        if cur:
            chunks.append(cur)
        # 二次递归切分兜底
        final = []
        for c in chunks:
            final.extend(self.splitter.split_text(c) if len(c) > self.max_chunk else [c])
        return final

阈值0.72是调出来的:太低切太碎,太高切不动。我试过0.65/0.70/0.72/0.75/0.80,0.72的召回最好。

4.2 检索 + Rerank流水线

Milvus检索粗排Top-20,再用bge-reranker-large精排:

from FlagEmbedding import FlagReranker
from pymilvus import Collection
from sentence_transformers import SentenceTransformer

class RagRetriever:
    def __init__(self, collection: Collection, top_k=20, rerank_top=5):
        self.collection = collection
        self.embedder = SentenceTransformer("BAAI/bge-large-zh-v1.5")
        self.reranker = FlagReranker("BAAI/bge-reranker-large",
                                     use_fp16=True)
        self.top_k = top_k
        self.rerank_top = rerank_top
        # BGE推荐的query指令前缀
        self.query_prefix = "为这个句子生成表示以用于检索相关文章:"

    def _embed_query(self, query: str):
        return self.embedder.encode(
            self.query_prefix + query,
            normalize_embeddings=True
        ).tolist()

    def retrieve(self, query: str):
        # 1) 向量粗排
        self.collection.load()
        res = self.collection.search(
            data=[self._embed_query(query)],
            anns_field="embedding",
            param={"metric_type": "IP", "params": {"ef": 128}},
            limit=self.top_k,
            output_fields=["text", "doc_id", "chunk_id"],
        )
        candidates = [
            {"text": h.entity.get("text"),
             "doc_id": h.entity.get("doc_id"),
             "score": float(h.distance)}
            for h in res[0]
        ]
        if not candidates:
            return []

        # 2) Rerank精排
        pairs = [[query, c["text"]] for c in candidates]
        scores = self.reranker.compute_score(pairs, normalize=True)
        for c, s in zip(candidates, scores):
            c["rerank_score"] = float(s)
        candidates.sort(key=lambda x: x["rerank_score"], reverse=True)
        return candidates[:self.rerank_top]

注意query_prefix这个细节:BGE系列在检索时给query加指令前缀,能涨2-3个点,很多人不知道。

Milvus的索引参数:IVF_FLATnlist=4096,检索时nprobe=32。试过HNSW,M=32, efConstruction=256,召回略高但内存吃紧,最终选了IVF_FLAT。

五、踩坑与优化

坑1:切分后chunk长度分布严重不均
一开始语义切分出来的chunk有的20字,有的800字。太短的噪声大,太长的embedding被截断(BGE最大512 token)。解决办法是设置min_chunk=80,低于这个值就与相邻块合并。

坑2:Reranker拖慢响应
bge-reranker-large在A10上跑20个pair耗时约140ms(fp16)。如果并发上来会成瓶颈。我的做法是:把rerank封装成独立服务,用FastAPI + batch推理,单次请求最多等50ms攒批。

坑3:BGE模型首次加载慢
冷启动加载bge-large-zh-v1.5要8-10秒。生产环境用预热脚本,服务启动时先encode一条假数据。

坑4:Milvus的IP度量与归一化
用IP(内积)时embedding必须归一化,否则分数无意义。BGE的normalize_embeddings=True别漏。

坑5:overlap导致重复召回
overlap=80时,相邻chunk会共享尾部内容,Top-5里可能出现两个高度相似的chunk。加了一个简单的去重:如果两个chunk的Jaccard相似度>0.85,只保留rerank分高的。

六、效果数据

每阶段单独评测,1200条测试集,指标如下:

阶段 Top-5召回 Top-1命中 端到端准确 检索延迟 总延迟
基线(512固定切分+ada-002) 62.3% 41.8% 58.7% 42ms 1.8s
+语义混合切分 71.5% 52.4% 66.2% 48ms 1.9s
+bge-large-zh-v1.5 80.1% 63.7% 74.5% 55ms 1.9s
+bge-reranker-large 89.4% 76.2% 84.1% 195ms 2.1s

几个观察:
1. 切分策略贡献了约9个点,比我想象的重要。固定切分把完整段落切碎是最大问题。
2. Embedding切换贡献8.6个点,中文场景下BGE对ada-002是碾压式的。
3. Rerank贡献9.3个点,但代价是延迟从55ms涨到195ms。不过端到端只涨了200ms,因为生成阶段占大头。
4. Top-1命中率提升幅度(41.8%→76.2%)比Top-5更明显,说明rerank对"把正确结果顶到第一"特别有效。

成本方面:ada-002按token收费,38万chunk的embedding花了约$18;切到BGE后本地推理,一次性投入电费可忽略。Reranker增加了GPU占用,但省下了不少GPT-4的调用(准确率上去了,不用靠大模型兜底)。

七、总结

这次优化的核心体会是:RAG系统的瓶颈通常在检索,不在生成。很多人一上来就换GPT-4,其实先把召回做好,GPT-3.5也能给出不错答案。

如果只能做一件事,我会选引入rerank——投入产出比最高,代码改动最小,效果立竿见影。但前提是粗排的Top-20里得包含正确答案,所以chunk策略和embedding是地基,不能跳过。

后续还想试的方向:
- 用bge-m3替换当前embedding,支持多语言+稀疏稠密混合检索
- 引入query改写(HyDE或query2doc),解决短查询召回差的问题
- 用ColBERT式的late interaction做粗排,可能比双塔更准

代码都跑通了,但生产环境永远有新的边界情况。RAG没有银弹,只有不断迭代。