一、问题背景:为什么我的RAG答非所问

先说场景。我们做的是一个内部技术文档问答系统,语料是企业多年积累的产品手册、API文档和运维SOP,总共约12万篇,平均每篇1800字,中文为主,夹杂大量代码片段和英文术语。用户提问基本是「XX接口超时怎么排查」「YY配置项默认值是多少」这类具体问题。

第一版系统是典型的「能跑就行」方案:

  • 文档按固定512 token切分,无重叠
  • embedding用OpenAI的 text-embedding-ada-002
  • 向量库用Milvus 2.3.1,HNSW索引
  • 召回Top-5直接拼进prompt,没有重排
  • 生成用 gpt-3.5-turbo

上线后人工抽检200条问题,Top-5命中率(正确答案所在chunk出现在召回结果中)只有62%。更糟的是,很多case里正确答案其实在库里,但就是没被召回。用户反馈最多的是「答案沾边但不对」和「明明文档里有却答不出来」。

这就不是生成模型的问题了,是检索环节烂。于是开始了一轮系统性的优化。

二、环境与版本

先交代下环境,方便复现:

Python 3.10.13
milvus 2.3.1 (standalone, HNSW, M=16, efConstruction=200)
FlagEmbedding 1.2.10
sentence-transformers 2.2.2
torch 2.1.0 + cu118
langchain 0.1.0 (只用了text splitter部分)
openai 1.6.1 (仅用于生成)

评估集:从真实用户日志里抽了300条问题,人工标注了每条的标准答案所在文档和chunk。评估指标用两个:

  • Hit@5:正确chunk是否出现在Top-5召回中
  • MRR@5:正确chunk排名的倒数均值

生成质量暂时不动,只盯检索。因为检索不行,后面怎么调prompt都是白搭。

三、方案设计:分三轮迭代

我的思路是每次只改一个变量,方便归因:

  • Round 1:只改chunk策略(固定切分 → 语义分段+重叠)
  • Round 2:在Round 1基础上切换embedding模型(ada-002 → bge-large-zh-v1.5)
  • Round 3:在Round 2基础上引入rerank(bge-reranker-large)

每轮都跑同一套300条评估集,记录Hit@5和MRR@5。

四、核心实现

Round 1:chunk策略调整

固定512 token切分最大的问题是语义截断。比如一个API的错误码说明表,被从中间切开,前半段在chunk A,后半段在chunk B,用户问「错误码5003什么意思」,召回A只有表头,召回B只有半张表,都不完整。

我改成了两级策略:

  1. 先用Markdown标题(#、##)做一级切分,保证每个章节独立
  2. 章节内超过400 token的,用 RecursiveCharacterTextSplitter 按段落、句子递归切,chunk_size=400,chunk_overlap=80

重叠窗口很关键。80 token的重叠大概能覆盖2-3句话,正好让跨chunk的语义有个缓冲。

from langchain.text_splitter import RecursiveCharacterTextSplitter, MarkdownHeaderTextSplitter

def semantic_chunking(md_text: str, max_chunk_size: int = 400, overlap: int = 80):
    # 一级:按Markdown标题切
    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)

    # 二级:章节内递归切分
    text_splitter = RecursiveCharacterTextSplitter(
        chunk_size=max_chunk_size,
        chunk_overlap=overlap,
        separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""],
        length_function=lambda x: len(x)  # 中文按字符数近似token
    )

    final_chunks = []
    for sec in sections:
        # 把标题拼回正文,保证chunk自带上下文
        header_prefix = " > ".join([f"{k}:{v}" for k, v in sec.metadata.items()])
        content = f"[{header_prefix}]\n{sec.page_content}"
        if len(content)  错误码」这种前缀能显著提升相关性;二是中文的 `length_function` 直接用字符数,别傻乎乎用tiktoken,中文token和字符差不多1:1,没必要多一次编码开销。

Round 1结果:Hit@5从62% → 71%,MRR@5从0.41 → 0.52。chunk数量从原来的平均每篇8个涨到13个,库总量从96万涨到156万条向量。存储成本涨了60%,但召回质量实打实提升。

### Round 2:切换embedding模型

ada-002是通用多语言模型,但在中文技术文档上表现一般。我换成了智源的 `bge-large-zh-v1.5`,768维,中文语义对齐明显更好。

这里有个坑:**query和passage要用不同的前缀**。bge系列训练时query加了指令前缀,passage没有。如果两边都不加,效果会掉一截。

