1. 问题背景:为什么我的RAG系统答非所问?

几个月前我接手了一个内部知识库问答系统,基于LangChain + OpenAI兼容接口构建。用户反馈很直接:“问一个具体参数,它给我一堆无关文字”“有时候回答完全不对”。经过简单压测,发现两个核心问题:

  1. 召回率低:标准问题集的召回率(recall@5)只有62%,意味着近四成相关文档根本没被检索到。
  2. 排序不准:即使召回了正确文档,top-1准确率仅35%,正确片段往往排在第三、第四位。

当时使用的组件如下:
- 文档切分:RecursiveCharacterTextSplitter,chunk_size=512,chunk_overlap=128
- Embedding模型:text2vec-large-chinese(moka-ai/m3e-base之前的流行选项)
- 向量库:Chroma(单机测试,hnsw:space=cosine)
- 检索参数:similarity_top_k=5

系统版本:Python 3.10.12,LangChain 0.1.16,chromadb 0.4.22。

2. 环境与版本:本次优化使用的技术栈

优化过程分三阶段进行,为了结果可复现,所有测试使用同一台机器(Mac M2 Pro,16GB内存)和同一份测试集(200个问答对,来自技术手册和FAQ)。主要依赖如下:

# 核心依赖版本
langchain==0.2.10
langchain-community==0.2.9
chromadb==0.5.5
sentence-transformers==3.0.1
FlagEmbedding==1.3.0  # 用于BGE模型
torch==2.3.0          # MPS后端(Mac专用)

测试集说明:200个问题中,约30%需要多段落推理(如“A模块的B参数在C场景下的限值是多少”),40%为单段落事实性查询,30%为模糊描述(如“那个连接器有几种规格”)。

3. 方案设计:三步走,先查全再排准

优化策略分三个阶段,每个阶段只改变一个变量:

阶段 改动点 预期目标
1 chunk策略调整 提升recall@5到75%以上
2 embedding模型替换 提升recall@5到85%+,top-3准确率提升
3 引入rerank 提升top-1准确率至50%+,不降低召回

核心思想:先保证相关文档能被检索到,再保证最相关的结果排在第一位

4. 核心实现:三阶段代码与关键参数

4.1 阶段一:chunk策略调整

原方案使用固定的512字符切分,遇到长技术文档时,一段关键信息可能被切到两个chunk中,导致任何一个单独chunk都缺乏完整上下文。新方案使用语义感知的递归切分,并针对中文技术文档调整分隔符优先级。

from langchain.text_splitter import RecursiveCharacterTextSplitter

# 新策略:基于句子和段落边界,加大chunk_size允许更多上下文
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=768,           # 从512提升到768,适配中文技术文档
    chunk_overlap=256,        # 重叠从128提升到256,减少边界信息丢失
    separators=[
        "\n\n",               # 段落分隔(最高优先级)
        "\n",                 # 行分隔
        "。",                 # 中文句号(新增)
        ";",                 # 中文分号(新增)
        ",",                 # 中文逗号
        " ",                  # 空格
        ""                    # 字符级别
    ],
    length_function=len,
)

改动要点
- 增加chunk_size(512→768),让技术文档中的完整段落尽量不被切断
- 增加中文标点分隔符(句号、分号),使切分更符合中文语义
- 加大重叠(128→256),保证跨chunk的关键信息至少有一个完整版本

4.2 阶段二:embedding模型替换

text2vec-large-chinese(768维)对技术术语的语义理解偏弱,比如“过流保护阈值”和“电流限值”在它的向量空间中距离较远。替换为BAAI的bge-m3模型(1024维),它在多语言和密集检索任务上表现更好。

from langchain_community.embeddings import HuggingFaceBgeEmbeddings

model_name = "BAAI/bge-m3"
model_kwargs = {
    'device': 'mps',          # Mac M系列GPU加速
    'torch_dtype': 'float16'  # 半精度减少显存占用
}
encode_kwargs = {
    'normalize_embeddings': True,
    'batch_size': 32
}

embeddings = HuggingFaceBgeEmbeddings(
    model_name=model_name,
    model_kwargs=model_kwargs,
    encode_kwargs=encode_kwargs,
    query_instruction="为这个句子生成表示以用于检索相关文章:",  # BGE的query前缀
)

关键参数
- normalize_embeddings=True:余弦相似度计算前归一化,提高检索稳定性
- query_instruction:BGE模型要求对query添加特定前缀,否则检索效果下降10-15%
- 推理时间:embedding 200条query+850个chunk,bge-m3耗时约8秒(text2vec约5秒),可接受

