一、问题背景

三个月前接手公司内部知识库问答系统。系统底层是一个标准RAG(检索增强生成)流水线:文档解析 → 切分 → embedding → 向量库检索 → LLM生成答案。上线初期"能用",但随着文档量从200篇涨到3000+篇,用户反馈越来越差:

  • 问"报销流程需要哪些材料",检索回来的却是"差旅报销标准"这种语义相近但答非所问的片段;
  • 问一个跨段落的问题(答案分散在两节里),召回的内容总是缺一半;
  • 有些问题明明文档里有原话,但就是检索不到,LLM只能回答"根据已知信息无法确定"。

我拉了200条真实用户问题做了人工标注(每条标注ground truth所在的chunk),跑了一遍评测,基线数据相当难看:

指标 基线值
Recall@5 61.5%
Top-1 命中率 48.0%
端到端答案准确率(人工判定) 54.0%
平均检索延迟 120ms

于是开始了为期三周的优化。整体思路是:先修切分(数据层),再换embedding(表示层),最后加rerank(排序层),每一层单独评测、逐步叠加,避免"一把梭"导致无法归因。

二、环境与版本

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

  • Python 3.10.13
  • PyTorch 2.1.2 + CUDA 12.1
  • sentence-transformers 2.7.0
  • FlagEmbedding 1.2.10(用于bge系列模型和reranker)
  • Milvus 2.3.4(向量库,HNSW索引,M=16,efConstruction=200)
  • LangChain 0.1.16(仅用于文档加载和文本分割工具)
  • LLM:Qwen1.5-14B-Chat(本地部署,vLLM 0.4.2)
  • 硬件:单卡 A100 40G(embedding和reranker共用),检索服务与LLM服务分离部署

评测集:200条人工标注问答,覆盖制度、流程、技术文档三类,每条标注了答案所在的原始段落。评测脚本固定,每次优化只改变一个变量。

三、方案设计

整体优化路线分三步走,每步都做A/B对比:

Step 1:chunk策略调整。 从固定长度切分改为"标题感知 + 语义边界"的混合切分。核心改动:按Markdown标题层级先做一级切分,标题下的内容再按句子边界聚合到目标长度,超长段落用滑动窗口兜底。

Step 2:embedding模型切换。 从OpenAI的text-embedding-ada-002(1536维)切换到BAAI的bge-large-zh-v1.5(1024维)。原因有二:一是中文语义匹配上bge明显更强,二是本地部署省掉API调用成本和网络延迟。

Step 3:引入rerank。 检索阶段先召回Top-20,再用bge-reranker-base做cross-encoder精排,取Top-5送给LLM。

设计上刻意保持"检索召回→精排→生成"三段解耦,每段可独立替换和评测。

四、核心实现

4.1 语义分块

最初的切分就是 RecursiveCharacterTextSplitter(chunk_size=512, chunk_overlap=50),问题很明显:512字符经常把一个完整条款从中间劈开,overlap 50又不足以覆盖上下文。改后的分块逻辑:

import re
from typing import List

def split_by_headers(text: str, max_len: int = 800, overlap: int = 150) -> List[str]:
    """按Markdown标题切分,再按句子边界聚合到max_len"""
    # 1. 按标题切分为section,保留标题作为上下文前缀
    sections = re.split(r'(?m)^(#{1,3}\s+.+)$', text)
    chunks = []
    # sections结构: [前文, 标题1, 内容1, 标题2, 内容2, ...]
    buffer_title, buffer_text = "", ""
    for i in range(0, len(sections), 2):
        pre = sections[i].strip()
        title = sections[i + 1].strip() if i + 1  max_len and cur:
            chunks.append("".join(cur).strip())
            # 保留尾部overlap,用句子粒度回退
            tail, tail_len = [], 0
            for s in reversed(cur):
                if tail_len + len(s) > overlap:
                    break
                tail.insert(0, s)
                tail_len += len(s)
            cur, cur_len = tail, tail_len
        cur.append(sent)
        cur_len += len(sent)
    if cur:
        chunks.append("".join(cur).strip())
    return [c for c in chunks if len(c) > 30]

关键点:标题会被拼进chunk正文,这样embedding能感知"这一块属于哪一节",对制度类文档提升明显。chunk_size从512提到800,overlap从50提到150,是拿评测集网格搜索出来的(512/800/1024 × 50/100/150/200)。

4.2 embedding切换与rerank流水线

from FlagEmbedding import FlagModel, FlagReranker
import numpy as np

# 本地embedding模型,归一化后走内积等价余弦
embedder = FlagModel(
    "BAAI/bge-large-zh-v1.5",
    query_instruction_for_retrieval="为这个句子生成表示以用于检索相关文章:",
    use_fp16=True,
)
reranker = FlagReranker("BAAI/bge-reranker-base", use_fp16=True)

