一、从“答非所问”说起:一个RAG系统的真实困境

我们团队维护着一个面向内部运维的文档问答机器人,知识库包含设备手册、故障工单、变更记录等约2300篇Markdown文档,总量约1.2GB。v1.0版本上线后,用户反馈最集中的问题是“我查防火墙策略配置,它给我返回交换机VLAN的配置”——召回结果驴唇不对马嘴。

当时的技术栈是:langchain==0.1.0 + chromadb==0.4.22 + sentence-transformers/bge-small-zh-v1.5。chunk策略是粗暴的固定长度256字符,无重叠。embedding维度512。

通过日志分析,我们发现两个核心问题:
1. 中文长文档被硬切后,语义完整性被破坏。比如“禁止root远程登录”被切成“禁止root远程”和“登录”两块,检索时语义丢失严重。
2. 512维的bge-small对领域术语(如“BGP community”、“QoS队列调度”)的表征能力不足,召回结果中大量出现字面相似但语义无关的文本块。

二、环境与版本基线

先交代一下排查时的环境,方便大家复现对比:

Python: 3.10.12
langchain: 0.1.0 → 0.2.1(优化后升级)
chromadb: 0.4.22
sentence-transformers: 2.2.2
FlagEmbedding: 1.2.6(rerank用)
torch: 2.1.0+cu118
CUDA: 11.8
单卡: A10 24G(仅推理,batch_size=32)

评估集:从真实工单中抽了300条query,每条标注了对应的正确文档ID(最多5个)。评价指标:
- Recall@5:前5个召回结果中是否包含正确文档
- Top-1 Accuracy:重排后第一个结果是否正确

三、第一刀:chunk策略从“定长”改“语义边界”

问题定位:通过可视化embedding向量相似度矩阵,我们发现被截断的chunk在向量空间中分布异常分散。例如一篇关于“OSPF邻居建立”的文档,被切成8个chunk后,有3个chunk的向量与query的余弦相似度低于0.3。

方案设计:放弃固定长度,改用RecursiveCharacterTextSplitter + 自定义分隔符优先级。核心思路:优先按\n##\n###\n\n\n逐级降级切割。同时设置chunk_size=800chunk_overlap=150——这比之前大了三倍,因为语义完整的段落往往更长。

核心实现

from langchain.text_splitter import RecursiveCharacterTextSplitter

text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=800,
    chunk_overlap=150,
    separators=[
        "\n## ",   # 二级标题
        "\n### ",  # 三级标题
        "\n\n",    # 空行
        "\n",      # 换行
        "。",      # 句号
        ";",      # 分号
        " ",       # 空格
    ],
    keep_separator=True,  # 关键:保留分隔符,避免标题丢失
)

# 对每篇文档执行
chunks = text_splitter.split_text(document)

踩坑记录:最初keep_separator=False,结果“## 故障排查”变成了“故障排查”,丢失了层级信息。后来改为True,并在chunk开头注入文档标题前缀:

# 给每个chunk加上父文档标题,增强上下文
chunk_with_title = f"# {doc_title}\n{chunk}"

效果数据Recall@5从58.2%提升至68.5%。但检索时发现一个问题:chunk变长导致向量维度不变但信息量增加,单条query的检索耗时从45ms升至70ms(chromadb暴力搜索)。此时还没上rerank,Top-1准确率仅为61%。

四、第二刀:embedding模型从bge-small换成bge-m3

问题定位:抽样分析了100个Failed case,其中43%属于“术语不匹配”。例如query包含“OSPF邻居卡在ExStart”,文档里写的是“邻居状态机停留在ExStart状态”,字面差异大,但语义相同。bge-small的512维向量对这种同义改写不敏感。

方案设计:切换为BAAI/bge-m3,维度1024,支持多语言和长文本(最长8192 token)。bge-m3在MTEB中文检索任务上比bge-small高约4个点,且对领域文本有更好的泛化能力。

核心实现(使用FlagEmbedding加载,替代sentence-transformers):

from FlagEmbedding import BGEM3FlagModel
import chromadb
from chromadb.utils import embedding_functions

# 初始化bge-m3,使用dense+sparse混合检索
model = BGEM3FlagModel(
    'BAAI/bge-m3',
    use_fp16=True,
    device='cuda:0'
)

def encode_texts(texts):
    outputs = model.encode(
        texts,
        batch_size=32,
        max_length=1024,
        return_dense=True,
        return_sparse=True,
        return_colbert_vecs=False  # 省显存
    )
    # 混合向量:dense + sparse加权(sparse权重0.3)
    dense = outputs['dense_vecs']
    sparse = outputs['lexical_weights']
    return dense, sparse

# chromadb自带的embedding_function需要统一接口,
# 这里我们直接离线生成向量后写入
collection = client.get_or_create_collection(
    name="docs_v2",
    metadata={"hnsw:space": "cosine"}
)

# 写入时同时存dense和sparse(sparse存为metadata)
for i, chunk in enumerate(chunks):
    dense, sparse = encode_texts([chunk])
    # sparse weights需要序列化存储,这里简化为存top-50词
    sparse_simplified = {k: v for k, v in list(sparse[0].items())[:50]}
    collection.add(
        ids=[f"chunk_{i}"],
        embeddings=dense.tolist(),
        metadatas=[{"text": chunk, "sparse": json.dumps(sparse_simplified)}]
    )

