一、问题背景:检索结果看着像,但答非所问

我们内部有一个基于RAG的运维知识库问答系统,文档是几十本操作手册的PDF转Markdown。用户提问“如何修改Nginx的worker进程数”,系统返回的Top5片段里全是“Nginx配置文件路径”、“worker_processes指令语法”这些相关但不直接的内容,最终答案生成出来也是拼凑感极强。

线上监控数据很扎心:Top5命中率(即答案在Top5检索结果内)只有62%,用户满意度评分连续两周下滑。当时技术栈是:LangChain 0.1.0 + Chroma 0.4.22 + ZhipuAI embedding + GPT-3.5-turbo。

问题定位很明确:源头在检索质量,生成模型再强也救不回来。

二、环境与版本:锁定基线再动手

优化前先冻结环境,避免多个变量同时变化导致无法定位问题。我们的基线环境:

Python 3.10.13
langchain 0.1.0
langchain-community 0.0.10
chromadb 0.4.22
zhipuai 2.1.2
torch 2.1.2+cu118
sentence-transformers 2.2.2
FlagEmbedding 1.2.8

向量库用的是Chroma的PersistentClient模式,collection名ops_docs,距离函数默认的L2。

三、方案设计:三步走,每一步都量化效果

整个优化分三步,每一步都单独验证效果,避免“混合优化”后不知道谁起作用:

  1. Chunk策略调整:从固定512字符切分改为语义边界切分(按标题、段落、句子边界)。
  2. Embedding模型切换:从ZhipuAI的embedding-v2(输出维度1024)换到阿里的text-embedding-v3(输出维度1024,但语义表达能力更强)。
  3. 引入Rerank重排:在向量检索后加一个cross-encoder的rerank阶段,对Top20结果精排取Top5。

评估方式:从测试集里抽了200个问题,人工标注答案所在的原始文档位置,计算Top5命中率(检索到的5个chunk中是否包含答案)。

四、核心实现:每一步的代码与参数

4.1 Chunk分割:从“死板”到“有脑子”

原来的切分就是RecursiveCharacterTextSplitter(chunk_size=512, chunk_overlap=50),中文文档按字符硬切,经常把一句话切断,或者把一个小节标题和正文拆到两个chunk里。

改成基于Markdown标题层级+段落边界的分割器。核心思想:先按标题拆成小节,再在小节内按段落拆,最后段落太长才按句子边界兜底

from langchain.text_splitter import MarkdownHeaderTextSplitter, RecursiveCharacterTextSplitter

# 第一层:按Markdown标题层级切分
headers_to_split_on = [
    ("#", "H1"),
    ("##", "H2"),
    ("###", "H3"),
]
md_splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers_to_split_on)

# 第二层:对每个小节做段落级切分
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=400,          # 调小,语义更聚焦
    chunk_overlap=80,        # 保证段落衔接
    separators=["\n\n", "\n", "。", "!", "?", ".", "!", "?", ";", ";", ",", ","],
    keep_separator=True,     # 保留分隔符,避免句子被硬拆
)

def smart_split(markdown_doc: str):
    sections = md_splitter.split_text(markdown_doc)
    chunks = []
    for section in sections:
        # 每个section保留其标题前缀,便于溯源
        sub_chunks = text_splitter.split_text(section.page_content)
        for sub in sub_chunks:
            chunks.append({
                "content": sub,
                "metadata": {"header": section.metadata}
            })
    return chunks

踩坑记录separators列表里中英文标点混排时,英文句点.要放在中文标点后面,否则会先按英文句点切分,把“3.5版本”这种带小数点的内容切断。另外keep_separator=True很重要,否则切完的句子末尾丢句号,embedding时语义会偏移。

4.2 Embedding切换:向量维度没变,但检索质量变了

ZhipuAI的embedding-v2在中文场景也不算差,但它的训练语料更偏通用对话,对运维文档里的“参数名+操作步骤”这种密集实体文本表达不够敏感。

换用阿里的text-embedding-v3(通过DashScope接口调用),维度同样是1024,不需要重建向量库,直接换embedding函数重新写入即可。

from langchain.embeddings import DashScopeEmbeddings

