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

去年底我接手了公司内部知识库问答系统的优化工作。系统基于RAG(检索增强生成)架构,服务约2000篇技术文档和产品手册,日均查询量3000+。

上线三个月后,用户反馈集中在两类问题:

  1. 答非所问:问"如何配置OAuth2.0的token过期时间",检索回来的却是"OAuth2.0简介"和"用户登录流程"这类泛泛的文档。
  2. 答案不完整:明明文档里有完整步骤,模型只答出一半,或者把两个版本的配置混在一起。

我拉了一周的badcase,做了一次离线评估,结果不太好看:

指标 数值
Top-5 召回率 0.71
Top-1 命中率 0.48
答案准确率(人工评估200条) 62%
P99 延迟 1.8s

这个水平属于"demo能跑,生产挨骂"。于是开始了为期三周的优化。

二、环境与版本

先交代一下技术栈,方便对照:

  • Python 3.10.13
  • LangChain 0.1.16
  • 向量库:Milvus 2.3.4(standalone)
  • LLM:GPT-4o-mini(当时为了成本)
  • 初始Embedding:OpenAI text-embedding-ada-002(1536维)
  • 初始chunk:RecursiveCharacterTextSplitter,chunk_size=512,overlap=50
  • 检索:纯向量检索,Top-5

评估集是我自己标注的200条query-doc对,覆盖配置、故障排查、API使用三类场景。每次改动都跑同一套评估,保证可比性。

三、方案设计:分三步走

我没有一次性全改,而是拆成三个阶段,每阶段只动一个变量,方便定位收益来源:

阶段一:Chunk策略调整
- 从固定512改为按文档结构(Markdown标题)+ 语义分块
- chunk_size 调整为 800,overlap 提升到 150
- 对代码块、表格做特殊处理,不切断

阶段二:Embedding模型切换
- 从 text-embedding-ada-002 切到 BAAI/bge-m3
- 维度从1536变为1024
- 顺便把Milvus的索引从IVF_FLAT换成HNSW

阶段三:引入Rerank
- 检索阶段先召回Top-20
- 用 bge-reranker-v2-m3 重排,取Top-5送入LLM

四、核心实现

4.1 语义分块

固定长度分块最大的问题是会把一个完整的配置说明从中间切断。我改成了两级分块:先按Markdown标题切,再按语义切。

from langchain.text_splitter import MarkdownHeaderTextSplitter, RecursiveCharacterTextSplitter
from langchain_experimental.text_splitter import SemanticChunker
from langchain_community.embeddings import HuggingFaceBgeEmbeddings

# 第一级:按标题切
headers_to_split_on = [
    ("#", "h1"),
    ("##", "h2"),
    ("###", "h3"),
]
md_splitter = MarkdownHeaderTextSplitter(
    headers_to_split_on=headers_to_split_on,
    strip_headers=False,
)

# 第二级:语义切分,用bge-m3做embedding
embed_model = HuggingFaceBgeEmbeddings(
    model_name="BAAI/bge-m3",
    model_kwargs={"device": "cuda"},
    encode_kwargs={"normalize_embeddings": True},
)

semantic_splitter = SemanticChunker(
    embeddings=embed_model,
    breakpoint_threshold_type="percentile",
    breakpoint_threshold_amount=92,   # 阈值调到92,避免切太碎
    buffer_size=1,
)

def chunk_document(raw_text: str):
    md_chunks = md_splitter.split_text(raw_text)
    final_chunks = []
    for c in md_chunks:
        # 短块直接保留
        if len(c.page_content)  ".join([v for k, v in c.metadata.items() if v])
            final_chunks.append({
                "content": f"[{header_path}]\n{sc}",
                "metadata": c.metadata,
            })
    return final_chunks

这里有个细节:breakpoint_threshold_amount 一开始我设的默认值95,切出来的chunk太碎,很多只有一两句话。调到92之后平均chunk长度从原来的420字符涨到约780字符,更符合"一个知识点一块"的直觉。

另外我把标题路径拼进了chunk内容里,这样embedding能感知到层级信息,对"XX模块下的YY配置"这类query效果明显。

4.2 切换Embedding与HNSW索引

bge-m3 是去年比较火的多语言embedding,1024维,支持长文本(8192 token),而且对中文友好。切换后重建了Milvus collection:

from pymilvus import CollectionSchema, FieldSchema, DataType, Collection, connections

connections.connect(host="localhost", port="19530")

fields = [
    FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=True),
    FieldSchema(name="content", dtype=DataType.VARCHAR, max_length=8192),
    FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=1024),
    FieldSchema(name="doc_id", dtype=DataType.VARCHAR, max_length=128),
]
schema = CollectionSchema(fields, description="kb_v2")
collection = Collection(name="kb_v2", schema=schema)

