一、问题背景:一个"能用但不好用"的知识库

我们内部有一个技术文档知识库,大约12000篇Markdown文档,总字数约1800万。产品需求是让员工用自然语言提问,系统基于文档回答并给出引用来源。第一版RAG系统两周就上线了,用的是最朴素的方案:固定长度切块 + OpenAI embedding + 向量相似度检索 + GPT-4生成。

上线后用户反馈集中在三点:

  1. 问"XX接口的超时重试配置在哪",检索到的却是另一篇提到"重试"的无关文档;
  2. 跨段落的问题(比如配置项在A段、示例在B段)经常只召回一半;
  3. 同一个问题换个问法,召回结果波动很大。

我们建了一个200条的人工标注评测集(每条标注了ground truth文档ID),跑出来的基线数据很难看:

指标 基线值
Recall@5 61.5%
Recall@10 71.0%
MRR 0.48
答案准确率(人工评估) 54%
P99 延迟 0.9s

问题定位下来,核心在三个环节:分块把语义切碎了、embedding对中文技术术语不敏感、纯向量检索缺乏精排能力。下面按优化顺序逐个说。

二、环境与版本

先把环境交代清楚,避免版本差异导致结果不可复现:

  • Python 3.10.13
  • LangChain 0.1.20(用了它的splitter和retriever抽象)
  • sentence-transformers 2.7.0
  • FlagEmbedding 1.2.10(BGE reranker)
  • Milvus 2.4.0(向量库,HNSW索引,M=16,efConstruction=200)
  • OpenAI API(gpt-4-0613 用于生成)
  • 硬件:单卡 A10 24GB,检索服务与生成服务分离部署

评测脚本固定随机种子,每次改动只动一个变量,保证对比公平。

三、方案设计:三阶段递进优化

整体思路是先把"原料"弄干净,再换"更好的尺子",最后加"精排裁判"。

阶段一:分块策略调整。 从固定512字符切块,改为基于Markdown标题层级的语义分块,再对超长块做滑窗切分,块间保留15%重叠。

阶段二:Embedding模型切换。 从 text-embedding-ada-002(1536维)切到 BAAI/bge-m3(1024维),它支持多语言、长文本(8192 token),对中文技术语料明显更友好。

阶段三:引入Rerank。 向量检索先召回Top-50,再用 bge-reranker-v2-m3 交叉编码器精排到Top-5。

每一阶段都单独跑评测,最后再叠加,这样能看清每个改动的边际收益。

四、核心实现

4.1 语义分块

固定切块最大的问题是把"配置项说明"和它的"示例代码"切到两个块里。改用Markdown结构感知的分块:

from langchain.text_splitter import MarkdownHeaderTextSplitter, RecursiveCharacterTextSplitter

def semantic_chunk(md_text: str, max_chunk: int = 800, overlap: int = 120):
    # 按标题层级先切
    headers = [("#", "h1"), ("##", "h2"), ("###", "h3")]
    md_splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers)
    sections = md_splitter.split_text(md_text)

    # 对超长 section 做滑窗二次切分,带重叠
    rec = RecursiveCharacterTextSplitter(
        chunk_size=max_chunk,
        chunk_overlap=overlap,
        separators=["\n\n", "\n", "。", ";", " ", ""],
    )
    chunks = []
    for sec in sections:
        title_path = " > ".join([v for k, v in sec.metadata.items() if v])
        if len(sec.page_content)  超时重试 > 参数说明`)拼进块文本这一步对召回提升非常明显因为用户提问往往带层级语境

### 4.2 BGE-M3 嵌入与重排

```python
from FlagEmbedding import BGEM3FlagModel, FlagReranker
from pymilvus import Collection

embed_model = BGEM3FlagModel("BAAI/bge-m3", use_fp16=True)
reranker = FlagReranker("BAAI/bge-reranker-v2-m3", use_fp16=True)

