一、问题背景:为什么我的RAG系统像个智障

项目背景是为某律所构建私有法律知识库问答系统,基于LlamaIndex 0.9.3 + FastAPI。初期上线后用户反馈极差——“问‘劳动合同到期不续签有赔偿吗’,系统回了一堆关于试用期辞退的内容”。

定位问题后发现两个核心缺陷:
1. chunk粒度不合理:固定512字符切分,把法律条款的“条件-后果”逻辑链拦腰截断,导致语义碎片化。
2. 检索排序不精准:top-5检索结果中往往只有1-2条相关,且相关条目排在第三位之后,生成器根本来不及用。

我们用2000条法律咨询问题构建了评估集(每条含标准答案段落ID),基线指标如下:

指标 基线值
Hit Rate@5 61.3%
MRR (Mean Reciprocal Rank) 0.42

二、环境与版本:直接抄作业

  • Python 3.10.12
  • llama-index 0.9.3(后续升级到0.10.12)
  • sentence-transformers 2.2.2
  • cohere 5.5.8(Rerank API)
  • ChromaDB 0.4.22(向量库)
  • 文档集:3200篇法律条文/案例,约80MB文本

Embedding基线:OpenAI text-embedding-ada-002(1536维),切换后:BAAI/bge-large-zh-v1.5(1024维)。

三、方案设计:三步走优化策略

优化前流程:文档 → 固定切分512字符 → OpenAI Embedding → ChromaDB检索 → 直接生成
优化后流程:文档 → 递归结构切分(256-1024) → BGE Embedding → ChromaDB初筛(top-20) → Cohere Rerank(top-5) → 生成

三个核心改动点:
1. chunk策略:改用手写递归切分器,优先按标题/章节/条款边界,再按句子边界,最后按token上限截断。
2. Embedding替换:BGE-large-zh-v1.5在中文语义匹配上比ada-002强得多,且本地化部署无API延迟。
3. Rerank引入:初检召回20条,用Cohere Rerank排序取前5,重排序模型更擅长捕捉query与doc的细粒度相关性。

四、核心实现:关键代码与参数调优

4.1 递归结构感知切分器

这是本次优化收益最大的改动,没有之一。核心逻辑:优先保持文档的语义完整性。

# chunking.py
import re
from typing import List, Dict

