一、问题背景:一个"看起来能用"的RAG系统

去年底我们把一个内部技术知识库接上了RAG问答,语料是约12万篇技术文档(Markdown为主,含少量PDF转出来的文本),平均每篇1800字。第一版做得非常"标准":LangChain 0.1.x + text-embedding-ada-002 + Chroma,chunk_size=512、chunk_overlap=50,top_k=5 直接塞进 prompt。

上线后很快被用户教做人。运营侧收集了两周的badcase,问题集中在三类:

  1. 答案碎片化:很多文档的关键结论在一个小节末尾,512定长切分正好把它切断,召回的chunk只有半句话。
  2. 语义漂移:用户问"XX服务超时怎么排查",召回的是"XX服务部署配置",字面相似但意图不同。ada-002 对中文技术术语的区分度一般。
  3. Top5里有效chunk排在第4、第5位:模型注意力被前几条无关内容稀释,回答开始"编"。

我们定了个朴素的评估口径:人工标注了500条query的ground truth chunk,算 Recall@5 和人工抽检的答案准确率。基线数据:

指标 基线值
Recall@5 0.71
答案准确率(人工抽检200条) 68%
答非所问占比 23%
端到端 P95 延迟 2.1s

目标很明确:Recall@5 拉到 0.85+,准确率 80%+,延迟别涨太多。

二、环境与版本

先把环境钉死,避免"优化优化着环境变了":

  • Python 3.10.13
  • langchain 0.1.20 / langchain-community 0.0.38
  • 向量库:Milvus 2.4.4(从Chroma迁过来,主要是要HNSW参数可控 + 支持稀疏向量)
  • Embedding:text-embedding-ada-002(基线)→ BAAI/bge-m3(优化后)
  • Reranker:BAAI/bge-reranker-v2-m3
  • 推理:torch 2.2.2 + CUDA 12.1,单卡 A10 24G
  • 评估脚本自研,ground truth 500条,指标用 Recall@k + MRR

三、方案设计:三步走,每步单独评估

我们的原则是一次只动一个变量,每步都跑一遍500条评估集,避免"三个一起改,好了不知道谁的功劳,坏了不知道谁背锅"。

  • 阶段一:chunk策略。固定切分 → 结构化切分(按Markdown标题层级)+ 父子块(child 256 token用于检索,parent 1024 token用于喂LLM)。
  • 阶段二:embedding切换。ada-002 → bge-m3,顺带把Milvus索引从 IVF_FLAT 换成 HNSW(M=16, efConstruction=200)。
  • 阶段三:引入rerank。召回top 30 → bge-reranker-v2-m3 重排 → 取top 5。

四、核心实现

4.1 阶段一:结构化 + 父子块切分

核心思路:检索用小块(精度高),生成用大块(上下文完整)。用 MarkdownHeaderTextSplitter 先按标题切,再对超长段落做二次切分,并保留 parent_id 映射。

from langchain.text_splitter import MarkdownHeaderTextSplitter, RecursiveCharacterTextSplitter
from langchain_core.documents import Document
import hashlib

def build_parent_child_chunks(md_text: str, doc_id: str,
                              child_size: int = 256,
                              child_overlap: int = 32,
                              parent_size: int = 1024):
    """返回 (child_chunks, parent_map),child 带 parent_id 元数据"""
    headers = [("#", "h1"), ("##", "h2"), ("###", "h3")]
    md_splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers)
    sections = md_splitter.split_text(md_text)

    rec_splitter = RecursiveCharacterTextSplitter(
        chunk_size=child_size, chunk_overlap=child_overlap,
        separators=["\n\n", "\n", "。", ";", " ", ""]
    )

    children, parent_map = [], {}
    for sec in sections:
        # parent:整节内容(超长再按 parent_size 切)
        parent_text = sec.page_content
        pid = hashlib.md5(f"{doc_id}:{parent_text[:64]}".encode()).hexdigest()[:16]
        parent_map[pid] = parent_text

        for sub in rec_splitter.split_text(parent_text):
            children.append(Document(
                page_content=sub,
                metadata={**sec.metadata, "doc_id": doc_id, "parent_id": pid}
            ))
    return children, parent_map

这里有两个细节值得说:

  1. child_overlap 从50降到32,因为结构化切分后语义边界本来就清楚,overlap太大反而让相邻child高度重复,检索时互相挤占top位置。
  2. parent 用整节而不是固定窗口,是因为技术文档的小节通常自包含,跨节拼接反而引入噪音。实测 parent 平均 900 token 左右,正好。

