一、问题背景:为什么检索效果突然不“够用”了

项目是一个面向内部客服的RAG问答系统,知识库包含产品手册、故障排查文档和运维工单。初期上线时,基于text2vec-large-chinese + 固定512字符chunk的方案,简单问题还能应付,但随着知识库文档数量突破2000篇,问题逐渐暴露:

  • 召回不准确:用户问“磁盘IO延迟高如何排查”,返回的top5片段里有两三条是无关的“CPU使用率”或“内存泄漏”内容。
  • 上下文割裂:固定512字符切块,经常把表格或列表拦腰截断,导致Embedding向量语义混乱。
  • 排序不合理:向量相似度最高的片段,有时并不是真正回答问题的片段,尤其是当问题包含多个限定条件时。

最直观的指标是:在人工标注的200条测试集上,Recall@5只有61.3%,而且人工评估的“答案可直接使用率”不到50%。

二、环境与版本:先把基线固定下来

调优前必须锁死环境,不然改了代码没法对比。我这边用的是:

Python 3.10.12
langchain 0.1.11
chromadb 0.4.22
sentence-transformers 2.5.1
FlagEmbedding 1.2.8
bge-reranker-v2-m3 (本地部署)

Embedding模型最初是text2vec-large-chinese(512维),chunk策略是RecursiveCharacterTextSplitter的固定512字符、overlap 50。检索方式是直接查ChromaDB,取top5,不重排。这个就是基线。

三、方案设计:三个改动,逐个验证

我的思路是:先解决“切不对”,再解决“算不准”,最后解决“排不好”。所以分三步走:

  1. chunk策略重设计:从固定长度改为基于文档结构(Markdown标题、列表、表格)的语义切块。
  2. Embedding模型升级:换用bge-large-zh-v1.5(1024维),它在中文本语语义上比text2vec强不少,而且支持长文本(最大512 token)。
  3. 引入Rerank:用bge-reranker-v2-m3对向量检索的top20结果做交叉编码重排,再取top5。

每一步都单独跑一遍测试集,看Recall@5和人工可读性评分。

四、核心实现:每一步的关键代码

4.1 chunk策略:按Markdown结构切块

这是改动最大的一步。原来的RecursiveCharacterTextSplitter对文档结构无感知。我写了一个简单的MarkdownAwareSplitter,核心逻辑是:

  • 优先按######标题切分;
  • 如果标题下内容超过800字符,再按段落(\n\n)切;
  • 表格块(|开头)整体保留,不拆分。
from typing import List
import re

class MarkdownAwareSplitter:
    def __init__(self, max_chunk_size: int = 800, min_chunk_size: int = 200):
        self.max_chunk_size = max_chunk_size
        self.min_chunk_size = min_chunk_size

    def split_text(self, text: str) -> List[str]:
        # 按Markdown标题切分
        sections = re.split(r'(?m)^(#{1,4})\s+', text)
        chunks = []
        # 处理每个标题及其内容
        for i in range(1, len(sections), 2):
            header = sections[i]
            content = sections[i+1] if i+1  self.max_chunk_size:
                paragraphs = content.split('\n\n')
                current = f"{header} {paragraphs[0]}"
                for para in paragraphs[1:]:
                    if len(current) + len(para) = self.min_chunk_size]

注意:表格块需要单独处理,我在实际代码里加了is_table_block判断,保持表格完整性。这个改动让chunk数量从2876个变为3120个,平均长度从512字符变为约430字符,但语义完整性明显提升。

4.2 Embedding模型切换

换模型很简单,用sentence-transformers加载BAAI/bge-large-zh-v1.5,注意要加query_instruction前缀(这是bge系列的要求):

from sentence_transformers import SentenceTransformer

# 加载bge模型
model = SentenceTransformer('BAAI/bge-large-zh-v1.5')
# 查询时需要加指令前缀
query = "磁盘IO延迟高如何排查"
query_emb = model.encode(f"为这个句子生成表示以用于检索相关文章:{query}", normalize_embeddings=True)
# 文档向量不需要前缀
doc_emb = model.encode("磁盘IO延迟高的排查步骤包括...", normalize_embeddings=True)

注意:这里有个隐藏坑,bge-large-zh-v1.5最大输入长度是512 token,如果你的chunk超过这个长度会被截断,我后来把max_chunk_size从800降到600,确保大部分chunk在512 token以内(中文约700字)。

4.3 Rerank引入

Rerank阶段我用FlagEmbedding加载BAAI/bge-reranker-v2-m3,先向量检索top20,再用reranker打分取top5:

from FlagEmbedding import FlagReranker

reranker = FlagReranker('BAAI/bge-reranker-v2-m3', use_fp16=True)

def rerank(query: str, doc_chunks: List[str], top_k: int = 5):
    # 构造(query, doc)对
    pairs = [[query, doc] for doc in doc_chunks]
    scores = reranker.compute_score(pairs, normalize=True)
    # 按分数排序
    sorted_idx = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)
    return [doc_chunks[i] for i in sorted_idx[:top_k]], [scores[i] for i in sorted_idx[:top_k]]

use_fp16=True在A100上能加速约30%,但如果你用的是T4或者CPU推理,记得关掉。

五、踩坑与优化:三个值得记录的细节

坑1:bge模型需要归一化。normalize_embeddings=True的话,余弦相似度计算会退化,Recall@5直接掉了5个百分点。

坑2:reranker对长文本不友好。 bge-reranker-v2-m3输入长度上限是512 token,如果chunk太长,会被截断,导致重排效果打折。我最后把max_chunk_size调到600字符,并限制表格块不超过1000字符,超出部分单独成chunk。

坑3:ChromaDB的metadata过滤。 之前没注意,ChromaDB默认不支持metadata过滤和向量检索同时高效执行。改了chunk策略后,我加了where={"source": doc_name}过滤,注意要先建索引,不然查询会全表扫描。

六、效果数据:每一步的变化

阶段 Recall@5 人工可读性评分(1-5) 平均响应延迟
基线(512字符chunk + text2vec) 61.3% 2.8 320ms
+ Markdown结构chunk 68.7% 3.5 310ms
+ bge-large-zh-v1.5 79.2% 4.1 450ms
+ bge-reranker-v2-m3 89.7% 4.6 780ms

人工可读性评分是让3个同事盲评的,取平均。可以看到:

  • 结构切块带来的提升最明显(+7.4%),说明之前很多问题是“切错位置”导致的。
  • Embedding换模型提升了10.5%,符合预期,bge在中文语义上确实强。
  • Rerank提升了10.5%,但延迟也翻倍了。如果你对延迟敏感,可以考虑只对top10重排,或者用bge-reranker-base

幻觉率(用LLM生成答案时引用错误)从原来的38%降到22%,主要因为top5片段更精准了。

七、总结

这次优化的核心心法:不要一上来就调LLM的prompt,先把检索链路做扎实。chunk切不对,embedding算不准,rerank排不好,后面生成阶段再努力也是白搭。

如果你想快速见效,我建议按这个顺序来:

  1. 检查chunk是否割裂了语义单元(表格、代码块、列表);
  2. 如果知识库以中文为主,直接上bge-large-zh-v1.5
  3. 最后再加rerank,它能过滤掉向量检索的“语义噪声”,但别指望它逆天改命。

当前系统已上线运行两个月,线上问答的“无法回答”率从26%降到11%。下一步准备尝试gte-Qwen2-7B-instruct做多向量融合,以及用LLM做下一步的自动查询改写,到时候再写一篇。