一、问题背景:上线两周,用户投诉不断
我们做的是一套面向内部运维团队的知识库问答系统,文档来源包括Confluence、GitLab Wiki和一些PDF手册,总量约1.2万篇,平均每篇800字。技术栈是LangChain + Chroma + GPT-3.5-turbo,典型的RAG架构。
上线第一周,我就收到了十几条投诉。典型问题包括:
- 问“Nginx 502排查步骤”,返回的却是“Nginx安装配置”
- 问“K8s Pod OOM怎么处理”,检索到的片段只包含“OOM”这个词但完全不相关
- 多轮追问时,上下文经常丢失关键信息
我搭了一个简单的评测集:200个问题,每个问题标注了标准答案和应召回的文档ID。跑下来数据很难看:
| 指标 | 数值 |
|---|---|
| Recall@5 | 62% |
| MRR | 0.48 |
| 答案准确率(人工评估) | 54% |
这个水平根本没法交付。于是开始了为期三周的优化。
二、环境与版本
先交代一下基础环境,避免版本差异导致复现问题:
Python: 3.10.12
langchain: 0.1.16
chromadb: 0.4.24
openai: 1.23.2
sentence-transformers: 2.7.0
torch: 2.2.1 (CUDA 12.1)
FlagEmbedding: 1.2.10
硬件:一台A10 GPU服务器(24G显存),用于本地embedding和rerank推理。LLM继续用GPT-3.5-turbo,这块不是本文优化重点。
三、方案设计:三步走
我没有一次性全改,而是分三步,每步单独评测,这样才能知道哪个改动真正有效:
- Chunk策略调整:从固定512字符改为256字符+50重叠,并保留标题层级
- Embedding模型切换:text-embedding-ada-002 → bge-large-zh-v1.5
- 引入Rerank:bge-reranker-large,对top-20召回结果重排取top-5
评测方法保持一致:同样的200题评测集,同样的LLM,只改检索环节。
四、核心实现
4.1 Chunk策略调整
原来的做法很粗暴:
from langchain.text_splitter import CharacterTextSplitter
splitter = CharacterTextSplitter(
chunk_size=512,
chunk_overlap=0,
separator="\n"
)
问题很明显:512字符对中文来说太长了,一个chunk里可能混了好几个主题,embedding被“平均”掉,语义焦点模糊。而且没有重叠,跨chunk的信息直接被切断。
改成按标题层级切分 + 递归字符切分:
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)
# 再按字符递归切,控制粒度
char_splitter = RecursiveCharacterTextSplitter(
chunk_size=256,
chunk_overlap=50,
separators=["\n\n", "\n", "。", "!", "?", ";", ",", ""],
length_function=len,
)
def split_doc(text):
md_chunks = md_splitter.split_text(text)
final_chunks = []
for c in md_chunks:
# 把标题拼回内容,保证chunk自带上下文
header = " > ".join([v for k, v in c.metadata.items() if v])
for sub in char_splitter.split_text(c.page_content):
final_chunks.append({
"text": f"{header}\n{sub}" if header else sub,
"metadata": c.metadata
})
return final_chunks
关键点有两个:一是把标题拼进chunk内容,这样embedding时能感知到“这是Nginx下的502排查”;二是overlap=50,保证跨chunk的句子不被硬切。
4.2 Embedding模型切换
原来用OpenAI的text-embedding-ada-002,1536维,英文强但中文一般,而且每次调用都要走网络,成本和延迟都高。换成BGE:
from sentence_transformers import SentenceTransformer
import torch
device = "cuda" if torch.cuda.is_available() else "cpu"
model = SentenceTransformer("BAAI/bge-large-zh-v1.5", device=device)
model.max_seq_length = 512
def embed(texts, is_query=False):
# BGE检索时query需要加instruction前缀
if is_query:
texts = [f"为这个句子生成表示以用于检索相关文章:{t}" for t in texts]
with torch.no_grad():
emb = model.encode(
texts,
batch_size=32,
normalize_embeddings=True, # 归一化后用内积=余弦
show_progress_bar=False
)
return emb
这里有个坑我后面会细说:BGE的query和passage处理方式不一样,query要加instruction前缀,passage不加。我一开始两边都加了,效果反而掉了。
4.3 Rerank引入
Rerank的思路是:先用embedding粗排召回top-20,再用cross-encoder精排。Cross-encoder把query和doc拼在一起过模型,能捕捉细粒度交互,但计算量大,所以只用于小候选集。
from FlagEmbedding import FlagReranker
reranker = FlagReranker("BAAI/bge-reranker-large", use_fp16=True)
def rerank(query, candidates, top_k=5):
pairs = [[query, c["text"]] for c in candidates]
scores = reranker.compute_score(pairs, normalize=True)
for c, s in zip(candidates, scores):
c["rerank_score"] = s
candidates.sort(key=lambda x: x["rerank_score"], reverse=True)
return candidates[:top_k]
use_fp16=True很关键,A10上fp16推理,20个pair耗时约180ms,可以接受。
五、踩坑与优化
坑1:BGE的instruction前缀。 BGE-large-zh-v1.5的官方说明是query加instruction,passage不加。我一开始图省事两边都加,Recall@5反而从68%掉到64%。改回来后正常。
坑2:Chunk太小导致召回碎片化。 256字符确实提升了语义聚焦,但有些答案需要跨2-3个chunk。解决办法是在最终组装context时,把相邻chunk(通过metadata里的doc_id和chunk_index)合并,最多合并3个,总长度不超过1500字符。
坑3:Rerank的top_k选择。 一开始粗排取top-10,精排取top-3,发现有些问题标准答案排在第4、5位。改成粗排top-20、精排top-5后,Recall@5明显提升。代价是rerank耗时从90ms涨到180ms,但值得。
坑4:Chroma的collection需要重建。 换embedding模型后维度从1536变成1024,必须删库重建。我写了脚本批量重新灌数据,1.2万篇文档约15分钟。
六、效果数据
200题评测集,每步改动的对比:
| 阶段 | Recall@5 | MRR | 答案准确率 | 平均检索延迟 |
|---|---|---|---|---|
| 基线(512 chunk + ada-002) | 62% | 0.48 | 54% | 320ms |
| + Chunk调整(256+50重叠) | 71% | 0.56 | 63% | 310ms |
| + 切换bge-large-zh-v1.5 | 83% | 0.71 | 74% | 95ms |
| + 引入bge-reranker-large | 91% | 0.84 | 84% | 275ms |
几个观察:
- Chunk调整单独贡献了+9个点的Recall,说明长chunk对中文语义确实是灾难
- Embedding切换贡献最大,+12个点,而且延迟从320ms降到95ms(本地推理 vs 网络调用),成本也省了
- Rerank再贡献+8个点,虽然延迟回到275ms,但相比基线还是更快,且准确率提升显著
- 答案准确率的提升幅度小于Recall,说明还有LLM生成环节的优化空间,这是下一步的事
线上灰度一周后,用户投诉从每周十几条降到2条,主要是些边界case。
七、总结
这次优化的核心体会:
- Chunk策略是RAG的地基,中文场景下256字符+重叠+标题保留,比512无重叠强太多
- Embedding模型要选对语言,中文场景bge-large-zh-v1.5性价比极高,本地部署还省成本
- Rerank是性价比最高的增量优化,在粗排召回够全的前提下,精排能把准确率拉上一个台阶
- 每步单独评测,别一次性全改,否则出了问题不知道是哪个环节的锅
下一步计划:一是试试bge-m3做多向量检索,二是针对LLM生成环节做prompt工程和few-shot,三是引入查询改写(HyDE)处理短query。
代码都贴在文里了,有需要的可以直接跑。如果你们的RAG系统也卡在60%多的召回率,建议按这个顺序试一遍。