一、问题背景:一个“看起来能用”的RAG系统
去年底我接手了一个企业内部知识库问答系统,底层是典型的RAG(检索增强生成)架构。文档量不大,约1.2万篇Markdown和PDF,覆盖产品手册、运维SOP、API文档。用户是内部工程师,问题偏具体,比如“网关超时重试次数怎么配置”“订单服务降级开关在哪”。
上线初期用的是最朴素的方案:
- 分块:按字符数512定长切分,重叠64
- 嵌入:OpenAI text-embedding-ada-002,1536维
- 向量库:Milvus 2.3.1,HNSW索引
- 检索:纯向量Top-5
- 生成:GPT-3.5-turbo,temperature=0
业务方反馈“有时答得对,有时答非所问”。我搭了一个1200条问题的评测集(人工标注标准答案段落),跑出来几个关键指标:
- Recall@5 = 0.71
- MRR = 0.58
- 答案准确率(人工判定)= 0.63
- 端到端P95延迟 = 1.2s
问题很明确:召回不够,且召回的段落里噪声多。生成模型本身没问题,是喂给它的上下文不对。于是我开始按“分块 → 嵌入 → 重排”的顺序逐层优化。
二、环境与版本
先固定环境,避免版本漂移导致对比失真:
Python 3.10.13
torch 2.1.2 + cu121
transformers 4.36.2
sentence-transformers 2.3.1
FlagEmbedding 1.2.10
Milvus 2.3.1
langchain 0.1.0
openai 1.6.1
硬件:单卡A10 24GB,Milvus单机。评测脚本固定随机种子,所有对比在同一评测集上跑。
三、方案设计:三层递进优化
我的优化思路是分层定位瓶颈,而不是一次性全换:
- 分块层:定长切分把语义单元切碎了,先换语义分块
- 嵌入层:ada-002对中文技术文档的语义区分度一般,换中文强化的bge
- 重排层:向量检索是“粗排”,Top-20里混入噪声,引入cross-encoder重排
每层单独做A/B,记录指标,最后叠加。这样能清楚知道每一层贡献了多少。
四、核心实现
4.1 语义分块
定长512字符的问题在于:一个配置说明可能被从中间切断,或者把两个不相关的小节拼在一起。我改用基于标点和标题的递归分块,目标块大小256-512 token,重叠50 token。
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.document_loaders import UnstructuredMarkdownLoader
def build_chunks(file_path: str):
loader = UnstructuredMarkdownLoader(file_path, mode="single")
docs = loader.load()
splitter = RecursiveCharacterTextSplitter(
chunk_size=400, # 约400 token
chunk_overlap=50,
length_function=lambda x: len(x), # 中文按字符近似
separators=["\n## ", "\n### ", "\n\n", "\n", "。", ";", ",", ""],
keep_separator=True,
)
chunks = splitter.split_documents(docs)
# 过滤过短块,避免噪声
chunks = [c for c in chunks if len(c.page_content.strip()) >= 80]
return chunks
关键点:separators里把Markdown标题放在最前,保证标题层级不被破坏;过滤掉小于80字符的块,这类块在检索时几乎全是噪声。
4.2 嵌入模型切换
从ada-002切到BAAI/bge-large-zh-v1.5。bge在中文语义相似度上表现更好,且支持指令前缀。注意:查询要加指令前缀,文档不加,这是bge的用法约定。
from FlagEmbedding import FlagModel
import numpy as np
class BGEEmbedder:
def __init__(self, model_name="BAAI/bge-large-zh-v1.5", device="cuda"):
self.model = FlagModel(
model_name,
query_instruction_for_retrieval="为这个句子生成表示以用于检索相关文章:",
use_fp16=True,
device=device,
)
def encode_docs(self, texts):
# 文档不加前缀
return self.model.encode(texts, batch_size=64, max_length=512)
def encode_query(self, text):
# 查询加前缀
return self.model.encode_queries([text], max_length=128)[0]
维度从1536降到1024,Milvus集合需要重建。索引参数:M=16, efConstruction=200,检索时ef=128。
4.3 重排引入
向量检索是双塔结构,query和doc各自编码,交互不足。引入BAAI/bge-reranker-large做cross-encoder重排:先向量召回Top-20,再用reranker精排取Top-5。
from FlagEmbedding import FlagReranker
class Reranker:
def __init__(self, model_name="BAAI/bge-reranker-large", device="cuda"):
self.reranker = FlagReranker(model_name, use_fp16=True, device=device)
def rerank(self, query, docs, top_k=5):
pairs = [[query, d] for d in docs]
scores = self.reranker.compute_score(pairs, normalize=True)
ranked = sorted(zip(docs, scores), key=lambda x: x[1], reverse=True)
return ranked[:top_k]
在线检索流程变成:
def retrieve(query, top_k=5, recall_k=20):
q_vec = embedder.encode_query(query)
hits = milvus.search(q_vec, limit=recall_k) # 粗排20
docs = [h.entity.get("text") for h in hits[0]]
reranked = reranker.rerank(query, docs, top_k=top_k) # 精排5
return reranked
五、踩坑与优化
坑1:分块重叠导致重复召回。语义分块后,相邻块有50 token重叠,Top-5里出现两块内容高度相似,浪费上下文窗口。解决:在重排后做一次去重,按内容前100字符做哈希,重复的只保留分数高的。
坑2:bge查询前缀漏加。一开始文档和查询都没加前缀,Recall只从0.71涨到0.74,几乎没效果。后来发现查询必须加指令前缀,加上后直接到0.82。这个细节FlagEmbedding文档里有,但很容易忽略。
坑3:reranker拖慢延迟。Top-20重排,单次约300ms(A10)。P95从1.2s涨到2.1s。优化:把recall_k从20降到15,reranker batch推理,延迟降到1.7s,Recall只掉0.01。最终取recall_k=15。
坑4:Milvus重建集合时忘了改维度。ada-002是1536维,bge是1024维,直接往旧集合插会报错。必须新建集合并重新灌数据。灌1.2万篇文档约8分钟。
六、效果数据
在1200条评测集上的对比(相同生成模型GPT-3.5-turbo):
| 方案 | Recall@5 | MRR | 答案准确率 | P95延迟 |
|---|---|---|---|---|
| 基线(512定长+ada+无重排) | 0.71 | 0.58 | 0.63 | 1.2s |
| +语义分块 | 0.75 | 0.62 | 0.67 | 1.2s |
| +bge-large-zh-v1.5 | 0.82 | 0.71 | 0.75 | 1.3s |
| +bge-reranker-large | 0.93 | 0.85 | 0.86 | 2.1s |
| +recall_k调至15 | 0.92 | 0.84 | 0.86 | 1.7s |
贡献拆解:分块+0.04,嵌入+0.07,重排+0.11。重排贡献最大,但前提是召回池里得有正确文档——如果嵌入层没做好,重排也救不回来。这三层是有序依赖的。
七、总结
这次优化最大的体会是:RAG的瓶颈几乎永远在检索,不在生成。很多人一上来就换更大的LLM,但上下文里没有正确答案,模型再强也只能胡编。
具体到可复用的经验:
- 分块要尊重文档结构,Markdown标题、代码块边界不能破坏
- 中文场景优先用bge系列,注意查询指令前缀这个细节
- 重排是性价比最高的一步,但召回池质量是前提,recall_k取15-20比较平衡
- 每层单独A/B,别一次性全换,否则出问题不知道是哪层的锅
当前系统还有优化空间:比如用混合检索(BM25+向量)补足关键词匹配,或者对长文档做层级摘要。但就目前业务反馈,准确率0.86已经能覆盖大部分内部问答场景了。下一步打算试试bge-m3的多向量检索,看看能不能把Recall再往上推一推。