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,否则BGERerankernormalize参数不生效。另外,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适合技术文档,换成法律文书可能就要调整。希望这篇记录能帮你少走两天弯路。