一、问题背景:一个"看起来能用"的RAG系统
我们做的是一个面向企业内部法务和HR的知识库问答系统,语料大概是1200份PDF文档,涵盖劳动合同、公司制度、合规手册等,总token量约480万。技术栈是最朴素的方案:LangChain + FAISS + GPT-3.5-turbo,chunk_size=1000,chunk_overlap=200,embedding用的是OpenAI的text-embedding-ada-002。
上线初期效果还行,但用户量上来之后问题就暴露了:
- 用户问"试用期最长可以多久",检索回来的却是"竞业限制期限"的段落,因为两者都含"期限";
- 问"年假天数怎么算",答案里漏掉了"司龄满10年"这一档,因为那部分被切到了相邻chunk里;
- 有些长条款被从中间切断,检索到的chunk语义不完整,模型只能瞎编。
我们用200条人工标注的QA做了基线评测,指标如下:
| 指标 | 基线值 |
|---|---|
| Top-3 命中率 | 0.71 |
| MRR | 0.62 |
| 答案忠实度(人工打分) | 0.68 |
| P95 延迟 | 1.2s |
目标很明确:命中率上0.85,忠实度上0.8,延迟不超过1.5s。
二、环境与版本
先把环境交代清楚,避免大家复现时踩版本坑:
- Python 3.10.13
- langchain 0.1.16
- langchain-community 0.0.36
- faiss-cpu 1.8.0
- sentence-transformers 2.7.0
- FlagEmbedding 1.2.10(用于BGE reranker)
- openai 1.30.1
- torch 2.2.2 (CUDA 12.1)
- 向量库:FAISS(后期换Milvus 2.4做对比,本文以FAISS为主)
LLM仍然用gpt-3.5-turbo-0125,没换,因为问题主要出在检索侧,不是生成侧。
三、方案设计:三个环节,逐个击破
优化思路很直接:RAG的错误大部分来自"没检索到对的chunk",而不是"模型不会答"。所以我把注意力放在检索链路上:
- Chunk策略:从固定长度切分改成"语义+结构"混合切分,保留标题层级和条款边界;
- Embedding模型:从text-embedding-ada-002切到BGE-M3(本地部署),支持中英混合和长文本;
- Rerank:引入bge-reranker-v2-m3做二阶段精排,Top-20召回→Top-5输入LLM。
每一环节单独评测,最后叠加,看边际收益。
四、核心实现
4.1 Chunk策略调整
原来的RecursiveCharacterTextSplitter对中文法律文本很不友好,经常把"第X条"从中间切断。我换成了基于标题层级的语义切分,核心思想是:先按markdown化的标题切大块,再在大块内按句子边界细分,并强制chunk不超过800 token。
import re
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.schema import Document
def semantic_chunk(text: str, doc_id: str, max_tokens: int = 800, overlap: int = 80):
# 1. 按标题层级切大块(假设PDF已转成带 # / ## 的markdown)
sections = re.split(r'\n(?=#{1,3}\s)', text)
chunks = []
splitter = RecursiveCharacterTextSplitter(
chunk_size=max_tokens,
chunk_overlap=overlap,
separators=["\n\n", "\n", "。", ";", ",", " "],
length_function=lambda x: len(x) // 2, # 中文粗估token
)
for sec in sections:
title_match = re.match(r'(#{1,3}\s.*)', sec)
title = title_match.group(1).strip() if title_match else ""
body = sec[len(title):].strip() if title else sec
if not body:
continue
# 2. 大块内再细分,标题拼回每个子chunk,保证上下文
for sub in splitter.split_text(body):
content = f"{title}\n{sub}" if title else sub
chunks.append(Document(
page_content=content,
metadata={"doc_id": doc_id, "title": title}
))
return chunks
关键点:标题回填。这一步让每个chunk都自带"所属章节"信息,检索时即便chunk本身短,语义也完整。
效果:chunk平均长度从1000降到620,chunk总数从1.1万涨到1.9万,但Top-3命中率从0.71涨到0.76。涨幅不大,但为后面rerank留了空间。
4.2 Embedding模型切换
ada-002的问题是对中文长句和专有名词(比如"竞业限制补偿金")区分度不够。我换成了BGE-M3,本地跑,1024维,支持8192长度。
from FlagEmbedding import BGEM3FlagModel
import numpy as np
model = BGEM3FlagModel('BAAI/bge-m3', use_fp16=True, device='cuda:0')
def embed_texts(texts, batch_size=32):
# 官方推荐:query侧加instruction,doc侧不加
vecs = model.encode(
texts,
batch_size=batch_size,
max_length=1024,
return_dense=True,
return_sparse=False,
return_colbert_vecs=False,
)['dense_vecs']
# BGE系列需要归一化后用内积
return vecs / np.linalg.norm(vecs, axis=1, keepdims=True)
# 查询侧
query_vec = embed_texts(["试用期最长可以多久"], is_query=True)
注意两个坑:
1. query和doc要区别对待。BGE-M3官方建议query加上"为这个句子生成表示以用于检索相关文章:"前缀,doc不加,否则掉点约3个点。
2. 必须归一化,否则内积和余弦不一致。
切换后:Top-3命中率从0.76 → 0.82,MRR从0.62 → 0.71。本地推理单条约18ms(A10),1.9万条doc离线编码约40分钟。
4.3 Rerank引入
到这里召回已经不错了,但Top-5里还是经常混入"看起来像但不对"的chunk。引入bge-reranker-v2-m3做cross-encoder精排。
from FlagEmbedding import FlagReranker
reranker = FlagReranker('BAAI/bge-reranker-v2-m3', use_fp16=True, device='cuda:0')
def rerank(query, candidates, top_k=5):
# candidates: List[Document]
pairs = [[query, c.page_content] for c in candidates]
scores = reranker.compute_score(pairs, normalize=True)
ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
return [c for c, s in ranked[:top_k]], [s for c, s in ranked[:top_k]]
# 检索链路:FAISS召回Top-20 -> rerank取Top-5
def retrieve(query, faiss_index, docs, top_n=20, top_k=5):
q_vec = embed_texts([query], is_query=True)
_, idx = faiss_index.search(q_vec, top_n)
cands = [docs[i] for i in idx[0]]
return rerank(query, cands, top_k=top_k)
Reranker推理延迟:20对约110ms(A10,fp16)。这是延迟增加的主要来源,但非常值。
引入后:Top-3命中率从0.82 → 0.89,MRR从0.71 → 0.81。单这一步就贡献了7个点,是ROI最高的环节。
五、踩坑与优化
坑1:chunk变小后,FAISS的top_n要调大。 原来top_n=5够用,现在chunk小、信息密度低,top_n=5召不全,改成20才稳。这个参数是rerank的输入宽度,直接决定上限。
坑2:BGE-M3的max_length别设8192。 我一开始图省事设了8192,显存直接吃满,batch只能开到4,离线编码跑了3小时。改成1024后,速度翻4倍,效果基本无损失——因为我们的chunk本身不超过800 token。
坑3:reranker的normalize=True很重要。 不加的话score是logits,范围[-10, 10],做阈值过滤时完全没法设。归一化到[0,1]后,我设了0.3的阈值,低于就返回"未找到相关内容",减少幻觉。
坑4:别急着换LLM。 我一度想上GPT-4,但评测发现检索修好后,gpt-3.5-turbo的忠实度已经从0.68涨到0.85,换GPT-4只再涨2个点,成本翻10倍,不值。
坑5:延迟。 全链路P95从1.2s涨到1.38s,主要来自reranker的110ms和embedding从API换成本地(本地反而更快,从280ms降到18ms)。整体在预算内。
六、效果数据
200条标注QA,全部优化叠加后:
| 指标 | 基线 | chunk优化 | +BGE-M3 | +Rerank |
|---|---|---|---|---|
| Top-3 命中率 | 0.71 | 0.76 | 0.82 | 0.89 |
| MRR | 0.62 | 0.66 | 0.71 | 0.81 |
| 答案忠实度 | 0.68 | 0.71 | 0.78 | 0.85 |
| P95 延迟 | 1.20s | 1.18s | 0.95s | 1.38s |
几个观察:
- chunk优化是"必要不充分",单独收益小,但不做后面rerank效果打折;
- embedding切换收益稳定且顺手把延迟降了(本地推理 vs API);
- rerank是收益最大的一步,但如果召回质量差(比如Top-20里都没对的),rerank也救不回来。
线上灰度两周,用户投诉量下降约62%,"答非所问"类工单从每周23件降到7件。
七、总结
这次优化让我确认了几件事:
- RAG的问题90%在检索侧,别一上来就换大模型;
- chunk策略要贴合文档结构,通用splitter对法律、医疗这类强结构文本就是灾难;
- rerank是当前性价比最高的环节,一个本地小模型+110ms,换来7个点命中率;
- 每一步都要单独评测,否则你根本不知道收益来自哪里。
后续计划:把FAISS换成Milvus做混合检索(dense+sparse),试试BGE-M3的稀疏向量;再引入query改写,处理多轮对话里的指代问题。有进展再写一篇。
如果这篇对你有帮助,欢迎交流你踩过的RAG坑。