一、问题背景:一个"能跑但不好用"的RAG
去年底我们给公司内部搭了一个知识库问答系统,文档来源包括Confluence导出的Markdown、产品手册PDF和一堆历史工单。数据量不大,约1.2万篇文档,切完chunk大概8万多条。技术栈是最朴素的组合:LangChain 0.1.x + OpenAI text-embedding-ada-002 + FAISS + GPT-3.5-turbo。
上线第一周就被业务方吐槽:"问它A功能怎么配置,它给我答B功能的参数。"我拉了一批badcase看,问题非常集中:
- 答案明明在库里,但top5检索结果里根本没有那个chunk——召回失败。
- 召回里有正确chunk,但排在第4、第5位,前面的噪声把LLM带偏了——排序失败。
- 有些chunk本身就是"半句话",因为被硬切断了——切分失败。
这三个问题基本对应RAG优化的三个经典抓手:chunk策略、embedding模型、rerank。我花了大概三周时间逐层替换并做了A/B对比,下面把过程和数字都摊开讲。
二、环境与版本
先把环境固定下来,避免"我本地好的"这种扯皮:
Python 3.10.13
langchain 0.1.20
langchain-community 0.0.38
faiss-cpu 1.8.0
sentence-transformers 2.7.0
FlagEmbedding 1.2.10
openai 1.30.1
torch 2.2.1 (CUDA 12.1)
评测集:人工从真实工单里抽了200个问题,每个问题标注了"标准答案所在的文档ID"。指标用Recall@5(正确文档是否进前5)、MRR@10、以及最终的端到端准确率(人工判定答案是否正确,二分类)。
基线数据(优化前):
- Recall@5 = 0.71
- MRR@10 = 0.63
- 端到端准确率 = 62%
- P95延迟 = 2.1s
三、方案设计
整体思路是"分层解耦":把召回和排序拆开,召回负责"别漏",排序负责"别错"。三个阶段独立替换、独立评测,这样每一步的增益都能归因。
- 阶段1:chunk策略从固定长度改为递归+语义边界混合。
- 阶段2:embedding从ada-002换成bge-large-zh-v1.5(中文场景)。
- 阶段3:召回top20后接bge-reranker-large重排,取top5给LLM。
四、核心实现
4.1 Chunk策略调整
原来的切分是RecursiveCharacterTextSplitter(chunk_size=512, chunk_overlap=50),纯按字符数。问题在于Markdown里的表格、代码块、列表经常被从中间劈开。
我改成两级策略:先用Markdown结构切,再对超长块做递归切。
from langchain.text_splitter import MarkdownHeaderTextSplitter, RecursiveCharacterTextSplitter
# 第一级:按Markdown标题切
headers_to_split_on = [
("#", "h1"),
("##", "h2"),
("###", "h3"),
]
md_splitter = MarkdownHeaderTextSplitter(
headers_to_split_on=headers_to_split_on,
strip_headers=False,
)
# 第二级:对超长section做递归切,保留语义分隔符优先级
recursive_splitter = RecursiveCharacterTextSplitter(
chunk_size=800,
chunk_overlap=120,
separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""],
length_function=len,
)
def hybrid_chunk(md_text: str):
chunks = []
for section in md_splitter.split_text(md_text):
content = section.page_content
if len(content) <= 800:
chunks.append({
"text": content,
"meta": section.metadata,
})
else:
for sub in recursive_splitter.split_text(content):
chunks.append({
"text": sub,
"meta": section.metadata,
})
return chunks
关键参数选择理由:chunk_size从512提到800,是因为中文技术文档一个完整"操作步骤"通常600-900字;overlap从50提到120,是为了避免跨chunk的指代丢失。separators里把中文标点放在英文空格前面,是因为我们文档以中文为主,按句号切比按空格切更符合语义。
4.2 Embedding模型切换
ada-002是通用多语言模型,在中文技术语料上其实不算强。我选了BAAI的bge-large-zh-v1.5,1024维,中文MTEB榜单当时排前列。用sentence-transformers加载,加query instruction前缀(bge官方推荐)。
from sentence_transformers import SentenceTransformer
import numpy as np
model = SentenceTransformer(
"BAAI/bge-large-zh-v1.5",
device="cuda",
)
model.max_seq_length = 512
QUERY_INSTRUCTION = "为这个句子生成表示以用于检索相关文章:"
def embed_docs(texts):
# 文档侧不加instruction
return model.encode(
texts,
batch_size=64,
normalize_embeddings=True,
show_progress_bar=True,
)
def embed_query(query: str):
return model.encode(
[QUERY_INSTRUCTION + query],
normalize_embeddings=True,
)[0]
# 建FAISS索引,用内积等价于cosine(因为已归一化)
import faiss
dim = 1024
index = faiss.IndexFlatIP(dim)
doc_embs = embed_docs([c["text"] for c in chunks]).astype("float32")
index.add(doc_embs)
注意:bge系列文档侧不加instruction,query侧加,这个不对称如果搞反了,召回会掉好几个点,我第一版就踩了这个坑。
4.3 Rerank引入
召回top20,用bge-reranker-large做cross-encoder重排。reranker是query和doc拼一起过模型,精度高但慢,所以只对候选集做。
from FlagEmbedding import FlagReranker
reranker = FlagReranker(
"BAAI/bge-reranker-large",
use_fp16=True, # 显存不够就开fp16,精度损失可忽略
)
def retrieve_and_rerank(query: str, top_k_recall=20, top_k_final=5):
q_emb = embed_query(query).astype("float32").reshape(1, -1)
scores, indices = index.search(q_emb, top_k_recall)
candidates = [chunks[i] for i in indices[0]]
pairs = [[query, c["text"]] for c in candidates]
rerank_scores = reranker.compute_score(pairs, normalize=True)
ranked = sorted(
zip(candidates, rerank_scores),
key=lambda x: x[1],
reverse=True,
)
return ranked[:top_k_final]
use_fp16=True在我们这张卡上把rerank耗时从约420ms压到约180ms,分数差异在0.002以内,值得。
五、踩坑与优化
坑1:语义切分反而变差。 我一开始想上SemanticChunker,用embedding相似度找断点。结果在工单类短文本上切得稀碎,一个工单被切成七八块。后来放弃,回到Markdown结构+递归的组合。结论:语义切分适合长散文,不适合结构化文档。
坑2:reranker的batch size。 默认batch下20个pair一次算,显存直接OOM(我们卡是16G)。改成batch_size=8后稳定,耗时只增加约15%。
坑3:归一化不一致。 换模型时忘了改FAISS的metric,从L2换IP,导致分数排序全乱。这个bug藏了两天才被发现,因为"看起来还能返回结果"。建议索引构建时把metric和normalize写进配置并断言。
坑4:chunk元数据丢失。 MarkdownHeaderTextSplitter切完metadata里有h1/h2,但递归二次切分后要手动继承,否则过滤和引用溯源全废。这个在代码里已经补上。
六、效果数据
每一步单独评测(同一评测集、同一LLM、同一prompt):
| 阶段 | Recall@5 | MRR@10 | 端到端准确率 | P95延迟 |
|---|---|---|---|---|
| 基线(512固定切+ada-002) | 0.71 | 0.63 | 62% | 2.1s |
| +混合chunk | 0.76 | 0.67 | 68% | 2.2s |
| +bge-large-zh-v1.5 | 0.84 | 0.76 | 77% | 2.3s |
| +bge-reranker-large | 0.89 | 0.83 | 84% | 2.9s |
几个观察:
- 召回提升最猛的是embedding切换,单步+8个点,说明中文场景下模型选择比什么都重要。
- rerank主要提升的是MRR和端到端准确率,Recall@5只涨了5个点,因为它本质是"把对的往前排",不是"把对的捞出来"。
- 延迟从2.1s到2.9s,主要来自reranker的180ms和embedding模型比ada-002慢的部分。对内部工具来说可接受,对C端可能要再压缩。
badcase复看:剩下的16%错误里,约一半是多跳问题(需要跨文档推理),这已经不是检索层能解决的了,得上query rewrite或者agent式多轮检索。
七、总结
这次优化最大的体会是:RAG不是一个模型问题,是一个系统工程问题。 chunk决定上限,embedding决定召回,rerank决定精度,三者各司其职。不要指望换个更强的LLM就能救RAG,检索层烂,GPT-4也答不对。
如果只让我保留一条经验:先建评测集,再动手优化。 没有那200条标注,我根本分不清是chunk的锅还是模型的锅,所有"感觉变好了"都是自欺欺人。
后续计划:把query rewrite加进来处理多跳问题,另外试试bge-m3做稀疏+稠密混合检索,看看能不能把Recall@5再推一推。有进展再写一篇。