class RecursiveChunker:
    def __init__(self, chunk_size: int = 512, chunk_overlap: int = 50):
        self.chunk_size = chunk_size
        self.chunk_overlap = chunk_overlap
        self.section_pattern = re.compile(r'^(第[一二三四五六七八九十百]+[章节条]|第\d+条)', re.MULTILINE)
        self.sentence_pattern = re.compile(r'(? List[Dict[str, str]]:
        chunks = []
        # 1级切分:按章节/条款
        sections = self.section_pattern.split(text)
        for section in sections:
            if not section.strip():
                continue
            # 2级切分:按句子
            sentences = self.sentence_pattern.split(section)
            current_chunk = ""
            for sent in sentences:
                if len(current_chunk) + len(sent) > self.chunk_size:
                    if current_chunk:
                        chunks.append({"text": current_chunk.strip()})
                    current_chunk = sent
                else:
                    current_chunk += sent
            if current_chunk.strip():
                chunks.append({"text": current_chunk.strip()})
        return chunks

参数调优记录:
- chunk_size=512时,Hit Rate 61.3% → 调至384后提升到67.8%,但降到256反而下降到64.2%
- 最终采用动态chunk_size:按章节切分后,保持章节内句子完整,若超过1024才强制截断
- overlap从50调到80,MRR从0.42提升到0.51

4.2 Embedding切换:从OpenAI到BGE

切换原因除了效果,还有成本控制——每天API调用量太大。BGE模型的加载方式:

# embedding_switch.py
from sentence_transformers import SentenceTransformer
from llama_index.embeddings import BaseEmbedding
import numpy as np

class BGEEmbedding(BaseEmbedding):
    def __init__(self, model_name: str = "BAAI/bge-large-zh-v1.5"):
        self.model = SentenceTransformer(model_name)
        self.model.max_seq_length = 512
        self.model.encode_kwargs = {"normalize_embeddings": True}  # 关键:必须归一化
        super().__init__(embed_batch_size=32)

    def _get_query_embedding(self, query: str) -> List[float]:
        return self.model.encode(query).tolist()

    def _get_text_embedding(self, text: str) -> List[float]:
        return self.model.encode(text).tolist()

    async def _aget_query_embedding(self, query: str) -> List[float]:
        return self._get_query_embedding(query)

    def get_query_embedding(self, query: str) -> List[float]:
        return self._get_query_embedding(query)

    def get_text_embedding(self, text: str) -> List[float]:
        return self._get_text_embedding(text)

踩坑记录:BGE模型官方要求query需要加“为这个句子生成表示以用于检索相关文章:”前缀,不加的话MRR会掉10%以上。加上后效果立竿见影。

4.3 Rerank集成

# rerank_retriever.py
import cohere
from typing import List

class CohereReranker:
    def __init__(self, api_key: str, model: str = "rerank-multilingual-v3.0"):
        self.client = cohere.Client(api_key)
        self.model = model

    def rerank(self, query: str, docs: List[str], top_n: int = 5) -> List[int]:
        response = self.client.rerank(
            model=self.model,
            query=query,
            documents=docs,
            top_n=top_n,
            return_documents=False
        )
        # 返回排序后的索引
        return [item.index for item in response.results]

注意:Cohere的rerank-multilingual-v3.0对中文支持比v2好很多,v2在长文档上容易出现位置偏置。初检召回20条,rerank后取5条,生成质量提升明显。

五、踩坑与优化:那些文档不会告诉你的细节

5.1 切分器的坑

  • 不要用LangChain的RecursiveCharacterTextSplitter:它按["\n\n", "\n", " ", ""]切分,对法律条文这种结构化文本非常不友好,经常把“第X条”和正文拆开。
  • 中文切分必须处理编码问题,\u3000全角空格会导致句子边界正则失效。
  • 条款标题如“第八条”只有3个字符,单独成chunk会污染检索,需要合并到下一句。

5.2 Embedding的坑

  • BGE模型对query和document的预处理不同(query要加指令,document不加),忘了这个会导致检索质量大幅下降。
  • 归一化Embedding之前,ChromaDB的余弦相似度计算会偏向量模长,必须normalize_embeddings=True
  • BGE-large-zh第一版v1.5在Mac M2上跑不动,需要torch.backends.mps降级处理,最终在T4 GPU上跑通。

5.3 Rerank的坑

  • rerank API有并发限制,batch请求控制在50个/分钟,否则429。
  • 初检召回数量从10调到20,rerank效果提升最大,但调到30反而下降——噪声太多干扰排序。
  • 如果预算有限,可以用bge-reranker-base本地部署,效果接近Cohere v3但延迟高约40ms。

六、效果数据:真实对比,不是玄学

最终在2000条测试集上的结果:

配置 Hit Rate@5 MRR 平均响应延迟 成本/千次查询
基线(固定切分+ada) 61.3% 0.42 1.2s $2.1
+递归结构切分 67.8% 0.51 1.1s $2.1
+BGE替换 78.5% 0.63 0.9s $0.4
+Cohere Rerank 89.2% 0.78 1.4s $1.1
最终全量优化 89.2% 0.78 1.4s $1.1

额外收益:
- 用户反馈“答非所问”的比例从18%降到3.2%
- 长文档(>2000字)的检索准确率提升最明显,从52%到84%

七、总结与反思

这次优化的核心认知:RAG系统的瓶颈往往不在生成,而在检索的precision和recall平衡。chunk策略决定召回上限,embedding决定语义匹配质量,rerank则是最后的精准打击。

几个保留观点:
- 如果你的文档是纯非结构化文本(如聊天记录),结构感知切分收益不大,用固定切分+大overlap即可。
- BGE在中文场景几乎吊打OpenAI,但英文场景差距不大,别盲目切换。
- Rerank是“锦上添花”而非“雪中送炭”,如果初检top-10里没有正确答案,rerank再强也没用。

最后,所有代码和配置已上传GitHub仓库(见文末),有问题评论区见。下篇准备写关于多路召回+LLM生成质量评估的内容,想看的点个关注。