一、问题背景:一个"看起来能用"的RAG系统
去年底我接手了一个内部知识库问答项目,语料约 12 万条文档片段,来源包括 Confluence、飞书文档和 PDF 手册。第一版 RAG(检索增强生成)系统上线后,业务方给的反馈很直接:"答非所问,还不如直接搜索。"
我拉了 200 条真实 query 做人工评测,结果如下:
| 指标 | 数值 |
|---|---|
| Top-5 召回率 | 0.62 |
| 答案准确率(人工判定) | 58% |
| P95 端到端延迟 | 1.2s |
问题很明显:检索环节没把正确片段捞出来,后面的 LLM 再强也白搭。于是我从 chunk、embedding、rerank 三个方向开始逐项优化。
二、环境与版本
先把基线环境列清楚,避免"版本不一致导致结论不可复现"的坑:
- 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
- openai 1.30.1(仅用于生成,不再用其 embedding)
- 向量库:FAISS(IndexFlatIP,内积 + 归一化)
- LLM:gpt-3.5-turbo-0125,temperature=0.1
基线方案:
- chunk_size=512,chunk_overlap=0,按字符硬切
- embedding:text-embedding-ada-002(1536 维)
- 无 rerank,直接取 Top-5 塞进 prompt
三、方案设计:三步走的优化路线
我的优化思路是按"收益/成本"排序,先动影响最大的环节:
- Chunk 策略:从固定字符切分改为语义切分,chunk_size 降到 256,overlap 给 64。原因是知识库里有大量表格和短条目,512 字符会把不同主题混在一起,向量被"平均化"。
- Embedding 模型:中文语料下 ada-002 表现一般,换成 BAAI 的 bge-large-zh-v1.5(1024 维),并加 query instruction 前缀。
- Rerank:引入 bge-reranker-large,先召回 Top-20 再重排取 Top-5。
整体链路变成:语义切分 → bge 向量召回 Top-20 → reranker 精排 → Top-5 进 prompt。
四、核心实现
4.1 语义切分
我没有用 LangChain 的 SemanticChunker(它默认按句间余弦差切,对中文断句不友好),而是自己用"标题 + 段落 + 长度"三段式规则:
import re
from langchain.text_splitter import RecursiveCharacterTextSplitter
def semantic_chunk(text: str, chunk_size: int = 256, overlap: int = 64):
# 1. 按 Markdown 标题先做一级切分
sections = re.split(r"\n(?=#{1,3} )", text)
splitter = RecursiveCharacterTextSplitter(
chunk_size=chunk_size,
chunk_overlap=overlap,
separators=["\n\n", "\n", "。", ";", ",", " ", ""],
length_function=len,
)
chunks = []
for sec in sections:
if not sec.strip():
continue
# 2. 表格/代码块整体保留,不切
if sec.count("|") > 6 or "```" in sec:
chunks.append(sec.strip())
continue
chunks.extend(splitter.split_text(sec))
return [c for c in chunks if len(c) > 20]
实测:chunk_size 从 512 降到 256 后,单条 chunk 主题更聚焦,召回命中率直接涨了约 9 个百分点。overlap=64 是权衡,再大反而引入噪声。
4.2 切换 Embedding 模型
用 FlagEmbedding 加载 bge-large-zh-v1.5,注意 query 侧要加指令前缀,doc 侧不加(这是 bge 系列的官方约定,加错会掉点):
from FlagEmbedding import FlagModel
import numpy as np
model = FlagModel(
"BAAI/bge-large-zh-v1.5",
query_instruction_for_retrieval="为这个句子生成表示以用于检索相关文章:",
use_fp16=True,
)
def embed_docs(docs):
return model.encode(docs, batch_size=64, max_length=512, normalize_embeddings=True)
def embed_query(q):
return model.encode_queries([q], normalize_embeddings=True)[0]
4.3 引入 Rerank
召回 Top-20,用 bge-reranker-large 精排,取 Top-5:
from FlagEmbedding import FlagReranker
reranker = FlagReranker("BAAI/bge-reranker-large", use_fp16=True)
def retrieve_and_rerank(query, index, docs, top_k=20, final_k=5):
q_vec = embed_query(query)
_, idx = index.search(np.array([q_vec], dtype="float32"), top_k)
candidates = [docs[i] for i in idx[0]]
pairs = [[query, c] for c in candidates]
scores = reranker.compute_score(pairs, normalize=True)
ranked = sorted(zip(candidates, scores), key=lambda x: -x[1])
return ranked[:final_k]
五、踩坑与优化
坑 1:bge 的 instruction 前缀用错。 一开始我给 doc 也加了前缀,召回率反而比 ada-002 低。查了官方说明才知道只有 query 侧需要加。
坑 2:reranker 把延迟拉高。 单次重排 20 条,CPU 下要 600ms+。后来改成 fp16 + batch,并只对 Top-20 重排(不是 Top-50),P95 延迟控制在 1.9s。
坑 3:FAISS 忘记归一化。 bge 输出要 L2 归一化再用内积,否则相似度排序会乱。normalize_embeddings=True 别漏。
坑 4:chunk 太碎导致上下文断裂。 256 字符对表格类内容不够,我额外做了"父子块":检索用小块,喂给 LLM 时用其所属的大块,兼顾精度和上下文。
六、效果数据
同样 200 条 query,人工评测结果:
| 方案 | Top-5 召回率 | 答案准确率 | P95 延迟 |
|---|---|---|---|
| 基线(512/ada-002/无rerank) | 0.62 | 58% | 1.2s |
| +语义切分(256/64) | 0.71 | 65% | 1.2s |
| +bge-large-zh-v1.5 | 0.80 | 72% | 1.4s |
| +bge-reranker-large | 0.89 | 81% | 1.9s |
召回率从 0.62 提升到 0.89,准确率从 58% 到 81%,代价是延迟增加约 0.7s。对内部知识库场景,这个 trade-off 完全值得。
七、总结
这次优化的核心结论就三条:
- chunk 策略的收益被严重低估,语义切分 + 小 chunk + 父子块是性价比最高的改动。
- 中文场景别迷信 ada-002,bge-large-zh-v1.5 在中文检索上明显更强,但指令前缀必须用对。
- rerank 是召回率的"最后一公里",Top-20 精排到 Top-5,能把 0.80 拉到 0.89,但要注意延迟和 batch 优化。
下一步我准备试试 bge-m3 做混合检索(稠密 + 稀疏),以及用 query 改写缓解长尾问题。有进展再写一篇。