一、问题背景:召回不准,再强的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。另外,由于我们处理的是中文文档,分词器差异对结果影响巨大,后面会细说。

三、方案设计:三阶段递进优化

我的优化思路不是一锅乱炖,而是分三步走,每一步都单独评估:

  1. 阶段A:chunk策略重构 —— 默认的512字符硬切分导致大量语义断句,中文尤甚。目标是把chunk从“固定长度”改为“语义完整单元”。
  2. 阶段B:embedding模型切换 —— ada-002通用能力强但中文领域语义捕捉弱,尤其是工单里的专业术语缩写。目标切成开源的bge-large-zh-v1.5,并尝试用领域数据做无监督对比学习微调。
  3. 阶段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。

七、踩坑与优化细节汇总

  1. ChromaDB版本兼容问题chromadb==0.4.24langchain 0.1.16配套良好,千万别用0.5.x,API改动会直接导致collection.query无法返回documents字段。
  2. bge模型输入长度bge-large-zh-v1.5最大长度是512(不是宣称的1024,官方文档有误),我们在写embedding时用max_seq_length=512,超过部分截断,实测截断对检索影响不大,因为关键信息通常在前半段。
  3. rerank温度参数FlagReranker.compute_scorenormalize=True会做sigmoid,建议保留。如果不归一化,分数范围是[-5, 5],阈值不好调。
  4. 不要对全量库rerank:先向量召回Top-100,再rerank Top-100到Top-10,比直接全量cross-encoder快一个数量级,且效果几乎无损。
  5. 微调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选型经验。