# 阿里云DashScope的text-embedding-v3
embeddings = DashScopeEmbeddings(
    model="text-embedding-v3", 
    dashscope_api_key="sk-xxxx",
    dimensions=1024  # 显式指定维度,与旧向量保持一致
)

# 写入向量库时指定embedding函数
from langchain.vectorstores import Chroma
vectorstore = Chroma.from_documents(
    documents=chunks, 
    embedding=embeddings,
    persist_directory="./chroma_db",
    collection_metadata={"hnsw:space": "cosine"}  # 注意:这里改为余弦距离
)

踩坑记录:text-embedding-v3默认输出维度是1024,但如果你不显式传dimensions参数,它可能返回不同的维度(比如1024或768),导致写入向量库时与旧数据冲突。另外,距离函数从L2换成了cosine。L2对向量模长敏感,而text-embedding-v3的向量模长分布和ZhipuAI不一样,用L2会导致某些长文本被“惩罚”。切换后需要重建整个collection。

五、引入Rerank:Top5命中率从78%到89%

embedding切换后Top5命中率到了78%,但距离业务可用的90%还有距离。问题在于:双塔embedding的检索结果中,相关片段和干扰片段在向量空间里距离接近,无法进一步区分。

引入bge-reranker-base(cross-encoder),对向量检索返回的Top20结果做精排,取前5。这个模型只有约1.1亿参数,CPU推理单条耗时约80ms,可用GPU加速。

from FlagEmbedding import FlagReranker

# 初始化reranker(加载到GPU)
reranker = FlagReranker('BAAI/bge-reranker-base', use_fp16=True, device='cuda:0')

def search_with_rerank(query: str, top_k: int = 5, rerank_pool: int = 20):
    # 第一步:向量召回Top20
    initial_docs = vectorstore.similarity_search_with_score(query, k=rerank_pool)

    # 第二步:构造(query, doc)对
    pairs = [(query, doc.page_content) for doc, score in initial_docs]

    # 批量计算rerank分数
    scores = reranker.compute_score(pairs, normalize=True)  # normalize=True 输出0-1分数

    # 按分数排序取Top5
    ranked = sorted(
        zip(initial_docs, scores), 
        key=lambda x: x[1], 
        reverse=True
    )[:top_k]

    return [doc for (doc, score), rerank_score in ranked]

踩坑记录
- compute_score传入list of pairs时,返回的是list of scores;传入单个pair时返回的是单个float。别混用,否则取索引会报错。
- 如果文档特别长,reranker的输入长度限制是512个token,超出部分被截断。早期跑的时候没注意,导致有些长chunk的rerank分数不准。后来把chunk_size从512降到400,这个问题基本消失。

六、效果数据与最终效果

优化过程的每一步量化结果如下:

阶段 Top5命中率 平均检索耗时(ms) 备注
基线(固定512chunk + ZhipuAI + L2) 62% 120 用户满意度最低点
+ 语义chunk分割 71% 118 耗时几乎无变化
+ 切换text-embedding-v3 78% 135 接口调用略慢
+ bge-reranker-base重排 89% 280 增加约150ms,可接受

最终线上配置:chunk_size=400,chunk_overlap=80,向量召回Top20,rerank后取Top5。多轮对话的上下文窗口保持4轮,超出后自动丢弃最早的对话。

额外收益:因为检索精度上去了,GPT-3.5-turbo生成的内容里“编造”的比例明显降低,用户反馈“答案更扎实了”。

七、总结

这次优化的核心就一句话:RAG的瓶颈在检索,检索的瓶颈在“语义切分”和“语义排序”。如果你也遇到类似问题,建议按这个顺序排查:

  1. 先看chunk是否把完整语义切碎了(打印几个被命中的chunk,看看是否前言不搭后语)。
  2. 再换一个更强的embedding模型(不要只看公开benchmark,拿自己的数据集跑一遍)。
  3. 最后加上reranker,这是提升精度最直接的手段,代价就是多几十毫秒耗时。

如果预算有限,优先上reranker,它能把Top5命中率拉高10个百分点以上。但如果chunk本身切得烂,reranker也救不回来。