def retrieve_and_rerank(query: str, collection, top_k_recall: int = 20, top_k_final: int = 5):
    # 1. 向量召回
    q_vec = embedder.encode_queries([query])[0]
    q_vec = q_vec / np.linalg.norm(q_vec)
    hits = collection.search(
        data=[q_vec.tolist()],
        anns_field="embedding",
        param={"metric_type": "IP", "params": {"ef": 64}},
        limit=top_k_recall,
        output_fields=["text", "doc_id"],
    )[0]
    candidates = [{"text": h.entity.get("text"), "score": h.score} for h in hits]

    # 2. cross-encoder精排
    pairs = [[query, c["text"]] for c in candidates]
    rerank_scores = reranker.compute_score(pairs, normalize=True)
    for c, s in zip(candidates, rerank_scores):
        c["rerank_score"] = float(s)
    candidates.sort(key=lambda x: x["rerank_score"], reverse=True)
    return candidates[:top_k_final]

def build_prompt(query: str, docs: list) -> str:
    ctx = "\n\n".join(f"[{i+1}] {d['text']}" for i, d in enumerate(docs))
    return (
        "请仅根据以下资料回答问题,资料不足时明确说明。\n\n"
        f"资料:\n{ctx}\n\n问题:{query}\n回答:"
    )

注意bge的query instruction很重要——query侧加指令、doc侧不加,这是bge官方推荐用法,实测Recall@5能差3~4个点。

五、踩坑与优化

坑1:切换embedding后忘记重建索引。 一开始只换了模型没重灌向量库,还在用ada-002的向量查bge的query向量,Recall直接掉到40%。教训:换模型必须全量重灌,并且维度从1536变1024,Milvus的collection要重建。

坑2:rerank的延迟。 Top-20送进bge-reranker-base,单次精排约35ms(A100,fp16,batch=1)。并发上来后成了瓶颈。优化手段:把Top-20降到Top-15(Recall只掉0.5个点),并对rerank做batch推理;同时把召回阶段的HNSW参数 ef 从128降到64,检索延迟从120ms降到85ms,召回损失可忽略。

坑3:语义分块把表格切碎了。 制度文档里有大量Markdown表格,按句子切分会把表头和表体分开。后来加了规则:检测到连续的 | 开头行,整体作为一个不可分割单元,超长时按行切并重复表头。这个改动让表格类问题的准确率提升了约12个点。

坑4:bge模型在短query上表现一般。 用户问题经常只有五六个字("年假怎么算")。解决办法是在query侧做轻量改写:用LLM把短query扩展成一句完整问句再embedding。这一步把短query的Top-1命中率从58%拉到71%,但增加了约200ms的LLM调用延迟,最后只对长度<10字的query启用。

六、效果数据

三阶段逐步叠加的评测结果(同一200条评测集,指标为平均值):

阶段 Recall@5 Top-1命中率 端到端准确率 检索+精排延迟
基线(512固定切分 + ada-002 + 无rerank) 61.5% 48.0% 54.0% 120ms
+ 语义分块(800/150) 70.0% 55.5% 61.5% 125ms
+ bge-large-zh-v1.5 79.5% 68.0% 70.0% 105ms
+ bge-reranker-base(Top-15→5) 89.5% 82.0% 78.5% 140ms

几点观察:

  1. 分块是收益最大的单步,Recall@5涨了8.5个点,且几乎没有延迟代价,性价比最高。很多人一上来就换模型,其实数据层的收益被低估了。
  2. embedding切换的收益主要在Top-1(+12.5个点),说明bge在中文细粒度语义上确实更强,且本地部署后延迟反而降了(省掉API往返)。
  3. rerank对Recall@5提升明显(+10个点),但对端到端准确率只贡献了8.5个点——因为有些问题即使召回了正确片段,LLM也会被干扰片段带偏。这也说明rerank之后prompt里的片段顺序和数量值得再调。
  4. 端到端准确率78.5%距离理想还有距离,剩余错误里约六成是"多片段聚合"类问题,下一步计划上query分解和多跳检索。

七、总结

这次优化的核心经验就三条:

第一,先修数据再修模型。分块策略是RAG的地基,地基歪了,后面换再强的embedding和reranker都是在补窟窿。建议任何RAG优化都从切分开始做A/B。

第二,每步只改一个变量并固定评测集。我们全程用同一份200条标注集,每步单独评测再叠加,才能清楚知道每个改动的边际收益。否则你永远不知道rerank那8个点里有多少其实是分块带来的。

第三,延迟要和效果一起看。rerank效果好但贵,最终用Top-15 + batch推理把延迟压在140ms,是在效果和体验之间找到的平衡点。没有银弹,只有权衡。

代码和评测脚本都跑在内部仓库里,上面贴的两段是可以直接复用的核心逻辑。如果你的RAG系统也卡在60%左右的召回上,建议先照着第一节的评测方法把基线跑出来,再按"分块→embedding→rerank"的顺序逐层优化,大概率能复现类似的提升曲线。