一、问题背景:上线两周,用户开始骂人了

我们做的是一个面向内部技术支持人员的RAG问答系统,知识库是大约1.2万篇产品文档和工单记录,总量约480MB纯文本。技术栈是典型的"LangChain + FAISS + GPT-3.5-turbo"组合,2024年3月上线。

上线第一周风平浪静,第二周开始,客服群里开始出现反馈截图:"问的是退款流程,它给我答成了发票申请"、"问的是A型号参数,回答的是B型号"。

我拉了一批线上日志,人工标注了200条query,结果如下:

  • Top-5检索命中率(正确文档出现在前5):0.42
  • 端到端回答准确率(人工判断):0.51
  • P95响应时间:2.3s

这个数字很难看。0.42的检索命中率意味着超过一半的问题,LLM根本拿不到正确的上下文,后面再强的生成模型也白搭。问题定位很清楚:检索环节是瓶颈

于是我开始了一轮系统性优化,分三步走:chunk策略、embedding模型、rerank。

二、环境与版本:先把基线固定住

为了做对比实验,我先把环境固定下来,避免"优化了但不知道是哪一步起作用"。

Python: 3.10.12
langchain: 0.1.13
langchain-community: 0.0.29
faiss-cpu: 1.7.4
sentence-transformers: 2.6.1
FlagEmbedding: 1.2.10
openai: 1.14.3
torch: 2.2.1 (CUDA 12.1)

评测集:从线上日志里随机抽了300条query,人工标注了ground truth文档ID。评测指标用Hit@5和MRR@10,脚本我自己写的,跑一次大概40秒。

基线配置:
- chunk_size=512, chunk_overlap=0(按字符硬切)
- embedding: text-embedding-ada-002
- 无rerank,直接FAISS top-5喂给LLM

三、第一轮:chunk策略调整

3.1 问题分析

我抽了50个失败case,发现一个共性问题:答案被切断了

比如一篇"退款流程"的文档,第一部分讲"适用场景",第二部分讲"操作步骤",第三部分讲"到账时间"。按512字符硬切,很可能"操作步骤"被切到chunk 2的后半段和chunk 3的前半段。用户问"退款怎么操作",检索到的chunk 2里只有半段步骤,LLM自然答不全。

更糟糕的是,硬切完全不考虑段落边界,有时候一句话被从中间劈开。

3.2 方案:语义切分 + 段落感知

我换成了RecursiveCharacterTextSplitter,按中文标点和段落切,同时加overlap。参数调了三组:

配置 chunk_size overlap Hit@5
A 512 0 0.42
B 512 100 0.51
C 800 150 0.58
D 1024 200 0.56

C组最好。800字符大致对应中文400字左右,一个完整段落或一个小节,语义完整性够;150的overlap能兜住跨界的答案。

代码:

from langchain.text_splitter import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=800,
    chunk_overlap=150,
    length_function=len,
    separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""],
    keep_separator=True,
)

def split_docs(docs):
    chunks = []
    for doc in docs:
        # 先按一级标题预切,避免跨章节合并
        sections = doc.page_content.split("\n# ")
        for i, sec in enumerate(sections):
            if not sec.strip():
                continue
            for chunk in splitter.split_text(sec):
                chunks.append({
                    "text": chunk,
                    "source": doc.metadata["source"],
                    "section_idx": i,
                })
    return chunks

这里有个细节:separators里我把中文句号放在换行之后、逗号之前。原因是中文文档里段落通常以"。"结尾,优先在句号处切能保证语义完整;逗号优先级低,是为了避免切出太碎的chunk。

另外我加了一层"按一级标题预切",因为我们的文档有明确的章节结构,跨章节合并会引入噪音。

3.3 效果

Hit@5从0.42提到0.58,MRR@10从0.31到0.44。这一轮改动成本最低,收益却最大,chunk策略真的是RAG里性价比最高的优化点

四、第二轮:embedding模型切换

4.1 为什么换

text-embedding-ada-002是通用模型,英文很强,但中文语义匹配一般。而且走OpenAI API有两个问题:一是贵(我们每天约8万次检索),二是延迟不稳定(P95有时到800ms)。

我对比了几个中文embedding模型:

模型 维度 Hit@5 单次检索延迟(P95)
text-embedding-ada-002 1536 0.58 320ms(API)
m3e-base 768 0.61 18ms(本地)
bge-large-zh-v1.5 1024 0.67 25ms(本地)
bge-m3 1024 0.68 45ms(本地)

bge-large-zh-v1.5性价比最高。bge-m3虽然略好,但模型大一倍,延迟翻倍,对我们这种QPS较高的场景不划算。

4.2 实现

from sentence_transformers import SentenceTransformer
import numpy as np
import faiss

# 加载模型,指定GPU
model = SentenceTransformer(
    "BAAI/bge-large-zh-v1.5",
    device="cuda:0",
    cache_folder="/data/models",
)

# bge系列官方建议:query前加instruction,doc不加
QUERY_INSTRUCTION = "为这个句子生成表示以用于检索相关文章:"

