一、问题背景:一个"看起来能用"的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

三、方案设计:三步走的优化路线

我的优化思路是按"收益/成本"排序,先动影响最大的环节:

  1. Chunk 策略:从固定字符切分改为语义切分,chunk_size 降到 256,overlap 给 64。原因是知识库里有大量表格和短条目,512 字符会把不同主题混在一起,向量被"平均化"。
  2. Embedding 模型:中文语料下 ada-002 表现一般,换成 BAAI 的 bge-large-zh-v1.5(1024 维),并加 query instruction 前缀。
  3. 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 完全值得。

七、总结

这次优化的核心结论就三条:

  1. chunk 策略的收益被严重低估,语义切分 + 小 chunk + 父子块是性价比最高的改动。
  2. 中文场景别迷信 ada-002,bge-large-zh-v1.5 在中文检索上明显更强,但指令前缀必须用对。
  3. rerank 是召回率的"最后一公里",Top-20 精排到 Top-5,能把 0.80 拉到 0.89,但要注意延迟和 batch 优化。

下一步我准备试试 bge-m3 做混合检索(稠密 + 稀疏),以及用 query 改写缓解长尾问题。有进展再写一篇。