一、问题背景:一个"能用但不好用"的RAG系统
先说背景。我们做的是一个面向企业内部知识库的RAG问答系统,文档量大概12万篇,涵盖产品手册、工单记录、规章制度,中文为主,夹杂少量英文技术文档。技术栈是LangChain 0.1.x + Chroma 0.4.x + GPT-3.5-turbo,检索用text-embedding-ada-002,chunk策略是最朴素的RecursiveCharacterTextSplitter(chunk_size=512, chunk_overlap=50)。
上线两个月,后台收集了约1800条用户反馈。人工抽检200条,结果很难看:
- 答案完全正确:61%
- 答案部分正确/遗漏关键信息:23%
- 答非所问(检索到的内容和问题无关):16%
也就是说,超过三分之一的回答是有问题的。进一步归因分析,发现16%的"答非所问"里,有八成是检索阶段就没召回正确文档——生成模型再强也救不回来。这就是典型的RAG瓶颈:检索质量决定上限。
所以这次优化的目标很明确:不动生成模型,专攻检索链路。分三步:chunk策略、embedding模型、rerank。
二、环境与版本
先把环境交代清楚,避免读者复现时踩版本坑:
python 3.10.13
langchain 0.1.16
langchain-community 0.0.38
chromadb 0.4.24
sentence-transformers 2.7.0
torch 2.2.1 (cu121)
FlagEmbedding 1.2.10
openai 1.23.2
评测集是我们自己标的300条QA对,每条有标准答案和对应的gold document。指标用两个:Recall@5(Top-5里是否包含gold chunk)和端到端准确率(人工/LLM双评判定答案是否正确)。
基线数据:Recall@5 = 72.3%,端到端准确率 = 0.61。
三、方案设计与核心实现
3.1 第一步:chunk策略调整
原来的512字符固定切分问题很明显:中文按字符切,经常把一句话、一个表格、一个条款从中间劈开。比如"第三条规定……"被切成两半,前半段进了上一个chunk,检索时语义残缺。
我们改成语义切分 + 递归兜底的混合策略:
- 先按文档结构切(Markdown标题、HTML的h1-h3、PDF的段落边界);
- 结构块超过阈值再用语义切分(基于相邻句子embedding相似度的断点检测);
- 最后用RecursiveCharacterTextSplitter兜底,分隔符针对中文调整为
["\n\n", "\n", "。", "!", "?", ";", ",", ""]。
chunk_size从512调到800,overlap从50调到120。这个数值不是拍脑袋:我们统计了gold document的平均长度是760字符,512切分导致38%的答案跨chunk。800能覆盖大部分完整语义单元,120的overlap保证边界信息不丢。
核心代码如下:
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_experimental.text_splitter import SemanticChunker
from langchain_community.embeddings import HuggingFaceBgeEmbeddings
# 中文友好的embedding,用于语义切分
bge_embeddings = HuggingFaceBgeEmbeddings(
model_name="BAAI/bge-large-zh-v1.5",
model_kwargs={"device": "cuda"},
encode_kwargs={"normalize_embeddings": True},
)
# 语义切分器:按百分位断点,threshold_type用percentile更稳
semantic_splitter = SemanticChunker(
embeddings=bge_embeddings,
breakpoint_threshold_type="percentile",
breakpoint_threshold_amount=95, # 只在前5%的相似度低谷处切
)
# 递归兜底,分隔符针对中文重排
recursive_splitter = RecursiveCharacterTextSplitter(
chunk_size=800,
chunk_overlap=120,
separators=["\n\n", "\n", "。", "!", "?", ";", ",", ""],
length_function=len,
)
def hybrid_split(text: str, max_len: int = 1200):
"""先语义切,超长的再递归切,短块合并"""
sem_chunks = semantic_splitter.split_text(text)
final_chunks = []
buffer = ""
for c in sem_chunks:
if len(c) > max_len:
if buffer:
final_chunks.append(buffer)
buffer = ""
final_chunks.extend(recursive_splitter.split_text(c))
elif len(buffer) + len(c) <= 800:
buffer += c
else:
final_chunks.append(buffer)
buffer = c
if buffer:
final_chunks.append(buffer)
return final_chunks
这一步单独做完,Recall@5从72.3%提到79.1%。提升不算爆炸,但符合预期——切分解决的是"信息完整性",不是"语义匹配"。
3.2 第二步:embedding模型切换
text-embedding-ada-002是通用模型,中文语义匹配其实一般,而且走API有延迟和成本。我们对比了三个候选:bge-large-zh-v1.5、m3e-base、gte-large-zh。在自建评测集上跑Recall@5:
| 模型 | 维度 | Recall@5 | 单条编码延迟 |
|---|---|---|---|
| text-embedding-ada-002 | 1536 | 79.1% | ~180ms (API) |
| m3e-base | 768 | 81.4% | ~15ms (GPU) |
| gte-large-zh | 1024 | 84.7% | ~28ms (GPU) |
| bge-large-zh-v1.5 | 1024 | 86.2% | ~30ms (GPU) |
选bge-large-zh-v1.5,两个原因:一是中文语义检索确实是它的强项,二是它和后面的bge-reranker同源,配合更好。切换时有个关键细节:query要加指令前缀。bge系列检索时query需要加"为这个句子生成表示以用于检索相关文章:",passage不加。不加前缀会掉2-3个点,这个坑很多人踩。
from sentence_transformers import SentenceTransformer
import chromadb
from chromadb.config import Settings
model = SentenceTransformer("BAAI/bge-large-zh-v1.5", device="cuda")
QUERY_INSTRUCTION = "为这个句子生成表示以用于检索相关文章:"
def encode_queries(queries):
return model.encode(
[QUERY_INSTRUCTION + q for q in queries],
normalize_embeddings=True,
batch_size=64,
)
def encode_passages(passages):
return model.encode(
passages,
normalize_embeddings=True,
batch_size=64,
)
client = chromadb.PersistentClient(
path="./chroma_db",
settings=Settings(anonymized_telemetry=False),
)
# 距离度量必须用cosine,bge归一化后cosine等价内积,但Chroma要显式指定
collection = client.create_collection(
name="kb_bge_v2",
metadata={"hnsw:space": "cosine"},
)
换完embedding,Recall@5到86.2%。注意这里全量重建了索引,12万篇文档、约41万个chunk,单卡A10跑编码花了约3.5小时,一次性成本可接受。
3.3 第三步:引入rerank
到86.2%之后其实还能再榨。双塔embedding的本质是把query和doc各自编码再算相似度,交互信息丢失严重。Rerank是cross-encoder,把query和doc拼一起过模型,精度高但慢,所以只对Top-K候选做重排。
我们选bge-reranker-base(不是large,base在延迟和效果间更平衡)。流程:向量检索召回Top-20 → rerank打分 → 取Top-5喂给LLM。
from FlagEmbedding import FlagReranker
reranker = FlagReranker(
"BAAI/bge-reranker-base",
use_fp16=True, # fp16能砍掉近一半延迟,精度损失可忽略
)
def retrieve_with_rerank(query: str, top_k: int = 5, recall_k: int = 20):
q_emb = encode_queries([query])[0].tolist()
res = collection.query(
query_embeddings=[q_emb],
n_results=recall_k,
include=["documents", "metadatas", "distances"],
)
docs = res["documents"][0]
metas = res["metadatas"][0]
# cross-encoder打分
pairs = [[query, d] for d in docs]
scores = reranker.compute_score(pairs, normalize=True)
ranked = sorted(zip(docs, metas, scores), key=lambda x: x[2], reverse=True)
top = ranked[:top_k]
return [{"text": t, "meta": m, "score": float(s)} for t, m, s in top]
rerank阈值我们设了0.35,低于这个分数的候选直接丢弃,避免把不相关文档硬塞给LLM。这个阈值是在验证集上调的,0.3会引入噪声,0.4会误杀,0.35是Recall和Precision的平衡点。
四、踩坑与优化
说几个真踩过的坑,比方案本身更有参考价值。
坑1:Chroma的cosine距离没生效。 第一次建collection忘了指定hnsw:space: cosine,默认是l2。bge向量归一化后l2和cosine排序理论上一致,但Chroma的l2实现有数值精度问题,实测Recall掉了1.8个点。显式指定后正常。
坑2:rerank的batch和内存。 一开始recall_k设到50,batch_size默认,A10显存直接OOM。改成recall_k=20、rerank时手动分批(每批16对),显存稳定在6GB以内。另外FlagReranker的compute_score传list of pairs比分两次传query/doc快。
坑3:语义切分的成本。 SemanticChunker每条文档都要调embedding算相邻句子相似度,12万篇全量跑了一遍花了近6小时。后来改成只对超过2000字符的长文档做语义切分,短文档直接递归切,时间降到1.8小时,效果几乎无损。
坑4:overlap导致的重复召回。 chunk_overlap=120会让相邻chunk有重叠内容,Top-5里偶尔出现两条几乎一样的文本,浪费上下文窗口。加了个简单的去重:rerank后按文本前100字符做hash去重。
五、效果数据
三步全部落地后,在同一个300条评测集上对比(P95延迟是线上真实流量统计):
| 阶段 | Recall@5 | 端到端准确率 | P95延迟 |
|---|---|---|---|
| 基线(512切分 + ada-002) | 72.3% | 0.61 | 1.42s |
| + 混合chunk策略 | 79.1% | 0.68 | 1.45s |
| + bge-large-zh-v1.5 | 86.2% | 0.79 | 1.51s |
| + bge-reranker-base | 94.1% | 0.89 | 1.63s |
几个观察:
- rerank是单步收益最大的,Recall@5直接+7.9个点,端到端+0.10。原因是cross-encoder能纠正双塔的"语义相近但答案不对"的误召回。
- 端到端准确率的提升(0.61→0.89)比Recall提升(72.3→94.1)幅度小,说明生成阶段还有天花板,下一步该动prompt和上下文组装了。
- 延迟只增加210ms,其中rerank占约150ms(Top-20重排),embedding切换反而因为本地GPU比API快而省了一点。这个代价完全可接受。
线上灰度两周,用户"答非所问"反馈比例从34%降到11%,效果是实打实的。
六、总结
回头看,这次优化的核心逻辑就一句话:RAG的瓶颈在检索,检索的瓶颈在"召回精度"和"语义匹配"两件事上。chunk策略管前者(保证信息完整、不跨块),embedding和rerank管后者(保证语义对齐)。
几个可以复用的经验:
- chunk_size不要拍脑袋,先统计gold document长度分布再定;中文分隔符一定要重排,默认的英文分隔符在中文上很拉胯。
- 换embedding模型务必做离线评测再上线,别信榜单,信自己的评测集。bge系列记得query加指令前缀。
- rerank性价比极高,但要注意recall_k和batch的显存平衡,以及分数阈值过滤。
- 每一步单独评测、单独归因,别一把梭全改,否则效果不好时根本不知道是哪步的问题。
下一步计划:上下文组装做压缩(去掉和query无关的句子)、prompt里加few-shot、试试bge-reranker-large看能不能再榨2个点。有进展再写一篇。
代码都在上面了,环境版本也给了,照抄能跑。有问题评论区聊。