一、问题背景:答案明明在,就是搜不出来

我们做的是一套面向内部运维文档的RAG问答系统,文档量大概1.2万篇,主要是Markdown和Confluence导出的HTML,切片后约18万个chunk。用户问"MySQL主从延迟怎么排查"这类问题,系统经常返回一堆讲"MySQL安装"、"主从配置"的片段,真正讲延迟排查的那段反而排在第8、第9位。

最初的方案很朴素:

  • 固定长度切分,chunk_size=512,overlap=0
  • embedding用OpenAI的text-embedding-ada-002
  • 向量库用Milvus 2.3.1,HNSW索引,top_k=5直接喂给GPT-3.5-turbo

我拉了一周的真实query日志,人工标注了200条作为评测集,算出来的基线数据是:

指标 数值
Hit@5 0.62
MRR@10 0.48
平均检索延迟 95ms
P99检索延迟 210ms
端到端P99 3.4s

Hit@5只有0.62,意味着近四成的问题,正确片段压根没进top5。这个数字不解决,后面换多好的LLM都是白搭。所以我把优化重点全放在检索链路上。

二、环境与版本

先把环境交代清楚,避免大家复现时对不上:

  • Python 3.10.13
  • Milvus 2.3.1(standalone,Docker部署)
  • pymilvus 2.3.4
  • FlagEmbedding 1.2.10
  • sentence-transformers 2.2.2
  • PyTorch 2.1.0 + CUDA 12.1
  • 机器:单卡A10 24G,检索服务独立部署
  • LLM:GPT-3.5-turbo-0613(全程没动,控制变量)

三、方案设计:三步走

我的优化顺序是刻意的——先动切分,再动embedding,最后加rerank。原因很简单:切分是数据层,embedding是表示层,rerank是排序层,越靠前的改动收益越大、成本越低。如果顺序反了,先加rerank,很可能是在给一堆烂chunk做精排,白费算力。

具体三步:

  1. Chunk策略:固定512 → 按标题层级+语义切分,chunk_size=384,overlap=64
  2. Embedding:text-embedding-ada-002 → bge-large-zh-v1.5(本地部署)
  3. Rerank:无 → bge-reranker-large,召回top50精排取top5

四、核心实现

4.1 Chunk策略调整

原来的固定切分最致命的问题是会把一个完整语义单元拦腰砍断。比如"排查步骤"这种列表,切到一半,前半段讲现象、后半段讲命令,两边向量都不完整。

我改成两级切分:先按Markdown标题切,再在段落内做语义切分,并保留64的overlap。

from langchain.text_splitter import MarkdownHeaderTextSplitter, RecursiveCharacterTextSplitter

def split_document(md_text: str):
    # 第一级:按标题层级切
    headers_to_split_on = [
        ("#", "h1"),
        ("##", "h2"),
        ("###", "h3"),
    ]
    md_splitter = MarkdownHeaderTextSplitter(
        headers_to_split_on=headers_to_split_on,
        strip_headers=False,
    )
    sections = md_splitter.split_text(md_text)

    # 第二级:在section内部按语义递归切
    text_splitter = RecursiveCharacterTextSplitter(
        chunk_size=384,
        chunk_overlap=64,
        separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""],
        length_function=len,
    )

    chunks = []
    for sec in sections:
        header_path = " > ".join(
            [sec.metadata.get(k, "") for k in ["h1", "h2", "h3"] if sec.metadata.get(k)]
        )
        for sub in text_splitter.split_text(sec.page_content):
            # 把标题路径拼到chunk前面,给检索提供上下文
            chunks.append({
                "text": f"[{header_path}]\n{sub}" if header_path else sub,
                "metadata": {**sec.metadata, "header_path": header_path}
            })
    return chunks

这里有个小细节值得说:我把标题路径拼进了chunk正文。因为很多chunk本身内容很短,比如一条命令,脱离标题根本不知道在讲什么。拼上"[MySQL > 主从复制 > 延迟排查]"之后,向量语义明显更完整了。

