问题背景:检索不准,生成全靠编
事情要从上个月说起。我们团队负责的智能客服系统接入了RAG,知识库是2000多份产品说明书和故障处理手册,PDF格式,平均每份20页。上线后发现一个尴尬的现象:答非所问的比例高达39%。
我做了个简单的错误分析,发现根因不在生成模型(用的Qwen-14B-Chat),而在检索环节。用户问“打印机卡纸但指示灯不亮”,系统召回的内容全是关于“更换硒鼓”的段落,或者干脆是“安全警告”部分。Top-5召回的余弦相似度都挺高,但语义上压根不相关——典型的“词面匹配”陷阱。
基线数据(优化前):
- 召回准确率(Top-5包含正确答案):78.3%
- 问答准确率(人工标注,基于召回内容可回答):61.2%
- 平均首token延迟:1.8s
这数据没法上线。我决定从三条线同时动刀:chunk怎么切、向量怎么算、排序怎么排。
环境与版本:一套可复现的配置
先说下环境,方便大家复现。我用的Python 3.10,LangChain 0.1.0(注意,0.2.x的API变了),向量库是Milvus 2.3.3。Embedding和Rerank模型都跑在本地一张A10显卡上(24G显存),Qwen-14B-Chat用vLLM部署在另一张A10上。
关键依赖版本:
langchain==0.1.0
langchain-community==0.1.0
pymilvus==2.3.3
sentence-transformers==2.3.1
torch==2.1.2
vllm==0.3.3
PDF解析用的PyMuPDF(fitz)1.23.8,比pdfplumber快不少,中文表格支持也还行。文本清洗主要靠正则,去掉了页眉页脚、页码、以及“第X页共Y页”这类垃圾。
方案设计:三个模块逐个击破
1. chunk策略:从“固定字符”到“语义段落+滑动窗口”
原来的方案是粗暴的split_text(text, chunk_size=512, chunk_overlap=50),用LangChain的RecursiveCharacterTextSplitter。问题很明显:产品说明书的“警告”和“操作步骤”经常被拦腰截断,语义完整性被破坏。而且512字符对中文来说太长了,一个chunk里经常混入两三个不相关的话题。
我改成了两步走。第一步,用MarkdownHeaderTextSplitter按标题切分出语义块(说明书目录结构比较清晰,有“第1章 安装指南”这种层级)。第二步,对每个语义块内部,如果长度超过阈值(我设的512字符),再用滑动窗口二次切分,overlap设为64。
这样既保留了标题的语义边界,又不会让单个chunk过长。段落归属做得细一些,召回时能找到更精确的位置。
2. embedding模型切换:从text2vec到bge-large
原系统用的是text2vec-large-chinese,向量维度1024。这模型在短文本相似度上还行,但长文档场景下,对“因果逻辑”和“否定语义”的理解明显不足。比如用户问“指示灯不亮”,它容易匹配到“指示灯亮起”的段落——因为词面重合度高。
我换成了BAAI/bge-large-zh-v1.5,维度也是1024,但训练数据里包含了大量的网页搜索日志,对中文长尾表达更鲁棒。BGE系列有个特点:query侧需要加指令前缀“为这个句子生成表示以用于检索相关文章:”,不加的话效果打折扣。这个细节坑了不少人。
3. 引入rerank:粗排+精排两阶段
Milvus的ANN检索是向量相似度粗排,它找的是“和query最像的Top-K”。但“像”不等于“对”。我引入了BAAI/bge-reranker-base做精排,它是一个cross-encoder,直接计算query和每个候选文档的交互得分,能捕捉到向量内积丢失的细粒度语义。
流程变成:Milvus召回Top-20 → bge-reranker精排 → 取Top-5送LLM。延迟增加了一些(rerank 20条大概耗时80ms),但换来的是准确率的显著提升。
核心实现:关键代码解析
chunk重写实现
from langchain.text_splitter import MarkdownHeaderTextSplitter, RecursiveCharacterTextSplitter
def smart_chunking(pdf_text: str, max_chunk: int = 512, overlap: int = 64):
# 先用标题粗切,保留语义边界
headers_to_split_on = [
("#", "H1"),
("##", "H2"),
("###", "H3"),
]
md_splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers_to_split_on)
header_splits = md_splitter.split_text(pdf_text)
# 对每个标题块内部,用滑动窗口细切
char_splitter = RecursiveCharacterTextSplitter(
chunk_size=max_chunk,
chunk_overlap=overlap,
separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""],
length_function=len,
)
final_chunks = []
for section in header_splits:
# 保留标题元数据
metadata = section.metadata
sub_chunks = char_splitter.split_text(section.page_content)
for sub in sub_chunks:
final_chunks.append({"text": sub, "metadata": metadata})
return final_chunks
注意那个separators列表——我加了中文标点。!?;,作为切分点,默认的["\n\n", "\n", " "]对中文不友好,经常把一句话从中间劈开。这个细节让chunk的语义完整性提升了一个档次。
bge模型加载与rerank逻辑
from sentence_transformers import CrossEncoder
import numpy as np
class Reranker:
def __init__(self, model_path: str = "/models/bge-reranker-base"):
self.model = CrossEncoder(model_path, max_length=512)
def rerank(self, query: str, documents: list, top_k: int = 5) -> list:
# 构造query-doc对
pairs = [[query, doc] for doc in documents]
# 得到相关性得分(sigmoid输出,越大越相关)
scores = self.model.predict(pairs, batch_size=32)
# 按得分降序排序,取top_k
top_indices = np.argsort(scores)[::-1][:top_k]
return [documents[i] for i in top_indices], [float(scores[i]) for i in top_indices]
我踩过的一个坑:bge-reranker-base的max_length默认是512,但我的chunk切成384个字符后,加上query,经常超过512。超过部分被截断,导致精排得分失真。解决办法是设定max_length=512,同时控制chunk长度在384以内(留出query的空间)。这个参数配了很久,最后还是用chunk_size=384 + overlap=64定的。
踩坑与优化:三个意想不到的坑
坑1:chunk_size=384反而比512效果差?——不是差,是overlap没调对
我一开始只把chunk_size从512改成384,overlap还是50。结果准确率不升反降,从61%掉到58%。排查发现:chunk变小后,如果overlap跟不上,文字段落的“腰部”(两个chunk的交界处)经常被切掉关键信息。比如“如果不亮,请检查电源线”被切成前一个chunk的“如果不亮”和后一个chunk的“请检查电源线”——两边都缺主语。
后来我把overlap调成64,并且强制overlap区域包含上一段的末尾句。代码里用了个小技巧:切分时记录每个chunk的起始偏移量,确保下一chunk的起始位置落在上一chunk的倒数第二句话的句号之后。
坑2:bge-large-zh-v1.5需要query指令前缀,忘了加直接掉7个点
这个是我在验证集上发现的。对比加了前缀“为这个句子生成表示以用于检索相关文章:”和没加的差异,Top-5召回准确率从78%降到了71%。原因是bge模型在训练时,query侧都带了这个指令,推理时不加就存在分布偏移。embedding模型一定要看官方文档的“Usage”部分,别想当然。
坑3:Milvus的metric_type设成了IP而不是COSINE
Milvus 2.3默认的相似度度量是L2,而bge模型官方推荐用余弦相似度。我配置collection时如果忘了设metric_type="COSINE",检索结果会一团糟。这个bug很隐蔽,因为L2距离和余弦相似度在小维度(384)下差异不明显,但1024维度下差异巨大。查了一下午才定位到,最后在创建collection时显式指定:
from pymilvus import CollectionSchema, FieldSchema, DataType, Collection, connections
connections.connect(host="localhost", port="19530")
fields = [
FieldSchema(name="id", dtype=DataType.INT64, is_primary=True),
FieldSchema(name="vector", dtype=DataType.FLOAT_VECTOR, dim=1024),
FieldSchema(name="text", dtype=DataType.VARCHAR, max_length=8192),
]
schema = CollectionSchema(fields, description="rag_chunks")
collection = Collection(name="knowledge_base", schema=schema)
collection.create_index(
field_name="vector",
index_params={"index_type": "IVF_FLAT", "metric_type": "COSINE", "params": {"nlist": 1024}}
)
效果数据:每个改动点的具体贡献
我做了消融实验,严格控制变量,每个改动单独验证。测试集是500条人工构造的QA对,覆盖了说明书里最常见的“安装”“故障”“维护”三类问题。
| 配置组合 | Top-5召回准确率 | 问答准确率 | 首token延迟 |
|---|---|---|---|
| 基线(512chunk + text2vec + 无rerank) | 78.3% | 61.2% | 1.80s |
| + 语义chunk(384+64) | 81.1% | 64.7% | 1.65s |
| + bge-large-zh-v1.5 | 86.4% | 72.3% | 1.20s |
| + bge-reranker(Top-20→Top-5) | 93.2% | 87.4% | 1.04s |
延迟分析:
- 语义chunk缩短了chunk长度,向量化时间从每文档1.2s降到0.8s(因为chunk数变多,但总token数减少,因为切掉了大量空白和废话)
- bge-large比text2vec慢约10%,但Milvus检索快了5%(1024维向量在GPU上计算更快?不确定,可能是偶然)
- rerank增加了80ms开销,但减少了LLM端的“无效生成”——原来经常生成很长的“根据提供内容无法回答”的废话,现在这个比例从39%降到11%,整体端到端体感反而快了
额外收益:用户反馈“答非所问”的工单量下降明显,从每天220条降到63条左右。这说明RAG系统的瓶颈往往不在模型,而在检索质量。你喂给LLM的东西不对,它再聪明也白搭。
总结与建议
这次优化最大的体会是:RAG系统像一条水管,每一段的过水能力都影响最终出水。chunk决定了信息的“颗粒度”,embedding决定了语义匹配的“分辨率”,rerank则是最后的“质检员”。三者缺一不可。
给后来者三个具体建议:
1. 别迷信“大chunk”——512字符对中文来说太长,384是兼顾语义完整和定位精度的合理值,但务必配合overlap调整
2. 切换embedding模型时,务必看README里关于query指令前缀、归一化、维度大小的说明,bge系列在中文场景下性价比很高
3. 如果计算资源允许,rerank是性价比最高的优化手段——80ms的延迟换来近15个点的准确率提升,这笔账怎么算都划算
下一步我准备做两件事:一是把rerank模型换成bge-reranker-large看能不能再涨2-3个点(预计延迟增加150ms);二是尝试用LLM做自动评估,替代人工标注的测试集,这样可以快速迭代调参。等有条件了再写一篇对比文章。