摘要RAG 项目从 Demo 推到生产第一个崩的就是检索召回率。本文记录了一次真实优化过程——从最基础的分词切片开始经过语义分块、混合检索、Reranker 重排、Query 改写 4 轮迭代把召回率从 30% 拉到 92%。每个方案都有可复现代码和踩坑实录。1. 背景与痛点事情要从一个朋友的项目说起。上个月他找我帮忙看一个知识库问答系统——10 万份技术文档搭了个 RAGDemo 阶段跑得挺好。结果一上生产用户反馈翻车率惊人十个问题里至少三四个搜不到答案。明明文档里写了模型就是找不到。他说。我让他跑了个召回率测试结果出来了——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轻量做实验够用pipinstallchromadb0.6.2# Embedding 模型BAAI/bge-large-zh-v1.5pipinstallsentence-transformers3.3.1# 文档处理pipinstalllangchain-text-splitters0.3.6# 混合检索用 BM25pipinstallrank-bm250.2.2# Rerankerpipinstallcross-encoder1.0.0版本号都写着别装错了——踩坑 4 就是被版本兼容搞的后面会讲。# 基础加载代码fromsentence_transformersimportSentenceTransformerimportchromadbembed_modelSentenceTransformer(BAAI/bge-large-zh-v1.5)chroma_clientchromadb.PersistentClient(path./rag_demo_db)collectionchroma_client.get_or_create_collection(nametech_docs,metadata{hnsw:space:cosine})4. 实战实现5 次迭代迭代 1基础分词召回率 30%最朴素的做法——按长度硬切每 500 字符一段不重叠。fromlangchain_text_splittersimportRecursiveCharacterTextSplittersplitterRecursiveCharacterTextSplitter(chunk_size500,chunk_overlap0,separators[\n\n,\n,。, ,])chunkssplitter.split_text(raw_doc_text)问题在哪一句话信息被切成两半了。比如一段文档写了连接数据库时如果端口不是默认的 3306需要在连接字符串中指定端口号刚好在端口号那里被切开了。后面那半段单独检索语义是残缺的。模型根本不知道它在说什么。结果召回率 30%。等于白做。迭代 2语义分块 重叠窗口召回率 50%加两个东西语义分割——按自然段落和标题层级切而不是硬切字符数重叠窗口——相邻 chunk 重叠 10%-15%确保边界内容不会被丢弃fromlangchain_text_splittersimportMarkdownHeaderTextSplitterheaders_to_split_on[(#,header1),(##,header2),(###,header3),]markdown_splitterMarkdownHeaderTextSplitter(headers_to_split_onheaders_to_split_on)# 按标题分层sectionsmarkdown_splitter.split_text(markdown_doc)# 再对过长的 section 做语义切分final_splitterRecursiveCharacterTextSplitter(chunk_size500,chunk_overlap75,# 15% 重叠separators[\n\n,\n,。, ,])final_chunks[]forsectioninsections:sub_chunksfinal_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]bm25BM25Okapi(tokenized_corpus)defhybrid_search(query,top_k10,alpha0.5):# 向量检索query_embembed_model.encode(query)vector_resultscollection.query(query_embeddings[query_emb],n_resultstop_k*2)# BM25 检索tokenized_querylist(jieba.cut(query))bm25_scoresbm25.get_scores(tokenized_query)bm25_top_indicessorted(range(len(bm25_scores)),keylambdai:bm25_scores[i],reverseTrue)[:top_k*2]# 融合加权 Reciprocal Rank Fusionall_scores{}forrank,idxinenumerate(vector_indices):all_scores[idx]all_scores.get(idx,0)(1-alpha)/(rank1)forrank,idxinenumerate(bm25_top_indices):all_scores[idx]all_scores.get(idx,0)alpha/(rank1)# 按融合得分排序rerankedsorted(all_scores.items(),keylambdax:-x[1])[:top_k]return[all_chunks[idx]foridx,_inreranked]这一轮加到 68%。确实有效但还不够。排查下来发现新问题Top-K 里混进了大量看起来相关但其实无关的噪音。模型把噪音也塞进上下文反而干扰了生成。迭代 4Reranker 重排序召回率 82%混合检索拿回 Top-50然后用 Cross-Encoder Reranker 重新排只取 Top-10。fromsentence_transformersimportCrossEncoderrerankerCrossEncoder(BAAI/bge-reranker-large)defrerank_search(query,candidates,top_k10):pairs[[query,doc]fordocincandidates]scoresreranker.predict(pairs)scoredsorted(zip(candidates,scores),keylambdax:-x[1])return[docfordoc,scoreinscored[:top_k]]Reranker 是把 Query 和每个候选文档一起送入模型做深度交叉计算。慢——每次大概多花 200-500ms——但效果值得。加了这个之后召回率从 68% 冲到 82%。知道为什么吗我翻了几条被 Reranker 捞回来的文档发现它们和 Query 的关键词完全不重合但语义确实是相关的。向量检索因为这俩嵌得太远没召回BM25 因为没有关键词共现也没命中。只有 Cross-Encoder 能把用 A 工具实现 B 效果和B 效果的另一种实现方式这种弱关联识别出来。不过还有 18% 没抓到。这个最让人头疼——因为它们大多数是用户问法和文档措辞差异太大导致的。迭代 5Query 改写 HyDE召回率 92%最后一轮不折腾检索链路了改折腾问题本身。defrewrite_query(original_query,llm_client):promptf请将用户问题改写成适合检索的形式。原始问题{original_query}改写规则1. 提取核心关键词和实体2. 补全缩写和简称3. 如果是口语化表达改写为书面语4. 输出 2-3 个不同角度的改写版本改写结果responsellm_client.chat(prompt)returnresponse.strip().split(\n)# 同时对改写后的多个版本做检索去重合并queries[original_query]rewrite_query(original_query,llm_client)all_results[]forqinqueries:resultsfull_search_pipeline(q)all_results.extend(results)# 去重unique_resultslist(dict.fromkeys(all_results))[:top_k]配合 HyDE假设性文档嵌入——先生成一个假设回答再用这个回答去检索defhyde_query(original_query,llm_client):promptf根据问题写一段假设性的回答文档。问题{original_query}回答应该用技术文档的语体、包含可能的关键词和专有名词、长度约 200 字。hypothetical_docllm_client.chat(prompt)# 用假文档的 embedding 去检索hyde_embembed_model.encode(hypothetical_doc)resultscollection.query(query_embeddings[hyde_emb],n_resultstop_k)returnresultsQuery 改写 HyDE 的组合拳把最后 10% 的缺口补上了召回率达到 92%。5. 效果验证迭代方案召回率单次检索耗时增量难度1基础字符切片30%50ms低2语义分块 重叠窗口50%50ms低3混合检索BM25 向量68%120ms中4Reranker 重排序82%450ms中高5Query 改写 HyDE92%1.2s高最后一轮耗时从 450ms 涨到 1.2s——因为多调了两次 LLM。要不要上 HyDE取决于你的场景对延迟的容忍度。6. 踩坑记录坑 1Embedding 模型升级导致向量维度不匹配项目中期想把 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(向量检索)坑 3Reranker 的 batch size 设太小一开始 batch_size1300 个候选文档跑了 2 分钟。改成 batch_size32 以后耗时降到 3 秒。Cross-Encoder 的 batch inference 优化效果显著。坑 4Chroma 0.5.x 升级到 0.6.x 不兼容我的 Chroma 版本是 0.6.2但朋友机器上是 0.5.9。他导入了我导出的 collection 文件直接挂了。Chroma 0.6 改了内部存储格式不能向下兼容。解决方案两边统一版本。坑 5HyDE 生成质量不稳定LLM 生成的假文档质量方差很大。有时候写得很好检索效果爆棚有时候瞎写一通拉低召回率。做了个质量检查如果假文档长度 50 字或包含抱歉之类的话就 fallback 到普通检索。7. 总结与展望5 轮迭代下来结论很简单先切好片说的话——语义分块是性价比最高的优化别只靠向量——BM25 低成本高回报Reranker 值得上——但注意延迟成本Query 改写是终局方案——但需要 LLM 调用成本较高上完这 5 步召回率从 30% 到了 92%。剩下 8%我觉得不在检索链路本身了。有些问题是文档质量造成的——原文写得太含糊模型怎么搜都搜不到。对了如果你的场景对延迟特别敏感200ms迭代 3混合检索 迭代 2语义分块应该是最优组合性价比最高。HyDE 和 Reranker 适合对准确率要求高、延迟容忍度大的场景。你做的 RAG 项目卡在哪一步是切片策略不对还是检索召回太差评论区说说我整理到后续的工程化专题里。