RAG 检索精度提升全链路指南从检索前、检索中到检索后的工程实践基于我自己写的真实项目的 RAG 知识库实际构建经验系统梳理检索精度优化的方法、策略与取舍。前言RAG 检索的精度问题出在哪RAGRetrieval-Augmented Generation的核心公式看起来简单检索相关文档 → 塞进 Prompt → LLM 生成答案。但真正落地时每一个环节都可能成为精度瓶颈检索前文档切得太大导致语义稀释用户问题太口语化导致关键词失配检索中纯向量检索对专有名词不敏感纯关键词检索又不懂同义词检索后召回的结果冗余、重复或者最相关的被埋在后面本文按照检索前 → 检索中 → 检索后的时间线逐一拆解我们在本项目中落地的方法和策略以及它们的实际收益。一、检索前把数据准备好把问题问清楚检索前的优化是成本最低、收益最大的环节。输入端的一点点改进能让后续所有环节都受益。1.1 文档切片粒度决定上限问题如果把一篇 5000 字的文档整段存入向量库embedding 会被稀释为平均语义——一段讲 5 个主题的文字向量指向的却是哪个都不精准的中间地带。我们的方案fromlangchain_text_splittersimportRecursiveCharacterTextSplitter splitterRecursiveCharacterTextSplitter(chunk_size500,# 每段约 500 字chunk_overlap50,# 相邻段重叠 50 字防止语义断裂separators[\n\n,\n,。,,, ],# 优先级段落 → 句子 → 词尽可能在语义边界上切)关键设计决策场景chunk_size理由工程文档/技术规范500平衡语义完整性和检索精度小说文本按章节切分后再递归切保留叙事结构QA 对保持 question answer reasoning 完整不做切分收益长文档场景下检索精度质的提升。小切片让向量更精准BM25 也不会因为关键词只出现在文档 5% 的位置而误判整段为相关。1.2 Schema 设计双向量字段 BM25 自动化核心思路让 Milvus 自己维护稠密向量语义和稀疏向量关键词两套索引插入时只需提供文本和 embedding稀疏向量由 BM25 Function 自动生成。# 文本字段开启分词和匹配schema.add_field(field_nametext,datatypeDataType.VARCHAR,max_length2000,enable_analyzerTrue,# 启用内置分词器enable_matchTrue,# 启用 BM25 文本匹配)# BM25 自动生成稀疏向量bm25_functionFunction(nametext_bm25,input_field_names[text],output_field_names[sparse_vector],function_typeFunctionType.BM25,)schema.add_function(bm25_function)关键设计决策——QA 集合的 search_text 字段QA 集合有三个字段question、answer、reasoning。用户查询可能命中任何一个字段的关键词。我们的做法是# 插入时合并为 search_textsearch_textf{question}{answer}{reasoning}# BM25 Function 绑定到 search_text而不是只绑 question这让 QA 检索的 BM25 召回率大幅提升——一个关于变压器接线方式的查询即使问题字段里没出现接线方式但只要答案或推理过程里提到了就能命中。1.3 批量并发 Embedding入库效率 5-10x 提升串行调 embedding API 在文档量上百时不可接受。我们用批量 API asyncio 并发# 批量调用每批 10-25 条defembed_texts(texts:list[str],batch_size:int10)-list[list[float]]:all_vectors[]foriinrange(0,len(texts),batch_size):batchtexts[i:ibatch_size]resultTextEmbedding.call(modeltext-embedding-v3,inputbatch,dimension1024,)all_vectors.extend([r[embedding]forrinresult[data]])returnall_vectors注意事项text-embedding-v3 输出 1024 维。如果后续需要降级到本地模型必须确保维度一致否则不能写入同一个 Collection。1.4 查询改写输入端放大器10-50ms 换 10-20% Recall这是性价比最高的单点优化。问题用户输入那个可以做向量搜索的数据库叫啥来着“直接检索——BM25 找不到Milvus”语义向量也一头雾水。方案用一个廉价 LLM 在检索前先改写查询defrewrite_query(user_query:str)-str:responsellm.chat(messages[{role:system,content:(你是一个搜索查询优化器。将用户的口语化问题改写为简洁、关键词密集的检索查询。保留所有技术术语和专有名词。只输出改写后的查询不要解释。)},{role:user,content:user_query}],temperature0.1,# 低温度保证一致性max_tokens200,)returnresponse.strip()改写原则只服务于检索不改变用户意图专有名词、产品名、代码符号必须保留输出尽量短、关键词密集保留original_query给 LLM 回答用retrieval_query给检索引擎用容错改写失败自动回退到原始问题不中断检索流程。1.5 预处理小结优化项延迟代价精度收益实施阶段文档切片0ms离线长文档场景质的提升离线入库Schema 双向量0ms打通混合检索基础离线入库批量 Embedding0ms入库速度 5-10x离线入库Query Rewrite10-50msRecall 10-20%在线检索二、检索中双路召回 融合 智能路由检索阶段的核心思想就一个字多。多条路径各取所长然后聪明地融合。2.1 混合检索稠密 稀疏两条腿走路这是整个检索架构的骨架。稠密向量擅长语义匹配BM25 擅长精确关键词——它们天然互补维度稠密向量COSINEBM25 稀疏向量匹配方式余弦相似度语义距离TF-IDF字面匹配擅长同义词、改写、上下文理解专有名词、代码、精确术语典型失败Transformer无特殊权重汽车和轿车无法关联实现# 稠密检索 — 语义匹配req_denseAnnSearchRequest(data[query_vector],anns_fielddense_vector,param{metric_type:COSINE,nprobe:16},limittop_k,)# 稀疏检索 — 关键词匹配直接传文本不是向量req_sparseAnnSearchRequest(data[rewritten_query],anns_fieldsparse_vector,param{metric_type:BM25},limittop_k,)# 两路一起搜resultsclient.hybrid_search(collection_namecollection_name,reqs[req_dense,req_sparse],rankerRRFRanker(k60),limit10,)2.2 RRF 融合排名算术不是模型精排一个常见误区是把 RRF 当成精排。RRF 的本质是把两路检索的排名做一次算术融合纯 O(K) 计算耗时 1ms几乎零成本。RRF score(doc) Σ 1/(k rank_i(doc)) 其中 k60平滑参数rank_i 是文档在第 i 路检索中的排名什么时候用加权排序替代 RRF默认首选 RRF因为它不需要调参对分数不敏感如果明确知道语义更重要 → 加权 [0.7 稠密, 0.3 稀疏]如果关键词更重要如代码搜索→ 加权 [0.3 稠密, 0.7 稀疏]2.3 双集合并行检索文档 QA 对我们的系统有两个知识来源集合存什么像什么document_chunks文档切片 来源元数据搜索引擎的文档索引qa_pairs问题 答案 推理过程FAQ 知识库两者并行检索结果统一为Candidate结构{id:...,source:document_chunks | qa_pairs,text:用于精排和 Prompt 的主体文本,title:标题或问题,score:0.0,file_name:来源文件,doc_id:原始文档 ID,chunk_index:3,metadata:{source_type:qa,created_at:1718697600},}2.4 QA 优先路由能直接回答就别折腾生产环境中很多问题已经被 QA 对覆盖了。我们实现了一个智能路由用户问题 → 先搜 source_type qa → QA Top-1 分数 阈值 且 与 Top-2 分差 安全边际 ├ YES → 直接返回 QA 答案零 LLM 成本 100ms └ NO → 继续搜文档 chunks → 召回 → LLM 生成这带来两个好处降低延迟QA 命中时直接返回跳过 LLM 调用提高精度已审核的 QA 对比 LLM 现编更可靠2.5 检索中小结策略作用延迟稠密 稀疏双路语义 关键词互补50msRRF 融合自动融合两路排名 1ms双集合并行覆盖文档 FAQ 两类知识10msQA 优先路由高频问题零 LLM 成本-500ms**命中时反而省了 LLM 时间三、检索后去重、精排、组装、降级检索回来的 Top-K 不等于最终的上下文。检索后的链路决定了塞进 Prompt 的到底是不是最好的那几条。3.1 CrossEncoder 精排模型级别的相关性打分RRF 只看排名高低完全不理解 query 和 doc 的内容。可能某个文档在两个路径排名都不错但实际上跟问题只有一点点关系。CrossEncoder 就是来解决这个问题的。RRF 融合 → Top-10 候选 → CrossEncoder 精排 → Top-3 → 进入 Prompt └─ 融合 ─┘ └─ 精排 1ms─────────┘实现fromsentence_transformersimportCrossEncoderclassReranker:def__init__(self,model_nameBAAI/bge-reranker-v2-m3):self.modelCrossEncoder(model_name)defrerank(self,query:str,candidates:list[dict],top_k:int3):# 将 query 和每个候选 doc 拼接后一起过 Transformerpairs[[query,c[text]]forcincandidates]scoresself.model.predict(pairs)# 按分数重排rankedsorted(zip(candidates,scores),keylambdax:x[1],reverseTrue)returnranked[:top_k]延迟实测候选数MiniLM (80MB)bge-reranker-v2-m3 (560MB)Top-5~25ms~150msTop-10~50ms~300msTop-20~100ms~600ms按场景决策实时对话 / 延迟 300ms → 不加 CrossEncoder直接用 RRF Top-K精准问答 / 用户能接受 1-2 秒 → 加 CrossEncoderN10 → 300ms 换 5-15% P3 提升离线批处理 → 必加N 甚至可以到 20容错CrossEncoder 加载失败或推理报错时自动跳过精排直接使用 RRF Top-K。3.2 Small-to-Big把命中的邻居也带上一个 500 字的 chunk 被命中时它前后的 chunk 往往也包含有用信息。我们的做法是按doc_idchunk_index查询相邻 chunk作为邻居合并到结果中deffetch_neighbors(hits,collection):对每个命中 chunk拉取它的 chunk_index ± 1 的邻居expanded{}forhitinhits:neighborscollection.query(filterfdoc_id {hit[doc_id]} and fchunk_index in [{hit[chunk_index]-1},{hit[chunk_index]1}])# 合并到 expanded标记 is_neighborTruereturnexpanded邻居在后续排序中优先级低于原生命中但高于被丢弃的低分结果。这样既补全了上下文又不会让 LLM 读到一堆无关信息。3.3 两重去重别让 LLM 看到三遍同样的内容双集合检索 small-to-big 扩展后很容易出现重复——比如 QA 对和文档切片同时提到了变压器接线方式两个结果几乎一模一样。defdeduplicate(candidates,jaccard_threshold0.85):# 第一层相同 (doc_id, chunk_index) 直接去掉seen_keysset()unique[]forcincandidates:key(c.get(doc_id),c.get(chunk_index))ifkeynotinseen_keys:seen_keys.add(key)unique.append(c)# 第二层Jaccard 字符集相似度 0.85 的视为重复result[]forcinunique:is_dupFalseforrinresult:ifjaccard_charset(c[text],r[text])jaccard_threshold:is_dupTruebreakifnotis_dup:result.append(c)returnresult3.4 Token 预算管理精打细算每一段上下文检索回 6 条结果每条 500 字总共 3000 字——已经超出 DeepSeek 的上下文窗口了吗不会。但如果不控制预算系统很容易在大量检索结果面前塞满上下文LLM 反而抓不住重点。defbuild_context_blocks(candidates,max_tokens2000):# 1. 排序非邻居优先原始命中同级按 rerank_score 降序sorted_hitssorted(candidates,keylambdah:(h.get(is_neighbor,False),# 邻居放后面-(h.get(rerank_score,h.get(score,0)))))# 2. 逐个加入上下文累积 token 数blocks[]tokens_used0forhitinsorted_hits:formattedformat_context(hit)estimatedestimate_tokens(formatted)# 中文保守估计1 字符 ≈ 1 tokeniftokens_usedestimatedmax_tokens:# 剩余预算 80 字则截断加入remainingmax_tokens-tokens_usedifremaining80:blocks.append(formatted[:remaining*4]...)breakblocks.append(formatted)tokens_usedestimatedreturnblocks优先级排序QA 精准答案 高分文档 chunk 邻居 chunk 低分结果。3.5 领域分数修正让模型更懂你的行话通用检索不知道哪些词在你的领域更重要。我们实现了一个领域感知的分数调节器针对工程 KB# 领域关键词列表DOMAIN_KEYWORDS[单机容量,总装机容量,接线方式,海缆,升压站,MW,kV,GIS,AIS,主变压器,开关站,...]defadjust_qa_score(query,candidate):scorecandidate[score]# 问题问本项目而结果在说参考项目→ 降权if本工程inqueryand参考工程incandidate[text]:score*0.5# 匹配到领域关键词 → 加分forkwinDOMAIN_KEYWORDS:ifkwincandidate[text]:score*1.1returnscore这个策略虽然简单但在特定领域的 QA 检索中效果显著——它让系统不会把参考项目的海缆方案当成本项目的海缆方案返回给用户。3.6 用户反馈闭环让人工标注反哺检索前述所有策略都是系统自己想办法但最精准的信号其实来自终端用户——用户看到答案后点个 或 这个反馈比任何模型打分都更接近真实相关性。核心思路是Relevance Feedback分两个阶段落地第一阶段轻量马上可用——文档级质量分调整不绑定具体 query而是统计每篇文档的全局反馈# 反馈记录表doc_feedback{doc_id:milvus_intro_003,thumbs_up:15,thumbs_down:3,}# 检索后乘一个信誉系数defapply_doc_reputation(candidate):feedbackget_feedback(candidate[doc_id])iffeedback.total0:returncandidate# 无反馈保持原分数# 好评率映射为 0.5x ~ 1.5x 的权重ratiofeedback.thumbs_up/feedback.total candidate[score]*0.5ratio# 好评率 100% → 1.5x0% → 0.5xreturncandidate实现成本极低——一个反馈表 检索后乘系数但能有效压制被反复踩过的劣质文档抬升经过多人验证的优质文档。第二阶段真正有效果——微调 CrossEncoder当反馈积累到一定量级500-1000 条以上将标注转化为精排模型的训练样本标注结构(query_text, doc_text, label) - 用户点 → label1正例 - 用户点 → label0负例 - 用户没反馈的 → 不参与训练 用这些样本定期微调 bge-reranker 或 MiniLM → 精排模型学会这类问题应该偏好这类文档 → 不增加检索链路延迟模型还是原来的推理速度 → 标注越多、效果越稳定Snowflake 的 Arctic 和 Cohere 的 Rerank 走的都是这条路——用真实用户反馈 Fine-Tune 精排模型比任何规则调参都有效。两个需要注意的问题同类问题怎么匹配不是简单字符串匹配。用户问变压器怎么接线和主变接线方案措辞不同但意图相同。最可靠的方式是对 query 做 embedding用向量相似度COSINE 0.9找邻近的历史标注把邻近标注也纳入当前查询的分数修正。标注稀疏性。假设每天 100 次查询、10% 用户愿意反馈一天只积累 10 条。建议第一阶段至少跑 1-2 个月积累够量再进入第二阶段。在此之前保持规则修正为主不要过早引入稀疏的反馈信号。3.7 检索后小结优化项作用延迟CrossEncoder模型级相关性重排50-300ms可选Small-to-Big补全上下文不丢信息10ms两重去重减少 LLM 上下文浪费5msToken 预算防止超长导致截断或注意力分散5ms领域分数修正让行话和语境更准确 1ms用户反馈闭环人工标注反哺检索持续进化5ms查反馈表四、兜底降级链路与质量防线每一层都可能出问题——API 挂了、模型加载失败、检索结果为 0。一个好的 RAG 系统必须有清晰的降级策略。4.1 全链路降级查询改写 ──失败──→ 使用原始问题 │ embedding API ──失败──→ 纯 BM25 检索 │ CrossEncoder ──失败──→ 直接用 RRF Top-K │ LLM API ──失败──→ 返回检索原文 模型暂时不可用 │ 检索结果为空 ──→ 明确告知知识库未收录相关内容4.2 置信度拒绝不该答的坚决不答检索分数太低时系统会直接拒答而不是凭 LLM 的记忆去编造def_should_refuse(results):Top-1 分数 retrieval_min_score默认 0.15→ 拒答ifnotresultsorresults[0][score]config.retrieval_min_score:returnTruereturnFalse这是防止幻觉的最后一道防线——宁可说我不知道也不能编一个看起来像真的错误答案。五、评估用数据说话没有评估的优化是盲目的。我们的评估方案分两个层次5.1 结构化的功能评估预定义测试用例每个 case 包含预期 answer mode、必须包含的关键词、不能出现的关键词、预期的来源类型classEvaluationCase:question:strexpected_mode:str# qa_direct | doc_retrieval | refusemust_include:list[str]# 答案必须包含的短语must_not_include:list[str]# 答案不能包含的短语expected_source_type:str# qa | document_chunks | None跑完后自动统计通过率每个失败 case 附原因。5.2 各阶段耗时追踪trace{rewrite_ms:45,embed_ms:120,search_ms:52,rerank_ms:280,generate_ms:800,recalled_ids:[...],citations:[...],fallback_reason:None,}这让我们能看到优化到底省没省时间、慢在哪一步。六、完整链路图所有策略的位置┌── 检索前离线────────────────────────────────────────────┐ │ │ │ 原始文档 → 多格式解析 → 清洗 → 递归切片(chunk500/overlap50) │ │ → [QA对合并 search_text] → 批量 Embedding(1024d) │ │ → Milvus 入库 [双向量 BM25自动生成 标量索引] │ │ │ ├── 检索前在线────────────────────────────────────────────┤ │ │ │ 用户问题 → Query Rewrite(LLM改写) → 生成 query_vector │ │ │ ├── 检索中 ──────────────────────────────────────────────────┤ │ │ │ 稠密检索(COSINE) ─┐ │ │ ├ RRF融合(k60) → Top-N 候选 │ │ BM25检索(稀疏) ──┘ │ │ │ │ ↓ 同时进行 ↓ │ │ │ │ QA集合检索 → QA分数判断 → 够高则直接返回跳过后续所有步骤 │ │ │ ├── 检索后 ──────────────────────────────────────────────────┤ │ │ │ RRF候选 → [CrossEncoder精排](可选) → small-to-big邻居扩展 │ │ → 两重去重(精确Key Jaccard 0.85) │ │ → Token预算控制(max 2000 tokens) │ │ → 领域分数修正 │ │ → [用户反馈加权] → 带来源标记的Prompt组装 → LLM生成 │ │ │ ├── 反馈闭环 ────────────────────────────────────────────────┤ │ │ │ 用户 / → 文档信誉系数 → [积累足够后] → 微调 CrossEncoder │ │ │ ├── 兜底 ────────────────────────────────────────────────────┤ │ │ │ 全链路降级 置信度拒答 结构化评估 │ │ │ └─────────────────────────────────────────────────────────────┘七、优先级排序如果资源有限先做什么从实际工程经验出发按性价比排序优先级优化项评级收益代价1Query Rewrite★★★★★Recall 10-20%10-50ms1 次 LLM 调用2文档切片★★★★★长文档场景质的提升0离线做完3CrossEncoder 精排★★★★P3 5-15%50-300ms按需开启4双路混合检索★★★★语义关键词互补0架构自带5Token 预算 去重★★★★防截断防幻觉10ms6QA 优先路由★★★★高频问题零 LLM 成本 5ms7领域分数修正★★★领域适配 1ms8降级链路★★★服务可用性保障09用户反馈闭环★★★长期持续进化阶段一 5ms阶段二离线前三项加在一起能把一个能跑的 RAG 系统提升到能用的水平。八、一句话总结检索精度不是靠单一技术堆出来的而是靠检索前改写切片、检索中双路融合路由、检索后精排去重预算三层防线层层打磨出来的。Query Rewrite 是性价比最高的单点优化双路混合检索是架构基石CrossEncoder 是按需开启的精修器而全链路降级和置信度拒答是生产环境的安全网。