一、问题背景:一个“能跑但不好用”的RAG

去年底我接手了公司内部知识库问答系统。数据源是Confluence导出的Markdown和少量PDF,总量约2000篇文档,平均每篇1800字。架构很朴素:LangChain 0.1.0 + Chroma 0.4.24 + OpenAI text-embedding-ada-002 + GPT-3.5-turbo,检索Top-5直接拼进prompt。

上线两周后,运营同学反馈“答非所问”占比很高。我搭了一个100条问题的评测集(人工标注标准答案段落),跑出来几个关键指标:

  • Top-5检索命中率(标准段落是否在前5):0.63
  • 答案可用率(人工判定可用):0.58
  • 平均检索耗时:120ms
  • 端到端P95延迟:1.9s

问题很明显:召回不够,模型再强也白搭。于是我开始按“chunk → embedding → rerank”的顺序做分层优化。

二、环境与版本

先把环境固定下来,避免后面归因混乱:

  • Python 3.10.13
  • LangChain 0.1.0
  • Chroma 0.4.24(持久化模式)
  • sentence-transformers 2.5.1
  • FlagEmbedding 1.2.10
  • torch 2.1.2 + cu121
  • GPU:单卡 RTX 4090 24GB
  • LLM:GPT-3.5-turbo(temperature=0.2)

评测脚本固定为同一份100题集,每次只改一个变量。

三、方案设计:三步走

我的优化顺序是刻意的:先解决“切得对不对”,再解决“表示得好不好”,最后才上rerank。因为如果chunk本身把语义切碎了,换再好的embedding也救不回来;而rerank是锦上添花,前两步没做好,rerank的候选池本身就是脏的。

  1. Chunk策略:从固定512字符,改为“按标题层级递归切分 + 语义相似度合并”,目标块长300-500 token,重叠80 token。
  2. Embedding模型:从ada-002切到bge-large-zh-v1.5,中文语义匹配明显更强,且可本地部署,省成本。
  3. Rerank:召回Top-20,用bge-reranker-large重排取Top-5。

四、核心实现

4.1 Chunk策略调整

原来的切分是RecursiveCharacterTextSplitter(chunk_size=512, chunk_overlap=50),问题是它按字符数硬切,经常把一个小节切成两半。我改成先按Markdown标题切,再在段内做递归切分,最后用相邻块embedding相似度决定是否合并。

import re
from langchain.text_splitter import RecursiveCharacterTextSplitter
from sentence_transformers import SentenceTransformer
import numpy as np

def split_by_headers(md_text: str):
    # 按二级、三级标题切分,保留标题作为上下文
    pattern = r'(?=^#{2,3}\s)'
    parts = re.split(pattern, md_text, flags=re.MULTILINE)
    return [p.strip() for p in parts if p.strip()]

def semantic_merge(chunks, model, threshold=0.82, max_len=500):
    merged, buf = [], chunks[0]
    for nxt in chunks[1:]:
        emb = model.encode([buf, nxt], normalize_embeddings=True)
        sim = float(np.dot(emb[0], emb[1]))
        if sim > threshold and len(buf) + len(nxt) < max_len:
            buf += "\n" + nxt
        else:
            merged.append(buf)
            buf = nxt
    merged.append(buf)
    return merged

embed_model = SentenceTransformer("BAAI/bge-large-zh-v1.5")
splitter = RecursiveCharacterTextSplitter(
    chunk_size=400, chunk_overlap=80,
    separators=["\n\n", "\n", "。", ";", " "]
)

def build_chunks(md_text):
    header_blocks = split_by_headers(md_text)
    fine = []
    for b in header_blocks:
        fine.extend(splitter.split_text(b))
    return semantic_merge(fine, embed_model)

参数上我试过threshold 0.75/0.82/0.88,0.82时块数从原来的4200降到2600,平均块长从380字符升到460字符,效果最好。

4.2 Embedding模型切换

Chroma的collection需要重建,因为维度从1536变成1024。我用FlagEmbedding加载bge-large-zh-v1.5,注意query要加指令前缀。

from FlagEmbedding import FlagModel
import chromadb

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

client = chromadb.PersistentClient(path="./chroma_bge")
col = client.get_or_create_collection(
    name="kb_v2", metadata={"hnsw:space": "cosine"}
)

def index_chunks(chunks):
    embs = model.encode(chunks, batch_size=64, max_length=512)
    col.add(
        ids=[f"c{i}" for i in range(len(chunks))],
        documents=chunks,
        embeddings=embs.tolist()
    )

def retrieve(query, top_k=20):
    q_emb = model.encode_queries([query])[0]
    res = col.query(query_embeddings=[q_emb.tolist()], n_results=top_k)
    return res["documents"][0]

这里有个坑:encodeencode_queries不能混用,query不加指令前缀会掉3-5个点。

4.3 Rerank引入

召回Top-20后用bge-reranker-large重排。它比cross-encoder更划算,4090上20条重排约45ms。

from FlagEmbedding import FlagReranker

reranker = FlagReranker("BAAI/bge-reranker-large", use_fp16=True)

def rerank(query, candidates, top_n=5):
    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 [c for c, _ in ranked[:top_n]]

五、踩坑与优化

  1. Chroma维度不匹配:切换embedding后没重建collection,直接报维度错误。一定要新建collection名。
  2. rerank拖慢首字延迟:Top-20重排虽然只要45ms,但串行在检索后会让P95从1.9s升到2.6s。后来把rerank和LLM的prompt组装并行,压回2.3s。
  3. 语义合并过度:threshold设0.75时,把不同小节的块合并了,反而丢信息。0.82是甜点。
  4. bge模型显存:large模型fp16占约1.3GB,reranker约2.2GB,4090完全够,但如果你用T4要留意。

六、效果数据

同一份100题评测集,逐项对比:

阶段 Top-5命中率 答案可用率 检索耗时 P95延迟
基线(ada-002+512) 0.63 0.58 120ms 1.9s
+chunk优化 0.71 0.66 135ms 2.0s
+bge-embedding 0.83 0.78 110ms 2.0s
+rerank 0.89 0.85 165ms 2.6s

检索命中率从0.63到0.89,提升26个百分点;答案可用率从0.58到0.85。代价是P95延迟多了0.7s,但用户主观感受“答得准”比“答得快”重要得多。

七、总结

这次优化最大的体会是:RAG的瓶颈90%在检索,检索的瓶颈80%在chunk。换embedding和加rerank都有效,但如果chunk把语义切碎,后面全是徒劳。另外,评测集一定要提前建好,否则你根本不知道每次改动是涨是跌。下一步我打算试试query改写和多路召回(BM25+向量),目标把命中率推到0.92以上。