1. 问题背景:当RAG变成“复读机”
上个月接手了一个客服工单智能助手项目,核心逻辑就是经典的RAG:用户提问 → 向量检索Top5 → 拼接Prompt → 调用GPT-3.5生成回答。上线一周后,被业务部门吐槽“这AI是不是只会把工单原文念出来?”
我拉取了200条失败case,发现两个致命问题:
- 检索噪声大:用户问“退款超时怎么办”,召回的前5个chunk里有3个是关于“退货流程”的,语义相似但主体不同。
- 答案碎片化:由于chunk固定按256字切分,经常把一个完整的“退款纠纷处理SOP”拦腰截断,导致生成答案缺步骤。
当时用的技术栈:langchain==0.1.0 + chromadb==0.4.22 + text2vec-large-chinese(该模型是当时HuggingFace中文榜下载量前三)。但这套组合在长尾问法上表现极差。
2. 环境与版本基线
先交代基线环境,方便大家复现对比:
Python 3.10.12
langchain 0.1.0
chromadb 0.4.22
sentence-transformers 2.2.2
text2vec-large-chinese (768维)
GPT-3.5-turbo-1106 (生成端)
评测集:从历史工单中构造500对(问题,标准答案片段),按业务线(支付/账号/物流)分层抽样。评估指标采用Recall@5(标准答案片段是否在召回Top5内)和幻觉率(人工判定生成答案是否包含工单中不存在的信息)。
基线结果惨不忍睹:Recall@5 = 61.3%,幻觉率 = 18.6%。用业务的话说:“答非所问是常态,一本正经胡说八道是特色。”
3. 第一刀:chunk策略从“切西瓜”到“切牛排”
最初代码用的是langchain的RecursiveCharacterTextSplitter,固定chunk_size=256,chunk_overlap=40。问题在于工单文本有大量结构化内容(如“问题描述:……处理结果:……”),256字会把“问题描述”切走一半,把“处理结果”留在另一个chunk。
方案设计:改用语义段落切分。先按换行符和句号做粗切分(保持段落完整),再用embedding模型计算相邻句子的余弦相似度,相似度低于0.85的强制断开。这样能保留完整的“问题-处理”链路。
核心实现代码如下:
from langchain.text_splitter import TextSplitter
import numpy as np
from sentence_transformers import SentenceTransformer
class SemanticChunkSplitter(TextSplitter):
def __init__(self, model_name: str, threshold: float = 0.85):
self.model = SentenceTransformer(model_name)
self.threshold = threshold
def split_text(self, text: str) -> list[str]:
# 先按换行符粗切,保留段落
rough_chunks = [c.strip() for c in text.split("\n") if len(c.strip()) > 20]
if len(rough_chunks) = self.threshold:
current += "\n" + rough_chunks[i]
else:
final_chunks.append(current)
current = rough_chunks[i]
final_chunks.append(current)
return final_chunks
踩坑记录:
- 阈值调参很痛苦。从0.8调到0.9,会发现chunk数量从800变成300,但Recall不升反降。原因是阈值太高导致把两个相关段落拆散,阈值太低又把“用户骂人”和“客服道歉”合在一起。
- 最终结论:不要对全文统一阈值。我的做法是先按\n\n切分,只对超过500字的段落做递归语义切分。
第一轮优化后:Recall@5 = 65.8%(提升4.5%),幻觉率 = 15.2%。虽然进步不大,但chunk数量从1480降到970,向量库体积减少34%,检索延迟从180ms降到120ms。
4. 第二刀:embedding模型从“老黄牛”到“尖刀连”
text2vec-large-chinese是2023年的模型,对口语化客服文本(如“我钱怎么还没退啊!!!”)经常编码成中性情感向量,导致检索时偏好返回那些“语气平和的工单”。而bge-large-zh-v1.5(BAAI出品)在Retrieval任务上的MTEB中文榜排名第一,并且针对短文本匹配做了针对性训练。
关键改动:不仅换模型,还要换查询指令前缀。bge官方文档明确要求:query侧需要加"为这个句子生成表示以用于检索相关文章:"前缀,而文档侧(chunk)不加。如果不加,效果直接掉5个点。
from sentence_transformers import SentenceTransformer
import numpy as np
# 加载bge模型
model = SentenceTransformer("BAAI/bge-large-zh-v1.5")
model.max_seq_length = 512 # 重要!默认是512,但text2vec是768
# 编码文档时不加前缀
doc_embeddings = model.encode(documents, normalize_embeddings=True)
# 编码查询时强制加前缀
query = "为这个句子生成表示以用于检索相关文章:" + user_question
query_embedding = model.encode(query, normalize_embeddings=True)
# 相似度计算(注意:bge官方建议用内积,不是余弦)
scores = query_embedding @ doc_embeddings.T
top_indices = np.argsort(scores)[::-1][:5]
版本坑点:
- sentence-transformers 2.2.2无法正确加载bge的module名,需要升级到2.3.1以上。我因为没升级,跑出来的向量全是随机数,调了两天才发现是版本兼容问题。
- bge模型参数量是326M,比text2vec的110M大很多,GPU显存占用从2GB飙到5.8GB。我后来改用bge-base-zh-v1.5(102M)作为替代,效果只下降1.2%,但显存占用回落。
效果对比(第二刀后):Recall@5 = 73.4%(从65.8%提升7.6%),幻觉率 = 11.3%。最直观的感受是:用户问“银行卡解绑失败”,检索出来的前3个chunk都是关于“解绑流程”的,不再是“绑卡失败”的工单。
5. 第三刀:rerank让“好酒不再怕巷子深”
即使bge足够强,但向量检索本质上是双塔内积,它擅长找“语义相似”,但不擅长找“答案直接命中”。比如用户问“退款到账时间”,chunkA说“退款将在3-5个工作日到账”,chunkB说“用户询问退款时间,客服回答需3-5个工作日”。向量相似度上两者可能相近,但chunkA明显更优。
方案引入:在向量召回Top50后,接一个bge-reranker-v2-m3交叉编码器,对(query, chunk)对计算精排分数,取Top5。交叉编码器直接拼接query和chunk,能捕获细粒度的token级交互。
from transformers import AutoModelForSequenceClassification, AutoTokenizer
import torch
# 加载reranker模型(注意:这是交叉编码器,不是双塔)
reranker_tokenizer = AutoTokenizer.from_pretrained("BAAI/bge-reranker-v2-m3")
reranker_model = AutoModelForSequenceClassification.from_pretrained(
"BAAI/bge-reranker-v2-m3", torch_dtype=torch.float16
).cuda()
def rerank(query: str, candidate_chunks: list[str], top_k: int = 5) -> list[str]:
pairs = [[query, chunk] for chunk in candidate_chunks]
inputs = reranker_tokenizer(
pairs, padding=True, truncation=True,
return_tensors="pt", max_length=256
).to("cuda")
with torch.no_grad():
scores = reranker_model(**inputs).logits.squeeze(-1).cpu().float()
# 按分数降序取前k个
top_indices = torch.topk(scores, k=min(top_k, len(scores))).indices
return [candidate_chunks[i] for i in top_indices.tolist()]
调参心得:
- 召回数量(Top-N)影响巨大。我用Top50做rerank,最终效果最好;Top20时,最好的答案可能压根没进初选集。但Top50意味着Reranker要跑50次前向推理,单次耗时约40ms,总延迟增加200ms。
- 延迟优化:把Top50的rerank放到异步线程池,用批处理(batch_size=16)跑,延迟反而降了20%。记得用torch.inference_mode()而不是torch.no_grad(),快15%左右。
- 千万别对top5直接rerank——那样效果和不用rerank没区别,因为你需要给交叉编码器足够的候选多样性。
6. 最终效果与总结
三轮优化后的完整对比:
| 指标 | 基线 | +chunk优化 | +embedding切换 | +rerank引入 |
|---|---|---|---|---|
| Recall@5 | 61.3% | 65.8% | 73.4% | 78.9% |
| 幻觉率 | 18.6% | 15.2% | 11.3% | 6.2% |
| 平均检索延迟 | 180ms | 120ms | 135ms | 340ms |
| 单次问答总延迟 | 2.1s | 1.8s | 1.9s | 2.6s |
几点经验总结:
- chunk策略的优先级最高。embedding模型再强,喂进去的文本是碎的,后面全白搭。我最后用的是“段落粗切 + 语义细切 + 长度上限512字”的组合。
- 不要迷信最新大模型。
bge-large-zh-v1.5比text2vec-large-chinese强,不是因为参数多,而是训练数据里中英文混合语料更贴近真实场景。建议在自己数据上跑一遍Recall@5再决定。 - Rerank不是万能药。它只能在你已经召回的内容里排序,召回阶段如果漏了,rerank也无力回天。我的经验是:先保证Top50里包含正确答案(可以用召回率@50作为内部指标),再优化排序。
- 性能瓶颈要提前规划。引入rerank后,总延迟从1.8s涨到2.6s,对于实时客服场景有点吃力。我最后的妥协方案是:简单问句(小于10个token)不经过rerank,直接走Top5;复杂问句才走rerank路径,通过规则判断(是否包含多个实体/时间词)。
这套方案上线运行两周,业务侧反馈“幻觉少了,但偶尔会把工单里的内部术语直接搬给用户”——这是下一步要做的事,把输出端的prompt加一层“白话翻译”约束。优化没有尽头,但这三步走完,至少从“不可用”变成了“可容忍”。希望这篇博客能帮你少踩几个坑。