1. 问题背景:答案“看起来对”,但总差一口气
上个月我们内部知识库问答机器人上线,基于LangChain + OpenAI接口搭的RAG流程。业务方反馈“能用,但经常答非所问”,尤其在查技术手册和故障处理文档时,经常只召回无关段落。
我拉了一周日志,抽样200条query人工评估,发现两个致命问题:
1. 召回缺失:答案引用的原文片段,有40%以上不是用户问题的真正答案所在段落。
2. 语义漂移:检索返回的chunk文本与用户问题在字面上高度重合,但语义上完全偏了。
当时的管线是:文档切分(固定512字符) → text2vec-large-chinese embedding → FAISS向量检索(返回top5) → LLM生成。没有任何重排,也没有对chunk做结构化处理。
2. 环境与版本:锁定基线
所有实验均在以下环境进行,避免版本干扰:
| 组件 | 版本/参数 |
|---|---|
| Python | 3.10.12 |
| langchain | 0.1.16 |
| faiss-cpu | 1.8.0 |
| sentence-transformers | 2.6.1 |
| text2vec-large-chinese | 1024维, 来自HuggingFace |
| bge-m3 | 1024维, 来自BAAI |
| bge-reranker-v2-m3 | 交叉编码器 |
| LLM | gpt-4o-mini (仅用于生成,不参与检索) |
| 测试集 | 2000条QA对,来自内部运维手册 |
评估指标用Hit Rate@5(正确答案片段是否在top5内)和MRR(倒数排名均值)。
3. 方案设计:三步走,逐步替换
我一开始就没指望一步到位,分三个独立实验,每步跑完整评估:
- Chunk策略重构:从固定512字符 → 基于Markdown标题的动态切片(保留标题层级作为上下文前缀)。
- Embedding模型升级:从text2vec-large(中文特化但语义粒度粗) → bge-m3(多语言、支持长文本、带dense+sparse混合)。
- 引入Rerank精排:在FAISS粗排top20基础上,用交叉编码器bge-reranker-v2-m3重排取top5。
每个阶段跑同一份2000条测试集,保证可对比。
4. 核心实现:关键代码与配置
4.1 Chunk重构:按标题切片,保留上下文
我写了一个MarkdownHeaderSplitter,先按#、##、###层级拆分,再把标题路径拼回chunk内容前面。这样每个chunk自带“章节上下文”,检索时即使命中子段落,LLM也能看懂所属主题。
from langchain.text_splitter import MarkdownHeaderTextSplitter
def create_structured_chunks(md_doc: str, max_chunk_size: int = 800):
headers_to_split_on = [
("#", "H1"),
("##", "H2"),
("###", "H3"),
]
splitter = MarkdownHeaderTextSplitter(
headers_to_split_on=headers_to_split_on,
strip_headers=False,
)
chunks_with_metadata = splitter.split_text(md_doc)
final_chunks = []
for chunk in chunks_with_metadata:
# 将标题路径拼入content,增强语义
header_path = " > ".join(
[f"{k}:{v}" for k, v in chunk.metadata.items()]
)
content = f"[{header_path}]\n{chunk.page_content}"
if len(content) > max_chunk_size:
# 超长段落再按句号切
content = content[:max_chunk_size]
final_chunks.append(content)
return final_chunks
踩坑:一开始我保留strip_headers=True,结果chunk内容丢失了标题,检索时完全不知道这段属于“故障排查”还是“安装指南”。改回False并手动拼前缀,Hit Rate立刻涨了5个点。
4.2 Embedding切换:bge-m3的dense+sparse混合检索
bge-m3支持同时输出dense向量和稀疏权重。FAISS只能存dense,我额外用BM25存sparse部分,两者分数做加权融合。这是bge-m3对比text2vec最大的优势——能捕捉精确词匹配。
from FlagEmbedding import BGEM3FlagModel
# 加载模型(首次会自动下载)
model = BGEM3FlagModel("BAAI/bge-m3", use_fp16=True)
def encode_texts(texts: list[str]):
# 输出dense向量 + lexical稀疏权重
output = model.encode(
texts,
return_dense=True,
return_sparse=True,
max_length=2048
)
return output["dense_vecs"], output["lexical_weights"]
# 使用时:dense部分存FAISS,sparse部分存BM25索引
# 检索时:dense_score * 0.7 + sparse_score * 0.3 加权取top20
关键参数:max_length=2048,因为我们的chunk平均长度在600-900字符,原默认512会截断导致语义丢失。同时,use_fp16在A100上能提速40%。
4.3 Rerank引入:交叉编码器精排
粗排取top20后,用bge-reranker-v2-m3对query与20个chunk计算相关性分数。交叉编码器比bi-encoder(embedding)精度高,但速度慢,所以只对top20精排。
from FlagEmbedding import FlagReranker
reranker = FlagReranker("BAAI/bge-reranker-v2-m3", use_fp16=True)
def rerank_top_k(query: str, candidate_chunks: list[str], top_k: int = 5):
# 构造 (query, chunk) 对
pairs = [(query, chunk) for chunk in candidate_chunks]
scores = reranker.compute_score(pairs, normalize=True)
# 按分数降序取top_k
sorted_idx = sorted(
range(len(scores)),
key=lambda i: scores[i],
reverse=True
)[:top_k]
return [candidate_chunks[i] for i in sorted_idx]
注意:normalize=True会把分数映射到0-1,方便设置阈值(我定为0.35,低于此值直接丢弃,避免LLM基于噪声生成)。
5. 踩坑与优化:那些文档没告诉你的
5.1 text2vec的“伪相关”陷阱
text2vec在中文短文本上表现不错,但对内部技术文档(大量中英混排、专有名词如“K8s”“etcd”)效果极差。它会把“Pod重启策略”与“容器启动命令”判为高相似,因为两段都含“容器”和“启动”。bge-m3的sparse检索能捕捉“Pod”和“重启策略”的精确共现,大大缓解了这个问题。
5.2 混合权重不是玄学,要调
我最初dense:sparse权重设为0.5:0.5,Hit Rate反而比纯dense低2%。后来用验证集网格搜索,发现0.7:0.3最优。原因是内部文档中精确术语更重要,但纯sparse会漏掉同义改写(如“重启” vs “restart”)。
5.3 rerank的batch size陷阱
compute_score默认单条处理,2000条测试集要跑很久。必须手动batch:
scores = reranker.compute_score(pairs, batch_size=64, normalize=True)
耗时从12分钟降到3分钟。另外,reranker对超长输入(>512 tokens)会截断,需要先对chunk做截断,我保留前450个字符。
5.4 关于chunk重叠
我试过overlap=100字符,发现对Hit Rate提升微乎其微(+0.8%),但增加了索引体积20%。最终放弃overlap,因为标题切片本身已经保证了语义连贯性。
6. 效果数据:每个阶段的变化一目了然
| 实验阶段 | Hit Rate@5 | MRR | 平均检索延迟 |
|---|---|---|---|
| 基线(固定chunk + text2vec + 无rerank) | 61.3% | 0.42 | 85ms |
| + 标题结构化chunk | 68.9% | 0.51 | 88ms |
| + 切换bge-m3(dense+sparse混合) | 79.6% | 0.63 | 132ms |
| + 引入rerank(top20→top5) | 89.7% | 0.71 | 310ms |
解读:
- Chunk重构贡献了7.6个点,说明数据形态比模型更重要。
- Embedding切换贡献了10.7个点,bge-m3的稀疏检索对专业术语命中极其有效。
- Rerank贡献了10.1个点,是性价比最高的步骤——只增加约180ms延迟,却换来了近10个点的提升。
业务方反馈“回答准确率明显提高”,尤其故障排查类问题,引用文档的精确度从“能看懂”变为“直接命中”。
7. 总结:RAG优化的优先级排序
如果重来一遍,我会按这个顺序做:
- 先修数据形态(chunk结构化) —— 免费且收益大,别用固定字符切。
- 再换embedding模型 —— 优先支持混合检索的(bge-m3、e5-mistral)。
- 最后加rerank —— 延迟换精度,值得,但别对全量库rerank,只精排top20。
另外,评估集一定要贴近真实query。我一开始用公开数据集测试,感觉提升不大,换成内部2000条QA对后,差距立刻显现。
最后提一句:RAG没有银弹,但chunk、embedding、rerank这三板斧砍下去,效果肉眼可见。如果你的系统还在用固定长度切分和单一向量检索,强烈建议试试这套组合拳。
版本备注:所有实验代码已放在内部GitLab,使用FlagEmbedding库版本为1.2.8,LangChain版本为0.1.16。如果有同学遇到类似问题,欢迎评论交流,我会补充更多细节。