一、问题背景:为什么我调的RAG像个人工智障
上个月,我们内部运维知识库的RAG系统被吐槽“人工智障”。用户问“如何修改Nginx的worker_processes参数”,系统返回的是“Nginx配置文件的语法检查”。人工抽检了200条真实query,首答命中率(Top1返回包含正确答案)只有54.3%,这个数字在老板面前实在拿不出手。
我们当时的架构很简单:BGE-M3(旧版)做Embedding -> Milvus向量检索 -> Top5直接拼进Prompt喂给GPT-4o-mini。问题看起来不在生成端,因为把正确文档手动塞进Prompt,模型回答完全没问题。所以核心瓶颈在召回侧:要么没召回该召回的,要么召回了但排序不对。
二、环境与版本:踩坑前的底子
先交代一下环境,方便大家复现对比:
- Python 3.10.12
- langchain 0.1.16(当时还没升0.2,后面踩了个坑)
- langchain-community 0.0.34
- sentence-transformers 2.6.1
- FlagEmbedding 1.2.8(用于加载rerank模型)
- Milvus 2.3.4(Docker单机部署,collection默认参数:
index_type=HNSW, metric_type=IP, nlist=1024) - 向量维度:旧模型384维,新模型1024维
注意:如果你的Milvus collection已经建好,切换Embedding模型后需要重建collection,因为向量维度变了,旧索引直接废了。
三、方案设计:三步走,不搞花活
我们的优化思路很朴素,分三步:
- 重构Chunk策略:从“固定大小死切”改成“语义段落优先,长文本父子分块”。
- 更换Embedding模型:从
MiniLM-L12-v2(384维)换成bge-m3(1024维),中文语义理解提升明显。 - 引入Rerank层:召回Top50,用Cross-Encoder精排取Top5,解决“向量相似≠语义相关”的问题。
每一步都单独上线评测,避免多个变量混在一起说不清楚。
四、核心实现:每一步的代码与参数
第一步:Chunk策略重构
旧逻辑(问题所在):
# 旧方案:固定256字,重叠50字,完全不看语义边界
from langchain.text_splitter import RecursiveCharacterTextSplitter
old_splitter = RecursiveCharacterTextSplitter(
chunk_size=256,
chunk_overlap=50,
separators=["\n\n", "\n", "。", "!", "?", ";"],
keep_separator=False
)
这个方案的问题在于:当一段文档讲完“A概念定义”后接着讲“B操作步骤”,如果正好被切在一个段落中间,Retriever很难匹配到完整语义。
新逻辑:语义段落+父子分块
from langchain.text_splitter import TextSplitter
import re
class SemanticParentChildSplitter(TextSplitter):
"""按Markdown标题/序号先切父块,再按递归字符切子块"""
def __init__(self, child_chunk_size=512, child_overlap=80):
self.child_chunk_size = child_chunk_size
self.child_overlap = child_overlap
self.recursive_splitter = RecursiveCharacterTextSplitter(
chunk_size=child_chunk_size,
chunk_overlap=child_overlap,
separators=["\n\n", "\n", "。", "!", "!", ";", ","]
)
def split_text(self, text: str):
# 先用正则切出语义段落(按二级/三级标题切)
parent_chunks = re.split(r'(?m)(?=^#{2,3}\s|\n\d+\.\s)', text)
parent_chunks = [c.strip() for c in parent_chunks if len(c.strip()) > 50]
# 父块直接作为一条完整记录,同时切子块用于精确匹配
final_chunks = []
for parent in parent_chunks:
final_chunks.append(parent) # 父块原文,用于Rerank读取
if len(parent) > self.child_chunk_size:
# 子块用于向量检索,携带parent_id
children = self.recursive_splitter.split_text(parent)
for child in children:
final_chunks.append({
'text': child,
'parent': parent # 这里简化了,实际会用id关联
})
return final_chunks
核心调整点:
- chunk_size从256提到512,因为大模型上下文够长,太碎反而丢失上下文。
- 父块不参与向量化,只作为“正确答案”存储;子块参与向量检索,但命中后返回父块给LLM。
- 分隔符增加了中文标点优先级,避免在句子中间硬切。
第二步:Embedding模型切换
旧模型是paraphrase-multilingual-MiniLM-L12-v2,384维,2019年的老将,中文效果一般。换成BAAI/bge-m3:
from sentence_transformers import SentenceTransformer
# 新模型:BGE-M3,支持8192长度,1024维
embed_model = SentenceTransformer("BAAI/bge-m3", device="cuda:0")
def embed_documents(texts):
# 注意:bge系列建议加query指令,但只有检索时加,入库时不要加
embeddings = embed_model.encode(
texts,
batch_size=32,
normalize_embeddings=True, # 必须归一化,因为Milvus用IP内积
show_progress_bar=True
)
return embeddings
切换后必须做的事:
1. 重建Milvus Collection:维度变了,旧的384维索引无法使用。
2. 重新入库:所有文档重新切分、重新向量化。
3. 加Query指令:BGE模型在检索时,建议在query前加"为这个句子生成表示以用于检索相关文章:",而入库文本不要加。
第三步:Rerank层引入
召回Top50后,用bge-reranker-v2-m3精排:
from FlagEmbedding import FlagReranker
reranker = FlagReranker("BAAI/bge-reranker-v2-m3", use_fp16=True)
def rerank_documents(query, docs, top_k=5):
# docs是[(doc_id, text, score, parent_text)],取parent_text用于重排
pairs = [[query, doc[3]] for doc in docs] # 用父块文本重排,效果更好
scores = reranker.compute_score(pairs, normalize=True)
# 按重排分数排序
sorted_docs = [doc for _, doc in sorted(zip(scores, docs), key=lambda x: x[0], reverse=True)]
return sorted_docs[:top_k]
参数细节:
- use_fp16=True:显存优化,如果显存不够,改成use_fp16=False但速度会慢30%。
- normalize=True:把score映射到0-1,方便和其他分数对比。
五、踩坑与优化:这坑我替你踩了
坑1:Milvus连接池爆掉
切换模型后,因为向量维度变大,单条查询耗时从8ms涨到23ms。并发一高,Milvus的gRPC连接池直接打满。解决:升级pymilvus到2.3.4,并把timeout从10秒调到30秒。
坑2:BGE-M3的query指令不能加错地方
一开始我在入库和查询时都加了"为这个句子生成表示...",结果召回率不升反降。后来查了官方文档,发现只有query端需要加指令,passage端不能加。这个细节直接导致Recall@5掉了5个点。
坑3:Rerank的输入长度
bge-reranker-v2-m3最大支持8192个token,但如果你把父块(可能2000字)和query拼一起,显存直接爆。我的解决方案是:如果父块超1024字,就截断前512字+后512字,中间用省略号。实测对效果影响不到1%。
坑4:LangChain 0.1.16的Bug
在升级langchain后,RetrieverOutput的get_relevant_documents方法返回的Document对象丢失了metadata中的parent_id。这导致我Rerank后无法回溯父块。解决:放弃LangChain的Retriever,直接手写Milvus搜索+自定义封装。
六、效果数据:数字说话
人工评测集200条真实query,每条标注了正确答案所在文档。评测指标:
| 方案 | Recall@5 | MRR@10 | Top1命中率 | 平均响应延迟 |
|---|---|---|---|---|
| 旧方案(256chunk+MiniLM+无Rerank) | 68.1% | 0.412 | 54.3% | 1.2s |
| +语义分块 | 72.5% | 0.468 | 61.0% | 1.3s |
| +BGE-M3 | 79.4% | 0.531 | 68.5% | 1.8s |
| +Rerank(Top50→Top5) | 86.7% | 0.612 | 81.2% | 2.4s |
结论:
- 语义分块提升最稳,主要是解决了“答案被切断”的问题。
- BGE-M3带来的提升比预期大,中文长尾query的召回明显改善。
- Rerank虽然增加了0.6秒延迟,但Top1命中率提升12.7个点,这波血赚。
七、总结:如果你也在调RAG,我的建议
- 先看召回,再看生成。如果你的LLM把正确答案塞进上下文就能答对,那问题100%在RAG侧。
- Chunk策略优先于模型选择。我见过很多人一上来就换最强Embedding,结果chunk乱切,再强的模型也白搭。
- Rerank是性价比最高的“外挂”。一个Cross-Encoder模型几十MB,却能带来10个点以上的提升,强烈建议加上。
- 别迷信“最强模型”。BGE-M3在中文场景够用,如果数据偏垂直领域(如法律、医疗),微调Embedding可能比换更大模型更有效。
最后提一句:RAG调优没有银弹。我们的方案未必适合所有场景,但方法论是通用的:先量化问题,再单变量实验,最后用数据说话。如果你也在调RAG,欢迎评论区交流。
(完)