def encode_queries(queries):
    queries = [QUERY_INSTRUCTION + q for q in queries]
    emb = model.encode(
        queries,
        batch_size=64,
        normalize_embeddings=True,  # 内积等价余弦
        show_progress_bar=False,
    )
    return emb.astype(np.float32)

def encode_docs(texts):
    emb = model.encode(
        texts,
        batch_size=128,
        normalize_embeddings=True,
    )
    return emb.astype(np.float32)

# 建索引:1024维,内积
dim = 1024
index = faiss.IndexFlatIP(dim)
doc_embs = encode_docs([c["text"] for c in chunks])
index.add(doc_embs)
faiss.write_index(index, "/data/faiss/bge_large_zh.index")

4.3 踩坑

坑1:bge模型对query要加instruction,doc不加。我第一次偷懒两边都不加,Hit@5只有0.63,加了instruction后到0.67。别小看这0.04。

坑2normalize_embeddings=True必须开,否则余弦和内积不等价,FAISS用IndexFlatIP会算错。

坑3:换embedding后整个索引必须重建。1.2万文档在单张3090上编码大约7分钟,别在生产环境直接跑,我写了个离线脚本+版本号管理。

坑4:GPU显存。bge-large-zh-v1.5 FP32占约1.3GB显存,如果batch_size设太大(我一开始设256)会OOM。128是稳的。

4.4 效果

Hit@5从0.58提到0.67,P95检索延迟从320ms降到25ms。省钱又提速,双赢

五、第三轮:引入rerank

5.1 思路

embedding是双塔模型,query和doc分别编码,交互只发生在最后的内积。这种架构快,但精度有限。Rerank是cross-encoder,把query和doc拼在一起过一遍模型,能捕捉更细的语义交互,但慢——所以只能用在Top-N精排。

典型做法:embedding召回Top-50,rerank精排取Top-5。

5.2 实现

from FlagEmbedding import FlagReranker

reranker = FlagReranker(
    "BAAI/bge-reranker-large",
    use_fp16=True,  # 3090上开fp16,延迟减半
    device="cuda:0",
)

def retrieve_with_rerank(query, top_k=5, recall_k=50):
    # 1. 向量召回
    q_emb = encode_queries([query])
    scores, indices = index.search(q_emb, recall_k)
    candidates = [chunks[i] for i in indices[0]]

    # 2. rerank
    pairs = [[query, c["text"]] for c in candidates]
    rerank_scores = reranker.compute_score(pairs, normalize=True)

    # 3. 排序取top_k
    ranked = sorted(
        zip(candidates, rerank_scores),
        key=lambda x: x[1],
        reverse=True,
    )[:top_k]
    return ranked

5.3 参数调优

recall_k设多少?我试了20/50/100:

recall_k Hit@5 rerank耗时(P95) 端到端P95
20 0.79 180ms 1.1s
50 0.89 420ms 1.7s
100 0.89 830ms 2.4s

50是拐点,100没收益还慢。最终选50

5.4 踩坑

坑1compute_score默认返回logits,不归一化的话分数范围可能到±10,排序没问题但阈值过滤不好做。加normalize=True变成0-1。

坑2:bge-reranker-large在3090上FP32推理,batch=1时约35ms/对,50对就是1.75s,太慢。开FP16后降到约8ms/对,50对420ms,可以接受。如果QPS更高,建议上TensorRT或者换base版。

坑3:rerank的输入长度限制是512 token,超长会截断。我们的chunk是800字符,中文大约400-500 token,勉强够。如果你的chunk更大,要小心截断丢信息。

六、最终效果与总结

三轮优化后的数据对比:

指标 基线 chunk优化 +embedding +rerank
Hit@5 0.42 0.58 0.67 0.89
MRR@10 0.31 0.44 0.52 0.76
端到端准确率 0.51 0.63 0.71 0.86
P95延迟 2.3s 2.2s 1.4s 1.7s
单次检索成本 $0.00012 $0.00012 ~$0 ~$0

几个结论,给正在做RAG的朋友参考:

  1. chunk策略是ROI最高的优化,不要一上来就换模型。我见过太多人直接上最贵的embedding,结果chunk切得稀碎,效果还是差。
  2. 中文场景别用OpenAI embedding,bge系列在中文上明显更强,还免费、还快。
  3. rerank是精度和延迟的trade-off,recall_k=50是个不错的起点,具体要看你的延迟预算。
  4. 每一步都要有评测集。我用300条标注query,每次改动跑一次,40秒出结果。没有评测集的优化就是盲调。
  5. 别忽略instruction和normalize这些细节,bge官方文档写得很清楚,但很多人不看,白白丢几个点。

最后说一句:RAG优化是个系统工程,检索、生成、prompt每一环都有空间。但先把检索做扎实,因为检索错了,后面全是白费。我现在还在继续做query改写和多路召回,有新进展再写一篇。

代码和评测脚本我整理在内部repo了,涉及公司数据没法开源,但上面贴的片段都是可以直接跑的,改改路径就能用。有问题评论区聊。