一、问题背景:为什么检索结果像在“大海捞针”

事情是这样的,我们内部有一个运维知识库的问答机器人,喂了大概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参数),说实话这是入门级模型,对于“重启服务”和“重新加载进程”这种同义表达,向量距离太远。我换成了两个候选:

  1. m3e-base(Moka AI,5亿参数)
  2. 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耗时),但换来的是回答质量的质变。

六、总结与后续优化方向

这次调优的核心结论:

  1. Chunk切分永远是第一步:不要迷信一个固定chunk_size打天下。结构化文档(Markdown、HTML)必须按结构切,这是性价比最高的优化。
  2. Embedding模型决定天花板:国产模型里,bge-large-zh-v1.5在中文场景下确实能打,但注意归一化和输入长度限制。
  3. Rerank是必选项:只要你的场景对排序敏感(比如问答、搜索),加一个交叉编码器Rerank能带来质的飞跃。别嫌它慢,现在轻量级的bge-reranker-base在GPU上跑20条也就几十毫秒。

后续计划
- 尝试ColBERT这种延迟交互模型,看看能否在召回阶段就提升精度。
- 针对多跳问题,引入Self-RAG或者GraphRAG的思路,目前纯向量检索处理“A与B的关系”这种问题还是吃力。
- 数据层面,打算构建一个基于真实用户query的评测集,而不是手工构造的20条测试用例,这样数据更有说服力。

调RAG这条路没有终点,只能一步步把每个环节抠细。希望这篇记录对你有所启发,有更好的方案欢迎评论区交流。