一、问题背景:为什么我的RAG系统像“人工智障”

上个月接手一个内部技术文档问答项目,基于LangChain + ChromaDB + OpenAI的经典组合搭建。初始版本上线后,用户反馈“答非所问”的比例高达40%。最典型的情况是:用户问“如何配置JWT过期时间”,系统返回的是“JWT介绍”或“OAuth2.0流程”——检索到的片段根本不在点上。

我拉取了连续7天的日志,统计了500条真实问答记录,发现三个核心问题:
1. 召回率低:Top-5相关文档命中率仅61%,很多关键信息根本没被检索到。
2. 片段不完整:即使召回了,chunk切分导致上下文断裂,答案生成时缺乏关键参数。
3. 排序不合理:最相关的文档经常排在第三、第四位,被生成器忽略。

这篇文章记录了我如何通过三步优化——调整chunk策略切换embedding模型引入rerank重排——将系统准确率从0.61提升到0.89的完整过程。

二、环境与版本:技术栈明细

先交代基础环境,方便读者对照复现:

# requirements.txt 核心依赖
langchain==0.1.12
langchain-openai==0.0.8
chromadb==0.4.24
sentence-transformers==2.7.0
rerankers==0.3.0
bge-reranker-base==1.0.0
paddleocr==2.7.3  # 用于PDF文档解析

硬件:单张T4 GPU(16GB显存),用于embedding和rerank模型推理。
数据集:1200篇技术文档(Java/Spring Boot/K8s/MySQL),共约80万字符。人工标注了200对问答对作为评测集。

评估方法:对每个问题,人工判断Top-5检索结果中是否包含正确答案(召回率),以及生成答案是否准确(准确率)。

三、方案设计:三步走的优化路径

我最初的方案是“一刀切”的:所有文档按固定512字符切分,使用OpenAI text-embedding-ada-002,不做重排。优化后改为:

  1. chunk策略:从固定大小改为按语义段落切分 + 重叠窗口,并针对代码块做特殊处理。
  2. embedding模型:从闭源ada-002换成开源的BAAI/bge-large-zh-v1.5,中文场景表现更好。
  3. rerank重排:增加bge-reranker-base作为第二阶段的精排模型,对召回的20个候选重新排序,取Top-5。

整体流程变为:

文档 → 段落切分 → chunk构建 → embedding → 向量库
查询 → embedding → 粗排召回Top-20 → rerank精排Top-5 → LLM生成

四、核心实现:chunk策略调整

4.1 初始方案的问题

最初用RecursiveCharacterTextSplitter,chunk_size=512,overlap=50。问题在于:
- 技术文档中的代码块经常被拦腰截断,导致语法不完整。
- 一个完整的概念描述(如“JWT包含三部分:Header、Payload、Signature”)可能被切到两个chunk里。

4.2 优化后的切分方案

我自定义了一个TechDocSplitter,核心逻辑是:

from langchain.text_splitter import RecursiveCharacterTextSplitter
import re

class TechDocSplitter:
    def __init__(self, chunk_size=400, overlap=80):
        self.chunk_size = chunk_size
        self.overlap = overlap

    def split(self, text):
        # 1. 先按代码块分隔
        code_blocks = re.split(r'(```\w*\n.*?```)', text, flags=re.DOTALL)

        chunks = []
        for block in code_blocks:
            if block.startswith('```'):
                # 代码块单独成chunk,不切分
                chunks.append(block)
            else:
                # 文本部分用递归切分,但按段落优先
                splitter = RecursiveCharacterTextSplitter(
                    chunk_size=self.chunk_size,
                    chunk_overlap=self.overlap,
                    separators=["\n\n", "\n", "。", ";", ",", " ", ""]
                )
                chunks.extend(splitter.split_text(block))

        # 2. 合并过小的chunk(<50字符)
        merged = []
        temp = ""
        for c in chunks:
            if len(temp) + len(c) < self.chunk_size:
                temp += c
            else:
                if temp:
                    merged.append(temp)
                temp = c
        if temp:
            merged.append(temp)

        return merged

