一、问题背景:一个"能用但不好用"的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,检索时语义残缺。

我们改成语义切分 + 递归兜底的混合策略:

  1. 先按文档结构切(Markdown标题、HTML的h1-h3、PDF的段落边界);
  2. 结构块超过阈值再用语义切分(基于相邻句子embedding相似度的断点检测);
  3. 最后用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

几个观察:

  1. rerank是单步收益最大的,Recall@5直接+7.9个点,端到端+0.10。原因是cross-encoder能纠正双塔的"语义相近但答案不对"的误召回。
  2. 端到端准确率的提升(0.61→0.89)比Recall提升(72.3→94.1)幅度小,说明生成阶段还有天花板,下一步该动prompt和上下文组装了。
  3. 延迟只增加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个点。有进展再写一篇。

代码都在上面了,环境版本也给了,照抄能跑。有问题评论区聊。