一、问题背景:为什么我的RAG答非所问
先说场景。我们做的是一个内部技术文档问答系统,语料是企业多年积累的产品手册、API文档和运维SOP,总共约12万篇,平均每篇1800字,中文为主,夹杂大量代码片段和英文术语。用户提问基本是「XX接口超时怎么排查」「YY配置项默认值是多少」这类具体问题。
第一版系统是典型的「能跑就行」方案:
- 文档按固定512 token切分,无重叠
- embedding用OpenAI的
text-embedding-ada-002 - 向量库用Milvus 2.3.1,HNSW索引
- 召回Top-5直接拼进prompt,没有重排
- 生成用
gpt-3.5-turbo
上线后人工抽检200条问题,Top-5命中率(正确答案所在chunk出现在召回结果中)只有62%。更糟的是,很多case里正确答案其实在库里,但就是没被召回。用户反馈最多的是「答案沾边但不对」和「明明文档里有却答不出来」。
这就不是生成模型的问题了,是检索环节烂。于是开始了一轮系统性的优化。
二、环境与版本
先交代下环境,方便复现:
Python 3.10.13
milvus 2.3.1 (standalone, HNSW, M=16, efConstruction=200)
FlagEmbedding 1.2.10
sentence-transformers 2.2.2
torch 2.1.0 + cu118
langchain 0.1.0 (只用了text splitter部分)
openai 1.6.1 (仅用于生成)
评估集:从真实用户日志里抽了300条问题,人工标注了每条的标准答案所在文档和chunk。评估指标用两个:
- Hit@5:正确chunk是否出现在Top-5召回中
- MRR@5:正确chunk排名的倒数均值
生成质量暂时不动,只盯检索。因为检索不行,后面怎么调prompt都是白搭。
三、方案设计:分三轮迭代
我的思路是每次只改一个变量,方便归因:
- Round 1:只改chunk策略(固定切分 → 语义分段+重叠)
- Round 2:在Round 1基础上切换embedding模型(ada-002 → bge-large-zh-v1.5)
- Round 3:在Round 2基础上引入rerank(bge-reranker-large)
每轮都跑同一套300条评估集,记录Hit@5和MRR@5。
四、核心实现
Round 1:chunk策略调整
固定512 token切分最大的问题是语义截断。比如一个API的错误码说明表,被从中间切开,前半段在chunk A,后半段在chunk B,用户问「错误码5003什么意思」,召回A只有表头,召回B只有半张表,都不完整。
我改成了两级策略:
- 先用Markdown标题(
#、##)做一级切分,保证每个章节独立 - 章节内超过400 token的,用
RecursiveCharacterTextSplitter按段落、句子递归切,chunk_size=400,chunk_overlap=80
重叠窗口很关键。80 token的重叠大概能覆盖2-3句话,正好让跨chunk的语义有个缓冲。
from langchain.text_splitter import RecursiveCharacterTextSplitter, MarkdownHeaderTextSplitter
def semantic_chunking(md_text: str, max_chunk_size: int = 400, overlap: int = 80):
# 一级:按Markdown标题切
headers_to_split_on = [
("#", "h1"),
("##", "h2"),
("###", "h3"),
]
md_splitter = MarkdownHeaderTextSplitter(
headers_to_split_on=headers_to_split_on,
strip_headers=False
)
sections = md_splitter.split_text(md_text)
# 二级:章节内递归切分
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=max_chunk_size,
chunk_overlap=overlap,
separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""],
length_function=lambda x: len(x) # 中文按字符数近似token
)
final_chunks = []
for sec in sections:
# 把标题拼回正文,保证chunk自带上下文
header_prefix = " > ".join([f"{k}:{v}" for k, v in sec.metadata.items()])
content = f"[{header_prefix}]\n{sec.page_content}"
if len(content) 错误码」这种前缀能显著提升相关性;二是中文的 `length_function` 直接用字符数,别傻乎乎用tiktoken,中文token和字符差不多1:1,没必要多一次编码开销。
Round 1结果:Hit@5从62% → 71%,MRR@5从0.41 → 0.52。chunk数量从原来的平均每篇8个涨到13个,库总量从96万涨到156万条向量。存储成本涨了60%,但召回质量实打实提升。
### Round 2:切换embedding模型
ada-002是通用多语言模型,但在中文技术文档上表现一般。我换成了智源的 `bge-large-zh-v1.5`,768维,中文语义对齐明显更好。
这里有个坑:**query和passage要用不同的前缀**。bge系列训练时query加了指令前缀,passage没有。如果两边都不加,效果会掉一截。
```python
from FlagEmbedding import FlagModel
import numpy as np
class BGEEmbedder:
def __init__(self, model_path: str = "BAAI/bge-large-zh-v1.5", device: str = "cuda:0"):
self.model = FlagModel(
model_path,
query_instruction_for_retrieval="为这个句子生成表示以用于检索相关文章:",
use_fp16=True,
device=device
)
def encode_queries(self, queries):
# 自动加query instruction
return self.model.encode_queries(queries, batch_size=64, max_length=512)
def encode_passages(self, passages):
# passage不加instruction
return self.model.encode(passages, batch_size=64, max_length=512)
# 批量重建索引
embedder = BGEEmbedder()
# 假设chunks是Round 1产出的所有chunk
vectors = embedder.encode_passages(chunks)
# 写入Milvus,collection维度从1536改成768
切换模型意味着全量重建索引,156万条向量在单张A100上跑了约40分钟(batch=64, fp16)。如果你们数据量更大,建议先抽1万条做A/B,确认有效再全量。
Round 2结果:Hit@5从71% → 82%,MRR@5从0.52 → 0.68。这一步是三轮里收益最大的,中文场景下换对embedding模型比调任何参数都管用。
Round 3:引入rerank
召回阶段用的是向量相似度,本质是「双塔」结构,query和doc各自编码,没有交互。Rerank是「交叉编码器」,把query和doc拼在一起过模型,能捕捉细粒度相关性,但推理慢,只能用在Top-K小集合上。
标准做法:召回Top-50 → rerank → 取Top-5。召回阶段放宽是为了保证正确chunk大概率在候选里,rerank负责精排。
from FlagEmbedding import FlagReranker
class RerankerPipeline:
def __init__(self, reranker_path: str = "BAAI/bge-reranker-large"):
self.reranker = FlagReranker(reranker_path, use_fp16=True)
def retrieve_and_rerank(self, query: str, milvus_client, collection, top_k_recall=50, top_k_final=5):
# 1. 向量召回
query_vec = embedder.encode_queries([query])[0]
search_params = {"metric_type": "IP", "params": {"ef": 128}}
results = milvus_client.search(
collection_name=collection,
data=[query_vec],
anns_field="embedding",
param=search_params,
limit=top_k_recall,
output_fields=["chunk_id", "content"]
)
candidates = [(hit.entity.get("content"), hit.entity.get("chunk_id")) for hit in results[0]]
# 2. 构造query-doc对
pairs = [[query, doc] for doc, _ in candidates]
scores = self.reranker.compute_score(pairs, normalize=True)
# 3. 排序取Top-K
ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
return [(doc, cid, score) for (doc, cid), score in ranked[:top_k_final]]
bge-reranker-large 在fp16下,50个pair打分大概需要120ms(A100)。这个延迟是可接受的,因为换来的是精度大幅提升。
Round 3结果:Hit@5从82% → 89%,MRR@5从0.68 → 0.81。延迟方面,端到端P95从1.8s涨到2.6s,其中rerank占约200ms,剩下是生成变慢(因为召回更准,prompt更长)。
五、踩坑与优化
说几个真实踩过的坑:
1. 重叠窗口不能乱设。 一开始我把overlap设成200,结果检索时同一个chunk的多个重叠版本一起被召回,挤占了其他相关chunk的名额,Hit@5反而掉了3个点。后来降到80才正常。经验值:overlap控制在chunk_size的15%-25%。
2. bge模型的query instruction不能省。 我一开始图省事,query和passage都用 encode(),结果比ada-002还差。查了官方文档才发现query必须加instruction。加上之后Hit@5直接涨了9个点。
3. rerank的normalize参数。 compute_score 默认不归一化,输出的logits范围可能是-10到10。做阈值过滤时如果按0.5卡,会漏掉大量正确结果。要么开 normalize=True 映射到0-1,要么自己sigmoid。
4. Milvus的ef参数。 HNSW的 ef 是查询时的搜索范围,默认值偏小。我调到128后,召回率有小幅提升(约1.5个点),但延迟增加约30ms。如果对延迟敏感,这个值可以保守一点。
5. 别忽略冷启动。 换embedding模型后,旧索引和新query的向量空间不一致,必须全量重建。我第一次忘了这茬,混用了一天,线上效果惨不忍睹。
六、效果数据汇总
三轮迭代的完整对比:
| 轮次 | 方案 | Hit@5 | MRR@5 | P95延迟 |
|---|---|---|---|---|
| 基线 | 512固定切分 + ada-002 | 62% | 0.41 | 1.8s |
| R1 | 语义切分+重叠 | 71% | 0.52 | 1.8s |
| R2 | R1 + bge-large-zh-v1.5 | 82% | 0.68 | 1.9s |
| R3 | R2 + bge-reranker-large | 89% | 0.81 | 2.6s |
几个观察:
- embedding模型切换收益最大,+11个点,性价比最高
- chunk策略是基础,不做这一步,后面换模型和rerank的效果都会打折
- rerank提升明显但成本高,延迟涨了37%。如果业务对延迟极敏感,可以只对Top-20做rerank,命中率能保住87%左右
- 库容量从96万涨到156万(chunk变多),存储成本涨60%,但相对于精度提升完全值得
生成端我也顺手把 gpt-3.5-turbo 换成了 gpt-4-turbo,人工评估的答案满意度从68%涨到85%。不过这不是本文重点,先不展开。
七、总结
RAG系统的优化顺序很重要。我的经验是:
- 先把chunk切对,这是地基。切不好,后面全是空中楼阁
- 再选对embedding模型,中文场景优先考虑bge、m3e这类国产模型,别迷信OpenAI
- 最后上rerank,这是精度和延迟的权衡,不是必须项
- 每次只改一个变量,否则出了问题都不知道是哪一步导致的
还有一点:评估集一定要自己标。网上的公开benchmark和你的业务场景差距太大,只有自己标注的300条真实问题才能反映真实效果。我们标注花了大概两天,但后面每一步优化都有据可依,这个投入非常值。
下一步我打算试试query改写(HyDE)和多路召回(BM25+向量混合),看看能不能把Hit@5推到92%以上。有进展再写一篇。
代码都跑通了,环境版本和参数都在文中,有需要的同学可以直接复现。有问题评论区聊。