4.2 Embedding模型切换

text-embedding-ada-002是通用模型,中文场景其实一般,而且调用有网络延迟、按token计费。我换成BGE的bge-large-zh-v1.5,1024维,本地推理。

from FlagEmbedding import FlagModel
import numpy as np

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

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

    def encode_query(self, query):
        # query侧加instruction,BGE官方要求
        return self.model.encode_queries([query], normalize_embeddings=True)[0]

踩坑提醒:BGE系列对query和doc的处理是不对称的。query必须加"为这个句子生成表示以用于检索相关文章:"这个instruction,doc不加。我一开始两边都加了,效果反而掉到0.58,比ada还差。查了官方文档才发现这个细节,改回来之后单这一项就涨了十几个点。

另外别忘了normalize_embeddings=True,配合Milvus的IP(内积)度量,等价于余弦相似度。

4.3 Rerank引入

向量检索是双塔结构,query和doc各自编码,速度快但精度有限。Rerank是交叉编码器,把query和doc拼一起过模型,精度高但慢,所以只对top50做精排。

from FlagEmbedding import FlagReranker

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

    def rerank(self, query, candidates, top_k=5):
        # candidates: List[dict],每个含 text
        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[:top_k]

检索链路改成:向量召回top50 → rerank精排 → 取top5喂LLM。recall阶段把Milvus的ef参数从64提到128,保证候选质量。

五、踩坑与优化

坑1:chunk变小后召回数变多,Milvus内存吃紧。 chunk从512降到384,chunk总数从18万涨到24万,HNSW索引内存涨了约30%。我把M从16降到12,efConstruction从200降到160,内存回落,召回率损失不到0.5%。

坑2:rerank拖慢P99。 top50精排,A10上单次约150ms。我做了两件事:一是开FP16,二是把候选数从50降到30。实测Hit@5只掉了0.01,但P99降了60ms。最终定在top30。

坑3:overlap导致重复召回。 64的overlap会让相邻chunk内容重叠,top5里经常出现两条几乎一样的。我在rerank后加了一步去重,按header_path+前50字符做key,重复的只留分数高的。

坑4:bge-reranker对超长文本会截断。 默认max_length=512,我的chunk最长到384字符,基本没影响,但保险起见还是显式设了max_length=512

六、效果数据

三轮优化,每轮单独测,评测集是同一批200条标注query:

阶段 Hit@5 MRR@10 检索P99 端到端P99
基线(ada+512切分) 0.62 0.48 210ms 3.4s
+语义切分 0.71 0.55 225ms 3.5s
+bge-large-zh 0.83 0.68 240ms 3.6s
+bge-reranker 0.89 0.77 420ms 3.8s

几个观察:

  1. 切分收益比想象中大:只改切分就涨了9个点,而且成本几乎为零,强烈建议先做这一步。
  2. embedding是最大单点收益:从0.71到0.83,涨了12个点,本地部署还省了API费用。
  3. rerank是锦上添花但很稳:0.83到0.89,涨6个点,代价是P99多了180ms。如果对延迟极度敏感,可以只对低置信度的query做rerank。
  4. 端到端只涨了0.4s:因为LLM生成占了3s多,检索优化的延迟增量被稀释了。用户几乎无感知。

七、总结

这次优化最大的体会是:RAG系统的瓶颈八成在检索,不在生成。很多人一上来就换GPT-4、调prompt,但如果top5里压根没有正确片段,LLM再强也只能一本正经地胡说。

优化顺序建议按"数据 → 表示 → 排序"来:先切分,再embedding,最后rerank。每一步都单独评测,别一次性全改,否则出了问题根本不知道是哪一环的锅。

最后提醒几个容易忽略的点:BGE的query instruction别漏、normalize要开、rerank候选数要权衡延迟、overlap记得去重。这些细节看着小,但每一个都能让效果掉好几个点。

下一步我打算试试query改写(HyDE)和多路召回融合,有进展再写一篇。