一、问题背景:不是模型不行,是检索先崩了

先说结论:我负责的运维知识库问答系统,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接口变了,如果你照抄网上老代码会直接报错。建议锁版本,别追新。

三、方案设计:三步走,每一步都要有量化指标

我拆成了三个独立优化点,每步单独上线、单独评估:

  1. Chunk策略重做:从“字数切”改为“语义切”。用RecursiveCharacterTextSplitter按分隔符优先级递归切,同时把chunk_size从256调到512。为什么是512?因为我们的FAQ条目平均长度在300字左右,256会把一条FAQ拦腰截断,导致向量语义残缺。
  2. Embedding换代:从bge-large-zh-v1.5换到Qwen3-Embedding-0.6B。原因很简单:bge-large-zh是2023年的模型,对代码块、混合中英文的工单文本表征能力已经跟不上了。Qwen3-Embedding支持8192长度,且对长文本的语义聚合更好。
  3. 引入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 chunkingHyDE,不过那又是另一个故事了。如果你们在做类似的RAG优化,建议按我这个顺序来:先切好chunk,再换embedding,最后加rerank,每一步都单独评估,别一把梭。