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)。这导致两个典型问题:

  1. 语义断裂:Nginx配置的server {}代码块被拦腰截断,后半段丢了listen 8080关键信息。
  2. 噪声块过多:一篇长文档被切成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,原因有两点:

  1. 在我们的评测集上,text2vec的Recall@5比bge-large高1.8%。
  2. 它支持同义词扩展(虽然我用的是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. 总结与后续优化方向

这次优化的核心经验可以浓缩为三句话:

  1. 先调chunk,再换模型,最后上rerank。这个顺序能帮助你隔离每个变量的影响,避免出现“改了model但效果没变,因为chunk本身是错的”这种玄学。
  2. Rerank不是万能药。它只能对向量召回的候选集做重排,如果Top50里根本没有正确答案,rerank也无力回天。所以我的下一步计划是尝试混合检索(BM25 + 向量),提升召回阶段的“天花板”。
  3. 延迟和准确率要按场景取舍。如果你的RAG是给客服系统用的(要求<200ms),那么rerank可能太贵;但如果是内部知识库查询(允许1秒内),420ms完全值得。

最后说一句,网上很多RAG优化文章喜欢直接上最重的模型,但实际工程中,chunk_size和overlap的调优往往能带来最意想不到的巨大收益。如果你的RAG效果不好,先别急着砸GPU,花半小时看看你的文档是怎么被切开又怎么被检索的。

以上。欢迎评论区交流你们的踩坑经历。