一、问题背景
去年底我们给公司内部做了一套技术文档问答系统,底层就是最朴素的RAG:文档切片 → 向量化 → 存向量库 → 检索Top-K → 拼Prompt → 调LLM生成答案。文档量不算大,大约1200篇内部Wiki,覆盖后端规范、运维手册、API文档,总计约8万段落。
上线之后反馈很一般。用户问“订单服务的超时重试策略是怎么配的”,系统经常召回一堆不相关的部署文档;问“MySQL慢查询阈值告警怎么配”,召回的却是Redis监控的内容。我们抽样了200条真实提问做人工评估,结果很难看:
- Recall@5(正确文档片段是否在前5条召回中):61.3%
- 答案准确率(人工判断回答是否正确):54.0%
- 平均首字延迟:1.2s
问题很明显:检索环节就是瓶颈。LLM本身用的是qwen-plus,生成能力没问题,但喂给它的上下文经常是错的。这篇文章就记录我们怎么一步步把这两个指标拉上来的。
二、环境与版本
先把环境交代清楚,避免版本差异导致结果不可复现:
- Python 3.10.13
- LangChain 0.1.16(只用了DocumentLoader和TextSplitter,检索链路自己写的)
- 向量库:Milvus 2.3.5(standalone模式,HNSW索引,M=16,efConstruction=200)
- Embedding:初期 openai text-embedding-ada-002(1536维),后期 BAAI/bge-large-zh-v1.5(1024维)
- Reranker:BAAI/bge-reranker-base(后期引入)
- LLM:qwen-plus(temperature=0.3,max_tokens=1024)
- 硬件:单卡A10 24G,embedding和reranker都跑在本地
评估集:200条人工标注问答对,每条标注了正确的文档片段ID。评估脚本自己写的,算Recall@K和人工打分两套指标。
三、方案设计与迭代过程
整个优化分三个阶段,每个阶段单独评估,避免一次改太多变量导致归因不清。
阶段一:Chunk策略调整
初始版本用的是LangChain的RecursiveCharacterTextSplitter,chunk_size=512,chunk_overlap=0。这个配置的问题在于:内部Wiki里大量文档是带层级标题的,512字符硬切经常把一个完整的配置说明从中间切断,比如“重试策略”的配置项和它的解释被分到两个chunk里,检索时只能命中半截。
我们改成按Markdown标题层级做语义分段,再对超长段落做滑动窗口切分,chunk_size=800,overlap=150。同时每个chunk头部拼上它所属的标题路径(比如“订单服务 > 超时配置 > 重试策略”),这个上下文前缀对embedding效果提升很明显。
核心切分代码如下:
import re
from typing import List
def split_by_heading(text: str, max_size: int = 800, overlap: int = 150) -> List[str]:
"""按Markdown标题分段,超长段再滑动窗口切分,每段拼上标题路径"""
# 按 # 标题切分,保留标题
sections = re.split(r'\n(?=#{1,4}\s)', text)
chunks = []
heading_stack = [] # [(level, title)]
for sec in sections:
if not sec.strip():
continue
m = re.match(r'^(#{1,4})\s+(.+)', sec)
if m:
level = len(m.group(1))
title = m.group(2).strip()
while heading_stack and heading_stack[-1][0] >= level:
heading_stack.pop()
heading_stack.append((level, title))
body = sec[m.end():].strip()
else:
body = sec.strip()
path = " > ".join(t for _, t in heading_stack)
prefix = f"[{path}]\n" if path else ""
# 超长段落滑动窗口切分
if len(body) = len(body):
break
start = end - overlap
return chunks
这个改动之后,Recall@5从61.3%涨到68.9%。提升不算爆炸,但方向对了——chunk里信息完整了,embedding才有东西可编码。顺带说一句,chunk_overlap从0改成150之后,chunk总数从8万涨到11万,向量库存储和检索成本涨了约37%,但这是必要代价。
阶段二:Embedding模型切换
text-embedding-ada-002是通用模型,对中文技术文档的语义区分度一般。我们做了一组对比测试,在同一个评估集上跑了几个候选模型:
| 模型 | 维度 | Recall@5 | 单条编码耗时 |
|---|---|---|---|
| text-embedding-ada-002 | 1536 | 68.9% | 约80ms(API) |
| text2vec-large-chinese | 1024 | 71.2% | 约25ms(本地A10) |
| bge-large-zh-v1.5 | 1024 | 76.4% | 约28ms(本地A10) |
bge-large-zh-v1.5明显更好,而且本地部署省了API调用成本和网络延迟。注意bge系列做检索时要给query加指令前缀,这个坑我们踩过——不加前缀Recall会掉3个点左右。正确用法是query加"为这个句子生成表示以用于检索相关文章:",document不加。
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("BAAI/bge-large-zh-v1.5", device="cuda")
def encode_queries(queries: List[str]) -> List[List[float]]:
# query 必须加指令前缀,document 不加
prefixed = [f"为这个句子生成表示以用于检索相关文章:{q}" for q in queries]
return model.encode(prefixed, normalize_embeddings=True, batch_size=32).tolist()
def encode_docs(docs: List[str]) -> List[List[float]]:
return model.encode(docs, normalize_embeddings=True, batch_size=64).tolist()
切换模型意味着全量重建索引,11万chunk在A10上重编码大约花了18分钟,可以接受。这一阶段Recall@5到76.4%,答案准确率到66.8%。
阶段三:引入Rerank
到这一步,向量检索的天花板开始显现。双塔模型(bi-encoder)本质上是query和doc各自编码后算余弦相似度,细粒度的语义匹配能力有限。比如“重试策略”和“重试机制”在向量空间里很近,但用户问的是“超时重试”,和“失败重试”就是两回事,向量模型区分不出来。
解法是两阶段检索:向量召回Top-20,再用cross-encoder做精排取Top-5。cross-encoder把query和doc拼在一起过模型,能捕捉token级别的交互,精度高但慢,所以只对小候选集用。
我们用的是bge-reranker-base,实测单条(query, doc)打分约12ms,20条候选约240ms,这个延迟可以接受。
from transformers import AutoTokenizer, AutoModelForSequenceClassification
import torch
rerank_tokenizer = AutoTokenizer.from_pretrained("BAAI/bge-reranker-base")
rerank_model = AutoModelForSequenceClassification.from_pretrained(
"BAAI/bge-reranker-base"
).cuda().eval()
@torch.no_grad()
def rerank(query: str, candidates: List[str], top_k: int = 5) -> List[int]:
"""返回按相关性降序的候选索引"""
pairs = [[query, doc] for doc in candidates]
inputs = rerank_tokenizer(
pairs, padding=True, truncation=True, max_length=512, return_tensors="pt"
).to("cuda")
scores = rerank_model(**inputs).logits.view(-1).float().cpu()
ranked = torch.argsort(scores, descending=True)[:top_k]
return ranked.tolist()
检索链路改成:向量召回Top-20 → rerank取Top-5 → 拼Prompt。这一阶段Recall@5(rerank后的Top-5)直接到89.7%,答案准确率82.4%。
四、踩坑与优化
几个值得说的坑:
-
bge query前缀:前面提过,不加指令前缀Recall掉约3个点。这个在bge的模型卡里有写,但很容易忽略。
-
rerank的max_length:bge-reranker-base默认max_length=512,我们的chunk是800字符,中文tokenizer下大约500-600 token,部分chunk被截断,截断后打分偏低。把max_length调到512保持默认、同时把chunk_size从800降到700,效果反而更稳。这是个权衡,chunk太大rerank看不全,chunk太小信息不完整。
-
Milvus的HNSW参数:召回Top-20时ef=64有点小,实际召回质量受索引影响。把search_params的ef从64调到128,Recall@20涨了约2个点,延迟增加约8ms,划算。
-
延迟:全链路从1.2s涨到1.9s,主要来自rerank的240ms和embedding本地推理比API慢一点点。我们做了个优化:rerank只对Top-20做,且用batch推理;另外把embedding服务做了常驻进程,避免每次冷启动加载模型。最终稳定在1.9s左右,用户可接受。
-
评估集偏差:一开始评估集是从线上日志里挑的“成功案例”,导致指标虚高。后来重新按问题类型分层抽样(配置类、排障类、API用法类各占三分之一),指标才真实。这个坑很隐蔽,建议评估集一定要独立于调优过程构建。
五、效果数据汇总
三个阶段的对比如下(同一评估集,200条):
| 阶段 | Recall@5 | 答案准确率 | 平均延迟 |
|---|---|---|---|
| 初始(512固定切分 + ada-002) | 61.3% | 54.0% | 1.2s |
| 阶段一(语义分段+overlap) | 68.9% | 59.5% | 1.2s |
| 阶段二(+ bge-large-zh-v1.5) | 76.4% | 66.8% | 1.4s |
| 阶段三(+ bge-reranker-base) | 89.7% | 82.4% | 1.9s |
几个观察:
- chunk策略的收益是“打地基”,不改这个后面都是白搭。它带来的Recall提升(+7.6pt)不如换模型(+7.5pt)显眼,但它让后续rerank有更好的候选集,是乘数关系。
- embedding切换是性价比最高的一步,本地部署还省了API钱。
- rerank是精度提升最大的一步(+13.3pt Recall),代价是延迟+0.5s。如果你的场景对延迟敏感(比如实时对话),可以考虑rerank候选集缩到Top-10,我们实测Top-10 rerank的Recall@5是87.1%,比Top-20低2.6个点但延迟少一半。
六、总结
这套RAG系统从“能用但不好用”到“基本可信”,核心就三件事:切分要尊重文档结构、embedding要选对中文模型、检索要加一层cross-encoder精排。三步下来Recall@5从61.3%到89.7%,答案准确率从54%到82.4%,延迟代价0.7s。
如果让我给正在做RAG的同学一条建议:先把评估集建好,再动手调。我们前期因为评估集有偏差,白调了两周。另外,chunk策略和embedding是耦合的,换模型之后要重新验证chunk参数,不要一套参数用到死。
后续我们打算试试query改写(用LLM把用户口语化问题改写成检索友好的query)和混合检索(BM25+向量),有结果再写一篇。