1. 问题背景:不是模型不行,是检索先崩了
两周前,运营反馈知识库问答“答非所问”的比例明显变高。我第一反应是LLM幻觉,毕竟用的是GPT-4o-mini,按理说不应该。但排查后发现,问题出在RAG链路的最前端——召回阶段。
线上日志显示,用户问“2024年Q3财报中研发费用占比”,我们的检索系统返回的chunk里全是“团队介绍”和“产品愿景”。显然,知识库更新后,文档结构从纯文本改成了带复杂嵌套的Markdown,而旧的切分逻辑还在用固定256字符硬切。结果就是语义被拦腰截断,Embedding向量全部跑偏。
2. 环境与版本:先交代工具链
- Python 3.10.12
- langchain==0.2.11(旧项目还在用,别笑)
- sentence-transformers==2.7.0
- FlagEmbedding==1.2.10(用于reranker)
- chromadb==0.5.3(向量库)
- 知识库规模:4286个文档,切分后约3.2万chunk
- 评测集:从真实用户日志中抽取500条,人工标注正确答案所在文档ID
3. 方案设计:三步走,每一步都有量化指标
我定了三个阶段的调优目标,每个阶段都必须有可对比的评测数据:
- chunk策略调整:从固定长度改为语义切分,观察Hit@5变化
- Embedding模型切换:bge-large-zh → bge-m3,对比长文本和细粒度语义的召回差异
- 引入Rerank:在向量召回Top-50基础上,用cross-encoder重排取Top-5,看最终命中率
所有实验固定使用同一批500条评测集,避免随机性干扰。
4. 核心实现:chunk重构与Embedding切换
4.1 语义chunk:别再用split_text了
旧代码是典型的懒人写法:
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(chunk_size=256, chunk_overlap=50)
chunks = splitter.split_documents(docs)
问题在于:RecursiveCharacterTextSplitter的优先级是["\n\n", "\n", " ", ""],它根本不懂Markdown的##和###层级。当知识库文档出现表格、代码块、嵌套列表时,切分点完全随机。
我的替代方案是自定义一个基于Markdown heading的分割器,核心逻辑:
import re
from typing import List
def markdown_semantic_split(text: str, max_chunk_size: int = 800) -> List[str]:
"""
按Markdown标题层级切分,保留父子结构信息。
规则:遇到##或###时强制切分,但若当前chunk小于200字符则合并到上一个。
"""
# 记录每个标题的层级和内容
lines = text.split('\n')
chunks = []
current_chunk = []
current_size = 0
current_heading_level = 0
for line in lines:
heading_match = re.match(r'^(#{2,4})\s+(.*)', line) # 匹配##到####
if heading_match:
level = len(heading_match.group(1))
# 如果当前chunk不为空且层级不深于父级,则封口
if current_chunk and (level max_chunk_size):
chunks.append('\n'.join(current_chunk))
current_chunk = []
current_size = 0
current_heading_level = level
current_chunk.append(line)
current_size += len(line)
else:
current_chunk.append(line)
current_size += len(line)
# 超过最大长度时,在段落边界截断
if current_size > max_chunk_size and line == '':
chunks.append('\n'.join(current_chunk))
current_chunk = []
current_size = 0
if current_chunk:
chunks.append('\n'.join(current_chunk))
return chunks
关键点:##和###是切分硬边界,但####不强制切分(避免过度碎片化)。同时,chunk_size从256调整到600-800,因为现在chunk是有语义边界的,大一点没关系。
4.2 Embedding切换:bge-m3的“多向量”救场
换完chunk后,Hit@5从61.3%涨到了72.8%。但还不够。分析bad case发现,大量长文本(超过512 token)被bge-large-zh截断,导致尾部信息丢失。
切换到bge-m3(支持8192长度)后,代码改动极小:
from sentence_transformers import SentenceTransformer
# 旧:bge-large-zh-v1.5, max_seq_length=512
# model = SentenceTransformer('BAAI/bge-large-zh-v1.5')
# 新:bge-m3, 支持8192长度,且自带稀疏向量
model = SentenceTransformer('BAAI/bge-m3')
# 关键:需要显式设置返回稠密+稀疏向量用于混合检索
model.encode_kwargs = {'return_dense': True, 'return_sparse': True}
但这里有个坑:bge-m3默认返回的是包含dense_vecs和lexical_weights的字典,直接丢给ChromaDB会报错。需要包装一下:
import numpy as np
from chromadb.utils import embedding_functions
class BGEM3EmbeddingFunction(embedding_functions.EmbeddingFunction):
def __call__(self, input: list[str]) -> list[np.ndarray]:
outputs = model.encode(input, return_dense=True, return_sparse=True)
# 只取稠密向量用于向量检索(稀疏向量用于后续的BM25混合)
return outputs['dense_vecs'].tolist()
换模型后,Hit@5提升到81.5%。同时因为bge-m3对中文细粒度实体识别更好,“研发费用占比”这种复合查询的召回明显改善。
5. Rerank引入:从Top-50到Top-5的精准打击
向量召回Top-50里其实已经有正确答案了,但被Top-5挤掉了。引入reranker做第二遍精排:
from FlagEmbedding import FlagReranker
# 初始化reranker
reranker = FlagReranker('BAAI/bge-reranker-v2-m3', use_fp16=True)
def rerank(query: str, candidates: list[str], top_k: int = 5) -> list[str]:
"""对候选chunk进行交叉编码重排"""
# 构造(query, doc)对
pairs = [[query, doc] for doc in candidates]
# 获取相关分数(注意:分数越高越相关)
scores = reranker.compute_score(pairs, normalize=True)
# 按分数降序排列
sorted_idx = np.argsort(scores)[::-1]
return [candidates[i] for i in sorted_idx[:top_k]]
关键设计:向量召回Top-50,但只取前20个chunk送入reranker(因为cross-encoder是平方级复杂度,50个会带来200ms+延迟)。实测Top-20已经能覆盖95%的正确结果,没必要用50个。
6. 踩坑与优化:两个让我血压飙升的Bug
6.1 Bug 1:ChromaDB的metadata过滤把结果全过滤没了
切分chunk时,我给每个chunk加了source和heading的metadata。结果在ChromaDB查询时用了:
collection.query(query_embeddings=emb, where={"source": {"$eq": target_source}})
问题:ChromaDB的$eq对字符串匹配是精确的,但知识库文件名有版本后缀(如v2.1.3),用户问题里根本不会带这个后缀。导致过滤后结果为空,然后代码走了兜底逻辑返回了全库随机结果。
修复:去掉metadata过滤,改为在rerank阶段根据source字段做后处理过滤。
6.2 Bug 2:混合检索权重反了
bge-m3支持稀疏向量,我做了稠密+稀疏的混合检索。但代码里权重写反了:
# 错误写法:稀疏权重0.7,稠密0.3
final_scores = 0.7 * sparse_scores + 0.3 * dense_scores
# 正确写法:稠密为主,稀疏辅助
final_scores = 0.8 * dense_scores + 0.2 * sparse_scores
这个Bug导致所有依赖关键词匹配的查询(如“财报2024”)表现正常,但语义查询(如“那个研发投入增多的季度”)全部乱套。调权重后,Hit@5又提升了3个百分点。
7. 效果数据与总结
最终上线后的对比:
| 指标 | 原始方案 | chunk优化 | +bge-m3 | +Rerank |
|---|---|---|---|---|
| Hit@5 (Top-5命中率) | 61.3% | 72.8% | 81.5% | 89.2% |
| 平均检索延迟 | 45ms | 52ms | 78ms | 208ms |
| 用户满意度(人工抽检) | 67% | 74% | 81% | 88% |
总结一下:
- chunk切分策略的影响被严重低估了,固定长度切分在复杂文档结构下就是灾难
- bge-m3的8192长度和稀疏向量能力对长文本召回是质的提升,但要注意ChromaDB的接口适配
- Rerank是性价比最高的优化,只增加130ms延迟,换来8个点的命中率提升
- 混合检索权重一定要做网格搜索,别想当然
最后说句实在话:RAG系统的效果瓶颈往往不在模型,而在数据管道的细节。如果你也在调RAG,建议先统计一下自己的bad case到底是“检索没召回到”还是“召回了但排序不对”,对症下药,别一上来就换大模型。