一、问题背景

去年底我们给一个内部知识库做了个RAG问答系统,接的是公司文档中心,大概1.2万篇技术文档、产品手册和FAQ,总量约400MB纯文本。上线第一周,用户反馈就很直接:"答非所问"、"检索出来的内容跟问题没关系"、"明明文档里有,就是答不出来"。

我拉了200条真实用户query做了人工标注评测,初始版本的数据很难看:

  • Top-3检索命中率:61%
  • 回答准确率(人工判定):58%
  • 平均首字延迟:1.8s,P95:2.3s

这个水平基本没法用。于是开始了一轮系统性优化,分三步走:chunk策略、embedding模型、rerank。下面按顺序记录整个过程,包括踩的坑。

二、环境与版本

先交代下技术栈,避免版本对不上:

  • Python 3.10.12
  • LangChain 0.1.16(只用了TextSplitter和部分工具,检索链路自己写的)
  • 向量库:Milvus 2.3.4(HNSW索引,M=16,efConstruction=200)
  • 初始embedding:OpenAI text-embedding-ada-002(1536维)
  • 后续embedding:BAAI/bge-large-zh-v1.5(1024维),通过FlagEmbedding 1.2.10本地部署
  • Rerank:BAAI/bge-reranker-base,同样本地部署
  • LLM:GPT-3.5-turbo(后续部分场景切到Qwen-72B,不影响检索链路)
  • 硬件:单卡A10 24G,embedding和rerank都跑在这张卡上

三、方案设计与优化过程

3.1 第一轮:chunk策略调整

初始chunk策略是拍脑袋定的:RecursiveCharacterTextSplitter(chunk_size=1024, chunk_overlap=0)。问题很明显——1024个字符对中文来说太长了,一段chunk里经常混着两三个主题,向量表示被"平均"掉了,检索时哪个主题都不像。而且overlap=0,跨段落的上下文直接断裂。

改成了 chunk_size=256, chunk_overlap=40(约15%重叠),中文按字符切,分隔符用 ["\n\n", "\n", "。", "!", "?", ";", ","]。同时给每个chunk在头部拼上文档标题和一级标题路径,比如 【产品手册/权限管理】...正文...,让chunk自带上下文。

from langchain.text_splitter import RecursiveCharacterTextSplitter

def build_chunks(doc_title: str, heading_path: str, text: str):
    splitter = RecursiveCharacterTextSplitter(
        chunk_size=256,
        chunk_overlap=40,
        separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""],
        length_function=len,
    )
    raw_chunks = splitter.split_text(text)
    # 头部注入上下文,提升chunk的语义自包含性
    prefix = f"【{doc_title}/{heading_path}】"
    return [prefix + c for c in raw_chunks if len(c.strip()) > 20]

这一轮改完,Top-3命中率从61%到72%,提升明显但还不够。副作用是chunk数量从约4.8万涨到19万,Milvus检索延迟从12ms涨到35ms左右,可接受。

3.2 第二轮:embedding模型切换

ada-002对中文的语义区分度一般,尤其在技术术语密集的场景。我们做了个对比实验:同一批200条query,用ada-002和bge-large-zh-v1.5分别建库检索。bge在中文短query上的召回明显更好,且支持指令前缀(query侧加"为这个句子生成表示以用于检索相关文章:")。

切换时要注意:建库和查询必须用同一个模型、同一套归一化逻辑。bge输出需要L2归一化后用内积(IP)检索,等价于余弦相似度。Milvus里索引类型要从原来的L2改成IP。

from FlagEmbedding import FlagModel
import numpy as np

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

def embed_docs(texts):
    vecs = model.encode(texts, batch_size=64, max_length=512)
    return vecs / np.linalg.norm(vecs, axis=1, keepdims=True)

def embed_query(q: str):
    vec = model.encode_queries([q])[0]
    return vec / np.linalg.norm(vec)

切换后Top-3命中率从72%到78%。注意这一步重建了整个Milvus collection,19万条chunk在A10上编码约25分钟。

3.3 第三轮:引入rerank

到78%之后进入瓶颈。分析badcase发现,向量检索的"召回"其实还行——正确答案有89%落在Top-20里,但Top-3排序不准。这就是典型的召回够、排序不够,正是rerank的用武之地。

方案:Milvus先召回Top-20,用bge-reranker-base做cross-encoder精排,取Top-3送给LLM。rerank模型输入是(query, chunk)对,输出相关性分数,比双塔的向量内积精细得多。

from FlagEmbedding import FlagReranker
import pymilvus

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

def retrieve_with_rerank(query: str, top_k=3, recall_k=20):
    q_vec = embed_query(query)
    search_res = milvus_client.search(
        collection_name="kb_chunks",
        data=[q_vec.tolist()],
        anns_field="embedding",
        param={"metric_type": "IP", "params": {"ef": 64}},
        limit=recall_k,
        output_fields=["chunk_id", "text"],
    )
    candidates = [
        {"chunk_id": h.entity.get("chunk_id"), "text": h.entity.get("text")}
        for h in search_res[0]
    ]
    pairs = [[query, c["text"]] for c in candidates]
    scores = reranker.compute_score(pairs, normalize=True)
    for c, s in zip(candidates, scores):
        c["rerank_score"] = float(s)
    candidates.sort(key=lambda x: x["rerank_score"], reverse=True)
    return candidates[:top_k]

3.4 踩坑记录

几个值得说的坑:

  1. rerank的max_length:bge-reranker-base默认512,但query+chunk拼起来容易超。超长会被截断,反而丢信息。我们把chunk控制在256 token以内后基本不触发截断,这也是第一轮chunk调小的额外收益。

  2. rerank分数阈值:一开始想用绝对阈值过滤低质量结果,发现分数分布随query类型波动很大,固定阈值不可靠。最后改成只取Top-3不过滤,把"是否该拒答"交给LLM的prompt判断。

  3. Milvus IP索引重建:切embedding时忘了改metric_type,检索结果乱序,排查了半天。建collection时metric_type和索引参数一定要跟embedding的归一化方式对齐。

  4. GPU显存:embedding和rerank同时加载在A10上,fp16下约占用14G,批量rerank时batch_size别开太大,我们设的32,再大就OOM。

四、效果数据

同一批200条标注query,三轮优化对比:

指标 初始 chunk优化 +embedding切换 +rerank
Top-3命中率 61% 72% 78% 89%
Top-20召回率 — 84% 89% 89%
回答准确率 58% 68% 74% 82%
检索P95延迟 12ms 35ms 48ms 210ms
端到端P95延迟 2.3s 2.4s 2.5s 2.8s

rerank让检索环节P95从48ms涨到210ms,但端到端只多了0.3s——因为LLM生成占了2s多的大头,rerank的绝对开销可以接受。如果对延迟极敏感,可以考虑rerank只对Top-10做,或者换更小的reranker。

五、总结

这轮优化的核心结论:

  1. chunk策略是第一优先级,比换模型便宜且收益大。中文场景256字符+15%重叠+标题注入是个不错的起点。
  2. embedding切换要看召回天花板。我们切换前先测了Top-20召回,确认向量侧还有空间才动手,避免盲目换模型。
  3. rerank解决的是排序问题,不是召回问题。引入前一定先确认"正确答案在不在召回集里",不在的话rerank救不了,得回去改召回。
  4. 每一步都要有标注评测集。200条query不多,但足以支撑判断,比拍脑袋强太多。

后续还想试的方向:query改写(多query召回融合)、按文档类型分collection检索、以及用更小的reranker(bge-reranker-v2-m3的量化版)压延迟。有进展再写一篇。