一、问题背景:一个"能用但不好用"的RAG系统

我们内部有一个面向客服的知识库问答系统,底层是典型的RAG架构:用户提问 → 向量检索Top-K文档片段 → 拼进Prompt → LLM生成答案。LLM用的是GPT-3.5-turbo,向量库是Milvus 2.3.1,embedding用的是OpenAI的text-embedding-ada-002。

上线三个月,运营那边反馈:"答案经常答非所问,或者只答对一半。"

我拉了一批真实query做人工评测(200条,覆盖产品咨询、故障排查、退款政策三类),结果如下:

指标 数值
Top-5召回率 0.63
答案准确率(人工判定) 0.58
P95响应时间 1.9s

也就是说,近四成的query,正确文档根本没被检索出来。LLM再强也救不了。问题出在检索侧,不是生成侧。

这篇文章就是记录我怎么一步步把它从0.63拉到0.89的。

二、环境与版本

先把环境列清楚,避免有人说"你版本不一样":

  • Python 3.10.13
  • Milvus 2.3.1(standalone部署)
  • sentence-transformers 2.2.2
  • FlagEmbedding 1.2.5(用于bge-reranker)
  • OpenAI API(text-embedding-ada-002,用于基线对比)
  • LLM:gpt-3.5-turbo-0613
  • 服务器:单卡A10 24G

三、方案设计:三个改动点

我没有一上来就换模型,而是按"成本从低到高"的顺序排查。最终确定的三个改动:

  1. Chunk策略:从固定512字符切分,改为基于标点+语义的递归分块,chunk大小256,overlap 50。
  2. Embedding模型:从text-embedding-ada-002切换到bge-large-zh-v1.5(中文场景,本地部署,零API成本)。
  3. Rerank:检索Top-20后,用bge-reranker-large做cross-encoder重排,取Top-5。

每个改动我都单独做了A/B,下面逐个说。

四、核心实现

4.1 Chunk策略调整

原来的切分代码很粗暴:

# 旧方案:固定长度切分
def split_fixed(text, size=512):
    return [text[i:i+size] for i in range(0, len(text), size)]

问题是:一句话被拦腰截断,语义破碎。检索时向量表达的是"半句话",自然不准。

改成递归分块,优先按段落、句子切,保证语义完整:

# 新方案:递归语义分块
from langchain.text_splitter import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=256,
    chunk_overlap=50,
    separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""],
    length_function=len,
)

def split_semantic(text):
    return splitter.split_text(text)

注意两点:
- chunk_size从512降到256。中文信息密度高,256字符已经能表达一个完整语义单元,太大反而稀释向量。
- separators里中文标点必须放前面。默认的LangChain分隔符是英文的,中文文档会退化成按字符切。

这一步单独做完,Top-5召回率从0.63 → 0.71。

4.2 Embedding模型切换

text-embedding-ada-002是通用模型,中文语义捕捉一般,而且每次调用都要钱、有网络延迟。

换成bge-large-zh-v1.5,本地部署:

from sentence_transformers import SentenceTransformer

model = SentenceTransformer("BAAI/bge-large-zh-v1.5")
model.max_seq_length = 512

def embed(texts, is_query=False):
    # bge模型检索时query需要加指令前缀
    if is_query:
        texts = [f"为这个句子生成表示以用于检索相关文章:{t}" for t in texts]
    return model.encode(texts, normalize_embeddings=True, batch_size=32)

关键坑:bge系列做检索时,query必须加指令前缀,passage不用加。不加前缀,召回率会掉5个点左右。这个细节官方文档写了,但很容易被忽略。

另外normalize_embeddings=True必须开,配合Milvus的IP(内积)度量。

Milvus collection的索引参数:

index_params = {
    "metric_type": "IP",
    "index_type": "HNSW",
    "params": {"M": 16, "efConstruction": 200}
}
search_params = {"metric_type": "IP", "params": {"ef": 128}}

换完embedding,Top-5召回率 0.71 → 0.82。

4.3 引入Rerank

向量检索是双塔(bi-encoder),query和doc分别编码,快但精度有限。Rerank是cross-encoder,query和doc拼一起过模型,精度高但慢。

所以策略是:向量检索粗召回Top-20,rerank精排取Top-5。

from FlagEmbedding import FlagReranker

reranker = FlagReranker("BAAI/bge-reranker-large", use_fp16=True)

def rerank(query, candidates, top_k=5):
    # candidates: List[str]
    pairs = [[query, doc] for doc in candidates]
    scores = reranker.compute_score(pairs, normalize=True)
    ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
    return [doc for doc, _ in ranked[:top_k]]

use_fp16=True在A10上能省一半显存,速度也快,精度几乎无损。

Rerank引入后,Top-5召回率 0.82 → 0.89。代价是每条query多180ms(20个候选,A10上batch推理)。

五、踩坑与优化

坑1:bge query前缀。 上面说了,不加前缀掉5个点。我一开始以为模型加载错了,debug了两小时。

坑2:Milvus的IP vs L2。 bge输出已经normalize了,用IP等价于cosine。我一开始用L2,分数排序反了,召回一塌糊涂。

坑3:rerank的batch size。 一开始逐条推理,20个候选要1.2s。改成一次传20个pair,降到180ms。FlagReranker的compute_score支持list输入,别写for循环。

坑4:chunk overlap太大导致重复。 overlap设100时,相邻chunk重复内容多,rerank后Top-5里出现3条几乎一样的,浪费上下文窗口。降到50后正常。

坑5:LLM上下文超长。 换256 chunk后,Top-5总长度反而更短了,因为每条更精炼。Prompt从平均3200 token降到2100 token,API成本还降了。

六、效果数据

最终对比(200条评测集,人工标注):

阶段 Top-5召回率 答案准确率 P95延迟
基线(512固定+ada-002) 0.63 0.58 1.9s
+语义分块 0.71 0.65 1.8s
+bge-large-zh 0.82 0.76 1.6s
+bge-reranker 0.89 0.85 1.78s

分场景看,故障排查类提升最明显(0.55 → 0.88),因为这类query专业术语多,ada-002抓不住,bge中文模型优势大。退款政策类提升最小(0.78 → 0.91),因为原文本身结构清晰。

成本侧:embedding从API改为本地,每月省约$120;rerank增加GPU占用,但A10本来就有余量。整体是降本增效。

七、总结

这次优化给我的几个结论:

  1. RAG的瓶颈90%在检索,不在生成。 别急着换GPT-4,先把召回率拉上去。
  2. 中文场景,bge系列确实比ada-002强。 尤其是专业术语多的领域,本地部署还省钱。
  3. Rerank是性价比最高的精度提升手段。 180ms换7个点召回,值。
  4. Chunk策略是最容易被忽视的环节。 很多人直接默认512,中文场景256更合适,且分隔符一定要配中文标点。
  5. 细节决定成败。 query前缀、normalize、度量类型,任何一个错了都白干。

下一步我打算试试bge-m3(支持多向量+稀疏),以及用LLM做query改写,看能不能把0.89再往上推。有进展再写一篇。

代码都在上面了,环境版本也给了,直接能跑。有问题评论区聊。