一、问题背景:为什么我的RAG像个“人工智障”

我们做了一个面向内部员工的IT支持问答机器人,底层是典型的RAG架构:用户提问 → 向量检索 → 拼prompt → LLM生成答案。知识库大概2000条FAQ,每条长度50~300字不等。

上线第一周,反馈很差。典型case:

  • 问“如何申请测试环境数据库权限”,检索到的是“生产环境数据库权限申请”,答案完全跑偏。
  • 问“VPN连不上怎么办”,返回了“VPN申请流程”,而不是排障步骤。
  • 多轮对话时,第二个问题经常召回第一个问题的文档。

我拉了100条真实query做人工标注,算了一下指标:

  • Top-1准确率:41%
  • Top-3召回率:61%
  • MRR:0.52

这个水平根本没法用。于是开始了为期两周的优化。下面按时间线记录。

二、环境与版本

先交代基线环境,避免“你用的什么版本”这种评论区的灵魂拷问:

  • Python 3.10.12
  • LangChain 0.1.16
  • 向量库:Chroma 0.4.24(本地持久化)
  • LLM:GPT-3.5-turbo-0125(生成端)
  • Embedding:OpenAI text-embedding-ada-002(1536维)
  • 原始chunk策略:按段落切,最大512 token,overlap 50
  • 检索:余弦相似度,Top-5送入LLM

数据规模:2000条FAQ,平均每条180字。

三、方案设计与第一轮优化:chunk策略调整

3.1 问题定位

我先把检索失败的case拉出来看,发现一个明显问题:chunk太大。比如“VPN申请流程”和“VPN故障排查”被切在同一个512 token的块里,embedding被平均了,导致两个意图都表达不清。

另外,原始切分是按\n\n段落切,但很多FAQ本身就是一个段落,结果一个chunk里塞了3~4个不同问题。

3.2 调整策略

改成基于语义的滑动窗口

  • chunk size:256 token
  • overlap:64 token
  • 切分优先级:\n\n\n → 固定长度
  • 每个chunk前面拼上所属FAQ的标题(关键!)

为什么加标题?因为很多chunk脱离上下文后,“申请流程”这种词根本不知道是申请什么。标题提供了实体信息。

3.3 核心实现

# chunk_utils.py
from langchain.text_splitter import RecursiveCharacterTextSplitter

def build_chunks(faq_list):
    """
    faq_list: [{"title": "...", "content": "..."}]
    """
    splitter = RecursiveCharacterTextSplitter(
        chunk_size=256,
        chunk_overlap=64,
        length_function=len,
        separators=["\n\n", "\n", "。", ";", ",", " ", ""],
        keep_separator=True
    )
    chunks = []
    for faq in faq_list:
        title = faq["title"].strip()
        content = faq["content"].strip()
        # 关键:标题作为前缀注入每个chunk
        for i, sub in enumerate(splitter.split_text(content)):
            chunks.append({
                "id": f"{faq.get('id', hash(title))}_{i}",
                "text": f"【{title}{sub}",
                "title": title,
                "source": faq.get("source", "faq")
            })
    return chunks

3.4 效果

只改chunk策略,其他不变:

  • Top-1:41% → 52%
  • Top-3:61% → 70%
  • MRR:0.52 → 0.61

有提升,但还不够。而且我注意到一个现象:语义相近但表述不同的query,检索结果波动很大。比如“怎么连VPN”和“VPN连接方法”召回的文档集合差异超过30%。这说明embedding模型本身对中文短文本的区分度不够。

四、第二轮优化:embedding模型切换

4.1 为什么换

text-embedding-ada-002是OpenAI的通用模型,英文很强,但中文短文本(尤其是FAQ这种口语化query)表现一般。而且我们数据不能出境,用OpenAI API本身也有合规风险(这是后来才意识到的)。

选型对比了几个中文模型:

模型 维度 中文MTEB 推理速度 备注
text-embedding-ada-002 1536 中等 API 需外网
m3e-base 768 较好 轻量
bge-large-zh-v1.5 1024 优秀 中等 推荐
text2vec-large-chinese 1024 较好 较老

最终选bge-large-zh-v1.5,理由是中文检索榜单表现最好,且支持本地部署。

4.2 切换代码

# embedding.py
from sentence_transformers import SentenceTransformer
import numpy as np

class BGEEmbedding:
    def __init__(self, model_path="BAAI/bge-large-zh-v1.5", device="cuda"):
        self.model = SentenceTransformer(model_path, device=device)
        self.model.eval()

    def encode(self, texts, batch_size=32, normalize=True):
        """
        bge系列官方建议:query前加"为这个句子生成表示以用于检索相关文章:"
        passage不加前缀
        """
        if isinstance(texts, str):
            texts = [texts]
        with torch.no_grad():
            emb = self.model.encode(
                texts,
                batch_size=batch_size,
                normalize_embeddings=normalize,
                show_progress_bar=False
            )
        return emb

    def encode_query(self, query):
        # 查询侧加指令前缀,这是bge的关键trick
        instruction = "为这个句子生成表示以用于检索相关文章:"
        return self.encode([instruction + query])[0]

    def encode_passages(self, passages):
        return self.encode(passages)

