一、问题背景:为什么检索结果像在“大海捞针”
事情是这样的,我们内部有一个运维知识库的问答机器人,喂了大概128篇Markdown格式的故障手册和操作指南。最初版本就是最朴素的“读文档 -> 切分 -> 向量化 -> 相似度检索 -> 丢给LLM”。
结果上线后,测试同学反馈:问“如何重启MySQL主从复制”,回答驴唇不对马嘴,经常返回一堆关于Redis的内容。我拉了一下日志,发现检索回来的Top-5片段里,真正相关的可能只有1个,而且排在第4位。这意味着我们的Retriever从源头就是“脏”的,你后面LLM再牛逼,喂进去的上下文不对,回答必然瞎扯。
当时评估指标是Hit Rate(Top-5中是否包含正确答案)和MRR(倒数排名)。基线数据如下:
- Hit Rate@5:67%
- MRR@5:0.42
这个数据意味着三分之一的问题,正确答案压根没进候选池。这没法用,必须得动刀。
二、环境与版本:别让版本坑了你
先说环境,避免大家踩坑。特别是LangChain的API变化特别快,网上的老代码经常直接跑不通。
- Python:3.10.13
- langchain:0.1.0(注意,这里不是0.0.x,API有变动)
- langchain-community:0.0.10
- faiss-cpu:1.7.4(向量库,轻量级,够用)
- sentence-transformers:2.2.2
- text2vec:1.2.0(用的
shibing624/text2vec-base-chinese) - FlagEmbedding:1.2.6(用于加载BGE系列模型)
- LLM:本地VLLM部署的Qwen-14B-Chat(量化版)
这里有一个坑:LangChain 0.1.0里,HuggingFaceEmbeddings的导入路径变成了langchain_community.embeddings,如果你还在用from langchain.embeddings import ...会直接报错。
# 正确的导入方式 (langchain 0.1.0)
from langchain_community.embeddings import HuggingFaceEmbeddings
from langchain_community.vectorstores import FAISS
from langchain.text_splitter import RecursiveCharacterTextSplitter
# 初始化Embedding模型(以bge-large为例)
embeddings = HuggingFaceEmbeddings(
model_name="/data/models/bge-large-zh-v1.5",
model_kwargs={'device': 'cuda:0'},
encode_kwargs={'normalize_embeddings': True} # BGE模型必须归一化!
)
注意上面normalize_embeddings必须开启,这是BGE模型使用时的官方建议,不归一化的话相似度计算会出问题,后面效果会差很多。
三、第一刀:Chunk策略调整——从“无脑切”到“语义块”
最初的Chunk方式是RecursiveCharacterTextSplitter,参数是chunk_size=500, chunk_overlap=50。这是最默认的参数,但文档中一个章节可能只有200字,另一个章节可能有1500字。直接按字符硬切,导致一个完整的技术步骤被拦腰截断,比如“步骤1”和“步骤2”被分到了两个块里,检索时只召回一半。
方案设计:改为MarkdownHeaderTextSplitter配合RecursiveCharacterTextSplitter。先按Markdown的标题层级(#、##、###)分割成语义完整的章节,再对超过500字的章节做二次切分。
from langchain.text_splitter import MarkdownHeaderTextSplitter, RecursiveCharacterTextSplitter
# 定义需要分割的标题层级
headers_to_split_on = [
("#", "H1"),
("##", "H2"),
("###", "H3"),
]
# 先按标题切,保留语义结构
md_splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers_to_split_on)
sections = md_splitter.split_text(doc_content)
# 对过长的章节,使用递归字符分割器处理
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=400, # 适当调小,保证精度
chunk_overlap=80, # 重叠增加,防止切断关键句
separators=["\n\n", "\n", "。", "!", "?"], # 中文标点优先
)
final_chunks = []
for section in sections:
# section.metadata 里包含 H1/H2/H3 信息,方便溯源
if len(section.page_content) > 500:
sub_chunks = text_splitter.split_text(section.page_content)
for sub in sub_chunks:
final_chunks.append({"content": sub, "metadata": section.metadata})
else:
final_chunks.append({"content": section.page_content, "metadata": section.metadata})
效果数据:
- Hit Rate@5:67% -> 76%(提升9个百分点)
- MRR@5:0.42 -> 0.51
数据有提升,但依然不够看。问题在于向量化模型选得不对,导致语义理解能力太弱。
四、第二刀:Embedding模型切换——从“识字”到“懂意”
之前用的text2vec-base-chinese(110M参数),说实话这是入门级模型,对于“重启服务”和“重新加载进程”这种同义表达,向量距离太远。我换成了两个候选:
m3e-base(Moka AI,5亿参数)bge-large-zh-v1.5(BAAI,3.26亿参数)
核心实现:切换模型非常简单,只需要改model_name,但注意bge系列对输入长度限制是512,m3e是1024。我们的文档块平均长度在300-400字,所以都OK。
# 切换为 bge-large-zh-v1.5
embeddings = HuggingFaceEmbeddings(
model_name="BAAI/bge-large-zh-v1.5",
model_kwargs={'device': 'cuda:0'},
encode_kwargs={'normalize_embeddings': True}
)
# 重新构建向量库(注意:必须重新embedding,不能复用旧的faiss索引)
vectorstore = FAISS.from_documents(documents_with_metadata, embeddings)
效果对比:
| 模型 | 维度 | Hit Rate@5 | MRR@5 | 单次检索耗时(ms) |
|---|---|---|---|---|
| text2vec-base | 768 | 76% | 0.51 | 35 |
| m3e-base | 768 | 82% | 0.58 | 42 |
| bge-large-zh-v1.5 | 1024 | 87% | 0.63 | 55 |
bge-large-zh-v1.5完胜。尤其在处理“重启”与“拉起进程”这种动词替换场景,BGE的语义泛化能力明显更强。
五、第三刀:引入Rerank——让排序“重新做人”
即使Hit Rate到了87%,但Top-1准确率只有58%。也就是说,正确答案虽然进了Top-5,但经常排在后面。这会导致LLM注意力被前面不相关的段落干扰。
方案设计:采用“双阶段检索”:
1. 召回阶段:用FAISS暴力检索Top-20(扩大候选池,防止漏网之鱼)。
2. 精排阶段:用bge-reranker-base对20个候选重新打分,取Top-3。
核心实现:
from FlagEmbedding import FlagReranker
# 加载rerank模型
reranker = FlagReranker('/data/models/bge-reranker-base', use_fp16=True)
def advanced_search(query, top_k=3):
# 1. 召回阶段:取Top-20
initial_docs = vectorstore.similarity_search_with_score(query, k=20)
# 2. 精排阶段:对候选进行交叉编码打分
pairs = [[query, doc.page_content] for doc, _ in initial_docs]
scores = reranker.compute_score(pairs)
# 3. 按分数降序排列,取前top_k个
sorted_results = sorted(zip(initial_docs, scores), key=lambda x: x[1], reverse=True)
final_docs = []
for (doc, _), score in sorted_results[:top_k]:
doc.metadata['rerank_score'] = score
final_docs.append(doc)
return final_docs
踩坑记录:
- Batch Size问题:compute_score传入list时,默认是逐条计算的,非常慢。我一开始没注意,20个候选要跑2秒。后来发现FlagReranker支持传入二维list进行batch计算,速度快了3倍。正确写法是compute_score([[q, c] for c in candidates])。
- 与FAISS分数冲突:FAISS返回的score是L2距离(越小越相似),而Rerank返回的分数是越大越相关。排序逻辑千万别搞混了。
最终效果:
| 指标 | 基线 | +Chunk优化 | +BGE模型 | +Rerank |
|---|---|---|---|---|
| Hit Rate@5 | 67% | 76% | 87% | 91% |
| MRR@5 | 0.42 | 0.51 | 0.63 | 0.78 |
| Top-1 准确率 | 38% | 45% | 58% | 79% |
Rerank带来的提升是立竿见影的,尤其是MRR从0.63到0.78,意味着正确答案的排名大幅提前。虽然增加了大约80ms的延迟(Rerank耗时),但换来的是回答质量的质变。
六、总结与后续优化方向
这次调优的核心结论:
- Chunk切分永远是第一步:不要迷信一个固定
chunk_size打天下。结构化文档(Markdown、HTML)必须按结构切,这是性价比最高的优化。 - Embedding模型决定天花板:国产模型里,
bge-large-zh-v1.5在中文场景下确实能打,但注意归一化和输入长度限制。 - Rerank是必选项:只要你的场景对排序敏感(比如问答、搜索),加一个交叉编码器Rerank能带来质的飞跃。别嫌它慢,现在轻量级的
bge-reranker-base在GPU上跑20条也就几十毫秒。
后续计划:
- 尝试ColBERT这种延迟交互模型,看看能否在召回阶段就提升精度。
- 针对多跳问题,引入Self-RAG或者GraphRAG的思路,目前纯向量检索处理“A与B的关系”这种问题还是吃力。
- 数据层面,打算构建一个基于真实用户query的评测集,而不是手工构造的20条测试用例,这样数据更有说服力。
调RAG这条路没有终点,只能一步步把每个环节抠细。希望这篇记录对你有所启发,有更好的方案欢迎评论区交流。