一、问题背景:一个"看起来能用"的RAG系统
三个月前我接手了公司内部的知识库问答系统,技术栈是典型的Naive RAG:LangChain + Chroma + OpenAI text-embedding-ada-002,后来因为数据合规要求换成了本地部署的 bge-small-zh-v1.5。
上线初期效果"看起来还行"——毕竟demo阶段随便问几个问题都能答上来。但真实用户一用就露馅了:
- 问"报销流程中差旅费的审批层级",召回的却是"办公用品采购流程";
- 问"年假结转规则",Top3里混进了两条完全不相关的考勤制度;
- 更离谱的是,有些答案明明在文档里,就是检索不到。
我拉了一份200条的真实用户问题测试集,人工标注了正确答案所在的chunk,跑了一遍指标:
| 指标 | 数值 |
|---|---|
| Recall@5 | 61.0% |
| MRR | 0.52 |
| 平均响应延迟 | 1.8s |
61%的召回率意味着近40%的问题在检索阶段就"死"了,后面LLM再强也救不回来。于是开始了为期两周的优化。
二、环境与版本
先交代下环境,避免版本差异导致结果不可复现:
Python: 3.10.13
langchain: 0.1.20
langchain-community: 0.0.38
chromadb: 0.4.24
sentence-transformers: 2.7.0
FlagEmbedding: 1.2.10
torch: 2.2.1 (CUDA 12.1)
Embedding和Reranker模型:
BAAI/bge-small-zh-v1.5(384维,约24M参数)BAAI/bge-large-zh-v1.5(1024维,约326M参数)BAAI/bge-reranker-base(约278M参数)
硬件:单卡 RTX 4090(24G),CPU 为 16 核 Xeon。文档规模约 1.2 万篇,切分后约 8.6 万个 chunk。
三、方案设计:三步走的优化路径
我没有一上来就堆技术,而是先做了错误归因。把200条失败样本分了三类:
- 切分破坏语义(约45%):答案被切断,或者一个完整规则被拆到两个chunk里;
- 向量表达力不足(约35%):small模型对长尾专业术语区分度差,相似问题向量几乎重合;
- Top-K排序不准(约20%):正确答案在Top20里,但排不进Top5。
对应三步优化:
- Step 1:切分策略从固定长度改为语义切分 + 父子块(Parent-Child);
- Step 2:Embedding 从 bge-small-zh-v1.5 换成 bge-large-zh-v1.5;
- Step 3:引入 bge-reranker-base 做二阶段重排。
每一步单独评估,确保收益可归因。
四、核心实现
4.1 语义切分 + 父子块
原来的 RecursiveCharacterTextSplitter(chunk_size=512, chunk_overlap=50) 是按字符硬切。改成先用句号、分号、换行做语义边界切分,再按 token 长度合并。
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_core.documents import Document
import re
def semantic_split(text: str, max_tokens: int = 400, min_tokens: int = 150):
# 先按语义边界切句
sentences = re.split(r'(? max_tokens and buf_len >= min_tokens:
chunks.append("".join(buf))
buf, buf_len = [sent], sent_len
else:
buf.append(sent)
buf_len += sent_len
if buf:
chunks.append("".join(buf))
return chunks
def build_parent_child(doc_id, text):
parents = semantic_split(text, max_tokens=800, min_tokens=400)
result = []
for p_idx, parent in enumerate(parents):
children = semantic_split(parent, max_tokens=200, min_tokens=80)
for c_idx, child in enumerate(children):
result.append({
"parent_id": f"{doc_id}_p{p_idx}",
"child_id": f"{doc_id}_p{p_idx}_c{c_idx}",
"child_text": child,
"parent_text": parent,
})
return result
关键点:检索时用 child(粒度小、向量准),返回给 LLM 时用 parent(上下文完整)。这一步单独跑,Recall@5 从 61% 提到 70.5%。
4.2 Embedding 模型切换
bge 系列有个坑:查询侧必须加指令前缀 "为这个句子生成表示以用于检索相关文章:",文档侧不加。很多人漏了这一步,白白损失几个点。
from FlagEmbedding import FlagModel
import chromadb
QUERY_INSTRUCTION = "为这个句子生成表示以用于检索相关文章:"
class BGEEmbedder:
def __init__(self, model_path="BAAI/bge-large-zh-v1.5", device="cuda"):
self.model = FlagModel(
model_path,
query_instruction_for_retrieval=QUERY_INSTRUCTION,
use_fp16=True, # 4090 上开启 fp16,显存减半,速度提升约 40%
devices=device,
)
def embed_docs(self, texts):
return self.model.encode(texts, batch_size=64, normalize_embeddings=True)
def embed_query(self, query):
return self.model.encode_queries([query], normalize_embeddings=True)[0]
embedder = BGEEmbedder()
client = chromadb.PersistentClient(path="./chroma_db")
collection = client.get_or_create_collection(
name="kb_large",
metadata={"hnsw:space": "cosine", "hnsw:M": 32, "hnsw:construction_ef": 200},
)
# 批量写入
batch = [item["child_text"] for item in child_chunks]
vectors = embedder.embed_docs(batch)
collection.add(
ids=[item["child_id"] for item in child_chunks],
embeddings=vectors.tolist(),
documents=batch,
metadatas=[{"parent_id": item["parent_id"]} for item in child_chunks],
)
从 384 维换到 1024 维,索引体积从约 130MB 涨到约 350MB,但Recall@5 从 70.5% 提到 78.0%。代价是单条 query 编码从 8ms 涨到 22ms,可接受。
4.3 Reranker 重排
向量检索是双塔的,query 和 doc 独立编码,交互信息丢失。Reranker 是 cross-encoder,把两者拼一起过模型,精度高得多,缺点是不能预计算。
策略:向量召回 Top20,reranker 精排取 Top5。
from FlagEmbedding import FlagReranker
reranker = FlagReranker("BAAI/bge-reranker-base", use_fp16=True)
def retrieve_and_rerank(query, top_k=5, recall_k=20):
q_vec = embedder.embed_query(query)
res = collection.query(
query_embeddings=[q_vec.tolist()],
n_results=recall_k,
include=["documents", "metadatas", "distances"],
)
docs = res["documents"][0]
metas = res["metadatas"][0]
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)
# 按 parent_id 去重,避免同一父块重复占位
seen, out = set(), []
for doc, meta, score in ranked:
pid = meta["parent_id"]
if pid in seen:
continue
seen.add(pid)
out.append({"text": doc, "parent_id": pid, "score": score})
if len(out) >= top_k:
break
return out
Reranker 在 4090 上处理 20 对文本约 90ms,加上向量检索和 query 编码,端到端延迟约 320ms,比优化前还快(因为不用再放大 Top-K 硬扛)。
Recall@5 从 78.0% 提到 84.0%,MRR 从 0.65 提到 0.79。
五、踩坑与优化
坑1:切换 embedding 模型后忘了重建索引。 一开始只改了 query 侧的模型,文档向量还是旧的,效果暴跌到 40% 以下。不同模型向量空间不兼容,必须全量重建。
坑2:bge-reranker 的 batch 推理。 compute_score 逐对调用会很慢。改成传 list 一次算:
# 慢:循环调用
# scores = [reranker.compute_score([[q, d]]) for d in docs]
# 快:批量
scores = reranker.compute_score([[q, d] for d in docs], normalize=True)
20 对文本从 400ms 降到 90ms。
坑3:父子块去重。 一个 parent 下可能有 3 个 child 都被召回,rerank 后占据 Top5 的三个位置,实际信息冗余。按 parent_id 去重后,有效信息密度明显提升。
坑4:Chroma 的 HNSW 参数。 默认 M=16 在大规模下召回不稳,调到 M=32、construction_ef=200,检索侧设 ef_search=100,召回率再涨约 1.5 个点,索引构建时间从 8 分钟涨到 15 分钟,一次性成本可接受。
六、效果数据
| 阶段 | 配置 | Recall@5 | MRR | 延迟 |
|---|---|---|---|---|
| Baseline | bge-small + 512定长切分 | 61.0% | 0.52 | 1.8s |
| +语义切分/父子块 | 同上 | 70.5% | 0.58 | 1.6s |
| +bge-large-zh-v1.5 | 1024维 | 78.0% | 0.65 | 1.5s |
| +bge-reranker-base | Top20→Top5 | 84.0% | 0.79 | 1.3s |
最终端到端(含 LLM 生成)平均响应从 3.2s 降到 2.4s。用户侧反馈"答非所问"的比例从约 30% 降到 8% 以下。
值得注意的是,引入 reranker 后整体反而更快了——因为不再需要靠放大 Top-K 来碰运气,向量库只需召回 20 条,LLM 输入的上下文也更干净,token 数从平均 3200 降到 1800,生成阶段省了不少时间。
七、总结
这次优化的核心心得有三点:
- 先归因,再动手。 45% 的问题其实出在切分上,换个更大的模型也救不回来。盲目升级 embedding 是浪费算力。
- 父子块是性价比最高的一招。 不改模型、不加硬件,只改切分逻辑,召回率涨了 9.5 个点。
- Reranker 不是锦上添花,是必需品。 双塔模型天生存在语义鸿沟,cross-encoder 精排能把"召回得到但排不上"的那 20% 捞回来。
下一步我打算试试 query 改写(HyDE)和混合检索(BM25 + 向量),据说在专有名词密集的场景还能再涨几个点。等跑完再来写续篇。
如果你的 RAG 系统召回率也卡在 60% 左右,建议按这个顺序排查:切分 → embedding → rerank,大概率能找到瓶颈。