一、问题背景:一个“看起来能用”的RAG系统

三个月前我接手了公司内部知识库问答系统。技术栈很标准:LangChain 0.1.0 + Chroma 0.4.24 + OpenAI GPT-3.5-turbo。文档量不大,约2000条产品FAQ和运维手册,每条平均300字。

上线第一周,运营同学反馈:“问‘退款流程要多久’,它回答的是‘注册流程’。”我手动测了50个问题,发现:

  • Top-3召回率:62%
  • 回答准确率(人工判断):约55%
  • 平均检索延迟:120ms
  • 端到端延迟:1.2s

问题很明显:检索环节太弱。用户问“退款”,返回的却是“注册”,说明embedding区分度不够,且chunk切分把上下文切碎了。我决定从三个方向优化:chunk策略、embedding模型、rerank。

二、环境与版本

先固定环境,避免“我本地能跑”的尴尬:

Python 3.10.13
langchain==0.1.0
chromadb==0.4.24
sentence-transformers==2.5.1
FlagEmbedding==1.2.10
torch==2.1.2+cu121
openai==1.6.1

硬件:单卡RTX 4090 24GB,CPU 32核,内存128GB。Embedding和Rerank模型都本地部署,不调OpenAI API,省成本且数据不出内网。

三、方案设计

优化分三步走,每步单独评估:

Step 1:Chunk策略调整
原方案:RecursiveCharacterTextSplitter(chunk_size=512, chunk_overlap=50),按字符硬切。
新方案:先按Markdown标题和段落做语义分割,再对超长段落用递归切分,chunk_size=256,overlap=32。原因:FAQ文档本身有结构,硬切会切断“问题-答案”对。

Step 2:Embedding模型切换
原方案:text-embedding-ada-002(1536维,API调用)。
新方案:bge-large-zh-v1.5(1024维,本地推理)。中文场景下bge在C-MTEB榜单上明显优于ada-002,且本地推理无网络延迟。

Step 3:引入Rerank
检索阶段先用bge召回Top-20,再用bge-reranker-large做cross-encoder重排,取Top-3送入LLM。Rerank能捕捉query和doc的交互特征,对“退款”vs“注册”这种语义相近但意图不同的场景区分度极高。

四、核心实现

4.1 语义+递归混合Chunk

from langchain.text_splitter import RecursiveCharacterTextSplitter, MarkdownHeaderTextSplitter
from langchain.docstore.document import Document

def hybrid_chunk(text: str, source: str):
    # 第一层:按Markdown标题切
    headers_to_split_on = [
        ("#", "h1"),
        ("##", "h2"),
        ("###", "h3"),
    ]
    md_splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers_to_split_on)
    md_docs = md_splitter.split_text(text)

    # 第二层:对超长段落递归切分
    recursive_splitter = RecursiveCharacterTextSplitter(
        chunk_size=256,
        chunk_overlap=32,
        separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""],
        length_function=len,
    )
    final_docs = []
    for doc in md_docs:
        if len(doc.page_content) > 256:
            sub_docs = recursive_splitter.split_documents([doc])
            final_docs.extend(sub_docs)
        else:
            final_docs.append(doc)

    # 补上source metadata
    for d in final_docs:
        d.metadata["source"] = source
    return final_docs

关键点:separators里把中文标点放进去,避免在句子中间断开。实测chunk平均长度从512降到238,但每个chunk语义完整度大幅提升。

4.2 Embedding与Rerank集成

from FlagEmbedding import FlagModel, FlagReranker
import numpy as np

# 初始化
embed_model = FlagModel(
    'BAAI/bge-large-zh-v1.5',
    query_instruction_for_retrieval="为这个句子生成表示以用于检索相关文章:",
    use_fp16=True
)
reranker = FlagReranker('BAAI/bge-reranker-large', use_fp16=True)

def retrieve_and_rerank(query: str, collection, top_k_recall=20, top_k_final=3):
    # 1. 向量召回
    q_emb = embed_model.encode_queries([query])[0]
    results = collection.query(
        query_embeddings=[q_emb.tolist()],
        n_results=top_k_recall,
        include=["documents", "metadatas", "distances"]
    )
    docs = results["documents"][0]
    metas = results["metadatas"][0]

    # 2. Rerank
    pairs = [[query, doc] for doc in docs]
    scores = reranker.compute_score(pairs, normalize=True)

    # 3. 排序取Top-3
    ranked = sorted(zip(docs, metas, scores), key=lambda x: x[2], reverse=True)
    return ranked[:top_k_final]

注意:query_instruction_for_retrieval必须加,bge模型对query和passage的编码方式不同,不加指令会导致召回率下降约8%。

五、踩坑与优化

坑1:bge模型首次加载慢
首次加载bge-large-zh-v1.5耗时约12秒(从磁盘读+GPU warmup)。解决:服务启动时预加载,并跑一次dummy inference。

坑2:Rerank延迟高
bge-reranker-large对20个pair打分,单次约380ms(4090)。如果召回Top-50,延迟飙到900ms。折中:召回Top-20,延迟控制在400ms内。

坑3:Chroma的distance是L2,不是cosine
bge模型建议用cosine相似度。Chroma默认L2,需要创建collection时指定metadata={"hnsw:space": "cosine"}。改完后召回率提升约5%。

坑4:Overlap太大导致重复
chunk_overlap从50降到32后,重复内容减少,LLM回答不再“车轱辘话”。但overlap不能为0,否则跨chunk的答案会丢。

六、效果数据

在2000条FAQ上,用100个标注问题做评估:

指标 优化前 Step1后 Step2后 Step3后
Top-3召回率 62% 71% 80% 89%
回答准确率 55% 63% 74% 84%
检索延迟 120ms 135ms 180ms 580ms
端到端延迟 1.2s 1.3s 1.4s 1.8s

Rerank引入后延迟增加400ms,但准确率提升10个百分点,性价比极高。最终线上P95延迟2.1s,用户可接受。

另外,Embedding从API切到本地后,每月省下约$120的API费用,且数据不出内网,安全合规。

七、总结

RAG优化没有银弹,但优先级很明确:chunk > embedding > rerank。chunk是地基,切碎了后面怎么调都白搭;embedding决定召回上限;rerank是精度放大器,但会牺牲延迟。

如果重来一次,我会:
1. 先花一天做chunk策略实验,而不是直接上默认参数。
2. embedding选型时用C-MTEB榜单做参考,别迷信OpenAI。
3. rerank只在召回Top-20以上时用,Top-5没必要,收益太小。

最后,评估集一定要提前建好,否则你根本不知道优化有没有用。我用了100个标注问题,花了半天时间,但省下了后面两周的盲目调参。