一、问题背景:上线两周,用户开始骂人了
我们做的是一个面向内部技术支持人员的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。
坑2:normalize_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 踩坑
坑1:compute_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的朋友参考:
- chunk策略是ROI最高的优化,不要一上来就换模型。我见过太多人直接上最贵的embedding,结果chunk切得稀碎,效果还是差。
- 中文场景别用OpenAI embedding,bge系列在中文上明显更强,还免费、还快。
- rerank是精度和延迟的trade-off,recall_k=50是个不错的起点,具体要看你的延迟预算。
- 每一步都要有评测集。我用300条标注query,每次改动跑一次,40秒出结果。没有评测集的优化就是盲调。
- 别忽略instruction和normalize这些细节,bge官方文档写得很清楚,但很多人不看,白白丢几个点。
最后说一句:RAG优化是个系统工程,检索、生成、prompt每一环都有空间。但先把检索做扎实,因为检索错了,后面全是白费。我现在还在继续做query改写和多路召回,有新进展再写一篇。
代码和评测脚本我整理在内部repo了,涉及公司数据没法开源,但上面贴的片段都是可以直接跑的,改改路径就能用。有问题评论区聊。