一、问题背景
我们做的是一个企业内部的智能知识助手,底层用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没有银弹,只有不断迭代。希望这篇记录能帮你少走点弯路。