一、问题背景:为什么61%的命中率不可接受
我们团队负责内部工单知识库的问答机器人。最初的架构很简单:LangChain + ChromaDB + bge-small-zh,chunk大小固定为256字符,无重叠。上线后,用户反馈频繁出现“答非所问”或者“一本正经地胡说八道”。
我拉取了最近一周的日志,发现一个典型case:用户问“如何重置VPN令牌”,知识库中明明有答案,但回答结果是“请联系管理员”。检索命中的chunk是“VPN令牌重置流程请参考《安全手册》第三章,其中涉及管理员审批”,后面的关键步骤被截断了。
经过统计,在人工标注的2000条QA对中,答案在top-5检索结果中的命中率(Hit@5)只有61.3%。这意味着近四成的用户问题,系统压根没找到正确的上下文。
二、环境与版本清单
本次优化的实验环境相对固定,便于对照:
- Python 3.10.12
- langchain==0.1.16
- langchain-community==0.0.30
- chromadb==0.4.24
- sentence-transformers==2.5.1
- FlagEmbedding==1.2.8
- 测试集:2000条工单QA(内部脱敏),每条包含问题、标准答案、相关文档ID
Embedding模型对比:
- 旧:BAAI/bge-small-zh-v1.5(维度512)
- 新:BAAI/bge-m3(维度1024)
重排模型:BAAI/bge-reranker-base(单条文档重排延迟约20ms)
三、方案设计:三步走策略
我并没有直接一股脑换大模型,而是分三步走,每一步都单独在测试集上评估,避免参数耦合导致无法归因。
- Chunk策略重构:放弃固定长度切块,改为“标题+段落感知”的递归切分。
- Embedding模型升级:从
bge-small换到bge-m3,同时调整检索的top_k策略。 - 引入Rerank层:在向量检索返回top-50后,用cross-encoder做精排,取top-5。
四、核心实现:Chunk重构与Embedding替换
第一步:Chunk重构
原来的代码是RecursiveCharacterTextSplitter(chunk_size=256, chunk_overlap=0)。问题在于工单文档有明确的章节结构,且关键操作步骤往往在300-400字之间。
我改成了基于文档结构的递归切分器,优先按标题切分,再对过长段落做语义补充:
from langchain.text_splitter import RecursiveCharacterTextSplitter
# 自定义分隔符:优先保留标题层级,再按句号/换行切分
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=400, # 增大至400,容纳完整操作步骤
chunk_overlap=80, # 增加重叠,避免关键句被割裂
separators=["\n## ", "\n### ", "\n#### ", "\n", "。", "!", "?", ". ", "! ", "? "],
keep_separator=True, # 保留标题,使chunk自带上下文
)
# 对每篇文档先按标题拆,再对子段落切分
def split_document(doc_text: str):
docs = text_splitter.split_text(doc_text)
# 额外处理:若chunk仍超过500字且无标题,则强制按句切
final_chunks = []
for chunk in docs:
if len(chunk) > 500 and "\n" not in chunk:
sub_splitter = RecursiveCharacterTextSplitter(chunk_size=250, chunk_overlap=30)
final_chunks.extend(sub_splitter.split_text(chunk))
else:
final_chunks.append(chunk)
return final_chunks
这里有个关键点:keep_separator=True 能让每个chunk自带小标题,比如“## 重置VPN令牌”,这样embedding时能更好地对齐语义空间。
第二步:Embedding模型切换
bge-m3支持8192长度的输入,且对中文长句的语义理解远好于small版本。切换时注意维度变化:
from sentence_transformers import SentenceTransformer
# 旧模型(注释掉)
# encoder = SentenceTransformer("BAAI/bge-small-zh-v1.5")
# 新模型
encoder = SentenceTransformer("BAAI/bge-m3", device="cuda:0")
# 注意:bge系列需要加query指令(仅检索时)
def embed_query(text: str):
return encoder.encode(f"为这个句子生成表示以用于检索相关文章:{text}", normalize_embeddings=True)
def embed_doc(text: str):
return encoder.encode(text, normalize_embeddings=True)
这里有个坑:如果不加“为这个句子生成表示”前缀,bge-m3的检索效果会下降约5%。我们花了半天才定位到这个问题,因为直接用SentenceTransformer.encode不会报错,但语义表示会偏向普通句向量而非检索向量。
五、踩坑与优化:Rerank的引入与延迟控制
踩坑一:Top-K设置不合理
换模型后,我直接沿用旧的top_k=5。结果Hit@5只提升到74.2%,增幅不大。分析发现,bge-m3对语义相近但非直接答案的段落打分偏高,正确段落往往排在10-20位。于是调整为先召回50条,再做重排。
踩坑二:Reranker延迟
直接对50条文档跑bge-reranker-base,单查询延迟从80ms飙到600ms。虽然准确率到了89.7%,但用户不可接受。
优化手段:
- 对50条候选先按向量分数截断,只取前30条进rerank。
- 使用FlagEmbedding的FlagReranker,设置batch_size=16,在GPU上并行处理。
from FlagEmbedding import FlagReranker
reranker = FlagReranker('BAAI/bge-reranker-base', use_fp16=True)
def rerank_top(query: str, docs: list[str], top_k: int = 5) -> list[int]:
# docs是[(doc_id, text), ...]格式
pairs = [[query, doc[1]] for doc in docs]
scores = reranker.compute_score(pairs, normalize=True, batch_size=16)
# 按分数降序排序,返回原始索引
sorted_idx = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)
return sorted_idx[:top_k]
优化后,单查询延迟稳定在180ms左右(其中rerank占150ms),命中率保持不变。
六、效果数据对比:每一步的独立贡献
为了确保每个改动都是有效的,我在相同2000条QA集上做了A/B测试。评估指标:Hit@5(标准答案对应文档是否在top-5)与答案BLEU(生成答案与标准答案的n-gram重叠)。
| 方案 | Hit@5 | 平均BLEU | 单查询延迟 |
|---|---|---|---|
| 基线(256chunk + bge-small + top5) | 61.3% | 0.41 | 75ms |
| + Chunk重构(400chunk+标题感知) | 68.7% | 0.47 | 78ms |
| + 换bge-m3(top_k=50) | 74.2% | 0.52 | 85ms |
| + Rerank(top-30重排取5) | 89.7% | 0.63 | 180ms |
关键观察:
1. Chunk重构的收益主要来自“减少截断”——错误case中“关键步骤缺失”的比例从32%降到14%。
2. bge-m3单独提升有限,但为rerank提供了更可靠的候选池。它把正确的文档召回到前50名的概率从81%提升到了96%,这是rerank能发挥效用的基础。
3. Rerank是最大的增幅来源,但前提是前两步做对。如果直接在旧chunk+旧embedding后加rerank,Hit@5只能到71%左右。
七、总结:RAG调优的优先级排序
这次优化让我确信,RAG系统的瓶颈往往不在大模型,而在检索链路。我的经验排序是:
- 先看召回:如果200条候选里没有正确答案,换再强的rerank也没用。
- 再调chunk:按文档结构切分,比盲目调
chunk_size更有效。一个常识是:一个chunk应该能独立回答一个问题,而不是恰好256个字。 - 最后上重排:cross-encoder是精度利器,但延迟成本高,适合对准确率要求高的场景。
当前系统已上线两个月,用户满意度从66%上升到87%。后续计划尝试bge-m3的稀疏检索权重与稠密向量混合召回,进一步压榨长尾查询的召回率。最后提醒一句:版本锁定很重要——我们中途升级过一次chromadb,导致旧集合的向量维度不兼容,差点回滚。