1. 问题背景:知识库变大之后,RAG突然“不聪明”了
我们团队维护一个面向内部员工的IT运维知识库RAG系统,底层是LangChain + Milvus + OpenAI Embedding(最初是text-embedding-ada-002)。上线前两个月效果还行,但知识库文档从2000份涨到8000份后,用户反馈“答非所问”的比例明显上升。
我拉取了近一周的线上日志,统计用户query在知识库中的Top-5召回命中率(即正确答案是否出现在召回的前5个chunk里),发现只有61%。更糟糕的是,很多答案明明在库里,但被切碎了——一个完整的故障处理流程被固定256字符切成三段,每段都缺上下文。
这次优化的目标很明确:在不换大模型(仍用GPT-4o-mini)的前提下,把Top-5召回命中率提升到85%以上,同时单次检索延迟控制在600ms以内。
2. 环境与版本:一套已经跑起来的系统
先交代一下基线环境,方便大家对照自己的版本:
- Python 3.10.12
- LangChain 0.2.4(注意:0.2版本API变化较大,后面代码里我用了新式接口)
- Milvus 2.4.1(Docker部署,单机模式)
- sentence-transformers 2.7.0
- 向量维度:1536(OpenAI旧版)→ 1024(bge-m3)
- 文档类型:Markdown运维手册、PDF故障报告、Excel设备清单
最初的Chunk策略是LangChain的RecursiveCharacterTextSplitter,chunk_size=256,chunk_overlap=32,没有任何语义边界识别。
3. 方案设计:三个维度的改动
我拆成三步走,每一步都单独做A/B测试,避免混合变量:
- Chunk策略重构:从固定字符数改为Markdown标题层级 + 段落边界优先,辅以重叠窗口。
- Embedding模型替换:从OpenAI text-embedding-ada-002换成BAAI的bge-m3(中文场景下效果更好,且支持8192长度)。
- 引入Reranker:在Milvus召回Top-50后,用bge-reranker-base做精排,取Top-5送入LLM。
每一步我都用同一个验证集评估:500条真实线上问题,人工标注了标准答案所在的源文档ID。
4. 核心实现:三步走的具体代码
4.1 第一步:语义化Chunk切分
我写了一个基于Markdown标题的递归切分器,核心逻辑是:优先按#级标题切分,每个标题下的内容作为一个候选chunk;如果候选chunk超过500字符,再按段落(\n\n)切;如果段落还超长,最后才按句子切。
from langchain.text_splitter import MarkdownHeaderTextSplitter
from langchain.text_splitter import RecursiveCharacterTextSplitter
def semantic_chunk_split(markdown_text: str):
# 先按标题层级切分
headers_to_split_on = [
("#", "H1"),
("##", "H2"),
("###", "H3"),
]
md_splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers_to_split_on)
chunks = md_splitter.split_text(markdown_text)
# 对过长的块进行二次切分
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=400,
chunk_overlap=80,
separators=["\n\n", "\n", "。", ";", ",", " "],
)
final_chunks = []
for chunk in chunks:
if len(chunk.page_content) > 500:
sub_chunks = text_splitter.split_text(chunk.page_content)
for sc in sub_chunks:
final_chunks.append({
"content": sc,
"metadata": chunk.metadata
})
else:
final_chunks.append({
"content": chunk.page_content,
"metadata": chunk.metadata
})
return final_chunks
关键参数:chunk_size=400,chunk_overlap=80。这里我踩了个坑——一开始用chunk_size=512,overlap=128,结果召回率反而下降了,因为重叠过多导致检索结果高度重复,浪费了Top-5名额。
4.2 第二步:切换Embedding模型
bge-m3在HuggingFace上可以直接加载,维度1024,支持中文优化。我用FlagEmbedding库加载,并在Milvus中重建了集合(注意:维度变了必须删集合重建,不能直接改)。
from FlagEmbedding import BGEM3FlagModel
from pymilvus import Collection, CollectionSchema, FieldSchema, DataType, connections
# 加载bge-m3模型
model = BGEM3FlagModel('BAAI/bge-m3', use_fp16=True)
def embed_texts(texts):
# 输出dense向量,维度1024
embeddings = model.encode(texts, max_length=8192)['dense_vecs']
return embeddings.tolist()
# 重建Milvus集合
connections.connect(alias="default", host="localhost", port="19530")
fields = [
FieldSchema(name="id", dtype=DataType.INT64, is_primary=True),
FieldSchema(name="content", dtype=DataType.VARCHAR, max_length=8000),
FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=1024),
]
schema = CollectionSchema(fields, description="RAG chunks with bge-m3")
collection = Collection(name="knowledge_base_v2", schema=schema)
collection.create_index(
field_name="embedding",
index_params={"index_type": "IVF_FLAT", "metric_type": "COSINE", "params": {"nlist": 1024}}
)
这里有个细节:bge-m3的encode方法默认返回dense_vecs,如果你需要稀疏向量可以加return_sparse=True,但我没启用——因为Milvus对混合检索的支持还得额外装插件,收益不大。
4.3 第三步:引入Reranker
Reranker我选了bge-reranker-base,因为它比bge-reranker-large快一倍,而且在这个场景下效果差距不到1个百分点。流程是:Milvus先用向量检索Top-50,然后用reranker对50个chunk和query计算相关性分数,取Top-5。
from FlagEmbedding import FlagReranker
reranker = FlagReranker('BAAI/bge-reranker-base', use_fp16=True)
def rerank_fusion(query: str, candidate_chunks: list, top_k: int = 5):
pairs = [[query, chunk['content']] for chunk in candidate_chunks]
scores = reranker.compute_score(pairs, normalize=True)
# 按分数降序排列
scored_chunks = sorted(
zip(candidate_chunks, scores),
key=lambda x: x[1],
reverse=True
)
return [chunk for chunk, _ in scored_chunks[:top_k]]
# 调用流程:先向量检索Top-50,再rerank取Top-5
retrieved = collection.search(
data=[embed_texts([query])[0]],
anns_field="embedding",
param={"metric_type": "COSINE", "params": {"nprobe": 16}},
limit=50,
)
candidates = [{"content": hit.entity.get("content"), "score": hit.score} for hit in retrieved[0]]
final_chunks = rerank_fusion(query, candidates, top_k=5)
5. 踩坑与优化:那些没写进代码里的血泪
坑1:chunk_size不是越大越好。 我试过512、1024,效果都不如400。原因很直接:bge-m3虽然支持长文本,但长chunk内部包含多个语义点,向量会被平均化,导致检索时“哪个都像,哪个都不像”。400字符大概能覆盖一个完整操作步骤,语义最集中。
坑2:Milvus的metric_type要改成COSINE。 原来用OpenAI embedding时我用的是L2距离,因为ada-002的向量模长基本一致。但bge-m3的向量模长差异很大,L2距离会放大模长影响,换成COSINE后Top-5命中率直接涨了4个百分点。
坑3:Reranker的输入长度限制。 bge-reranker-base的max_length是512,如果chunk超过512字符会被截断,导致长文档的尾部信息丢失。我在rerank前加了截断处理,保留前512字符,配合chunk_size=400刚好够用。
坑4:重叠窗口的副作用。 一开始我设overlap=128,结果发现同一个答案出现在4-5个chunk里,Milvus检索时返回的Top-5几乎全是同一个文档的不同片段,白白浪费了多样性。后来把overlap降到80,并且在检索时加了"radius": 0.5过滤低相似度结果,才解决。
6. 效果数据:每一步提升了多少
我用同一套500条验证集,跑了四组对比实验:
| 配置 | Top-5命中率 | 平均检索延迟 | 单次检索召回chunk数 |
|---|---|---|---|
| 基线:256字符+ada-002+无rerank | 61.2% | 380ms | 5 |
| +语义化chunk | 67.4% | 395ms | 5 |
| +切bge-m3(含COSINE) | 76.8% | 410ms | 5 |
| +reranker(Top-50→Top-5) | 89.1% | 520ms | 5 |
最终线上灰度一周后,用户侧“回答不相关”的反馈量下降了57%。延迟增加了140ms,但客服场景下用户几乎无感知。
另外提一句,embedding模型切换后,Milvus的索引必须重建,否则查询会直接报维度错误。我们花了一个小时重新灌了全部8000份文档的chunk数据(约12万条向量),用insert批量写入,速度还行。
7. 总结:这套方案能复制到什么程度
如果你也遇到类似问题,我的建议是:
- 如果知识库文档是结构化的(Markdown、HTML),优先做语义化chunk,收益最大且零成本。
- 如果文档是PDF扫描件或纯文本,chunk策略就退化为段落切分,这时候换embedding模型收益更明显。
- Reranker是最后一步,它只解决“排序不准”的问题,解决不了“召回缺失”的问题。如果Top-50里根本没有正确答案,reranker再强也没用。
最后补一句:bge-m3在中文场景下的优势是实打实的,但如果你处理的是英文技术文档,可以试试bge-large-en-v1.5,效果可能更好。别盲目抄作业,拿你自己的数据跑一遍A/B测试再说。
以上。有问题评论区聊,看到会回。