一、问题背景:为什么我的RAG像个智障?
事情要从我们内部的“磐石”系统说起,这是个面向运维人员的故障排查RAG助手,知识库里有3000多篇内部的故障手册、网络配置命令和变更记录。
上线初期,PM反馈“答非所问”成了高频词汇。我拉取了近一周的日志,做了个粗糙的人工评测:200个问题,只有136个回答的核心答案是对的,准确率68.2%。更离谱的是,当你问“如何调整OSPF的hello-interval”时,系统返回的参考文档竟然是“BGP路由反射器配置”,这明显是chunk切碎后,向量检索把“OSPF”和“BGP”这种同层级的词搞混了。
核心痛点有三个:
1. 分块无脑:当时图省事用的是FixedSizeChunker,256个token硬切,导致大量代码块和表格行被拦腰截断,语义严重缺失。
2. Embedding模型太弱:text2vec-large-chinese是2021年的老古董了,对中文长文本和专有名词(比如“MSTP”、“VRRP”)的表征能力很差,导致向量空间里“OSPF”和“BGP”距离太近。
3. 无排序机制:只做了向量召回Top-5,然后直接塞给LLM。没有精排,一旦召回的前几个都不相关,LLM就只能瞎编。
二、环境与版本基线
先交代一下当时的软硬件环境,方便大家复现对比:
- Python 3.9.18
- LangChain 0.1.16(当时还在用
langchain_community) - Embedding模型:
moka-ai/m3e-large(基线) →BAAI/bge-large-zh-v1.5(优化后) - Rerank模型:
BAAI/bge-reranker-v2-m3(新引入) - 向量库:Milvus 2.3.4(collection设置:
cosine距离,HNSW索引,M:16,efConstruction:200) - LLM:Qwen-14B-Chat-Int4(vLLM部署,版本0.4.2)
- 硬件:单张A800-SXM4-80G
三、方案设计:三个手术,一次做完
既然定位了三个问题,我的优化方案也对应三个步骤,每个步骤都配合离线评测集验证,不搞“玄学优化”。
Step 1:重写Chunk策略 —— 从“固定长度”到“结构感知”
技术文档不同于普通网页文本,它有明确的层级结构(标题、段落、列表、表格、代码块)。我写了一个基于Markdown语法的分块器,规则如下:
- 硬分隔:遇到#标题等级(#,##,###)必然在此处切分,保证一个语义块的完整性。
- 软分隔:对于表格,整表作为一个块(如果表头超过50行会按行列拆,但保留表头上下文);代码块则整体保留,不按行切割。
- 块大小上限:设为512个token,允许重叠100个token防止切断关键连接词。
Step 2:Embedding模型换血 —— 拥抱BGE系列
bge-large-zh-v1.5在MTEB中文榜单上比m3e高了好几个点。但最需要注意的是Query和Document必须使用不同的指令前缀。BGE官方规定:检索时,Query需要加上为这个句子生成表示以用于检索相关文章:前缀,而Doc不需要加(或者加返回文档表示)。这至关重要,不加前缀效果会掉5个点以上。
Step 3:引入Rerank —— 精排纠偏
纯向量召回的Top-5可能混入三个不相关的。我加了bge-reranker-v2-m3作为重排器,它是个cross-encoder,会把query和每个doc拼接做深层交互,比双塔的向量检索精度高一个量级。
流水线流程变为:Query → Embedding(BGE) → 召回Top-50(Milvus) → Rerank取Top-3 → 拼装Prompt → LLM生成。
四、核心实现:关键代码片段
这部分直接上代码。首先是自定义的结构感知分块器(基于LangChain的TextSplitter重写关键部分):
# chunker.py
import re
from langchain.text_splitter import TextSplitter
class MarkdownStructureSplitter(TextSplitter):
"""Markdown结构感知分块器:优先按标题切分,其次保护表格和代码块"""
def split_text(self, text: str) -> list[str]:
# 1. 按标题层级初步分段(保留标题内容在块内)
sections = []
current_section = []
for line in text.split('\n'):
if re.match(r'^#{1,3}\s', line): # 遇到1-3级标题
if current_section:
sections.append('\n'.join(current_section))
current_section = []
current_section.append(line)
if current_section:
sections.append('\n'.join(current_section))
# 2. 处理超大段落:按代码块和表格保护逻辑拆分
final_chunks = []
for sec in sections:
if len(self._tokenize(sec)) 0.4][:top_k]
return filtered
注意:normalize=True参数在FlagReranking的compute_score中一定要开启,这样分数才具有跨query的可比性。我实测阈值设为0.4时,能过滤掉80%的无效召回,但又不至于误杀正确答案。
五、踩坑与优化:那些文档没告诉你的细节
坑1:Embedding前缀不一致导致检索效果倒退
刚开始切换BGE模型时,我没有加query_instruction前缀。离线评测结果直接懵了:准确率不仅没升,反而从68%掉到62%。后来查了BGE的GitHub官方repo,发现中文模型对于查询和文档必须严格区分指令。加上前缀后,效果直接飙到78%。这个细节如果漏掉,换模型纯属白折腾。
坑2:Reranker的输入长度限制
bge-reranker-v2-m3的最大长度是512个token,而我的知识库chunk上限设了512。但中文下512个token实际可能对应600+汉字,导致部分长文档在rerank阶段被截断。我的解法是:在分块时,如果硬块(表格/代码)超过400个token,就拆成多个子块,并给子块标题加上“(续)”标记,保证rerank输入安全。
坑3:Milvus的EF参数不是越大越好
在做向量召回时,为了追求召回率我把ef设为256,结果单次查询延迟从20ms涨到150ms。后来发现ef=64时,Top-50的召回率只比256低了0.3%,但延迟砍半。最终我设置ef=64并配合rerank,整体效果没受影响。
六、效果数据:三顿操作猛如虎,一看战绩
所有优化上线后,我又用同样的200个真实问询做了一次回归测试。同时记录了线上A/B测试(流量切分50%)的数据,观察了3天。
| 指标 | 基线(m3e+固定256切块) | 优化后(BGE+结构切块+Rerank) | 提升幅度 |
|---|---|---|---|
| 问答准确率(人工打分) | 68.2% | 91.3% | +23.1% |
| Top-1召回命中率 | 52.0% | 79.5% | +27.5% |
| Top-3召回命中率 | 78.5% | 94.0% | +15.5% |
| 知识库无答案率 | 18.0% | 6.5% | -11.5% |
| 平均响应延迟(端到端) | 1.2s | 1.38s | +180ms |
关键收益解读:
- 准确率提升主要来自Rerank的纠偏,它把向量召回中混入的高相似度但语义错误的文档(比如OSPF和BGP)成功压到了Top-3之外。
- 结构感知分块让表格问答(比如“查询ACL规则10的动作为permit的配置”)的准确率从45%提升到88%,这是老分块器完全做不到的。
- 延迟增加180ms完全在可接受范围内,因为Rerank只处理Top-50的候选,即使并发100个请求,A800也能轻松hold住。
七、总结与后续规划
这次优化总结下来就一句话:RAG的瓶颈不在大模型,而在检索链路的前置细节。分块是地基,做不好后面全白搭;Embedding模型要跟上时代,且要严格遵循其指令规范;Rerank是最后的保险丝,能兜底。
后续我准备做两个方向:一是把Rerank的阈值做成动态的,根据query的类型(比如故障处理类 vs 概念解释类)自适应调整,进一步降低无答案率;二是尝试引入GraphRAG的思路,把文档中的实体关系也存进去,解决多跳推理问题。现在代码已全部合入主分支,有兄弟想要完整的chunker.py和检索Pipeline代码,可以在评论区留言,我整理后发出来。