一、问题背景:一个「看起来能用」的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测试,避免「一起改完不知道是谁的功劳」:
- Chunk策略:固定512字符 → 递归字符切分 + 语义边界
- Embedding模型:Ada-002 → bge-large-zh-v1.5
- 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,完全可以接受。
五、踩坑与优化
- bge-reranker不是越大越好:试过
bge-reranker-v2-m3,效果和large差不多,但显存翻倍,最后选large。 - FAISS索引选择:1.2万chunk用IndexFlatIP就够,暴力检索10ms内。别一上来就上IVF,反而损失精度。
- rerank的recall_k:从10调到20提升明显,调到30基本无收益,延迟还涨。最终定20。
- chunk元数据:每个chunk必须存doc_id和标题路径,否则rerank后无法溯源,也没法做去重。
- 缓存: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以上。有进展再写一篇。