4.3 阶段三:引入rerank

即使embedding模型再好,向量检索本质上是在“语义近似”空间找最近邻,容易把近义词但非正确答案排在前面。rerank模型通过交叉编码(cross-encoder)对query和每个候选文档进行深度匹配,重新打分排序。

from FlagEmbedding import FlagReranker

# 初始化reranker(加载到MPS)
reranker = FlagReranker(
    'BAAI/bge-reranker-v2-m3',
    use_fp16=True,
    device='mps'
)

def rerank_documents(query: str, documents: list, top_k: int = 3) -> list:
    """
    对向量检索结果进行重排序
    :param query: 用户问题
    :param documents: 向量检索返回的Document列表(score已排序)
    :param top_k: 最终返回条数
    :return: 重排序后的Document列表
    """
    pairs = [[query, doc.page_content] for doc in documents]
    scores = reranker.compute_score(pairs, normalize=True)  # 返回0-1之间的分数

    # 将分数附加到document对象上
    scored_docs = []
    for doc, score in zip(documents, scores):
        doc.metadata['rerank_score'] = score
        scored_docs.append((doc, score))

    # 按rerank分数降序排列
    scored_docs.sort(key=lambda x: x[1], reverse=True)
    return [doc for doc, _ in scored_docs[:top_k]]

注意normalize=True使rerank分数在0-1之间,方便与向量检索的相似度分数做对比或加权融合(本文未融合,直接使用rerank排序)。

5. 踩坑与优化:那些文档没告诉你的细节

踩坑1:chunk_size不是越大越好
我曾尝试chunk_size=1024,认为包含更多上下文。结果recall@5反而下降至73%,因为大chunk包含的信息太杂,embedding向量无法聚焦到问题相关的具体段落。768是当前知识库的最优值。

踩坑2:BGE模型的query指令必须加
不加query_instruction时,bge-m3的召回率仅71%,比text2vec还低。官方文档要求对query使用"为这个句子生成表示以用于检索相关文章:"前缀,对文档不加。我在测试中漏加了query前缀,浪费了半天排查。

踩坑3:rerank的推理延迟
bge-reranker-v2-m3对每个query-document对需要约15ms(MPS下),top-5检索结果rerank一次约75ms。如果QPS要求大于10,需要将rerank改为异步批处理或使用更轻量的reranker(如bge-reranker-v2-small)。我的场景是内部工具,延迟容忍度高,所以直接用full model。

踩坑4:chunk重叠导致的重复答案
chunk_overlap=256后,同一段信息可能出现在两个chunk中。向量检索时两个chunk都被召回,rerank后可能占据top-2。解决方案:在rerank后去重(根据文档hash),保留rerank分数最高的副本。

6. 效果数据:每个阶段提升了什么?

测试集200个问题,评估指标:
- recall@5:top-5结果中是否包含正确答案(正确性由人工标注)
- top-3准确率:正确答案是否出现在top-3中
- top-1准确率:第一个结果是否为正确答案

阶段 recall@5 top-3准确率 top-1准确率 平均检索延迟
初始(512+text2vec) 62% 69% 35% ~120ms
阶段1(chunk优化) 78% 81% 38% ~110ms
阶段2(bge-m3) 89% 86% 43% ~135ms
阶段3(+rerank) 89% 88% 58% ~210ms

关键发现
1. chunk优化贡献最大的是recall@5(+16%),对top-1提升有限
2. bge-m3相比text2vec,在语义区分上更优,top-3准确率提升5%
3. rerank是top-1准确率的最大功臣(+15%),且不影响召回率
4. 三阶段总效果:top-1准确率从35%→58%,用户反馈“答非所问”减少了约70%

7. 总结:RAG优化的优先级建议

基于本次实践,我的建议优先级是:

  1. 先调chunk策略:这是成本最低的优化,只需改几行参数,往往能直接提升召回。中文技术文档一定要加中文标点分隔符。
  2. 再换embedding模型:从m3e/text2vec升级到bge-m3或gte-large,收益明确,代价是模型加载和推理时间增加。
  3. 最后加rerank:当召回率足够高(>85%)但top-1不理想时,rerank是终结方案。注意推理延迟和模型选型。

最终系统上线后,用户满意度从62%(调研得分)提升至84%。后续计划引入HyDE(假设文档嵌入)和query重写,针对模糊查询做进一步优化。

代码及测试数据已上传至GitHub(链接见评论区),欢迎Star和PR。有疑问可以在评论区交流,我会尽量回复。