一、从“答非所问”说起:一个RAG系统的真实困境
我们团队维护着一个面向内部运维的文档问答机器人,知识库包含设备手册、故障工单、变更记录等约2300篇Markdown文档,总量约1.2GB。v1.0版本上线后,用户反馈最集中的问题是“我查防火墙策略配置,它给我返回交换机VLAN的配置”——召回结果驴唇不对马嘴。
当时的技术栈是:langchain==0.1.0 + chromadb==0.4.22 + sentence-transformers/bge-small-zh-v1.5。chunk策略是粗暴的固定长度256字符,无重叠。embedding维度512。
通过日志分析,我们发现两个核心问题:
1. 中文长文档被硬切后,语义完整性被破坏。比如“禁止root远程登录”被切成“禁止root远程”和“登录”两块,检索时语义丢失严重。
2. 512维的bge-small对领域术语(如“BGP community”、“QoS队列调度”)的表征能力不足,召回结果中大量出现字面相似但语义无关的文本块。
二、环境与版本基线
先交代一下排查时的环境,方便大家复现对比:
Python: 3.10.12
langchain: 0.1.0 → 0.2.1(优化后升级)
chromadb: 0.4.22
sentence-transformers: 2.2.2
FlagEmbedding: 1.2.6(rerank用)
torch: 2.1.0+cu118
CUDA: 11.8
单卡: A10 24G(仅推理,batch_size=32)
评估集:从真实工单中抽了300条query,每条标注了对应的正确文档ID(最多5个)。评价指标:
- Recall@5:前5个召回结果中是否包含正确文档
- Top-1 Accuracy:重排后第一个结果是否正确
三、第一刀:chunk策略从“定长”改“语义边界”
问题定位:通过可视化embedding向量相似度矩阵,我们发现被截断的chunk在向量空间中分布异常分散。例如一篇关于“OSPF邻居建立”的文档,被切成8个chunk后,有3个chunk的向量与query的余弦相似度低于0.3。
方案设计:放弃固定长度,改用RecursiveCharacterTextSplitter + 自定义分隔符优先级。核心思路:优先按\n##、\n###、\n\n、\n、。、;逐级降级切割。同时设置chunk_size=800,chunk_overlap=150——这比之前大了三倍,因为语义完整的段落往往更长。
核心实现:
from langchain.text_splitter import RecursiveCharacterTextSplitter
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=800,
chunk_overlap=150,
separators=[
"\n## ", # 二级标题
"\n### ", # 三级标题
"\n\n", # 空行
"\n", # 换行
"。", # 句号
";", # 分号
" ", # 空格
],
keep_separator=True, # 关键:保留分隔符,避免标题丢失
)
# 对每篇文档执行
chunks = text_splitter.split_text(document)
踩坑记录:最初keep_separator=False,结果“## 故障排查”变成了“故障排查”,丢失了层级信息。后来改为True,并在chunk开头注入文档标题前缀:
# 给每个chunk加上父文档标题,增强上下文
chunk_with_title = f"# {doc_title}\n{chunk}"
效果数据:Recall@5从58.2%提升至68.5%。但检索时发现一个问题:chunk变长导致向量维度不变但信息量增加,单条query的检索耗时从45ms升至70ms(chromadb暴力搜索)。此时还没上rerank,Top-1准确率仅为61%。
四、第二刀:embedding模型从bge-small换成bge-m3
问题定位:抽样分析了100个Failed case,其中43%属于“术语不匹配”。例如query包含“OSPF邻居卡在ExStart”,文档里写的是“邻居状态机停留在ExStart状态”,字面差异大,但语义相同。bge-small的512维向量对这种同义改写不敏感。
方案设计:切换为BAAI/bge-m3,维度1024,支持多语言和长文本(最长8192 token)。bge-m3在MTEB中文检索任务上比bge-small高约4个点,且对领域文本有更好的泛化能力。
核心实现(使用FlagEmbedding加载,替代sentence-transformers):
from FlagEmbedding import BGEM3FlagModel
import chromadb
from chromadb.utils import embedding_functions
# 初始化bge-m3,使用dense+sparse混合检索
model = BGEM3FlagModel(
'BAAI/bge-m3',
use_fp16=True,
device='cuda:0'
)
def encode_texts(texts):
outputs = model.encode(
texts,
batch_size=32,
max_length=1024,
return_dense=True,
return_sparse=True,
return_colbert_vecs=False # 省显存
)
# 混合向量:dense + sparse加权(sparse权重0.3)
dense = outputs['dense_vecs']
sparse = outputs['lexical_weights']
return dense, sparse
# chromadb自带的embedding_function需要统一接口,
# 这里我们直接离线生成向量后写入
collection = client.get_or_create_collection(
name="docs_v2",
metadata={"hnsw:space": "cosine"}
)
# 写入时同时存dense和sparse(sparse存为metadata)
for i, chunk in enumerate(chunks):
dense, sparse = encode_texts([chunk])
# sparse weights需要序列化存储,这里简化为存top-50词
sparse_simplified = {k: v for k, v in list(sparse[0].items())[:50]}
collection.add(
ids=[f"chunk_{i}"],
embeddings=dense.tolist(),
metadatas=[{"text": chunk, "sparse": json.dumps(sparse_simplified)}]
)
检索时使用混合评分:
def hybrid_search(query, top_k=20):
q_dense, q_sparse = encode_texts([query])
# chromadb只支持dense检索,sparse部分我们手动算BM25
dense_results = collection.query(
query_embeddings=q_dense.tolist(),
n_results=top_k
)
# sparse部分:用jieba分词后算BM25,与dense分数加权
bm25_scores = compute_bm25(query, collection)
# 最终分数 = 0.7 * dense_cosine + 0.3 * normalized_bm25
final_scores = 0.7 * dense_sim + 0.3 * bm25_norm
return sorted(zip(ids, final_scores), key=lambda x: x[1], reverse=True)[:top_k]
效果数据:Recall@5从68.5%提升到79.2%。但注意:bge-m3的推理耗时比bge-small高约2.3倍(单条query编码从12ms变成28ms),同时向量维度翻倍导致chromadb存储占用从2.1GB涨到4.3GB。此时Top-1准确率仅从61%提升到64%,说明“召回够了,但排序还是不行”——这就逼出了第三刀。
五、第三刀:引入bge-reranker重排
问题定位:混合检索返回20个候选,但Top-1准确率只有64%。原因:embedding检索是双塔结构,query和doc独立编码,交互信息(如词对共现)缺失。尤其对于“禁止root登录”和“root用户不允许远程登录”这种词序颠倒的句子,双塔模型很难学到位。
方案设计:引入BAAI/bge-reranker-base(约560M参数,交叉编码器)。流程变为:混合检索取Top-20 → reranker逐对打分 → 取Top-5。延迟增量:20对句子跑一次transformer,大约85ms(A10上fp16批量推理)。
核心实现:
from FlagEmbedding import FlagReranker
reranker = FlagReranker(
'BAAI/bge-reranker-base',
use_fp16=True,
device='cuda:0'
)
def rerank(query, candidates, top_n=5):
# candidates格式:[(id, text, score), ...]
pairs = [[query, cand[1]] for cand in candidates]
scores = reranker.compute_score(
pairs,
normalize=True, # 返回0-1之间的sigmoid分数
batch_size=16
)
# 合并分数并排序
scored = [(cand[0], cand[1], cand[2], scores[i]) for i, cand in enumerate(candidates)]
# 注意:这里我们直接用rerank分数排序,不再与向量分数融合
# 因为rerank是交互式,更可靠
scored.sort(key=lambda x: x[3], reverse=True)
return scored[:top_n]
踩坑记录:
1. reranker对输入长度敏感,bge-reranker-base最大序列长度512。我们的chunk是800字,直接塞进去会截断。解决方案:对候选chunk先做“首尾保留”截断——保留前200字和后200字,中间用省略号替代。实测发现对结构化文档(有标题和结论)效果影响很小。
2. 分数归一化问题:normalize=True返回的是sigmoid输出,范围0-1。但早期测试时发现分数普遍集中在0.1-0.3之间,区分度不够。后来改为输出原始logits(normalize=False),再自己做softmax归一化,区分度明显提升。
# 使用原始logits + 温度缩放
logits = reranker.compute_score(pairs, normalize=False)
scaled = [1 / (1 + np.exp(-(x / 0.3))) for x in logits] # 温度0.3
六、最终效果与资源消耗对比
| 版本 | Recall@5 | Top-1准确率 | 首响延迟 | 存储占用 |
|---|---|---|---|---|
| v1.0 (固定切块+bge-small) | 58.2% | 47% | 480ms | 1.8GB |
| v2.0 (语义切块+bge-small) | 68.5% | 61% | 520ms | 2.1GB |
| v2.5 (语义切块+bge-m3) | 79.2% | 64% | 680ms | 4.3GB |
| v3.0 (语义切块+bge-m3+rerank) | 79.2% | 82% | 765ms | 4.3GB |
关键结论:
1. chunk策略对召回影响最大(+10.3% Recall@5),且成本为零。强烈建议先检查chunk边界是否切碎了语义单元。
2. embedding模型升级对召回有增益(+10.7%),但提升Top-1有限——双塔模型的天花板就在那里。
3. rerank是“画龙点睛”的一笔:Top-1准确率从64%直接跳到82%,延迟只多了85ms。对于需要高精度回答的场景(如运维指令查询),这是性价比最高的一刀。
额外收益:由于rerank过滤掉了低质量候选,最终生成的回答里“幻觉”比例从31%降至14%(人工抽检50条query)。这是因为LLM看到的相关上下文更精准,减少了编造空间。
七、总结与后续规划
三轮优化下来,最大的感悟是:RAG系统的瓶颈往往不在模型大小,而在“数据准备”和“流程设计”。固定长度切块是最省事但最糟糕的做法——它默认所有文档的结构一致,但真实文档永远有标题、列表、代码块。
后续我们打算:
- 针对代码块和表格做特殊chunk处理(保留缩进和表头)
- 尝试jina-reranker-v2,据说在代码检索上更强
- 把rerank的候选数从20扩到50,看延迟能否控制在150ms内
最后提醒一句:所有的优化都要以线下评测集为准,不要只看几个case的直观感受。我们的评测集虽然只有300条,但覆盖了高频query类型,每次改动跑一遍,半天出结果,值得。