一、问题背景

我们做的是一个企业内部的智能知识助手,底层用RAG(检索增强生成)架构。知识库约12万份文档,涵盖产品手册、运维记录、合同模板。用户提问后,系统先从向量库召回Top-K片段,拼接后交给LLM生成答案。

上线第一版后,用户反馈很直接:“答非所问”、“关键条款漏了”、“同一个问题换个问法就找不到”。我们拉了一周的真实query做人工标注,结果如下:

  • Recall@3 = 0.61
  • Recall@5 = 0.68
  • 答案准确率(人工评估)= 69.2%

显然,检索环节是最大瓶颈。LLM本身是GPT-3.5-turbo,生成能力不差,但喂给它的上下文经常是错的或者不完整。于是我们决定系统性地优化检索链路:chunk策略、embedding模型、rerank,逐个击破。

二、环境与版本

先交代一下技术栈和版本,方便复现:

  • Python 3.10.12
  • LangChain 0.1.16
  • Milvus 2.3.5(向量库)
  • FlagEmbedding 1.2.10(用于bge系列模型)
  • sentence-transformers 2.6.1
  • PyTorch 2.1.2 + CUDA 12.1
  • NVIDIA A10 24GB(推理与rerank)
  • LLM:GPT-3.5-turbo-0125

原始配置:chunk_size=512,chunk_overlap=50,embedding=text-embedding-ada-002(通过API调用),无rerank。

三、方案设计

优化分三步走,每一步单独评估,避免混淆变量。

Step 1:chunk策略调整
- 放弃固定长度切分,改为基于标点与语义的递归切分。
- 对长文档先按段落切,再按句子切,最后合并到目标长度。
- 引入滑动窗口,overlap从50提升到128。
- 针对表格和代码块,单独作为一个chunk,不拆分。

Step 2:embedding模型切换
- 从OpenAI API切换到本地部署的bge-large-zh-v1.5。
- 原因:中文语义匹配更强,且无API延迟与费用。
- 向量维度从1536降到1024,Milvus索引重建。

Step 3:引入rerank
- 召回阶段用bge-large-zh-v1.5取Top-20。
- 精排阶段用bge-reranker-large对20个候选打分,取Top-5。
- 对比引入前后的Recall与答案准确率。

四、核心实现

4.1 语义chunk切分

from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.document_loaders import UnstructuredFileLoader

def semantic_chunk(file_path, chunk_size=768, overlap=128):
    loader = UnstructuredFileLoader(file_path, mode="single")
    docs = loader.load()

    splitter = RecursiveCharacterTextSplitter(
        separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""],
        chunk_size=chunk_size,
        chunk_overlap=overlap,
        length_function=len,
        is_separator_regex=False,
    )
    chunks = splitter.split_documents(docs)

    # 过滤过短chunk(小于50字符)
    chunks = [c for c in chunks if len(c.page_content.strip()) >= 50]
    return chunks

注意:chunk_size从512提升到768,是因为bge-large-zh-v1.5的最大输入长度是512 token,但中文token与字符比约1:1.5,768字符对应约512 token,刚好打满。overlap=128保证跨chunk语义不丢。

4.2 embedding与rerank流水线

from FlagEmbedding import FlagModel, FlagReranker
import numpy as np

# 初始化模型
embed_model = FlagModel(
    'BAAI/bge-large-zh-v1.5',
    query_instruction_for_retrieval="为这个句子生成表示以用于检索相关文章:",
    use_fp16=True
)
reranker = FlagReranker('BAAI/bge-reranker-large', use_fp16=True)

def retrieve_and_rerank(query, collection, top_k=20, top_n=5):
    # 1. 向量召回
    q_vec = embed_model.encode_queries([query])[0]
    search_params = {"metric_type": "IP", "params": {"nprobe": 16}}
    results = collection.search(
        data=[q_vec],
        anns_field="embedding",
        param=search_params,
        limit=top_k,
        output_fields=["text", "source"]
    )

    candidates = [hit.entity.get('text') for hit in results[0]]

    # 2. rerank
    pairs = [[query, c] for c in candidates]
    scores = reranker.compute_score(pairs, normalize=True)

    # 3. 排序取Top-N
    ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
    return ranked[:top_n]

关键参数:nprobe=16(Milvus IVF索引),use_fp16=True(显存减半,速度提升约40%)。bge-reranker-large在A10上单次20对推理耗时约180ms。

五、踩坑与优化

坑1:chunk_size变大后,召回噪声增加。
768字符的chunk里可能包含多个主题,向量表示被平均化。解决办法:在chunk元数据里保留原始段落标题,检索时对标题匹配度加权。简单做法是给每个chunk前面拼上标题文本再embedding。

坑2:bge模型对query指令敏感。
bge-large-zh-v1.5要求query加指令前缀,passage不加。一开始我忘了加,Recall直接掉8个点。加上"为这个句子生成表示以用于检索相关文章:"后恢复正常。

坑3:rerank显存溢出。
bge-reranker-large fp16约1.3GB,但batch_size=20时中间激活占了不少。A10 24GB够用,但如果你用T4 16GB,建议batch_size降到8,或者用bge-reranker-base。

坑4:Milvus索引重建耗时。
12万文档,1024维,IVF_FLAT索引,nlist=1024,重建用了约22分钟。建议在低峰期操作,或者用别名切换。

六、效果数据

我们固定了200条真实query做评估,结果如下:

阶段 Recall@5 答案准确率 平均检索耗时
原始(512/50 + ada-002 + 无rerank) 0.68 69.2% 210ms
仅chunk调整(768/128) 0.74 73.5% 225ms
+ bge-large-zh-v1.5 0.81 78.9% 190ms
+ bge-reranker-large 0.89 86.1% 370ms

注意:引入rerank后总耗时增加约180ms,但准确率提升7.2个百分点,性价比很高。如果对延迟敏感,可以只对Top-10做rerank,耗时可压到280ms。

另外,embedding从API切到本地后,单次检索成本从$0.0001降到几乎为0,且无网络抖动。

七、总结

这次优化让我重新理解了RAG的短板:检索质量决定上限,生成模型只决定下限。chunk策略是地基,embedding是核心,rerank是保险。三者叠加,效果从0.61到0.89,不是某一个技术的功劳。

几点建议给正在调RAG的朋友:
1. 先做chunk实验,别急着换模型。固定长度切分是最容易被低估的坑。
2. 中文场景优先考虑bge系列,bge-large-zh-v1.5 + bge-reranker-large是当前性价比很高的组合。
3. rerank不是必须,但如果你的Recall@20还行而Recall@5很差,那 rerank 就是解药。
4. 每次只改一个变量,记录数据。否则你根本不知道是哪个改动起了作用。

最后,RAG没有银弹,只有不断迭代。希望这篇记录能帮你少走点弯路。