一、问题背景:一个「看起来能用」的RAG

去年底我接手了一个内部知识库问答系统,数据源是大约1.2万篇技术文档和工单记录,总字符数约3800万。技术栈很朴素:LangChain 0.0.350 + FAISS 1.7.4 + text-embedding-ada-002 + GPT-3.5-turbo。

上线第一周,产品同学反馈「回答经常答非所问,或者干脆说找不到」。我搭了个200条人工标注的评测集(每条包含query、标准答案、golden chunk id),跑了一遍指标:

  • Recall@5:0.62
  • MRR:0.51
  • 端到端答案准确率(人工判定):0.58

问题很明显:检索环节就漏掉了近40%的正确文档。生成模型再强也救不回来。于是决定做一次系统性优化,目标是把Recall@5拉到0.85以上。

二、环境与版本

先把基线环境固定下来,方便对比:

Python 3.10.13
LangChain 0.0.350
FAISS 1.7.4 (IndexFlatIP)
OpenAI text-embedding-ada-002 (1536维)
GPT-3.5-turbo (temperature=0)
sentence-transformers 2.2.2
torch 2.1.0 + cu118

评估脚本用ragas 0.0.22跑自动指标,但最终判定以人工标注为准,因为ragas在中文场景下对「忠实度」的判断有时偏乐观。

三、方案设计:三段式优化路线

我没有一次性全改,而是拆成三个独立变量,逐个AB测试,避免「一起改完不知道是谁的功劳」:

  1. Chunk策略:固定512字符 → 递归字符切分 + 语义边界
  2. Embedding模型:Ada-002 → bge-large-zh-v1.5
  3. Rerank:无 → bge-reranker-large

每一阶段都保持其他变量不变,在同一评测集上跑指标。

四、核心实现

4.1 Chunk策略调整

原来的切分是CharacterTextSplitter(chunk_size=512, chunk_overlap=50),问题是它会把一个完整的代码块或一个表格从中间劈开,检索出来的片段语义不完整。

改成两级策略:先按Markdown标题和段落切,再对超长段落做递归切分。

from langchain.text_splitter import RecursiveCharacterTextSplitter, MarkdownHeaderTextSplitter

# 第一级:按Markdown标题切
headers_to_split_on = [
    ("#", "h1"),
    ("##", "h2"),
    ("###", "h3"),
]
md_splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers_to_split_on)

# 第二级:递归字符切分,中文标点优先
recursive_splitter = RecursiveCharacterTextSplitter(
    chunk_size=400,
    chunk_overlap=80,
    separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""],
    length_function=len,
)

def split_doc(text: str):
    md_chunks = md_splitter.split_text(text)
    final_chunks = []
    for c in md_chunks:
        if len(c.page_content)  H2:环境变量】`,这个trick对检索提升非常明显

### 4.2 Embedding模型切换

Ada-002在英文上很强但中文技术文档里大量专有名词比如Kubernetes」「灰度发布」「限流熔断」),它的中文语义区分度一般换成`BAAI/bge-large-zh-v1.5`,1024中文MTEB榜单长期靠前

```python
from sentence_transformers import SentenceTransformer
import numpy as np
import faiss

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]
    vecs = model.encode(texts, normalize_embeddings=True, batch_size=64)
    return np.asarray(vecs, dtype="float32")

# 建索引
chunks = [...]  # 上一步切好的
doc_vecs = embed(chunks, is_query=False)
index = faiss.IndexFlatIP(1024)  # 归一化后内积=余弦相似度
index.add(doc_vecs)

踩坑:一开始忘了给query加指令前缀,Recall@5只从0.62涨到0.68;加上前缀后直接到0.79。bge官方文档里写得很清楚,但很容易忽略。

4.3 Rerank引入

向量检索是双塔结构,query和doc各自编码,交互不够细。加一个cross-encoder重排序,对Top-20候选重新打分。

from FlagEmbedding import FlagReranker

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

def search_with_rerank(query, top_k=5, recall_k=20):
    q_vec = embed([query], is_query=True)
    scores, ids = index.search(q_vec, recall_k)
    candidates = [chunks[i] for i in ids[0]]
    pairs = [[query, c] for c in candidates]
    rerank_scores = reranker.compute_score(pairs, normalize=True)
    ranked = sorted(zip(candidates, rerank_scores), key=lambda x: -x[1])
    return ranked[:top_k]

use_fp16=True在A10上单条query重排20个候选约35ms,完全可以接受。

五、踩坑与优化

  1. bge-reranker不是越大越好:试过bge-reranker-v2-m3,效果和large差不多,但显存翻倍,最后选large。
  2. FAISS索引选择:1.2万chunk用IndexFlatIP就够,暴力检索10ms内。别一上来就上IVF,反而损失精度。
  3. rerank的recall_k:从10调到20提升明显,调到30基本无收益,延迟还涨。最终定20。
  4. chunk元数据:每个chunk必须存doc_id和标题路径,否则rerank后无法溯源,也没法做去重。
  5. 缓存:query embedding和rerank结果都做了LRU缓存,线上QPS 5时命中率约30%。

六、效果数据

同一评测集(200条),逐项累加改造:

阶段 Recall@5 Top-3命中 MRR 答案准确率 平均延迟
基线(Ada-002 + 512固定切分) 0.62 0.65 0.51 0.58 420ms
+ 递归切分 & 标题路径 0.71 0.74 0.60 0.65 430ms
+ bge-large-zh-v1.5 0.79 0.83 0.70 0.73 480ms
+ bge-reranker-large(recall_k=20) 0.89 0.93 0.82 0.84 620ms

延迟从420ms涨到620ms,其中rerank占约150ms,embedding从Ada-002的API调用改成本地推理反而省了网络往返。整体P99约900ms,产品可接受。

七、总结

这次优化的核心体会:

  • 检索是RAG的天花板。生成模型再换GPT-4,检索Recall只有0.62也白搭。先把检索打到0.85以上,再考虑生成侧。
  • 改一个变量,测一个指标。三个改动如果一起上,你根本不知道哪个有效、哪个在拖后腿。
  • 中文场景优先考虑bge系列。Ada-002不是不好,是在中文技术语料上性价比不占优,而且本地部署省成本、数据不出域。
  • rerank是性价比最高的一步。只增加150ms延迟,Recall@5直接涨10个点,强烈建议所有RAG系统都加上。

下一步计划:把chunk策略再细化到「按语义相似度动态合并」,以及试试query改写(HyDE),看能不能把Recall推到0.92以上。有进展再写一篇。