入库时 child 进向量库,parent 存 Redis(key=parent_id)。检索命中 child 后,按 parent_id 去重取 parent 喂给LLM。

4.2 阶段二:embedding 切换 + HNSW

bge-m3 输出1024维,比ada-002的1536维小,存储和检索都更省。用 FlagEmbedding 批量编码,注意 normalize_embeddings=True,因为后面用内积(IP)度量。

from FlagEmbedding import BGEM3FlagModel
from pymilvus import connections, Collection, FieldSchema, CollectionSchema, DataType, utility

model = BGEM3FlagModel("BAAI/bge-m3", use_fp16=True, device="cuda:0")

def encode(texts, batch_size=64):
    out = model.encode(texts, batch_size=batch_size, max_length=512,
                       return_dense=True, return_sparse=False)
    return out["dense_vecs"]  # 已归一化

# Milvus HNSW 索引
connections.connect(uri="http://milvus:19530")
fields = [
    FieldSchema("id", DataType.INT64, is_primary=True, auto_id=True),
    FieldSchema("parent_id", DataType.VARCHAR, max_length=32),
    FieldSchema("text", DataType.VARCHAR, max_length=2048),
    FieldSchema("vector", DataType.FLOAT_VECTOR, dim=1024),
]
coll = Collection("tech_docs_v2", CollectionSchema(fields))
coll.create_index("vector", {
    "index_type": "HNSW", "metric_type": "IP",
    "params": {"M": 16, "efConstruction": 200}
})
coll.load()

查询侧小坑:bge-m3 官方建议 query 加指令前缀,但只对短query有效。我们的query平均12个字,加了 "为这个句子生成表示以用于检索相关文章:" 前缀后 Recall@5 涨了约1.5个点,成本为零,加了。

4.3 阶段三:rerank

召回top 30,用 bge-reranker-v2-m3 做 cross-encoder 重排。30这个数是压测出来的:10条时Recall提升不明显,50条时P95延迟多400ms,30是拐点。

from FlagEmbedding import FlagReranker

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

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

五、踩坑与优化

坑1:父子块去重顺序错了。 一开始先取top5 child再映射parent,结果5个child经常来自同一个parent,最后只剩1-2个不同parent,上下文反而变少。改成先取top30 child → 按parent_id去重 → 取top5 parent,Recall@5直接多了3个点。

坑2:HNSW 的 ef 没调。 Milvus 默认 ef=64,top_k=30时召回质量打折。建collection时把 search_params={"ef": 128} 显式传进去,召回率涨了约2个点,延迟只多十几毫秒。

坑3:rerank 的 batch 和 max_length。 一次把30个pair塞进去,A10上显存直接飙到21G。改成 batch_size=8、max_length=512 后稳定在9G,P95重排耗时约280ms。另外 normalize=True 一定要开,不然分数尺度不一致,阈值没法卡。

坑4:评估集泄露。 早期调参时反复在500条评估集上看指标,看到后面已经开始"过拟合评估集"了。后来切了200条做dev、300条做test,调参只看dev。

六、效果数据

同一套500条评估集(300 test),逐步累加:

阶段 Recall@5 MRR 答案准确率 P95延迟
基线(512定长 + ada-002) 0.71 0.58 68% 2.1s
+ 结构化父子块 0.78 0.65 74% 2.2s
+ bge-m3 + HNSW 0.84 0.72 80% 2.3s
+ bge-reranker-v2-m3 0.89 0.79 86% 2.6s

几个观察:

  • chunk策略是性价比最高的一步,零推理成本,纯工程改动,涨了7个点。
  • embedding切换涨了6个点,但要重建全量索引(12万篇,A10上编码约40分钟),得安排好窗口。
  • rerank涨了5个点,代价是P95多了300ms。对问答场景完全值得,对搜索联想场景就不合适了。
  • 答非所问占比从23%降到9%,这是用户感知最明显的。

七、总结

回头看,这次优化没有用什么新潮的东西,就是把三个环节各自做扎实:切分尊重文档结构、embedding选对中文场景的模型、召回后加一层cross-encoder重排。三个动作叠加,Recall@5从0.71到0.89。

如果重来一次,我会先做chunk再做embedding——因为chunk策略决定了检索的上限,embedding和rerank都是在更好的候选集上做文章。另外,评估集一定要提前切分dev/test,不然调参调到后面全是自欺欺人。

后续打算试两个方向:一是bge-m3的稀疏向量做混合检索(dense+sparse),二是把rerank的top_k做成按query类型动态调整。有进展再写一篇。