一、问题背景:为什么61%的命中率不可接受

我们团队负责内部工单知识库的问答机器人。最初的架构很简单:LangChain + ChromaDB + bge-small-zh,chunk大小固定为256字符,无重叠。上线后,用户反馈频繁出现“答非所问”或者“一本正经地胡说八道”。

我拉取了最近一周的日志,发现一个典型case:用户问“如何重置VPN令牌”,知识库中明明有答案,但回答结果是“请联系管理员”。检索命中的chunk是“VPN令牌重置流程请参考《安全手册》第三章,其中涉及管理员审批”,后面的关键步骤被截断了。

经过统计,在人工标注的2000条QA对中,答案在top-5检索结果中的命中率(Hit@5)只有61.3%。这意味着近四成的用户问题,系统压根没找到正确的上下文。

二、环境与版本清单

本次优化的实验环境相对固定,便于对照:

  • Python 3.10.12
  • langchain==0.1.16
  • langchain-community==0.0.30
  • chromadb==0.4.24
  • sentence-transformers==2.5.1
  • FlagEmbedding==1.2.8
  • 测试集:2000条工单QA(内部脱敏),每条包含问题、标准答案、相关文档ID

Embedding模型对比:
- 旧:BAAI/bge-small-zh-v1.5(维度512)
- 新:BAAI/bge-m3(维度1024)

重排模型:BAAI/bge-reranker-base(单条文档重排延迟约20ms)

三、方案设计:三步走策略

我并没有直接一股脑换大模型,而是分三步走,每一步都单独在测试集上评估,避免参数耦合导致无法归因。

  1. Chunk策略重构:放弃固定长度切块,改为“标题+段落感知”的递归切分。
  2. Embedding模型升级:从bge-small换到bge-m3,同时调整检索的top_k策略。
  3. 引入Rerank层:在向量检索返回top-50后,用cross-encoder做精排,取top-5。

四、核心实现:Chunk重构与Embedding替换

第一步:Chunk重构

原来的代码是RecursiveCharacterTextSplitter(chunk_size=256, chunk_overlap=0)。问题在于工单文档有明确的章节结构,且关键操作步骤往往在300-400字之间。

我改成了基于文档结构的递归切分器,优先按标题切分,再对过长段落做语义补充:

from langchain.text_splitter import RecursiveCharacterTextSplitter

# 自定义分隔符:优先保留标题层级,再按句号/换行切分
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=400,           # 增大至400,容纳完整操作步骤
    chunk_overlap=80,         # 增加重叠,避免关键句被割裂
    separators=["\n## ", "\n### ", "\n#### ", "\n", "。", "!", "?", ". ", "! ", "? "],
    keep_separator=True,      # 保留标题,使chunk自带上下文
)

# 对每篇文档先按标题拆,再对子段落切分
def split_document(doc_text: str):
    docs = text_splitter.split_text(doc_text)
    # 额外处理:若chunk仍超过500字且无标题,则强制按句切
    final_chunks = []
    for chunk in docs:
        if len(chunk) > 500 and "\n" not in chunk:
            sub_splitter = RecursiveCharacterTextSplitter(chunk_size=250, chunk_overlap=30)
            final_chunks.extend(sub_splitter.split_text(chunk))
        else:
            final_chunks.append(chunk)
    return final_chunks

这里有个关键点:keep_separator=True 能让每个chunk自带小标题,比如“## 重置VPN令牌”,这样embedding时能更好地对齐语义空间。

第二步:Embedding模型切换

bge-m3支持8192长度的输入,且对中文长句的语义理解远好于small版本。切换时注意维度变化:

from sentence_transformers import SentenceTransformer

# 旧模型(注释掉)
# encoder = SentenceTransformer("BAAI/bge-small-zh-v1.5")

# 新模型
encoder = SentenceTransformer("BAAI/bge-m3", device="cuda:0")

# 注意:bge系列需要加query指令(仅检索时)
def embed_query(text: str):
    return encoder.encode(f"为这个句子生成表示以用于检索相关文章:{text}", normalize_embeddings=True)

def embed_doc(text: str):
    return encoder.encode(text, normalize_embeddings=True)

这里有个坑:如果不加“为这个句子生成表示”前缀,bge-m3的检索效果会下降约5%。我们花了半天才定位到这个问题,因为直接用SentenceTransformer.encode不会报错,但语义表示会偏向普通句向量而非检索向量。

五、踩坑与优化:Rerank的引入与延迟控制

踩坑一:Top-K设置不合理

换模型后,我直接沿用旧的top_k=5。结果Hit@5只提升到74.2%,增幅不大。分析发现,bge-m3对语义相近但非直接答案的段落打分偏高,正确段落往往排在10-20位。于是调整为先召回50条,再做重排。

踩坑二:Reranker延迟

直接对50条文档跑bge-reranker-base,单查询延迟从80ms飙到600ms。虽然准确率到了89.7%,但用户不可接受。

优化手段:
- 对50条候选先按向量分数截断,只取前30条进rerank。
- 使用FlagEmbeddingFlagReranker,设置batch_size=16,在GPU上并行处理。

from FlagEmbedding import FlagReranker

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

def rerank_top(query: str, docs: list[str], top_k: int = 5) -> list[int]:
    # docs是[(doc_id, text), ...]格式
    pairs = [[query, doc[1]] for doc in docs]
    scores = reranker.compute_score(pairs, normalize=True, batch_size=16)

    # 按分数降序排序,返回原始索引
    sorted_idx = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)
    return sorted_idx[:top_k]

优化后,单查询延迟稳定在180ms左右(其中rerank占150ms),命中率保持不变。

六、效果数据对比:每一步的独立贡献

为了确保每个改动都是有效的,我在相同2000条QA集上做了A/B测试。评估指标:Hit@5(标准答案对应文档是否在top-5)与答案BLEU(生成答案与标准答案的n-gram重叠)。

方案 Hit@5 平均BLEU 单查询延迟
基线(256chunk + bge-small + top5) 61.3% 0.41 75ms
+ Chunk重构(400chunk+标题感知) 68.7% 0.47 78ms
+ 换bge-m3(top_k=50) 74.2% 0.52 85ms
+ Rerank(top-30重排取5) 89.7% 0.63 180ms

关键观察
1. Chunk重构的收益主要来自“减少截断”——错误case中“关键步骤缺失”的比例从32%降到14%。
2. bge-m3单独提升有限,但为rerank提供了更可靠的候选池。它把正确的文档召回到前50名的概率从81%提升到了96%,这是rerank能发挥效用的基础。
3. Rerank是最大的增幅来源,但前提是前两步做对。如果直接在旧chunk+旧embedding后加rerank,Hit@5只能到71%左右。

七、总结:RAG调优的优先级排序

这次优化让我确信,RAG系统的瓶颈往往不在大模型,而在检索链路。我的经验排序是:

  1. 先看召回:如果200条候选里没有正确答案,换再强的rerank也没用。
  2. 再调chunk:按文档结构切分,比盲目调chunk_size更有效。一个常识是:一个chunk应该能独立回答一个问题,而不是恰好256个字。
  3. 最后上重排:cross-encoder是精度利器,但延迟成本高,适合对准确率要求高的场景。

当前系统已上线两个月,用户满意度从66%上升到87%。后续计划尝试bge-m3的稀疏检索权重与稠密向量混合召回,进一步压榨长尾查询的召回率。最后提醒一句:版本锁定很重要——我们中途升级过一次chromadb,导致旧集合的向量维度不兼容,差点回滚。