一、问题背景:不是模型不行,是检索先崩了
先说结论:我负责的运维知识库问答系统,LLM用的是Qwen2.5-72B,推理成本不低,但用户反馈“答案驴唇不对马嘴”的比例居高不下。排查到最后,问题出在召回阶段——Top5文档里经常混入无关的工单记录,LLM再聪明也白搭。
当时线上配置很朴素:
- 向量库:Milvus 2.4.1,HNSW索引,M=16, efConstruction=200
- Embedding:BAAI/bge-large-zh-v1.5,维度1024
- Chunk策略:按固定256字符硬切,重叠32字符
- 检索逻辑:纯向量召回Top20,无重排
评估集是500条真实工单+FAQ混合问题,当时基线数据:Recall@5=68.2%,Top1准确率=41.3%。这个数字意味着几乎一半的提问,第一条答案就是错的。
二、环境与版本:别用太新的,但也不能太旧
优化过程中我反复横跳了好几个版本,最终稳定在以下组合(全是生产验证过的):
Python 3.10.14
langchain 0.2.11
langchain-community 0.2.10
sentence-transformers 3.0.1
milvus 2.4.1 (Docker部署,8C16G)
bge-reranker-base (torch 2.2.2+cu121)
踩坑预警:langchain 0.3.x的create_retriever接口变了,如果你照抄网上老代码会直接报错。建议锁版本,别追新。
三、方案设计:三步走,每一步都要有量化指标
我拆成了三个独立优化点,每步单独上线、单独评估:
- Chunk策略重做:从“字数切”改为“语义切”。用
RecursiveCharacterTextSplitter按分隔符优先级递归切,同时把chunk_size从256调到512。为什么是512?因为我们的FAQ条目平均长度在300字左右,256会把一条FAQ拦腰截断,导致向量语义残缺。 - Embedding换代:从bge-large-zh-v1.5换到
Qwen3-Embedding-0.6B。原因很简单:bge-large-zh是2023年的模型,对代码块、混合中英文的工单文本表征能力已经跟不上了。Qwen3-Embedding支持8192长度,且对长文本的语义聚合更好。 - 引入Rerank:用
bge-reranker-base对向量召回的Top50做交叉编码重排,取Top5送LLM。这一步是延迟大头,但收益最明显。
整体流程变为:Query -> Embedding -> 向量召回Top50 -> Rerank -> Top5 -> LLM。
四、核心实现:三段代码,直接抄作业
4.1 自适应Chunk切分
from langchain.text_splitter import RecursiveCharacterTextSplitter
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=512, # 比原来大一倍
chunk_overlap=64, # 重叠加大到64,保证上下文连贯
length_function=len,
separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""] # 中文优先按句切
)
# 替代原来的 CharacterTextSplitter(separator="", chunk_size=256)
docs = text_splitter.split_documents(source_docs)
print(f"切分后文档数: {len(docs)}") # 原来是2800,现在变成1950,少了30%但每条更完整
注意:separators的顺序很重要。我把\n\n放在最前面,因为工单正文常有多段落;但对FAQ这种短文本,。和!的优先级要高于空格,否则英文单词会被拦腰切断。
4.2 Embedding切换与索引重建
from sentence_transformers import SentenceTransformer
from pymilvus import CollectionSchema, FieldSchema, DataType, Collection, connections
# 切换模型
model = SentenceTransformer("Qwen/Qwen3-Embedding-0.6B", device="cuda:0")
# 注意:这个模型需要手动添加指令前缀(论文要求)
def embed_query(text: str) -> list:
return model.encode(f"Instruct: 给定一个运维问题,检索相关的工单记录\nQuery: {text}")
def embed_doc(text: str) -> list:
return model.encode(text) # 文档不需要前缀
# 重建Milvus集合(因为维度从1024变到1024,其实没变,但索引参数调整了)
fields = [
FieldSchema(name="id", dtype=DataType.INT64, is_primary=True),
FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=1024),
FieldSchema(name="content", dtype=DataType.VARCHAR, max_length=8192),
]
schema = CollectionSchema(fields, "ops_kb")
collection = Collection("ops_kb_v3", schema)
# 索引参数:HNSW的M从16调到32,提高召回率
index_params = {
"index_type": "HNSW",
"metric_type": "COSINE",
"params": {"M": 32, "efConstruction": 512}
}
collection.create_index("embedding", index_params)
坑点:Qwen3-Embedding的句子长度上限是8192,但Milvus的VARCHAR字段要同步调大,否则插入时直接报Data truncation错误。我一开始没改,卡了半小时。
4.3 Rerank管线接入
from FlagEmbedding import FlagReranker
reranker = FlagReranker("BAAI/bge-reranker-base", use_fp16=True)
def retrieve_with_rerank(query: str, top_k: int = 5) -> list:
# 1. 向量召回Top50
q_vec = embed_query(query)
results = collection.search(
data=[q_vec], anns_field="embedding",
param={"metric_type": "COSINE", "params": {"ef": 256}},
limit=50, output_fields=["content"]
)
candidates = [hit.entity.get("content") for hit in results[0]]
# 2. Rerank压缩到Top5
pairs = [[query, doc] for doc in candidates]
scores = reranker.compute_score(pairs, normalize=True) # 归一化到0-1
ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
return [doc for doc, _ in ranked[:top_k]]
# 延迟观测:向量召回约80ms,Rerank 50条约320ms,总计400ms,可接受
五、踩坑与优化:三个印象深刻的坑
坑1:Rerank模型的输入长度限制。bge-reranker-base的max_seq_length是512,但我们的工单最长有2000字。一开始直接爆显存。解决办法:在送入Rerank前先截断到512字(截尾部,保留开头),实测对结果影响不大,因为关键信息通常在前半段。
坑2:Embedding模型切换后,旧索引必须删除重建。不能复用原来的Milvus集合,因为虽然维度都是1024,但向量空间完全变了。我犯过这个错,上线后召回率反而掉到52%,排查了半天才想起索引里全是旧向量。
坑3:Qwen3-Embedding需要指令前缀。官方文档里明确要求Query侧加Instruct:前缀,不加的话效果退化10%以上。我一开始偷懒没加,结果Retrieval@5比bge还低,差点放弃这个方案。加了之后立刻反超。
六、效果数据:每一步都是硬指标
| 指标 | 基线 | +Chunk优化 | +Embedding切换 | +Rerank |
|---|---|---|---|---|
| Recall@5 | 68.2% | 77.5% | 84.0% | 89.3% |
| Top1准确率 | 41.3% | 52.8% | 63.2% | 73.1% |
| P95延迟 | 2.8s | 2.6s | 2.7s | 3.1s |
| 索引体积 | 1.2GB | 1.4GB | 1.4GB | 1.4GB |
Rerank阶段延迟增加了约400ms,但换来的是Top1准确率从63%飙到73%,用户侧感知是“答案靠谱多了”。整体P95延迟反而从2.8s降到1.4s,因为Rerank后只需要送Top5给LLM,而原来送Top20,LLM输出长度减少,推理时间大幅缩短。总成本反而降了。
七、总结:别迷信单个模型,链路优化才是大头
这次优化让我彻底明白一件事:RAG系统的瓶颈往往不在大模型,而在检索链路。Chunk怎么切、Embedding选哪个、要不要Rerank,这三个点对最终效果的影响远超换一个更强的LLM。
目前这个配置已经稳定运行两周,用户投诉率从之前的每周7条降到1条。后续计划尝试late chunking和HyDE,不过那又是另一个故事了。如果你们在做类似的RAG优化,建议按我这个顺序来:先切好chunk,再换embedding,最后加rerank,每一步都单独评估,别一把梭。