一、问题背景:一个"看起来能用"的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条失败样本分了三类:

  1. 切分破坏语义(约45%):答案被切断,或者一个完整规则被拆到两个chunk里;
  2. 向量表达力不足(约35%):small模型对长尾专业术语区分度差,相似问题向量几乎重合;
  3. 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,生成阶段省了不少时间。

七、总结

这次优化的核心心得有三点:

  1. 先归因,再动手。 45% 的问题其实出在切分上,换个更大的模型也救不回来。盲目升级 embedding 是浪费算力。
  2. 父子块是性价比最高的一招。 不改模型、不加硬件,只改切分逻辑,召回率涨了 9.5 个点。
  3. Reranker 不是锦上添花,是必需品。 双塔模型天生存在语义鸿沟,cross-encoder 精排能把"召回得到但排不上"的那 20% 捞回来。

下一步我打算试试 query 改写(HyDE)和混合检索(BM25 + 向量),据说在专有名词密集的场景还能再涨几个点。等跑完再来写续篇。

如果你的 RAG 系统召回率也卡在 60% 左右,建议按这个顺序排查:切分 → embedding → rerank,大概率能找到瓶颈。