4.3 踩坑

坑1:bge官方文档说query要加instruction,passage不加。我一开始两边都加了,结果召回率反而降了3个点。后来只给query加,才正常。

坑2:归一化。bge默认输出未归一化向量,用余弦相似度必须归一化,否则内积计算会出问题。我一开始忘了,相似度分数全是乱的。

坑3:显存。bge-large-zh-v1.5 fp32加载约1.3GB,batch_size=32在8G显存卡上会OOM,改成16。

4.4 效果

切换embedding后(chunk策略保持256/64):

  • Top-1:52% → 63%
  • Top-3:70% → 78%
  • MRR:0.61 → 0.70

提升明显,尤其是短query。但仍有问题:Top-5里经常有2~3个是同一篇FAQ的不同chunk,挤占了其他相关文档的位置。而且有些语义相关但字面不匹配的,仍然排不上去。

五、第三轮优化:引入rerank

5.1 为什么需要rerank

向量检索是双塔结构,query和doc分别编码,优点是快,缺点是交互不充分。对于“VPN连不上”和“VPN无法连接”这种,向量能召回;但对于“我电脑连不上公司网络,是不是VPN问题”这种带上下文的query,向量检索容易漏。

Rerank是交叉编码,query和doc拼一起过模型,精度高但慢。所以典型做法是:向量召回Top-20,rerank精排取Top-3。

5.2 实现

# rerank.py
from FlagEmbedding import FlagReranker

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

    def rerank(self, query, candidates, top_k=3):
        """
        candidates: [{"text": "...", "id": "...", ...}]
        """
        if not candidates:
            return []
        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)
        ranked = sorted(candidates, key=lambda x: x["rerank_score"], reverse=True)
        return ranked[:top_k]

检索流程改为:

def retrieve(query, top_k=3, recall_k=20):
    # 1. 向量召回
    q_emb = embedder.encode_query(query)
    candidates = vector_store.search(q_emb, top_k=recall_k)
    # 2. rerank
    final = reranker.rerank(query, candidates, top_k=top_k)
    return final

5.3 踩坑

坑1:recall_k设太小。一开始我设10,结果rerank也没用,因为正确答案根本没被召回。改成20后才有明显提升。经验值:recall_k = top_k * 5~10。

坑2:bge-reranker-large fp16加载约1.3GB,和embedding模型同时放一张卡会OOM。我的方案是embedding用GPU,reranker用CPU(延迟增加约200ms,可接受)。如果卡多,可以分卡。

坑3:rerank分数归一化。FlagReranker的normalize=True输出是sigmoid后的0~1分,方便设阈值。我设了0.3的阈值,低于这个分的直接不返回,避免LLM被无关上下文干扰。

5.4 效果

引入rerank后(recall_k=20,top_k=3):

  • Top-1:63% → 74%
  • Top-3:78% → 89%
  • MRR:0.70 → 0.81

延迟变化:

  • 纯向量检索:~150ms
  • +rerank(CPU):~380ms
  • 端到端(含LLM):1.2s → 1.8s

延迟增加了50%,但准确率提升显著,业务上完全值得。

六、最终效果与总结

6.1 三轮优化汇总

阶段 Top-1 Top-3 MRR 端到端延迟
基线 41% 61% 0.52 1.2s
+chunk优化 52% 70% 0.61 1.2s
+embedding切换 63% 78% 0.70 1.3s
+rerank 74% 89% 0.81 1.8s

6.2 几条经验

  1. chunk策略是地基。chunk没切好,后面换什么模型都白搭。标题注入是个低成本高收益的trick。
  2. 中文场景别迷信OpenAI embedding。bge-large-zh-v1.5在中文FAQ上明显更好,而且能本地部署。
  3. rerank是性价比最高的优化。召回从20篇里精排,比在向量空间里调参有效得多。recall_k不要小于10。
  4. 延迟和精度要权衡。rerank放CPU会增加200ms,但Top-3从78%到89%,这个trade-off很划算。
  5. 别忽略数据清洗。2000条FAQ里有大概80条是重复或过期的,清掉后指标又涨了1~2个点。

6.3 还没做的

  • 多路召回(BM25 + 向量)融合,预期还能涨2~3个点
  • query改写(用LLM把口语query改写成检索友好形式)
  • 上下文压缩,减少LLM输入token

这些等下一轮迭代再搞。目前这套方案已经上线,人工评估满意度从2.8/5涨到4.2/5,算是能用了。


以上就是完整的优化记录。RAG这玩意儿,调优空间比想象中大,但每一步都要有数据支撑,别凭感觉调。希望这篇对正在踩坑的你有点帮助。