1. 问题背景:为什么基线RAG像个“半成品”
上个月接手了一个企业内部的智能客服项目,知识库是2000多份Markdown格式的产品文档,总计约400MB。最初版本跑通时,用户问“A100显卡的显存带宽是多少”,系统返回的Top-5文档里居然有一半是讲T4的。当时线上Hit@5只有61.3%,用户满意度直接击穿地板。
我仔细分析了失败case,发现两个致命伤:第一,chunk切分太粗暴。所有文档统一按256个字符硬切,导致一个完整的“特性对比表格”被拦腰截断,向量化后语义碎片化。第二,embedding模型选型问题。当时用的BGE-large-zh(v1.5)在C-MTEB上虽然不错,但对长文档的语义压缩能力不够,且768维向量表达细粒度信息有限。
2. 环境与版本:别小看版本号
先交代实验环境,方便大家复现对比:
Python 3.10.13
langchain 0.2.11
chromadb 0.4.24
sentence-transformers 2.6.1
FlagEmbedding 1.2.8
torch 2.1.2+cu118
注意:FlagEmbedding的版本必须≥1.2.0,否则BGEReranker的normalize参数不生效。另外,chromadb的max_seq_len参数在0.4.x版本中已弃用,我一开始还按照旧教程填了,直接报错。
3. 方案设计:三步走,每一步都量化验证
我的优化路径很明确,每一步都单独跑评测集验证,不搞混合变量:
- Step 1:重构chunk策略 —— 从固定字符数改为“结构感知切分+重叠窗口”
- Step 2:切换embedding模型 —— 从bge-large-zh-v1.5(768维)换到bge-m3(1024维)
- Step 3:引入rerank精排 —— 用bge-reranker-large对Top-20粗排结果做交叉编码重排,取Top-5
评测集是人工标注的300个QA对,每问标注了正确答案所在文档ID。指标用Hit@5(Top-5中是否包含正确答案)。
4. 核心实现:每个环节的代码与细节
4.1 chunk策略重构:解析Markdown结构
之前的切分方式:text_splitter = RecursiveCharacterTextSplitter(chunk_size=256, chunk_overlap=20)。问题在于它根本不懂Markdown结构,把代码块、表格、标题全部打乱。
我改用MarkdownHeaderTextSplitter配合自定义段落长度控制:
from langchain.text_splitter import MarkdownHeaderTextSplitter, RecursiveCharacterTextSplitter
# 按标题层级切片,保留标题作为上下文
headers_to_split_on = [
("#", "H1"),
("##", "H2"),
("###", "H3"),
]
markdown_splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers_to_split_on)
# 对每个标题块,再按段落和句子切分,但保证最小chunk和重叠
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=380, # 调大了一点,适应表格和代码块
chunk_overlap=64, # 重叠从20提升到64,保证跨块语义连贯
separators=["\n\n", "\n", "。", ";", " ", ""], # 按中文句读优先
length_function=len,
)
raw_documents = load_markdown_files("knowledge_base/")
all_chunks = []
for doc in raw_documents:
# 先按标题拆分
sections = markdown_splitter.split_text(doc.page_content)
for section in sections:
# 再按长度细分
sub_chunks = text_splitter.split_text(section.page_content)
for chunk in sub_chunks:
# 将标题路径拼接到chunk内容前,作为语义锚点
header_path = " > ".join([str(v) for k, v in section.metadata.items() if k.startswith("H")])
full_text = f"[{header_path}] {chunk}"
all_chunks.append(full_text)
print(f"Total chunks: {len(all_chunks)}")
# 输出:Total chunks: 18742 (相比原来硬切减少了23%)
关键点:chunk_size从256调到380,配合64字符重叠。原因是Markdown表格通常一行很长,256字符会切断列对齐信息。调大后配合separators优先按。切分,中文语义完整性大幅提升。
4.2 embedding模型切换:bge-m3的坑与收益
切换模型很简单,但有几个坑必须踩:
from sentence_transformers import SentenceTransformer
# 旧模型:768维
# model = SentenceTransformer("BAAI/bge-large-zh-v1.5")
# 新模型:1024维,支持8192长度
model = SentenceTransformer("BAAI/bge-m3")
# 重要:bge系列需要加query指令前缀(对检索效果影响3-5%)
QUERY_PREFIX = "为这个句子生成表示以用于检索相关文章:"
def embed_documents(texts: list[str]) -> list[list[float]]:
# 批量编码,自动用GPU
embeddings = model.encode(
texts,
batch_size=64,
normalize_embeddings=True, # 必须归一化,否则余弦相似度计算错误
show_progress_bar=False
)
return embeddings.tolist()
def embed_query(query: str) -> list[float]:
# 查询时加前缀
return model.encode([QUERY_PREFIX + query], normalize_embeddings=True)[0].tolist()
踩坑记录:
1. 维度变化导致旧向量失效。chromadb中存储的旧向量是768维,新模型是1024维,无法直接混用。必须清空collection重建。我一开始图省事直接添加,结果报Dimension Mismatch错误。
2. bge-m3在长文本上优势明显。实测对超过512字的chunk,m3的语义保持能力比v1.5强很多。但显存占用也翻倍(batch_size=64时约需9GB显存),建议1080Ti以上的卡。
4.3 rerank引入:交叉编码器的“降维打击”
粗排阶段用向量相似度,只计算浅层语义。rerank用交叉编码器将query和每个chunk拼接后全连接计算,精度更高。代码如下:
from FlagEmbedding import FlagReranker
# 加载rerank模型,注意需要GPU
reranker = FlagReranker("BAAI/bge-reranker-large", use_fp16=True)
def rerank_search(query: str, top_k: int = 5, candidate_k: int = 20):
# 第一步:向量检索粗排,取Top-20
query_emb = embed_query(query)
results = collection.query(
query_embeddings=[query_emb],
n_results=candidate_k,
include=["documents", "metadatas", "distances"]
)
docs = results["documents"][0]
distances = results["distances"][0]
# 第二步:交叉编码器精排
pairs = [[query, doc] for doc in docs]
scores = reranker.compute_score(pairs, normalize=True) # normalize=True输出0-1概率
# 按分数排序
sorted_indices = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:top_k]
final_docs = [docs[i] for i in sorted_indices]
final_scores = [scores[i] for i in sorted_indices]
return final_docs, final_scores
关键参数:normalize=True将分数映射到0-1,方便跟向量距离做对比。候选数candidate_k=20很重要——取太少(如5)会让rerank没有足够候选,取太多(如50)会导致延迟飙升。我测试过20是性价比最优值。
5. 踩坑与优化:那些官方文档没告诉你的
坑1:MarkdownHeaderTextSplitter丢metadata。在langchain 0.2.11中,split_text返回的Document对象的metadata会包含标题信息,但如果你在循环中直接取section.metadata,有时是空字典。原因是需要先转成Document对象再处理。我的解决办法是手动构造标题路径字符串,如代码所示。
坑2:bge-m3对GPU显存的“贪心”。如果显存不足(<8GB),建议使用model.encode(..., batch_size=16)并开启torch.cuda.amp.autocast()。我最初batch_size=64时,V100 16GB直接OOM。
坑3:rerank延迟优化。bge-reranker-large在V100上单条pair推理约15ms,20条候选就是300ms。加上向量检索100ms,总延迟从原来的200ms涨到500ms+。对于在线服务,我做了两个优化:
- 将rerank的输入chunk做截断(只保留前512字符)
- 使用use_fp16=True减少显存占用和加速
优化后延迟稳定在340ms左右,可接受。
6. 效果数据:每一步都不是白费的
我在300条评测集上逐阶段跑分,数据如下:
| 阶段 | Hit@5 | 平均响应延迟 | 失败case举例 |
|---|---|---|---|
| 基线(固定256chunk + bge-large-v1.5) | 61.3% | 185ms | “A100显存带宽”返回T4文档 |
| Step1(结构感知chunk + 重叠64) | 74.8% | 190ms | 跨章节连续性提升,表格完整 |
| Step2(+ bge-m3) | 81.2% | 210ms | 长段落语义不再撕裂 |
| Step3(+ rerank-large Top-20→5) | 89.4% | 540ms | 精确匹配率大幅提升 |
关键发现:rerank带来的提升(+8.2%)比换embedding(+6.4%)还要明显。说明对于私有知识库这种“关键词精确匹配”场景,交叉编码器的优势远大于向量表达能力的提升。另外,结构感知chunk带来的收益(+13.5%)最大,因为Markdown的表格、列表、代码块语义完整性被破坏了,再好的向量模型也无法从碎片中恢复信息。
7. 总结与下一步
如果你也在做RAG优化,我强烈建议按这个优先级来:
1. 先修chunk(成本最低,收益最大)
2. 再换embedding(如果垂直领域,考虑领域微调)
3. 最后上rerank(如果延迟预算允许)
目前我的系统在2000文档规模下Hit@5达到89.4%,但离生产标准(95%+)还有距离。下一步准备尝试:
- 用Late Chunking(先过LLM再切块)解决长文本语义压缩丢失问题
- 对query做HyDE(假设性文档嵌入)来提升模糊查询的召回
最后提醒一句:任何优化都要基于你自己的评测集,不要照搬别人的参数。我的chunk_size=380适合技术文档,换成法律文书可能就要调整。希望这篇记录能帮你少走两天弯路。