一、问题背景:为什么我的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生成质量评估的内容,想看的点个关注。