```python
from FlagEmbedding import FlagModel
import numpy as np

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

    def encode_queries(self, queries):
        # 自动加query instruction
        return self.model.encode_queries(queries, batch_size=64, max_length=512)

    def encode_passages(self, passages):
        # passage不加instruction
        return self.model.encode(passages, batch_size=64, max_length=512)

# 批量重建索引
embedder = BGEEmbedder()
# 假设chunks是Round 1产出的所有chunk
vectors = embedder.encode_passages(chunks)
# 写入Milvus,collection维度从1536改成768

切换模型意味着全量重建索引,156万条向量在单张A100上跑了约40分钟(batch=64, fp16)。如果你们数据量更大,建议先抽1万条做A/B,确认有效再全量。

Round 2结果:Hit@5从71% → 82%,MRR@5从0.52 → 0.68。这一步是三轮里收益最大的,中文场景下换对embedding模型比调任何参数都管用。

Round 3:引入rerank

召回阶段用的是向量相似度,本质是「双塔」结构,query和doc各自编码,没有交互。Rerank是「交叉编码器」,把query和doc拼在一起过模型,能捕捉细粒度相关性,但推理慢,只能用在Top-K小集合上。

标准做法:召回Top-50 → rerank → 取Top-5。召回阶段放宽是为了保证正确chunk大概率在候选里,rerank负责精排。

from FlagEmbedding import FlagReranker

class RerankerPipeline:
    def __init__(self, reranker_path: str = "BAAI/bge-reranker-large"):
        self.reranker = FlagReranker(reranker_path, use_fp16=True)

    def retrieve_and_rerank(self, query: str, milvus_client, collection, top_k_recall=50, top_k_final=5):
        # 1. 向量召回
        query_vec = embedder.encode_queries([query])[0]
        search_params = {"metric_type": "IP", "params": {"ef": 128}}
        results = milvus_client.search(
            collection_name=collection,
            data=[query_vec],
            anns_field="embedding",
            param=search_params,
            limit=top_k_recall,
            output_fields=["chunk_id", "content"]
        )
        candidates = [(hit.entity.get("content"), hit.entity.get("chunk_id")) for hit in results[0]]

        # 2. 构造query-doc对
        pairs = [[query, doc] for doc, _ in candidates]
        scores = self.reranker.compute_score(pairs, normalize=True)

        # 3. 排序取Top-K
        ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
        return [(doc, cid, score) for (doc, cid), score in ranked[:top_k_final]]

bge-reranker-large 在fp16下,50个pair打分大概需要120ms(A100)。这个延迟是可接受的,因为换来的是精度大幅提升。

Round 3结果:Hit@5从82% → 89%,MRR@5从0.68 → 0.81。延迟方面,端到端P95从1.8s涨到2.6s,其中rerank占约200ms,剩下是生成变慢(因为召回更准,prompt更长)。

五、踩坑与优化

说几个真实踩过的坑:

1. 重叠窗口不能乱设。 一开始我把overlap设成200,结果检索时同一个chunk的多个重叠版本一起被召回,挤占了其他相关chunk的名额,Hit@5反而掉了3个点。后来降到80才正常。经验值:overlap控制在chunk_size的15%-25%。

2. bge模型的query instruction不能省。 我一开始图省事,query和passage都用 encode(),结果比ada-002还差。查了官方文档才发现query必须加instruction。加上之后Hit@5直接涨了9个点。

3. rerank的normalize参数。 compute_score 默认不归一化,输出的logits范围可能是-10到10。做阈值过滤时如果按0.5卡,会漏掉大量正确结果。要么开 normalize=True 映射到0-1,要么自己sigmoid。

4. Milvus的ef参数。 HNSW的 ef 是查询时的搜索范围,默认值偏小。我调到128后,召回率有小幅提升(约1.5个点),但延迟增加约30ms。如果对延迟敏感,这个值可以保守一点。

5. 别忽略冷启动。 换embedding模型后,旧索引和新query的向量空间不一致,必须全量重建。我第一次忘了这茬,混用了一天,线上效果惨不忍睹。

六、效果数据汇总

三轮迭代的完整对比:

轮次 方案 Hit@5 MRR@5 P95延迟
基线 512固定切分 + ada-002 62% 0.41 1.8s
R1 语义切分+重叠 71% 0.52 1.8s
R2 R1 + bge-large-zh-v1.5 82% 0.68 1.9s
R3 R2 + bge-reranker-large 89% 0.81 2.6s

几个观察:

  • embedding模型切换收益最大,+11个点,性价比最高
  • chunk策略是基础,不做这一步,后面换模型和rerank的效果都会打折
  • rerank提升明显但成本高,延迟涨了37%。如果业务对延迟极敏感,可以只对Top-20做rerank,命中率能保住87%左右
  • 库容量从96万涨到156万(chunk变多),存储成本涨60%,但相对于精度提升完全值得

生成端我也顺手把 gpt-3.5-turbo 换成了 gpt-4-turbo,人工评估的答案满意度从68%涨到85%。不过这不是本文重点,先不展开。

七、总结

RAG系统的优化顺序很重要。我的经验是:

  1. 先把chunk切对,这是地基。切不好,后面全是空中楼阁
  2. 再选对embedding模型,中文场景优先考虑bge、m3e这类国产模型,别迷信OpenAI
  3. 最后上rerank,这是精度和延迟的权衡,不是必须项
  4. 每次只改一个变量,否则出了问题都不知道是哪一步导致的

还有一点:评估集一定要自己标。网上的公开benchmark和你的业务场景差距太大,只有自己标注的300条真实问题才能反映真实效果。我们标注花了大概两天,但后面每一步优化都有据可依,这个投入非常值。

下一步我打算试试query改写(HyDE)和多路召回(BM25+向量混合),看看能不能把Hit@5推到92%以上。有进展再写一篇。

代码都跑通了,环境版本和参数都在文中,有需要的同学可以直接复现。有问题评论区聊。