一、问题背景:检索结果看着像,但答非所问
我们内部有一个基于RAG的运维知识库问答系统,文档是几十本操作手册的PDF转Markdown。用户提问“如何修改Nginx的worker进程数”,系统返回的Top5片段里全是“Nginx配置文件路径”、“worker_processes指令语法”这些相关但不直接的内容,最终答案生成出来也是拼凑感极强。
线上监控数据很扎心:Top5命中率(即答案在Top5检索结果内)只有62%,用户满意度评分连续两周下滑。当时技术栈是:LangChain 0.1.0 + Chroma 0.4.22 + ZhipuAI embedding + GPT-3.5-turbo。
问题定位很明确:源头在检索质量,生成模型再强也救不回来。
二、环境与版本:锁定基线再动手
优化前先冻结环境,避免多个变量同时变化导致无法定位问题。我们的基线环境:
Python 3.10.13
langchain 0.1.0
langchain-community 0.0.10
chromadb 0.4.22
zhipuai 2.1.2
torch 2.1.2+cu118
sentence-transformers 2.2.2
FlagEmbedding 1.2.8
向量库用的是Chroma的PersistentClient模式,collection名ops_docs,距离函数默认的L2。
三、方案设计:三步走,每一步都量化效果
整个优化分三步,每一步都单独验证效果,避免“混合优化”后不知道谁起作用:
- Chunk策略调整:从固定512字符切分改为语义边界切分(按标题、段落、句子边界)。
- Embedding模型切换:从ZhipuAI的embedding-v2(输出维度1024)换到阿里的text-embedding-v3(输出维度1024,但语义表达能力更强)。
- 引入Rerank重排:在向量检索后加一个cross-encoder的rerank阶段,对Top20结果精排取Top5。
评估方式:从测试集里抽了200个问题,人工标注答案所在的原始文档位置,计算Top5命中率(检索到的5个chunk中是否包含答案)。
四、核心实现:每一步的代码与参数
4.1 Chunk分割:从“死板”到“有脑子”
原来的切分就是RecursiveCharacterTextSplitter(chunk_size=512, chunk_overlap=50),中文文档按字符硬切,经常把一句话切断,或者把一个小节标题和正文拆到两个chunk里。
改成基于Markdown标题层级+段落边界的分割器。核心思想:先按标题拆成小节,再在小节内按段落拆,最后段落太长才按句子边界兜底。
from langchain.text_splitter import MarkdownHeaderTextSplitter, RecursiveCharacterTextSplitter
# 第一层:按Markdown标题层级切分
headers_to_split_on = [
("#", "H1"),
("##", "H2"),
("###", "H3"),
]
md_splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers_to_split_on)
# 第二层:对每个小节做段落级切分
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=400, # 调小,语义更聚焦
chunk_overlap=80, # 保证段落衔接
separators=["\n\n", "\n", "。", "!", "?", ".", "!", "?", ";", ";", ",", ","],
keep_separator=True, # 保留分隔符,避免句子被硬拆
)
def smart_split(markdown_doc: str):
sections = md_splitter.split_text(markdown_doc)
chunks = []
for section in sections:
# 每个section保留其标题前缀,便于溯源
sub_chunks = text_splitter.split_text(section.page_content)
for sub in sub_chunks:
chunks.append({
"content": sub,
"metadata": {"header": section.metadata}
})
return chunks
踩坑记录:separators列表里中英文标点混排时,英文句点.要放在中文标点后面,否则会先按英文句点切分,把“3.5版本”这种带小数点的内容切断。另外keep_separator=True很重要,否则切完的句子末尾丢句号,embedding时语义会偏移。
4.2 Embedding切换:向量维度没变,但检索质量变了
ZhipuAI的embedding-v2在中文场景也不算差,但它的训练语料更偏通用对话,对运维文档里的“参数名+操作步骤”这种密集实体文本表达不够敏感。
换用阿里的text-embedding-v3(通过DashScope接口调用),维度同样是1024,不需要重建向量库,直接换embedding函数重新写入即可。
from langchain.embeddings import DashScopeEmbeddings
# 阿里云DashScope的text-embedding-v3
embeddings = DashScopeEmbeddings(
model="text-embedding-v3",
dashscope_api_key="sk-xxxx",
dimensions=1024 # 显式指定维度,与旧向量保持一致
)
# 写入向量库时指定embedding函数
from langchain.vectorstores import Chroma
vectorstore = Chroma.from_documents(
documents=chunks,
embedding=embeddings,
persist_directory="./chroma_db",
collection_metadata={"hnsw:space": "cosine"} # 注意:这里改为余弦距离
)
踩坑记录:text-embedding-v3默认输出维度是1024,但如果你不显式传dimensions参数,它可能返回不同的维度(比如1024或768),导致写入向量库时与旧数据冲突。另外,距离函数从L2换成了cosine。L2对向量模长敏感,而text-embedding-v3的向量模长分布和ZhipuAI不一样,用L2会导致某些长文本被“惩罚”。切换后需要重建整个collection。
五、引入Rerank:Top5命中率从78%到89%
embedding切换后Top5命中率到了78%,但距离业务可用的90%还有距离。问题在于:双塔embedding的检索结果中,相关片段和干扰片段在向量空间里距离接近,无法进一步区分。
引入bge-reranker-base(cross-encoder),对向量检索返回的Top20结果做精排,取前5。这个模型只有约1.1亿参数,CPU推理单条耗时约80ms,可用GPU加速。
from FlagEmbedding import FlagReranker
# 初始化reranker(加载到GPU)
reranker = FlagReranker('BAAI/bge-reranker-base', use_fp16=True, device='cuda:0')
def search_with_rerank(query: str, top_k: int = 5, rerank_pool: int = 20):
# 第一步:向量召回Top20
initial_docs = vectorstore.similarity_search_with_score(query, k=rerank_pool)
# 第二步:构造(query, doc)对
pairs = [(query, doc.page_content) for doc, score in initial_docs]
# 批量计算rerank分数
scores = reranker.compute_score(pairs, normalize=True) # normalize=True 输出0-1分数
# 按分数排序取Top5
ranked = sorted(
zip(initial_docs, scores),
key=lambda x: x[1],
reverse=True
)[:top_k]
return [doc for (doc, score), rerank_score in ranked]
踩坑记录:
- compute_score传入list of pairs时,返回的是list of scores;传入单个pair时返回的是单个float。别混用,否则取索引会报错。
- 如果文档特别长,reranker的输入长度限制是512个token,超出部分被截断。早期跑的时候没注意,导致有些长chunk的rerank分数不准。后来把chunk_size从512降到400,这个问题基本消失。
六、效果数据与最终效果
优化过程的每一步量化结果如下:
| 阶段 | Top5命中率 | 平均检索耗时(ms) | 备注 |
|---|---|---|---|
| 基线(固定512chunk + ZhipuAI + L2) | 62% | 120 | 用户满意度最低点 |
| + 语义chunk分割 | 71% | 118 | 耗时几乎无变化 |
| + 切换text-embedding-v3 | 78% | 135 | 接口调用略慢 |
| + bge-reranker-base重排 | 89% | 280 | 增加约150ms,可接受 |
最终线上配置:chunk_size=400,chunk_overlap=80,向量召回Top20,rerank后取Top5。多轮对话的上下文窗口保持4轮,超出后自动丢弃最早的对话。
额外收益:因为检索精度上去了,GPT-3.5-turbo生成的内容里“编造”的比例明显降低,用户反馈“答案更扎实了”。
七、总结
这次优化的核心就一句话:RAG的瓶颈在检索,检索的瓶颈在“语义切分”和“语义排序”。如果你也遇到类似问题,建议按这个顺序排查:
- 先看chunk是否把完整语义切碎了(打印几个被命中的chunk,看看是否前言不搭后语)。
- 再换一个更强的embedding模型(不要只看公开benchmark,拿自己的数据集跑一遍)。
- 最后加上reranker,这是提升精度最直接的手段,代价就是多几十毫秒耗时。
如果预算有限,优先上reranker,它能把Top5命中率拉高10个百分点以上。但如果chunk本身切得烂,reranker也救不回来。