一、问题背景:为什么我调的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,因为向量维度变了,旧索引直接废了。

三、方案设计:三步走,不搞花活

我们的优化思路很朴素,分三步:

  1. 重构Chunk策略:从“固定大小死切”改成“语义段落优先,长文本父子分块”。
  2. 更换Embedding模型:从MiniLM-L12-v2(384维)换成bge-m3(1024维),中文语义理解提升明显。
  3. 引入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后,RetrieverOutputget_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,我的建议

  1. 先看召回,再看生成。如果你的LLM把正确答案塞进上下文就能答对,那问题100%在RAG侧。
  2. Chunk策略优先于模型选择。我见过很多人一上来就换最强Embedding,结果chunk乱切,再强的模型也白搭。
  3. Rerank是性价比最高的“外挂”。一个Cross-Encoder模型几十MB,却能带来10个点以上的提升,强烈建议加上。
  4. 别迷信“最强模型”。BGE-M3在中文场景够用,如果数据偏垂直领域(如法律、医疗),微调Embedding可能比换更大模型更有效。

最后提一句:RAG调优没有银弹。我们的方案未必适合所有场景,但方法论是通用的:先量化问题,再单变量实验,最后用数据说话。如果你也在调RAG,欢迎评论区交流。

(完)