一、问题背景:召回不准,再强的LLM也是“幻觉制造机”
我们团队负责维护一个内部文档智能问答系统,文档库包含产品手册、故障排查指南以及历史工单。最初版本直接采用LangChain的VectorstoreIndexCreator默认配置:RecursiveCharacterTextSplitter(chunk_size=512, chunk_overlap=50),向量模型使用text-embedding-ada-002(1536维),检索TopK固定为5,直接把召回上下文塞给gpt-3.5-turbo-1106。
上线后用户反馈两极分化:简单问题(“如何开启设备”)回答尚可,但涉及多步骤排查或多条件约束的问题时,答案经常张冠李戴。我们建立了一个包含1278条(question, golden_chunk_id, golden_answer)的评测集,用HitRate(Top-5召回中能否命中golden_chunk)和LLM答案采纳率(基于关键词与语义双重打分)两个指标,基线结果令人沮丧:
- HitRate@5:62.4%
- LLM采纳率:58.1%
问题很清晰:召回阶段就有近四成问题找不到正确文档片段,后续生成阶段再强也是无米之炊。本文不讨论prompt工程,只聚焦于RAG链路中索引构建与检索排序两个核心环节。
二、环境与版本:锁定技术栈,避免玄学
先交代具体环境,方便复现。所有实验在单张A100-40G上进行,但推理阶段CPU足够。核心依赖版本:
langchain==0.1.16
langchain-community==0.0.38
sentence-transformers==2.6.1
FlagEmbedding==1.2.10
chromadb==0.4.24
openai==1.35.7
注意:langchain升级到0.2后VectorstoreIndexCreator部分API有变动,我们全程锁定0.1.x。另外,由于我们处理的是中文文档,分词器差异对结果影响巨大,后面会细说。
三、方案设计:三阶段递进优化
我的优化思路不是一锅乱炖,而是分三步走,每一步都单独评估:
- 阶段A:chunk策略重构 —— 默认的512字符硬切分导致大量语义断句,中文尤甚。目标是把chunk从“固定长度”改为“语义完整单元”。
- 阶段B:embedding模型切换 ——
ada-002通用能力强但中文领域语义捕捉弱,尤其是工单里的专业术语缩写。目标切成开源的bge-large-zh-v1.5,并尝试用领域数据做无监督对比学习微调。 - 阶段C:引入reranker —— Top-5召回里往往只有1-2个是真正有用的,直接喂给LLM会引入噪声。目标在生成前加入一个cross-encoder,把Top-50精排到Top-5。
下面详细拆解每个阶段的实现与数据。
四、阶段A:chunk策略调整——先解决“断句惨案”
4.1 默认策略的缺陷
看一下原始分片效果。我们有一篇“设备过热故障排查”文档,其中包含步骤列表:
1. 检查散热风扇是否转动
2. 若风扇正常,触摸设备背面温度...
3. 若温度高于60℃,检查导热硅脂是否干涸...
512字符硬切很可能把步骤2切到上一片结尾,把步骤3的“若温度高于60℃”的判断条件切到下一片开头,导致检索时上下文缺失,LLM只能瞎猜。
4.2 语义感知的递归切分
我们改用RecursiveCharacterTextSplitter但重新设计了分隔符优先级和参数,针对中文文本做了两点关键调整:
- 分隔符优先级:
["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""]—— 把句号、问号等句子终结符提前,确保chunk边界尽量落在完整句子后。 - chunk_size降至384,overlap增至80 —— 更小的chunk提高语义纯度,更大的overlap保证跨句信息不丢失。
同时还写了一个自定义分割逻辑,处理我们的工单数据中常见的“问题描述-解决步骤”结构:
from langchain.text_splitter import RecursiveCharacterTextSplitter
def create_chunks(documents):
# 针对中文工单,先按“问题/原因/解决”标题强拆
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=384,
chunk_overlap=80,
separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""],
keep_separator=True, # langchain 0.1.x支持
)
chunks = []
for doc in documents:
# 自定义预处理:把工单中的“步骤N:”保留在chunk开头
doc = doc.replace("步骤", "\n步骤")
chunks.extend(text_splitter.split_text(doc))
return chunks
# 实际调用
# docs = load_documents()
# chunk_list = create_chunks(docs)
# print(f"总chunk数: {len(chunk_list)}, 平均长度: {sum(len(c) for c in chunk_list)/len(chunk_list):.1f}")
4.3 效果对比与踩坑
- HitRate@5从62.4%提升到70.8% —— 提升8个百分点,主要收益来自故障排查类文档。
- 踩坑记录:把chunk_size降到256时HitRate反而下降至66%,因为部分长段落被拆成3-4片后,检索到的单片信息密度不足。384是个甜点值。
- 另外,
keep_separator=True很关键,否则句号被吞掉,召回重排序时语义完整性受损。
五、阶段B:embedding模型切换与领域微调
5.1 模型选型
ada-002的问题在于对中文专业术语(如“HVDC”(高压直流输电)、“IGBT”等)的表征不够判别性。我们对比了三个开源模型在内部验证集上的表现:
text2vec-large-chinese—— 对长文本效果一般BAAI/bge-large-zh-v1.5—— 在C-MTEB上中文检索SOTA,且支持1024长度BAAI/bge-m3—— 更强但推理延迟高
最终选择bge-large-zh-v1.5,因为它有成熟的无监督对比学习微调方案(不需要标注),且向量维度1024,相比ada-002的1536还能减少存储。
5.2 无监督微调(SimCSE风格)
我们没有标注数据,但有一万多条未标注的历史工单。利用bge官方的微调脚本,用领域语料做了3个epoch的SimCSE训练,让模型更区分同领域文本的细微差异。关键配置如下:
# train_embedding.py 核心逻辑
from sentence_transformers import SentenceTransformer, InputExample, losses
from torch.utils.data import DataLoader
model_path = "BAAI/bge-large-zh-v1.5"
model = SentenceTransformer(model_path)
model.max_seq_length = 512 # 原模型支持1024,但为效率限制到512
# 构造正负样本对(无监督:同一文档的相邻句子对为正,随机为负)
train_data = []
for doc in domain_docs:
sentences = split_to_sentences(doc)
for i in range(len(sentences)-1):
train_data.append(InputExample(texts=[sentences[i], sentences[i+1]], label=1))
# 随机负样本
neg = random_sentence(domain_docs)
train_data.append(InputExample(texts=[sentences[i], neg], label=0))
# 训练参数
train_dataloader = DataLoader(train_data, batch_size=32, shuffle=True)
loss = losses. ContrastiveTensionLoss(model) # SimCSE的一种实现
model.fit(train_objectives=[(train_dataloader, loss)], epochs=3, warmup_steps=100)
# 保存微调后模型
model.save("models/bge-large-zh-domain-v1")
5.3 切换后效果
- HitRate@5从70.8%提升到80.1%,微调后进一步到82.7%。
- 对比数据:仅切换模型(不微调)为80.1%,微调后再涨2.6个百分点,说明领域语料确实有效。
- 注意:切换embedding后必须重新生成所有向量并重建索引,不能混用。我们写脚本批量跑了一晚上,1024维向量在ChromaDB上占用比之前小很多。
六、阶段C:引入rerank——把Top-50变成Top-5
6.1 为什么需要rerank
经过阶段B,Top-5召回率80%+,但Top-5中只有平均1.8个是真正相关的。直接把这个5段塞给LLM,噪声过大。我们用同样模型召回Top-50,再用cross-encoder精排到Top-5。
选用BAAI/bge-reranker-v2-m3,这是个多语言cross-encoder,输入为(query, passage)对,输出0-1相关性分数。它比bge-m3的向量检索更精准(因为做了交互式注意力)。
6.2 实现与代码
from FlagEmbedding import FlagReranker
# 注意版本:FlagEmbedding 1.2.10支持v2-m3
reranker = FlagReranker('BAAI/bge-reranker-v2-m3', use_fp16=True)
def retrieve_with_rerank(query, top_k_recall=50, top_k_final=5):
# 1. 先用embedding模型召回Top-50
query_vec = embedding_model.encode(query, normalize_embeddings=True)
top_50 = collection.query(query_embeddings=[query_vec], n_results=top_k_recall)
# 2. cross-encoder精排
passages = top_50['documents'][0]
pairs = [[query, p] for p in passages]
scores = reranker.compute_score(pairs, normalize=True) # 返回0-1分数
# 3. 按分数排序取Top-5
sorted_idx = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:top_k_final]
final_docs = [passages[i] for i in sorted_idx]
return final_docs, [scores[i] for i in sorted_idx]
# 实际使用中,为控制延迟,我们用batch=32并行计算,50条pair耗时约120ms(A100)
6.3 效果数据与延迟权衡
- HitRate@5(经过rerank后)飙升至89.2% —— 为什么超过80%?因为原本Top-50里包含golden_chunk但没进Top-5的情况,被reranker救了回来。
- LLM采纳率从58%升至83%,用户反馈的“答非所问”工单减少60%以上。
- 延迟成本:embedding检索Top-50约20ms,rerank 50条pair约120ms(fp16),整体单次检索+重排总时长约160ms,相比之前直接Top-5(15ms)增加了可观但可接受的150ms。我们用了缓存机制,对高频query结果缓存10分钟,实际平均延迟只增加70ms。
七、踩坑与优化细节汇总
- ChromaDB版本兼容问题:
chromadb==0.4.24与langchain 0.1.16配套良好,千万别用0.5.x,API改动会直接导致collection.query无法返回documents字段。 - bge模型输入长度:
bge-large-zh-v1.5最大长度是512(不是宣称的1024,官方文档有误),我们在写embedding时用max_seq_length=512,超过部分截断,实测截断对检索影响不大,因为关键信息通常在前半段。 - rerank温度参数:
FlagReranker.compute_score的normalize=True会做sigmoid,建议保留。如果不归一化,分数范围是[-5, 5],阈值不好调。 - 不要对全量库rerank:先向量召回Top-100,再rerank Top-100到Top-10,比直接全量cross-encoder快一个数量级,且效果几乎无损。
- 微调embedding时的负样本:SimCSE的随机负样本要保证与正样本不同文档,否则模型学到的是“句子位置”而非语义差异。我们踩过这个坑,初期微调后HitRate不升反降,排查半天发现负样本采样代码bug。
八、总结:优化路径的普适性
以下是三条优化在整体效果中的贡献占比(用消融实验测出):
| 优化项 | HitRate@5提升绝对值 | 相对贡献 |
|---|---|---|
| chunk策略调整 | +8.4% | 31% |
| embedding切换+微调 | +11.9% | 44% |
| rerank引入 | +6.5% | 24% |
| 整体 | 62.4%→89.2% | 100% |
我的建议是:优先做chunk重切(成本最低),然后是embedding选型和领域微调(收益最大),最后再加rerank(锦上添花但引入额外延迟)。 如果你的系统还在用默认512固定切分和通用向量模型,那么大概率你能复现类似提升。
最后想说,RAG优化永远是“检索质量>生成技巧”,别一上来就调prompt。先让系统把正确的材料找出来,LLM自然会给你惊喜。如果我的分享对你有用,欢迎评论区交流你的chunk参数或embedding选型经验。