一、问题背景
三个月前接手公司内部知识库问答系统。系统底层是一个标准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 |
几点观察:
- 分块是收益最大的单步,Recall@5涨了8.5个点,且几乎没有延迟代价,性价比最高。很多人一上来就换模型,其实数据层的收益被低估了。
- embedding切换的收益主要在Top-1(+12.5个点),说明bge在中文细粒度语义上确实更强,且本地部署后延迟反而降了(省掉API往返)。
- rerank对Recall@5提升明显(+10个点),但对端到端准确率只贡献了8.5个点——因为有些问题即使召回了正确片段,LLM也会被干扰片段带偏。这也说明rerank之后prompt里的片段顺序和数量值得再调。
- 端到端准确率78.5%距离理想还有距离,剩余错误里约六成是"多片段聚合"类问题,下一步计划上query分解和多跳检索。
七、总结
这次优化的核心经验就三条:
第一,先修数据再修模型。分块策略是RAG的地基,地基歪了,后面换再强的embedding和reranker都是在补窟窿。建议任何RAG优化都从切分开始做A/B。
第二,每步只改一个变量并固定评测集。我们全程用同一份200条标注集,每步单独评测再叠加,才能清楚知道每个改动的边际收益。否则你永远不知道rerank那8个点里有多少其实是分块带来的。
第三,延迟要和效果一起看。rerank效果好但贵,最终用Top-15 + batch推理把延迟压在140ms,是在效果和体验之间找到的平衡点。没有银弹,只有权衡。
代码和评测脚本都跑在内部仓库里,上面贴的两段是可以直接复用的核心逻辑。如果你的RAG系统也卡在60%左右的召回上,建议先照着第一节的评测方法把基线跑出来,再按"分块→embedding→rerank"的顺序逐层优化,大概率能复现类似的提升曲线。