一、问题背景:为什么我们的RAG系统像“人工智障”

事情要从一次内部演示说起。我们给公司客服部门做了一个基于RAG的文档问答系统,知识库是3000多篇产品操作手册和故障排查文档。演示时,业务同事问了一句“如何修改付款审批人的权限”,系统返回的答案驴唇不对马嘴——它从一篇关于“权限角色划分”的文档里摘了一段,完全没提到“审批人”这个关键主体。

当时我第一反应是embedding模型不行,但冷静下来后用一套人工标注的200条问答对做了基线测试,结果如下:

  • 召回率(Recall@5):61.2%
  • 命中率(Hit@5):74.5%
  • 答案相关度(人工打分,1-5):2.8

这个数据说明,问题核心在于检索阶段——我们根本没有把相关chunk捞回来,后面生成再好也是白搭。于是开始逐层排查,最终锁定了三个关键因素。

二、环境与版本:先交代清楚家底

先列出当时的系统环境,方便各位对照:

Python 3.10.12
LlamaIndex 0.10.43
langchain 0.2.9
sentence-transformers 2.7.0
FAISS 1.7.4
torch 2.2.1+cu118

Embedding模型用的是当时主流的BAAI/bge-large-zh-v1.5(8192维),chunk策略是LlamaIndex默认的SentenceSplitter,chunk_size=1024,chunk_overlap=128。检索方式为FAISS向量库Top-5直接返回。

说实话,这套配置在2024年不算落伍,但效果就是不行。后来复盘发现,问题出在文档结构语义匹配粒度上。

三、第一轮优化:chunk策略的重切

问题分析:我们的技术文档有大量层级结构(章节、小节、步骤列表),默认的1024字符切法会把这些结构拦腰截断。比如“权限管理”章节下可能有“修改审批人”和“修改操作员”两个小节,默认切分会导致一个chunk里混杂两种不相关内容,语义向量被“平均”了。

方案设计:改用按段落和标题层级切分,利用LlamaIndex的HierarchicalNodeParser,先按markdown标题分块,再对每个块内部按句子切分,每个chunk大小控制在512字符左右,并且保留标题路径作为元数据。

from llama_index.core.node_parser import HierarchicalNodeParser, SentenceSplitter
from llama_index.core.schema import Document

# 核心:按标题层级构建父节点,子节点按句子切分
node_parser = HierarchicalNodeParser.from_defaults(
    chunk_sizes=[512, 256, 128],  # 从大到小,形成层级
    chunk_overlap=32,
    include_prev_next_rel=True  # 保留前后节点关系
)

doc = Document(text=raw_text, metadata={"source": file_name})
nodes = node_parser.get_nodes_from_documents([doc])

# 打印切割后的chunk示例,验证是否保留了标题路径
for node in nodes[:3]:
    print(f"Node ID: {node.node_id}")
    print(f"Metadata: {node.metadata}")
    print(f"Text snippet: {node.get_content()[:50]}...")

踩坑:切完chunk后召回率反而降了2%!排查发现,HierarchicalNodeParser生成的子节点没有自动关联父节点的标题元数据。需要手动遍历并注入路径信息:

def add_heading_path(nodes):
    for node in nodes:
        if node.relationships:
            parent = node.relationships.get("parent")
            if parent:
                node.metadata["heading_path"] = parent.metadata.get("heading_path", "") + " > " + parent.metadata.get("heading", "")
    return nodes

nodes = add_heading_path(nodes)

效果:调整后召回率从61.2%提升到68.5%。提升有限,说明chunk粒度不是唯一瓶颈。

四、第二轮优化:embedding模型换代

问题分析:bge-large-zh-v1.5虽然不错,但它是2023年的模型,对长文本语义理解有限。尤其是我们文档中有大量“表格”“步骤列表”这类结构化内容,普通向量模型处理起来很吃力。

方案设计:切换到BAAI/bge-m3(多语言、多功能、多粒度)。它有两大优势:一是支持8192长度,二是采用自编码和对比学习联合训练,对长文本和小粒度实体(如“审批人权限”)的区分度更好。

核心实现

from llama_index.core import Settings
from llama_index.embeddings.huggingface import HuggingFaceEmbedding

# 切换到BGE-M3,注意设置query和passage的指令
embed_model = HuggingFaceEmbedding(
    model_name="BAAI/bge-m3",
    device="cuda:0",
    embed_batch_size=32,
    query_instruction="为这个句子生成表示以用于检索相关文章:",
    text_instruction="为这个句子生成表示:"
)
Settings.embed_model = embed_model

