RAG 检索召回率优化实战:从 30% 到 92% 的 5 次迭代
摘要:RAG 项目从 Demo 推到生产,第一个崩的就是检索召回率。本文记录了一次真实优化过程——从最基础的分词切片开始,经过语义分块、混合检索、Reranker 重排、Query 改写 4 轮迭代,把召回率从 30% 拉到 92%。每个方案都有可复现代码和踩坑实录。
1. 背景与痛点
事情要从一个朋友的项目说起。
上个月他找我帮忙看一个知识库问答系统——10 万份技术文档,搭了个 RAG,Demo 阶段跑得挺好。结果一上生产,用户反馈翻车率惊人:十个问题里至少三四个搜不到答案。
"明明文档里写了,模型就是找不到。"他说。
我让他跑了个召回率测试,结果出来了——30%。也就是说,70% 的相关文档根本就没被向量检索捞回来。模型再聪明也没用,数据都没喂进去。
这个场景太典型了。国内 RAG 项目 POC 阶段准确率 90%+ 的很多,但你去看上线一周后的数据,60% 都算好的。原因就一条:Demo 用的几百份文档,切片随便切切就行;生产是几十万份文档,切片策略直接决定召回生死。
这篇文章就是那 5 次迭代的过程记录。如果你也在搞 RAG 项目,建议先跳过"进阶技巧",把基础检索链路调明白——这个坑踩不动。
2. RAG 检索链路(3 个环节都在漏)
先画一下检索链路,方便后面理解每次迭代在改什么。
flowchartLRA[用户问题]--> B[Query 处理]B--> C[向量检索]C--> D[相关性排序]D--> E[Top-K 输出]F[(文档库)]--> G[文档切片]G--> H[Embedding]H--> Csubgraph优化点1[阶段1:切片策略]F--> Gendsubgraph优化点2[阶段2:检索方式]Cendsubgraph优化点3[阶段3:排序优化]Dend问题往往不是单一环节造成的。三次漏:
- 切片环节漏:文档切得太粗或太细,关键信息被切丢
- 检索环节漏:向量检索本身对短文本和长文本的匹配能力有限
- 排序环节漏:Top-K 截断了本来相关的文档
每次迭代锁一个环节。
3. 环境准备
先准备好基础依赖:
# 向量库:Chroma 0.6.2(轻量,做实验够用)pipinstallchromadb==0.6.2# Embedding 模型:BAAI/bge-large-zh-v1.5pipinstallsentence-transformers==3.3.1# 文档处理pipinstalllangchain-text-splitters==0.3.6# 混合检索用 BM25pipinstallrank-bm25==0.2.2# Rerankerpipinstallcross-encoder==1.0.0版本号都写着,别装错了——踩坑 4 就是被版本兼容搞的,后面会讲。
# 基础加载代码fromsentence_transformersimportSentenceTransformerimportchromadbembed_model=SentenceTransformer("BAAI/bge-large-zh-v1.5")chroma_client=chromadb.PersistentClient(path="./rag_demo_db")collection=chroma_client.get_or_create_collection(name="tech_docs",metadata={"hnsw:space":"cosine"})4. 实战实现:5 次迭代
迭代 1:基础分词(召回率 30%)
最朴素的做法——按长度硬切,每 500 字符一段,不重叠。
fromlangchain_text_splittersimportRecursiveCharacterTextSplittersplitter=RecursiveCharacterTextSplitter(chunk_size=500,chunk_overlap=0,separators=["\n\n","\n","。"," ",""])chunks=splitter.split_text(raw_doc_text)问题在哪?一句话:信息被切成两半了。
比如一段文档写了"连接数据库时,如果端口不是默认的 3306,需要在连接字符串中指定端口号",刚好在"端口号"那里被切开了。后面那半段单独检索,语义是残缺的。模型根本不知道它在说什么。
结果:召回率 30%。等于白做。
迭代 2:语义分块 + 重叠窗口(召回率 50%)
加两个东西:
- 语义分割——按自然段落和标题层级切,而不是硬切字符数
- 重叠窗口——相邻 chunk 重叠 10%-15%,确保边界内容不会被丢弃
fromlangchain_text_splittersimportMarkdownHeaderTextSplitterheaders_to_split_on=[("#","header1"),("##","header2"),("###","header3"),]markdown_splitter=MarkdownHeaderTextSplitter(headers_to_split_on=headers_to_split_on)# 按标题分层sections=markdown_splitter.split_text(markdown_doc)# 再对过长的 section 做语义切分final_splitter=RecursiveCharacterTextSplitter(chunk_size=500,chunk_overlap=75,# 15% 重叠separators=["\n\n","\n","。"," ",""])final_chunks=[]forsectioninsections:sub_chunks=final_splitter.split_text(section.page_content)final_chunks.extend(sub_chunks)效果立竿见影:召回率从 30% 跳到 50%。
但还不够。50% 意味着还有一半的相关文档没找到。问题出在检索方式上——单靠向量检索干不过长尾关键词。
迭代 3:混合检索(BM25 + 向量)(召回率 68%)
向量检索擅长语义相似但短文本匹配不行。BM25(关键词匹配)正好补这个短板。
fromrank_bm25importBM25Okapiimportjieba# 中文需要分词tokenized_corpus=[list(jieba.cut(chunk))forchunkinall_chunks]bm25=BM25Okapi(tokenized_corpus)defhybrid_search(query,top_k=10,alpha=0.5):# 向量检索query_emb=embed_model.encode(query)vector_results=collection.query(query_embeddings=[query_emb],n_results=top_k*2)# BM25 检索tokenized_query=list(jieba.cut(query))bm25_scores=bm25.get_scores(tokenized_query)bm25_top_indices=sorted(range(len(bm25_scores)),key=lambdai:bm25_scores[i],reverse=True)[:top_k*2]# 融合:加权 Reciprocal Rank Fusionall_scores={}forrank,idxinenumerate(vector_indices):all_scores[idx]=all_scores.get(idx,0)+(1-alpha)/(rank+1)forrank,idxinenumerate(bm25_top_indices):all_scores[idx]=all_scores.get(idx,0)+alpha/(rank+1)# 按融合得分排序reranked=sorted(all_scores.items(),key=lambdax:-x[1])[:top_k]return[all_chunks[idx]foridx,_inreranked]这一轮加到 68%。确实有效,但还不够。
排查下来发现新问题:Top-K 里混进了大量"看起来相关但其实无关"的噪音。模型把噪音也塞进上下文,反而干扰了生成。
迭代 4:Reranker 重排序(召回率 82%)
混合检索拿回 Top-50,然后用 Cross-Encoder Reranker 重新排,只取 Top-10。
fromsentence_transformersimportCrossEncoderreranker=CrossEncoder("BAAI/bge-reranker-large")defrerank_search(query,candidates,top_k=10):pairs=[[query,doc]fordocincandidates]scores=reranker.predict(pairs)scored=sorted(zip(candidates,scores),key=lambdax:-x[1])return[docfordoc,scoreinscored[:top_k]]Reranker 是把 Query 和每个候选文档一起送入模型做深度交叉计算。慢——每次大概多花 200-500ms——但效果值得。
加了这个之后,召回率从 68% 冲到 82%。
知道为什么吗?我翻了几条被 Reranker 捞回来的文档,发现它们和 Query 的关键词完全不重合,但语义确实是相关的。向量检索因为这俩嵌得太远没召回,BM25 因为没有关键词共现也没命中。只有 Cross-Encoder 能把"用 A 工具实现 B 效果"和"B 效果的另一种实现方式"这种弱关联识别出来。
不过,还有 18% 没抓到。这个最让人头疼——因为它们大多数是用户问法和文档措辞差异太大导致的。
迭代 5:Query 改写 + HyDE(召回率 92%)
最后一轮,不折腾检索链路了,改折腾问题本身。
defrewrite_query(original_query,llm_client):prompt=f"""请将用户问题改写成适合检索的形式。原始问题:{original_query}改写规则:1. 提取核心关键词和实体2. 补全缩写和简称3. 如果是口语化表达,改写为书面语4. 输出 2-3 个不同角度的改写版本改写结果:"""response=llm_client.chat(prompt)returnresponse.strip().split("\n")# 同时对改写后的多个版本做检索,去重合并queries=[original_query]+rewrite_query(original_query,llm_client)all_results=[]forqinqueries:results=full_search_pipeline(q)all_results.extend(results)# 去重unique_results=list(dict.fromkeys(all_results))[:top_k]配合 HyDE(假设性文档嵌入)——先生成一个假设回答,再用这个回答去检索:
defhyde_query(original_query,llm_client):prompt=f"""根据问题,写一段假设性的回答文档。问题:{original_query}回答应该:用技术文档的语体、包含可能的关键词和专有名词、长度约 200 字。"""hypothetical_doc=llm_client.chat(prompt)# 用假文档的 embedding 去检索hyde_emb=embed_model.encode(hypothetical_doc)results=collection.query(query_embeddings=[hyde_emb],n_results=top_k)returnresultsQuery 改写 + HyDE 的组合拳把最后 10% 的缺口补上了,召回率达到 92%。
5. 效果验证
| 迭代 | 方案 | 召回率 | 单次检索耗时 | 增量难度 |
|---|---|---|---|---|
| 1 | 基础字符切片 | 30% | 50ms | 低 |
| 2 | 语义分块 + 重叠窗口 | 50% | 50ms | 低 |
| 3 | 混合检索(BM25 + 向量) | 68% | 120ms | 中 |
| 4 | Reranker 重排序 | 82% | 450ms | 中高 |
| 5 | Query 改写 + HyDE | 92% | 1.2s | 高 |
最后一轮耗时从 450ms 涨到 1.2s——因为多调了两次 LLM。要不要上 HyDE,取决于你的场景对延迟的容忍度。
6. 踩坑记录
坑 1:Embedding 模型升级导致向量维度不匹配
项目中期想把 ada-002 换成 bge-large-zh,结果发现向量库里的旧数据还是 1536 维,新数据切成 1024 维了。查询时直接报错。
解法:重建向量库,或者分 collection 存不同版本,查询时分别检索再合并。
坑 2:中文分词对 BM25 影响巨大
jieba 默认词典不包含技术术语,"RAG"被切成"R"和"AG"了。BM25 匹配基本报废。需要加自定义词典:
jieba.add_word("RAG")jieba.add_word("召回率")jieba.add_word("向量检索")坑 3:Reranker 的 batch size 设太小
一开始 batch_size=1,300 个候选文档跑了 2 分钟。改成 batch_size=32 以后,耗时降到 3 秒。Cross-Encoder 的 batch inference 优化效果显著。
坑 4:Chroma 0.5.x 升级到 0.6.x 不兼容
我的 Chroma 版本是 0.6.2,但朋友机器上是 0.5.9。他导入了我导出的 collection 文件,直接挂了。Chroma 0.6 改了内部存储格式,不能向下兼容。解决方案:两边统一版本。
坑 5:HyDE 生成质量不稳定
LLM 生成的假文档质量方差很大。有时候写得很好,检索效果爆棚;有时候瞎写一通,拉低召回率。做了个质量检查:如果假文档长度 < 50 字或包含"抱歉"之类的话,就 fallback 到普通检索。
7. 总结与展望
5 轮迭代下来,结论很简单:
- 先切好片说的话——语义分块是性价比最高的优化
- 别只靠向量——BM25 低成本高回报
- Reranker 值得上——但注意延迟成本
- Query 改写是终局方案——但需要 LLM 调用,成本较高
上完这 5 步,召回率从 30% 到了 92%。剩下 8%,我觉得不在检索链路本身了。有些问题是文档质量造成的——原文写得太含糊,模型怎么搜都搜不到。
对了,如果你的场景对延迟特别敏感(<200ms),迭代 3(混合检索)+ 迭代 2(语义分块)应该是最优组合,性价比最高。HyDE 和 Reranker 适合对准确率要求高、延迟容忍度大的场景。
你做的 RAG 项目,卡在哪一步?是切片策略不对,还是检索召回太差?评论区说说,我整理到后续的工程化专题里。
