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

三个月前接手公司内部知识库问答项目。语料是约2000篇技术文档、产品手册和FAQ,平均每篇1500字,中文为主,夹杂代码块和表格。用户是内部客服和售前,问题偏具体,比如"XX产品的退款流程要几天"、"YY接口报403怎么处理"。

第一版系统很朴素,典型的三段式:

  • 文档按固定长度切分,chunk_size=512overlap=50
  • embedding 用 OpenAI 的 text-embedding-ada-002
  • 向量库用 FAISS,Top-K=5,直接拼进 prompt 给 GPT-3.5-turbo

上线一周,反馈很一致:"答非所问"、"明明文档里有却检索不到"、"回答里两个版本的信息混在一起"。

我随手抽了100条真实问题做了个离线评测,结果很难看:

指标 数值
Recall@5 0.61
MRR 0.48
端到端答案准确率(人工判定) 52%
P95 延迟 1.2s

召回率0.61意味着近四成问题,正确文档压根没进候选集,后面LLM再强也救不回来。于是开始系统性优化。

二、环境与版本

先把环境钉死,方便复现:

Python 3.10.13
torch 2.1.2 + cu121
transformers 4.36.2
sentence-transformers 2.3.1
FlagEmbedding 1.2.10
faiss-cpu 1.7.4
langchain 0.1.0
openai 1.6.1

硬件:一台 A10 24G 的推理机,embedding 和 reranker 都跑在 GPU 上。向量库规模不大,2000篇文档切完约 1.8 万 chunk,FAISS 用 IndexFlatIP 就够,没必要上 IVF。

三、方案设计:三步走

优化的思路很明确,针对三个不同的失效环节:

  1. 切分环节:固定长度切分把语义切碎了,表格和代码块被拦腰截断。改成按 Markdown 标题层级 + 语义段落混合切分。
  2. 召回环节text-embedding-ada-002 在中文短查询上表现一般,且走 API 有网络抖动。换成中文优化过的 bge-large-zh-v1.5
  3. 排序环节:向量召回是双塔模型,query 和 doc 不交互,Top-5 里经常混入语义相近但答非所问的块。引入 cross-encoder 做 rerank。

每一步都单独评测,避免"一起改完不知道是谁的功劳"。

四、核心实现

4.1 Chunk 策略改造

原来的 RecursiveCharacterTextSplitter 对中文和 Markdown 都不友好。我写了个基于标题层级的切分器,核心逻辑:优先在 ##/### 处切,单块超过 800 字再按段落二次切,表格和代码块整体保留。

import re
from typing import List

def split_by_markdown(text: str, max_len: int = 800, min_len: int = 120) -> List[str]:
    # 按二级/三级标题切分,保留标题作为上下文
    pattern = re.compile(r'(?=^#{2,3}\s)', re.MULTILINE)
    raw_blocks = [b.strip() for b in pattern.split(text) if b.strip()]

    chunks = []
    for block in raw_blocks:
        if len(block) = min_len:
                chunks.append(block)
            continue

        # 超长块按空行段落聚合
        paras = [p for p in block.split("\n\n") if p.strip()]
        buf = ""
        for p in paras:
            # 代码块/表格不拆
            if p.startswith("```") or p.startswith("|"):
                if buf:
                    chunks.append(buf.strip()); buf = ""
                chunks.append(p.strip())
                continue
            if len(buf) + len(p) + 2 = min_len]

同时在每个 chunk 前面拼上所属标题路径(如 产品A > 退款 > 时效),给 embedding 更多上下文。这一步单独做完,Recall@5 从 0.61 涨到 0.68。

4.2 Embedding 切换

从 API 换到本地 bge-large-zh-v1.5。这个模型对中文语义匹配明显更好,而且 query 侧要加指令前缀 为这个句子生成表示以用于检索相关文章:,doc 侧不加,这是官方推荐的非对称检索用法。

from sentence_transformers import SentenceTransformer
import numpy as np
import faiss

model = SentenceTransformer("BAAI/bge-large-zh-v1.5", device="cuda")
model.max_seq_length = 512