检索时使用混合评分:

def hybrid_search(query, top_k=20):
    q_dense, q_sparse = encode_texts([query])
    # chromadb只支持dense检索,sparse部分我们手动算BM25
    dense_results = collection.query(
        query_embeddings=q_dense.tolist(),
        n_results=top_k
    )
    # sparse部分:用jieba分词后算BM25,与dense分数加权
    bm25_scores = compute_bm25(query, collection)
    # 最终分数 = 0.7 * dense_cosine + 0.3 * normalized_bm25
    final_scores = 0.7 * dense_sim + 0.3 * bm25_norm
    return sorted(zip(ids, final_scores), key=lambda x: x[1], reverse=True)[:top_k]

效果数据Recall@5从68.5%提升到79.2%。但注意:bge-m3的推理耗时比bge-small高约2.3倍(单条query编码从12ms变成28ms),同时向量维度翻倍导致chromadb存储占用从2.1GB涨到4.3GB。此时Top-1准确率仅从61%提升到64%,说明“召回够了,但排序还是不行”——这就逼出了第三刀。

五、第三刀:引入bge-reranker重排

问题定位:混合检索返回20个候选,但Top-1准确率只有64%。原因:embedding检索是双塔结构,query和doc独立编码,交互信息(如词对共现)缺失。尤其对于“禁止root登录”和“root用户不允许远程登录”这种词序颠倒的句子,双塔模型很难学到位。

方案设计:引入BAAI/bge-reranker-base(约560M参数,交叉编码器)。流程变为:混合检索取Top-20 → reranker逐对打分 → 取Top-5。延迟增量:20对句子跑一次transformer,大约85ms(A10上fp16批量推理)。

核心实现

from FlagEmbedding import FlagReranker

reranker = FlagReranker(
    'BAAI/bge-reranker-base',
    use_fp16=True,
    device='cuda:0'
)

def rerank(query, candidates, top_n=5):
    # candidates格式:[(id, text, score), ...]
    pairs = [[query, cand[1]] for cand in candidates]
    scores = reranker.compute_score(
        pairs,
        normalize=True,  # 返回0-1之间的sigmoid分数
        batch_size=16
    )
    # 合并分数并排序
    scored = [(cand[0], cand[1], cand[2], scores[i]) for i, cand in enumerate(candidates)]
    # 注意:这里我们直接用rerank分数排序,不再与向量分数融合
    # 因为rerank是交互式,更可靠
    scored.sort(key=lambda x: x[3], reverse=True)
    return scored[:top_n]

踩坑记录
1. reranker对输入长度敏感,bge-reranker-base最大序列长度512。我们的chunk是800字,直接塞进去会截断。解决方案:对候选chunk先做“首尾保留”截断——保留前200字和后200字,中间用省略号替代。实测发现对结构化文档(有标题和结论)效果影响很小。
2. 分数归一化问题:normalize=True返回的是sigmoid输出,范围0-1。但早期测试时发现分数普遍集中在0.1-0.3之间,区分度不够。后来改为输出原始logits(normalize=False),再自己做softmax归一化,区分度明显提升。

# 使用原始logits + 温度缩放
logits = reranker.compute_score(pairs, normalize=False)
scaled = [1 / (1 + np.exp(-(x / 0.3))) for x in logits]  # 温度0.3

六、最终效果与资源消耗对比

版本 Recall@5 Top-1准确率 首响延迟 存储占用
v1.0 (固定切块+bge-small) 58.2% 47% 480ms 1.8GB
v2.0 (语义切块+bge-small) 68.5% 61% 520ms 2.1GB
v2.5 (语义切块+bge-m3) 79.2% 64% 680ms 4.3GB
v3.0 (语义切块+bge-m3+rerank) 79.2% 82% 765ms 4.3GB

关键结论
1. chunk策略对召回影响最大(+10.3% Recall@5),且成本为零。强烈建议先检查chunk边界是否切碎了语义单元。
2. embedding模型升级对召回有增益(+10.7%),但提升Top-1有限——双塔模型的天花板就在那里。
3. rerank是“画龙点睛”的一笔:Top-1准确率从64%直接跳到82%,延迟只多了85ms。对于需要高精度回答的场景(如运维指令查询),这是性价比最高的一刀。

额外收益:由于rerank过滤掉了低质量候选,最终生成的回答里“幻觉”比例从31%降至14%(人工抽检50条query)。这是因为LLM看到的相关上下文更精准,减少了编造空间。

七、总结与后续规划

三轮优化下来,最大的感悟是:RAG系统的瓶颈往往不在模型大小,而在“数据准备”和“流程设计”。固定长度切块是最省事但最糟糕的做法——它默认所有文档的结构一致,但真实文档永远有标题、列表、代码块。

后续我们打算:
- 针对代码块和表格做特殊chunk处理(保留缩进和表头)
- 尝试jina-reranker-v2,据说在代码检索上更强
- 把rerank的候选数从20扩到50,看延迟能否控制在150ms内

最后提醒一句:所有的优化都要以线下评测集为准,不要只看几个case的直观感受。我们的评测集虽然只有300条,但覆盖了高频query类型,每次改动跑一遍,半天出结果,值得。