# 重建向量索引(必须重新embedding所有文档)
from llama_index.core import VectorStoreIndex, StorageContext
from llama_index.vector_stores.faiss import FaissVectorStore
import faiss

dim = 1024  # BGE-M3输出维度
faiss_index = faiss.IndexFlatIP(dim)
vector_store = FaissVectorStore(faiss_index=faiss_index)
storage_context = StorageContext.from_defaults(vector_store=vector_store)

index = VectorStoreIndex.from_documents(
    documents, 
    storage_context=storage_context,
    embed_model=embed_model
)

踩坑:BGE-M3在中文上需要加instruction,否则效果会退化到和普通模型差不多。另外,FAISS索引类型从L2距离换成了内积(IndexFlatIP),因为BGE-M3训练时用的是余弦相似度。

效果:召回率从68.5%提升到76.3%,命中率到了84.2%。但离目标还差一截,而且发现一个现象——Top-1经常是错的,但Top-5里有正确答案。这说明向量检索的“粗排”上限到了,需要引入重排。

五、第三轮优化:引入rerank模型

问题分析:向量检索本质是“语义近似”,对同义词、上下文歧义处理不够精细。比如“修改审批人”和“审批人权限设置”在向量空间距离较近,但实际含义有细微差别。我们需要一个更精确的模型对Top-5结果做二次排序。

方案设计:引入BAAI/bge-reranker-v2-m3,这是一个交叉编码器(Cross-Encoder),将query和候选chunk拼接输入模型,输出相关性分数。它的精度远高于双塔式的向量检索。

核心实现

from llama_index.core.postprocessor import SentenceTransformerRerank

# 初始化rerank模型,注意用专门的bge-reranker,不是embedding模型
reranker = SentenceTransformerRerank(
    model="BAAI/bge-reranker-v2-m3",
    top_n=3,  # 保留前3个
    keep_retrieval_score=True
)

# 查询时使用
from llama_index.core import QueryBundle

query_bundle = QueryBundle(query_str="如何修改付款审批人的权限?")
retrieved_nodes = index.as_retriever(similarity_top_k=5).retrieve(query_bundle)
reranked_nodes = reranker.postprocess_nodes(retrieved_nodes, query_bundle)

for node in reranked_nodes:
    print(f"Score: {node.score:.4f} | Text: {node.get_content()[:60]}...")

踩坑:这里犯了一个低级错误——一开始把rerank模型也设成了HuggingFaceEmbedding类,导致输出维度不匹配报错。后来才意识到SentenceTransformerRerankHuggingFaceEmbedding是两回事。

另一个坑是性能问题:rerank模型推理时间约200ms/条,5条就是1秒,对线上服务来说太慢。解决方案是限制向量检索的候选集(从Top-20先粗筛再rerank Top-5):

# 优化前:直接Top-5进行rerank,浪费了前面检索的潜力
# 优化后:先取Top-20,再rerank出Top-3
retriever = index.as_retriever(similarity_top_k=20)
reranker = SentenceTransformerRerank(model="BAAI/bge-reranker-v2-m3", top_n=3)

效果:召回率直接从76.3%跃升到89.4%,命中率到了93.1%。人工打分从2.8升到了4.2,业务同事反馈“终于像人话了”。

六、最终效果对比与总结

优化阶段 Recall@5 Hit@5 生成答案评分
基线(bge-large+1024chunk) 61.2% 74.5% 2.8
调整chunk策略后 68.5% 79.8% 3.2
切换BGE-M3后 76.3% 84.2% 3.7
引入rerank后 89.4% 93.1% 4.2

总结三条经验

  1. chunk切分要匹配文档结构,别用默认的纯长度切法。我们最终固定为按markdown标题层级切分,子节点512字符,带标题路径元数据。
  2. embedding模型要升级到BGE-M3这个级别,bge-large-zh-v1.5在2024年下半年已经不够看了。注意BGE-M3必须加instruction。
  3. rerank是提升精度的关键一步,强烈建议用bge-reranker-v2-m3,配合向量检索的Top-20粗筛+Top-3精排,效果和性能都能兼顾。

最后说一句,RAG调优是个系统工程,别指望一步到位。后面我们还做了prompt优化和知识蒸馏,但那是另一个故事了。如果你有类似问题,欢迎评论区交流。


(正文完)

附:本文所有代码均在LlamaIndex 0.10.43环境中验证通过,硬件为单卡A100-40G。如果使用CPU,建议将embed_batch_size调小至8,并降低chunk_size至256以加速。)