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测试,避免混合变量:

  1. Chunk策略重构:从固定字符数改为Markdown标题层级 + 段落边界优先,辅以重叠窗口。
  2. Embedding模型替换:从OpenAI text-embedding-ada-002换成BAAI的bge-m3(中文场景下效果更好,且支持8192长度)。
  3. 引入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=400chunk_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测试再说。

以上。有问题评论区聊,看到会回。