QUERY_INSTRUCTION = "为这个句子生成表示以用于检索相关文章:"

def encode_docs(texts):
    return model.encode(texts, batch_size=64, normalize_embeddings=True,
                        show_progress_bar=True)

def encode_query(q):
    return model.encode([QUERY_INSTRUCTION + q], normalize_embeddings=True)[0]

# 建索引
doc_vecs = encode_docs(all_chunks).astype("float32")
index = faiss.IndexFlatIP(doc_vecs.shape[1])
index.add(doc_vecs)

def search(query, top_k=20):
    qv = encode_query(query).astype("float32").reshape(1, -1)
    scores, idx = index.search(qv, top_k)
    return [(all_chunks[i], float(s)) for s, i in zip(scores[0], idx[0])]

这里检索 Top-K 我特意开到 20,为后面的 rerank 留候选空间。这一步单独做完,Recall@5 到 0.74,Recall@20 已经到 0.88。

4.3 引入 Rerank

向量召回的双塔结构决定了 query 和 doc 没有交互,Top-5 里常有"看起来像"的噪声。用 bge-reranker-large 做 cross-encoder 重排,把 Top-20 精排后取 Top-5。

from FlagEmbedding import FlagReranker

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

def rerank(query, candidates, top_n=5):
    pairs = [[query, doc] for doc, _ in candidates]
    scores = reranker.compute_score(pairs, normalize=True)
    ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
    return [(doc, score) for (doc, _), score in ranked[:top_n]]

def rag_retrieve(query):
    cands = search(query, top_k=20)
    return rerank(query, cands, top_n=5)

这一步是收益最大的。Recall@5 从 0.74 直接跳到 0.89,MRR 从 0.55 到 0.81。

五、踩坑与优化

坑1:bge 指令前缀搞反。 一开始 query 和 doc 都加了前缀,效果反而掉了3个点。官方文档明确写了这是非对称检索,只加在 query 侧。

坑2:reranker 拖慢延迟。 Top-20 全量重排,P95 从 1.2s 涨到 3.4s。后来做了两件事:一是 use_fp16=True,二是候选从 20 降到 15(Recall@15 已经 0.86,够用),P95 回落到 2.1s。

坑3:chunk 太碎导致上下文缺失。 min_len 设成 120 之前,产生了大量一句话的碎片,rerank 时分数虚高但没有信息量。加上标题路径前缀后缓解很多。

坑4:FAISS 归一化。IndexFlatIP 必须对向量做 L2 归一化,等价于余弦相似度。忘了归一化那版,检索结果完全是乱的,排查了半天。

六、效果数据

三轮迭代的数据汇总(同一份100题评测集):

阶段 Recall@5 MRR 答案准确率 P95延迟
基线(512切分 + ada-002) 0.61 0.48 52% 1.2s
+ 语义/标题切分 0.68 0.53 58% 1.2s
+ bge-large-zh-v1.5 0.74 0.55 65% 1.5s
+ bge-reranker-large 0.89 0.81 81% 2.1s

延迟从1.2s到2.1s,换来准确率29个点的提升,对内部工具来说完全值得。如果对延迟敏感,可以把 reranker 换成 bge-reranker-base,实测准确率只掉2个点,延迟能压到1.6s。

七、总结

这轮优化没有用任何花哨的技巧,就是老老实实把三个环节分别做好:切分贴合文档结构、embedding 用中文专优模型、排序用 cross-encoder 精排。三者的收益是叠加的,其中 rerank 贡献最大。

几点经验:

  • 先做评测集再优化。100条真实问题,人工标注正确 chunk,是所有决策的依据。没有它,改动全是拍脑袋。
  • 每一步单独评测。三个环节耦合严重,一起改根本不知道谁有效。
  • Recall@K 和最终准确率要分开看。召回是天花板,rerank 是在天花板下做精挑。
  • 别迷信大模型 API。本地 bge 系列在中文检索上不比 ada-002 差,还省了网络延迟和费用。

下一步打算试试 query 改写(HyDE)和混合检索(BM25 + 向量),看能不能把 Recall@5 推到 0.92 以上。有兴趣的可以一起交流。