1. 问题背景:搜索召回瓶颈,而非生成瓶颈
项目背景是做一个内部技术文档的问答机器人,语料约2000份Markdown文档。初版方案非常直白:LangChain + FAISS + bge-small-zh + ChatGLM3-6B。上线后发现一个尴尬现象——用户问“如何配置Nginx反向代理”,系统答非所问,但文档里明明写得清清楚楚。
我用一套包含300个问题的内部评测集跑了一遍,发现问题出在召回阶段:
- Recall@5:0.67(即正确答案在前5个召回块中的比例)
- 生成答案准确率(人工打分):57.3%
进一步排查发现,初版用了默认的chunk策略(RecursiveCharacterTextSplitter,chunk_size=500, overlap=0)。这导致两个典型问题:
- 语义断裂:Nginx配置的
server {}代码块被拦腰截断,后半段丢了listen 8080关键信息。 - 噪声块过多:一篇长文档被切成20个块,其中15个块与问题无关,却在向量检索时占据了前5名。
所以本次优化的核心思路就是:让“对的块”更容易被检索到,同时把“错的块”压下去。具体手段就是标题里写的三件事:chunk策略调整、Embedding模型替换、引入Rerank重排序。
2. 环境与版本说明
先列一下本次实验的硬性环境,方便复现:
- Python 3.10.12
- langchain 0.1.16(注意:0.2.x API有变动,后续代码基于0.1.x)
- faiss-cpu 1.8.0(后期为了速度换了faiss-gpu,但结果无差异)
- sentence-transformers 2.7.0
- torch 2.3.0 + CUDA 12.1
- GPU:单张RTX 4090 24GB
Embedding模型对比:
| 模型 | 维度 | 句向量长度限制 | 显存占用 |
|---|---|---|---|
| BAAI/bge-small-zh-v1.5 | 512 | 512 tokens | 约1.2GB |
| text2vec-large-chinese | 1024 | 512 tokens | 约3.8GB |
Rerank模型:BAAI/bge-reranker-base,参数约278M,推理时单条样本延迟约35ms。
3. 方案设计:三个独立优化点,按优先级排序
我并没有一次性梭哈三个改动,而是分三步走,每步都单独跑评测集,确保每次改动都能归因。
3.1 第一步:修复chunk切分逻辑
之前用过RecursiveCharacterTextSplitter,但它的默认分隔符列表对代码块不友好。我重新设计了切分策略:
- 分隔符优先级:
\n##>\n###>\n####>\n\n>\n>。>空格>空字符 - chunk_size:从500降到300
- overlap:从0增加到50(即约15%的重叠)
- 强制保留代码块:如果某个chunk包含markdown代码块标记(```),则将chunk边界强制对齐到代码块的结束位置
这样做的理由是:300字对于中文技术文档来说,基本能覆盖一个完整的知识点;overlap=50能缓解边界内容被截断的问题;代码块强制对齐则是针对本项目语料特性的定制。
3.2 第二步:Embedding模型升级
bge-small-zh(512维)在短文本匹配上表现不错,但我们的文档块平均长度在300字左右,实体和术语密集,小模型的语义表达能力不够。我测试了三个候选:
- BAAI/bge-large-zh-v1.5(1024维)
- text2vec-large-chinese(1024维)
- m3e-base(768维)
最终选了text2vec-large-chinese,原因有两点:
- 在我们的评测集上,text2vec的Recall@5比bge-large高1.8%。
- 它支持同义词扩展(虽然我用的是
sentence-transformers加载,但这个模型底层对中文近义词的编码确实更友好)。
3.3 第三步:引入Rerank
向量检索(FAISS)负责快速召回Top50,然后用bge-reranker-base对50个候选块逐条打分,取Top5送入LLM。这一步是计算量最大的,但对准确率提升最明显。
4. 核心实现:关键代码与参数
4.1 自定义Chunk切分器
from langchain.text_splitter import RecursiveCharacterTextSplitter
# 自定义分隔符,优先级从高到低
separators = [
"\n## ", "\n### ", "\n#### ",
"\n\n", "\n", "。", "!", "?",
";", ",", " ", ""
]
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=300,
chunk_overlap=50,
separators=separators,
keep_separator=True, # 保留标题符号,保证标题不被吞
)
# 强制代码块对齐:先按代码块拆分,再对非代码块部分做递归切分
def smart_split_document(doc_text: str):
if "```" not in doc_text:
return text_splitter.split_text(doc_text)
chunks = []
parts = doc_text.split("```")
for i, part in enumerate(parts):
if i % 2 == 1: # 代码块内容,整体作为一个块
code_block = "```" + part + "```"
if len(code_block) > 300:
# 超长代码块内部按行切,但保留注释头
chunks.extend(text_splitter.split_text(code_block))
else:
chunks.append(code_block)
else:
chunks.extend(text_splitter.split_text(part))
return chunks
4.2 Embedding与Rerank串联
from sentence_transformers import SentenceTransformer
from FlagEmbedding import FlagReranker
# Embedding模型:text2vec-large-chinese
embedder = SentenceTransformer("GanymedeNil/text2vec-large-chinese", device="cuda")
# FAISS索引构建(使用内积距离)
import faiss
vectors = embedder.encode(all_chunks, normalize_embeddings=True)
index = faiss.IndexFlatIP(vectors.shape[1])
index.add(vectors)
# Rerank模型:bge-reranker-base
reranker = FlagReranker("BAAI/bge-reranker-base", use_fp16=True)
def retrieve_with_rerank(query: str, top_k=50, final_k=5):
q_vec = embedder.encode([query], normalize_embeddings=True)
scores, indices = index.search(q_vec, top_k)
candidates = [all_chunks[i] for i in indices[0]]
pair = [[query, cand] for cand in candidates]
rerank_scores = reranker.compute_score(pair)
# 按重排序分数降序,取前final_k
sorted_indices = np.argsort(rerank_scores)[::-1][:final_k]
return [candidates[i] for i in sorted_indices]
注意:FlagReranker.compute_score接受的是List of pair,返回一维分数数组。这里没有归一化,因为只是排序。
5. 踩坑记录
坑1:chunk_size=300 + overlap=50导致向量维度爆炸
text2vec-large-chinese的维度是1024,配合overlap后,每个文档平均产生约7个块,比之前多出40%。FAISS索引构建时间从5秒涨到11秒,但查询时间几乎没变。这个代价可接受。
坑2:bge-reranker-base的FP16推理需要显存对齐
一开始直接使用默认FP32,在4090上跑50个候选对需要约1.2GB显存,但速度慢得离谱(单条查询380ms)。改用use_fp16=True后,延迟降到35ms,显存占用降到780MB。但注意:FP16模式下输入长度超过512 tokens会被截断,所以我在切chunk时硬性限制chunk_size <= 300,确保加上query后总长度不超限。
坑3:text2vec-large-chinese对英文代码块不友好
实测英文参数名(如listen 8080)编码后,在向量空间中与中文查询的相似度反而低于bge-small。解决方案是:在构建向量时,对包含代码块的chunk额外拼接一份“代码块语言提示语”,如[CODE]标签。这个trick让最终准确率提升了约0.8%。
6. 效果数据:三步优化后的对比
以下是内部评测集(300个问答对)的详细结果:
| 版本 | Chunk策略 | Embedding模型 | Rerank | Recall@5 | 准确率 | 平均查询延迟 |
|---|---|---|---|---|---|---|
| 初版 | 500/0 | bge-small-zh | 无 | 0.67 | 57.3% | 85ms |
| 仅调chunk | 300/50+代码对齐 | bge-small-zh | 无 | 0.74 | 63.1% | 88ms |
| +换embedding | 300/50+代码对齐 | text2vec-large | 无 | 0.82 | 71.5% | 120ms |
| +引入rerank | 300/50+代码对齐 | text2vec-large | bge-reranker-base | 0.91 | 82.4% | 420ms |
关键结论:
- 调整chunk策略是性价比最高的,代码量最小,收益+7%。
- 换Embedding模型在Recall上提升明显(+8%),但延迟也增加了30ms。
- 引入Rerank带来的收益最大(Recall+9%,准确率+11%),但延迟从120ms涨到420ms。对于内部工具性质的问答系统,420ms完全可接受。
另外,我还单独测试了只换Embedding不调chunk的效果,准确率只有68.9%,说明chunk切分是地基,地基不牢,模型再强也白搭。
7. 总结与后续优化方向
这次优化的核心经验可以浓缩为三句话:
- 先调chunk,再换模型,最后上rerank。这个顺序能帮助你隔离每个变量的影响,避免出现“改了model但效果没变,因为chunk本身是错的”这种玄学。
- Rerank不是万能药。它只能对向量召回的候选集做重排,如果Top50里根本没有正确答案,rerank也无力回天。所以我的下一步计划是尝试混合检索(BM25 + 向量),提升召回阶段的“天花板”。
- 延迟和准确率要按场景取舍。如果你的RAG是给客服系统用的(要求<200ms),那么rerank可能太贵;但如果是内部知识库查询(允许1秒内),420ms完全值得。
最后说一句,网上很多RAG优化文章喜欢直接上最重的模型,但实际工程中,chunk_size和overlap的调优往往能带来最意想不到的巨大收益。如果你的RAG效果不好,先别急着砸GPU,花半小时看看你的文档是怎么被切开又怎么被检索的。
以上。欢迎评论区交流你们的踩坑经历。