关键改动:
- 代码块(用``包裹的)**整体保留**,不切分。 - 文本部分按\n\n(段落)优先切分,保证语义完整性。 - 设置min_chunk_size=50`,避免碎片化。

效果对比:

切分策略 Top-5召回率 答案准确率
固定512字符 61% 52%
段落切分+重叠80 68% 59%
段落切分+代码块保留 74% 65%

召回率提升了13个百分点,主要来自代码块不再断裂。

五、核心实现:embedding模型切换

5.1 为什么换掉ada-002

在中文技术文档场景下,text-embedding-ada-002有两个明显的短板:
- 对中文专业术语(如“熔断降级”、“分布式事务”)理解不佳。
- 向量维度1536,检索速度慢,存储开销大。

我测试了4款模型,用200个问题的平均召回率做基准:

embedding模型 维度 Top-5召回率 推理耗时(ms/句)
text-embedding-ada-002 1536 74% 12
BAAI/bge-small-zh-v1.5 512 78% 3
BAAI/bge-base-zh-v1.5 768 81% 6
BAAI/bge-large-zh-v1.5 1024 85% 15

5.2 切换代码

from sentence_transformers import SentenceTransformer

# 加载BGE模型
model = SentenceTransformer("BAAI/bge-large-zh-v1.5")
# 注意:BGE模型需要加query指令前缀
query_prefix = "为检索到的文档生成表示:"  # 查询时加上

def embed_documents(docs):
    return model.encode(docs, normalize_embeddings=True)

def embed_query(query):
    # 查询向量需要加前缀,这是BGE的推荐用法
    return model.encode(query_prefix + query, normalize_embeddings=True)

踩坑记录:
- 必须加query前缀:不加前缀的话,召回率直接跌回70%。这是BGE模型特有的训练方式。
- 要归一化normalize_embeddings=True,否则后期计算余弦相似度会出问题。
- 显存占用:large模型需要约4GB显存,如果线上环境不足,可以选择base版本(768维)。

效果数据:切换BGE-large后,召回率从74%提升到85%,首篇命中率(正确答案排在第一位)提升了22%。

六、核心实现:rerank重排机制

6.1 为什么需要rerank

embedding模型做的是“语义相似度”粗排,但经常出现“语义相近但实际不相关”的情况。比如查询“JWT过期时间配置”,向量相似度高的可能是“JWT介绍”或“JWT刷新机制”。这时需要一个更精细的交叉编码器来重排。

6.2 实现方案

我选择bge-reranker-base,采用交叉编码器结构,将查询和文档拼接后输入模型,输出相关性得分。

from rerankers import CrossEncoderReranker

# 初始化reranker
reranker = CrossEncoderReranker(
    model_name="BAAI/bge-reranker-base",
    batch_size=32,
    device="cuda"
)

def rerank_query(query, candidates, top_k=5):
    """
    candidates: 粗排召回的文档列表
    返回重排后的Top-k文档
    """
    # 重排器返回排序后的文档和分数
    reranked = reranker.rank(query=query, docs=candidates)

    # 取Top-k
    top_docs = [r.document for r in reranked[:top_k]]
    top_scores = [r.score for r in reranked[:top_k]]

    return top_docs, top_scores

6.3 关键参数调优

  • 粗排数量:我测试了10/20/30/50,发现粗排取20效果最好。太少会让好文档漏掉,太多会拖慢rerank速度。
  • batch_size:配合T4 16GB显存,batch_size=32比较稳。
  • rerank耗时:每轮查询约80ms(20个候选),完全可以接受。

6.4 效果对比

配置 Top-5召回率 答案准确率 首篇准确率
仅embedding 85% 72% 58%
embedding + rerank 89% 84% 76%

最明显的变化是首篇准确率从58%提升到76%——这意味着答案生成器拿到正确上下文的概率大幅提升,直接反映到最终答案的准确性上。

七、踩坑与优化:三个隐藏的坑

  1. ChromaDB集合重建:切换embedding模型后,必须删除旧集合重建,否则会混入不同维度的向量导致崩溃。我踩过这个坑,报错信息是Dimension mismatch

  2. rerank模型输入长度:bge-reranker最大输入长度512 token,超长会被截断。长文档需要先做chunk再rerank,不能直接塞整篇。

  3. 生成模型的temperature:经过优化后,检索质量上来了,生成器的temperature建议从0.7降到0.3。之前检索差的时候,低温度会导致答案过于死板;现在检索准了,低温度能明显减少幻觉。

八、总结与建议

最终系统上线运行两周,实测数据:

指标 优化前 优化后 提升幅度
Top-5召回率 61% 89% +45.9%
答案准确率 52% 84% +61.5%
首篇准确率 38% 76% +100%
单次查询耗时 1.2s 1.5s +25%

结论:耗时只增加了0.3秒,但准确率几乎翻倍,完全值得。

给读者的三点建议:
1. 先优化chunk,再换embedding:chunk切分的优化成本最低、收益最明显。
2. 中文场景优先考虑BGE系列:在技术文档场景下,bge-large的表现全面优于ada-002。
3. rerank是最后的点睛之笔:但如果前面两步没做好,rerank的增益会大打折扣。

如果你的RAG系统也遇到了“答非所问”的问题,建议按照这个路径逐步排查。有任何问题欢迎在评论区交流。