一、问题背景:为什么检索效果突然不“够用”了
项目是一个面向内部客服的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,不重排。这个就是基线。
三、方案设计:三个改动,逐个验证
我的思路是:先解决“切不对”,再解决“算不准”,最后解决“排不好”。所以分三步走:
- chunk策略重设计:从固定长度改为基于文档结构(Markdown标题、列表、表格)的语义切块。
- Embedding模型升级:换用
bge-large-zh-v1.5(1024维),它在中文本语语义上比text2vec强不少,而且支持长文本(最大512 token)。 - 引入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排不好,后面生成阶段再努力也是白搭。
如果你想快速见效,我建议按这个顺序来:
- 检查chunk是否割裂了语义单元(表格、代码块、列表);
- 如果知识库以中文为主,直接上
bge-large-zh-v1.5; - 最后再加rerank,它能过滤掉向量检索的“语义噪声”,但别指望它逆天改命。
当前系统已上线运行两个月,线上问答的“无法回答”率从26%降到11%。下一步准备尝试gte-Qwen2-7B-instruct做多向量融合,以及用LLM做下一步的自动查询改写,到时候再写一篇。