# HNSW索引,M=16, efConstruction=200
index_params = {
    "metric_type": "COSINE",
    "index_type": "HNSW",
    "params": {"M": 16, "efConstruction": 200},
}
collection.create_index(field_name="embedding", index_params=index_params)
collection.load()

检索时 ef 设成64(搜索时参数),在2000条数据上基本感觉不到索引带来的延迟差异,但召回稳定性比IVF_FLAT好。

4.3 引入Rerank

这是收益最大的一步。向量检索本质是"语义相似",但query和doc的相关性判断是另一回事。Rerank用cross-encoder直接对(query, doc)对打分,精度高但慢,所以只对Top-20做重排。

from FlagEmbedding import FlagReranker

reranker = FlagReranker(
    "BAAI/bge-reranker-v2-m3",
    use_fp16=True,   # 半精度,速度提升约40%
)

def retrieve_and_rerank(query: str, top_k: int = 5, recall_k: int = 20):
    # 1. 向量召回
    q_vec = embed_model.embed_query(query)
    search_params = {"metric_type": "COSINE", "params": {"ef": 64}}
    results = collection.search(
        data=[q_vec],
        anns_field="embedding",
        param=search_params,
        limit=recall_k,
        output_fields=["content", "doc_id"],
    )

    candidates = [
        {"content": hit.entity.get("content"), "score": hit.distance}
        for hit in results[0]
    ]

    # 2. Rerank
    pairs = [[query, c["content"]] for c in candidates]
    rerank_scores = reranker.compute_score(pairs, normalize=True)

    for c, s in zip(candidates, rerank_scores):
        c["rerank_score"] = s

    candidates.sort(key=lambda x: x["rerank_score"], reverse=True)
    return candidates[:top_k]

use_fp16=True 这个很关键。我一开始没开,20条重排要380ms;开了之后降到约220ms,精度几乎无损。

五、踩坑与优化

坑1:chunk overlap设太大导致重复召回

阶段一我一度把overlap设到200,结果Top-5里经常出现同一段内容的两个切片,浪费了名额。后来把overlap降到150,并且在入库时做了内容去重(用SimHash,汉明距离<3视为重复)。

坑2:bge-m3的query指令

bge系列模型建议query前加指令前缀。bge-m3虽然官方说可以不加以支持多语言,但我实测在中文query上,不加前缀的Top-1命中率比加了低约2个点。我加的是:

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

坑3:Rerank把"关键词匹配但语义不相关"的排上来了

Rerank对字面匹配很敏感。有次query问"如何回滚版本",rerank把一篇讲"版本号命名规范"的排到了第一,因为它俩字面重合度高。解决办法是在rerank前做一次query改写,用LLM把query扩写成更完整的问句再检索。这一步我放在阶段三后期,收益约1.5个点。

坑4:延迟上升

三阶段全部上完后,P99从1.8s涨到2.4s。拆解一下:embedding从OpenAI API切到本地bge-m3,反而快了约150ms;HNSW比IVF_FLAT略慢但可忽略;rerank增加约220ms;query改写增加约300ms。总体增加600ms左右,用户端感知不明显(因为LLM生成本来就要1s+)。

六、效果数据

三阶段做完,同一套200条评估集的结果:

阶段 Top-5召回 Top-1命中 答案准确率 P99延迟
初始 0.71 0.48 62% 1.8s
+语义分块 0.78 0.55 68% 1.85s
+bge-m3 0.83 0.61 74% 1.75s
+rerank 0.89 0.72 84% 2.4s

分阶段看收益:

  • 分块策略:召回+7个点,主要来自"不再切断完整知识点",对故障排查类query提升最明显(+11个点)。
  • Embedding切换:召回+5个点,中文语义匹配明显更好,尤其是同义词和专业术语。
  • Rerank:召回+6个点,Top-1命中率提升最大(+11个点),因为重排主要解决"排序"问题而非"召回"问题。

线上灰度两周后,用户负反馈率从8.3%降到2.1%,"答非所问"类反馈下降最明显。

七、总结

这次优化下来,我的几点体会:

  1. Chunk策略是被低估的环节。很多人一上来就换模型,但其实分块没做好,再好的embedding也白搭。语义分块+标题路径注入,是性价比最高的一步。
  2. Embedding切换要看场景。ada-002在英文上不差,但中文技术文档场景下bge-m3确实更合适,而且本地部署省了API成本和网络延迟。
  3. Rerank是召回质量的"最后一道闸"。它不解决"召回不到"的问题,但能把"召回了但排后面"的救回来。前提是recall_k要够大,我设的20,实测再大收益递减,延迟线性增长。
  4. 每一步都要有评估集。没有评估集的优化就是玄学。我那200条评估集虽然不多,但足够定位问题、验证收益。

后续还打算试的方向:query改写用更小的模型(现在用GPT-4o-mini有点浪费)、混合检索(BM25+向量)、以及chunk级别的上下文压缩。等有结果再写一篇。

代码和评估脚本我整理在个人仓库里了,有需要的可以留言。