def embed_and_insert(chunks, collection: Collection):
    texts = [c["text"] for c in chunks]
    # BGE-M3 返回 dense 向量,normalize 后做内积等价余弦
    embs = embed_model.encode(texts, batch_size=16,
                              max_length=1024)["dense_vecs"]
    collection.insert([texts, embs.tolist()])
    collection.flush()

def retrieve_and_rerank(query: str, collection: Collection,
                        top_k: int = 50, final_k: int = 5):
    q_vec = embed_model.encode([query], max_length=512)["dense_vecs"][0]
    res = collection.search(
        data=[q_vec.tolist()],
        anns_field="embedding",
        param={"metric_type": "IP", "params": {"ef": 128}},
        limit=top_k,
        output_fields=["text"],
    )
    candidates = [hit.entity.get("text") for hit in res[0]]

    # 交叉编码器精排
    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 ranked[:final_k]

注意 BGE-M3 建议 query 侧加指令前缀不需要,但检索侧向量务必 normalize,否则 IP 和 cosine 结果不一致。

五、踩坑与优化

坑1:分块重叠过高导致重复召回。 一开始 overlap 设了30%,结果Top-5里经常出现两个几乎一样的块,浪费名额。降到15%后,Recall@5反而涨了2个点。

坑2:BGE-M3 的 max_length。 默认 8192,但文档块平均才600字符,设成 8192 会拖慢编码且无收益。我们把 max_length 设成 1024,编码吞吐提升约40%,效果无损。

坑3:Reranker 的 batch 与延迟。 bge-reranker-v2-m3 对50个候选逐对打分,单次约180ms(A10)。为了压P99,我们把 top_k 从50降到30,Recall只掉0.8%,但延迟降到约110ms。另外用 fp16 推理。

坑4:Milvus 索引参数。 HNSW 的 ef 检索参数从64提到128,Recall@10提升约1.5%,延迟增加可忽略。

坑5:生成阶段的引用丢失。 精排后Top-5顺序变了,但引用编号要跟着重排,否则用户点引用跳到错误文档。我们让 rerank 输出时保留原始 doc_id 映射。

六、效果数据

每一阶段单独评测(200条评测集,同一硬件):

阶段 Recall@5 Recall@10 MRR 答案准确率 P99延迟
基线(512固定块+ada-002) 61.5% 71.0% 0.48 54% 0.9s
+语义分块 70.0% 79.5% 0.57 63% 0.9s
+BGE-M3 78.5% 86.0% 0.66 71% 1.0s
+Rerank(top50) 89.5% 92.5% 0.79 82% 1.4s
+Rerank(top30调优) 88.7% 92.0% 0.78 81.5% 1.2s

几个观察:

  1. 语义分块是性价比最高的一步,零额外推理成本,Recall@5 直接涨8.5个点。
  2. 换 embedding 模型收益稳定,BGE-M3 对中文技术语料的语义区分度明显好于 ada-002。
  3. Rerank 是召回率飞跃的关键,Top-5 Recall 从78.5%跳到89.5%,因为交叉编码器能捕捉 query 和文档的细粒度交互,这是双塔向量做不到的。
  4. 延迟与效果的权衡:最终线上用 top_k=30,牺牲0.8% Recall 换200ms延迟,用户感知更流畅。

线上灰度两周后,用户"答非所问"的反馈下降了约70%。

七、总结

RAG 优化没有银弹,但有一个清晰的优先级:分块 > 嵌入模型 > 重排 > 生成。分块决定了召回上限,是地基;嵌入模型是尺子;重排是裁判;生成只是最后的表达。很多团队一上来就折腾 prompt 和生成模型,其实检索侧没做好,生成再强也是巧妇难为无米之炊。

后续我们还在做两件事:一是引入 BM25 混合检索(BGE-M3 本身支持 sparse 向量,可以省一个组件),二是对评测集做分层分析,看哪类问题仍然是短板。等有结论再写一篇。

如果这篇对你有帮助,欢迎在评论区交流你们的 chunk 策略和 rerank 选型。