一、问题背景:一个“看起来能用”的RAG系统
先说背景。我们做的是一个内部技术知识库问答系统,数据源是Confluence导出的Markdown文档,约2100篇,平均每篇1200字,涵盖后端、运维、数据平台等方向。用户是公司内部研发,问题偏具体,比如“Kafka消费者组rebalance超时怎么排查”“Flink checkpoint对齐慢的原因”。
第一版系统搭得很快,典型三段式:
- 文档按固定512 token切块,无重叠
- embedding用OpenAI的
text-embedding-ada-002 - 向量库用Milvus 2.3.3,HNSW索引
- 检索Top-5直接塞给GPT-3.5-turbo生成
上线后评测集100条问题,人工判定:
- Top-5召回率:61%
- 回答准确率(人工打分≥4/5):54%
问题很集中:召回的内容经常“差一点”。比如问“rebalance超时”,召回的是“rebalance机制介绍”,但真正讲超时排查的那段被切到了相邻chunk里,没被召回。还有用户问“checkpoint对齐”,召回的是“checkpoint原理”,而讲“对齐慢”的段落因为和原理段被硬切分开,语义被稀释。
结论很清楚:chunk策略和embedding是瓶颈,rerank是放大器。下面按优化顺序讲。
二、环境与版本
先列一下最终环境,避免版本问题踩坑:
- Python 3.10.13
- Milvus 2.3.3(standalone,HNSW,M=16,efConstruction=200)
- FlagEmbedding 1.2.10
- sentence-transformers 2.2.2
- torch 2.1.0 + cu118
- OpenAI SDK 1.3.0(仅用于生成,不再用于embedding)
- 评测集:100条人工标注问题,每条标注gold chunk id
GPU:单卡A10 24G。embedding和rerank都在这张卡上跑,batch控制好不会OOM。
三、方案设计:三步走,每步单独评测
我的原则是每次只改一个变量,否则你不知道收益来自哪。所以分三轮:
- Chunk策略调整:512无重叠 → 384+128重叠,按标题层级优先切
- Embedding切换:ada-002 → bge-large-zh-v1.5
- 引入Rerank:bge-reranker-large,Top-20召回后重排取Top-5
每轮都用同一套100条评测集,指标固定为Top-5召回率和回答准确率。
四、核心实现
4.1 Chunk策略:别再无脑按token切
第一版直接按512 token硬切,问题是无视文档结构。改成按Markdown标题层级优先切,超长再按token切,并加128 token重叠。
import re
from langchain.text_splitter import RecursiveCharacterTextSplitter
def split_by_heading(md_text, max_tokens=384, overlap=128):
# 先按二级/三级标题切
sections = re.split(r'\n(?=#{2,3}\s)', md_text)
splitter = RecursiveCharacterTextSplitter(
chunk_size=max_tokens,
chunk_overlap=overlap,
separators=["\n\n", "\n", "。", ";", " ", ""],
length_function=lambda x: len(x) # 中文按字符近似token
)
chunks = []
for sec in sections:
if len(sec) 30]
注意两点:
- 中文场景
length_function别用tiktoken,按字符数近似更稳,384字符≈300 token左右,实测效果和512 token接近但更细。 - 重叠128是经验值,再大召回冗余多,再小边界信息丢。
4.2 Embedding切换:bge-large-zh-v1.5
ada-002对中文技术术语其实一般,尤其“rebalance”“checkpoint”这类中英混排。换bge-large-zh-v1.5,1024维,中文检索榜上长期靠前。
from FlagEmbedding import FlagModel
model = FlagModel(
'BAAI/bge-large-zh-v1.5',
query_instruction_for_retrieval="为这个句子生成表示以用于检索相关文章:",
use_fp16=True
)
def embed_docs(texts):
return model.encode(texts, batch_size=32, max_length=512)
def embed_query(q):
return model.encode_queries([q])[0]
关键点:query和doc要用不同接口。bge系列query要加instruction,doc不加。我一开始图省事两边都用encode,召回掉了一大截,后来看文档才发现。
4.3 Rerank:Top-20 → 重排 → Top-5
向量召回是双塔,精度天然不如交叉编码。所以先召回Top-20,再用bge-reranker-large精排。
from FlagEmbedding import FlagReranker
reranker = FlagReranker('BAAI/bge-reranker-large', use_fp16=True)
def retrieve_and_rerank(query, top_k=5, recall_k=20):
q_vec = embed_query(query)
hits = milvus_client.search(
collection_name="kb",
data=[q_vec],
limit=recall_k,
output_fields=["text", "doc_id"]
)[0]
pairs = [[query, h["entity"]["text"]] for h in hits]
scores = reranker.compute_score(pairs, normalize=True)
ranked = sorted(zip(hits, scores), key=lambda x: x[1], reverse=True)
return [h for h, s in ranked[:top_k]]
compute_score传list比逐条快很多,batch内部会并行。A10上20对约120ms,可接受。
五、踩坑与优化
坑1:Milvus的HNSW参数没调。 默认ef=64,召回Top-20时实际只返回了部分真近邻。把search_params={"ef": 128}加上,单这一项Top-20召回就涨了约4%。
坑2:bge-reranker输入太长会截断。 默认max_length=512,有些chunk超了被截,反而排错。我把chunk控制在384字符后基本没这问题,但保险起见设了max_length=512并监控截断率。
坑3:重叠chunk导致重复召回。 128重叠会让相邻chunk都进Top-20,rerank后可能Top-5里有两段几乎一样。加了个简单的去重:按doc_id+前50字符做key,保留分数高的。
坑4:生成阶段prompt没跟着改。 召回质量上来后,prompt里还写着“如果不确定就说不确定”,导致模型过度保守。改成“优先基于以下内容回答,内容不足时再说明”,准确率又涨了3个点。
六、效果数据
同一套100条评测集,人工判定:
| 阶段 | Top-5召回率 | 回答准确率 | 平均延迟 |
|---|---|---|---|
| 基线(512无重叠+ada-002) | 61.0% | 54.0% | 1.8s |
| +Chunk调整(384+128) | 68.0% | 60.0% | 1.9s |
| +bge-large-zh-v1.5 | 76.0% | 69.0% | 2.1s |
| +bge-reranker-large | 83.6% | 78.2% | 2.4s |
延迟从1.8s涨到2.4s,主要来自rerank的120ms和bge比ada-002略慢的编码。对内部工具来说完全可接受。
分类型看,“排查类”问题提升最大(召回+31%),因为这类答案往往集中在某个小标题下,chunk策略改完后命中率明显变好。“概念类”问题提升小一些,因为本身召回就不差。
七、总结
这轮优化下来,我的几点体会:
- Chunk是RAG的地基,别偷懒用默认512。按文档结构切+适度重叠,成本极低收益极高。
- 中文场景别迷信ada-002,bge-large-zh-v1.5在技术语料上明显更稳,而且能本地部署,省成本。
- Rerank是性价比最高的一步,Top-20重排到Top-5,召回+7.6个点,只多了100多毫秒。
- 每次只改一个变量,否则你根本不知道哪个改动有效。
- 评测集要早建,100条就够,没有它你只能靠感觉调参,纯玄学。
下一步打算试试query改写(HyDE)和父子chunk(small-to-big),有进展再写一篇。