资讯动态

RAG检索实战:双塔召回+单塔精排,构建可靠知识库问答

发布时间:2026/10/17 4:21:30 来源:尧图企业网站定制
不做宣告式开头直接开始。如果你是个刚接触大模型的小白程序员多半听说过这么一件事模型本身再强也没法准确回答你公司内部知识库的问题。你问它“这个接口怎么调”它能给你写出标准参考答案但你问“咱们项目里的订单状态机是怎么定义的”它就只会一本正经地编。这个时候就需要RAG。很多人一听到RAG紧接着又会撞上两个高频名词双塔架构和单塔架构。听起来玄乎其实拆开看并没有那么难。这篇内容我准备用一路实操下来的理解把RAG里最核心的检索部分讲明白双塔负责什么、单塔负责什么、为什么两者要搭配着用、具体代码怎么写、上线前要避开哪些坑。标题里带上ChatGPT和Claude Code不是因为它们是什么神秘技术而是它们刚好代表了两类最典型的大模型使用场景一个是通用对话一个是编码助手。对话要回答私有知识编码助手要理解整个仓库的代码底层都逃不开“先检索、后生成”这套逻辑。适合刚入门大模型、想做知识库问答或代码检索的小白程序员收藏读完至少能自己搭出一个像样的检索系统。1. RAG到底是什么双塔和单塔为什么避不开1.1 换个角度理解RAG先翻书再答题RAG全称是Retrieval-Augmented Generation检索增强生成。你可以把它类比成一场开卷考试模型肚子里装着海量语料但考试题目可能是你自己公司内部的东西它没见过。于是你先给它一本参考书让它翻到对应章节再根据上下文答题。这里的“翻书动作”就是检索“答题”就是生成。很多人一开始天真地以为RAG就是把所有文档全部塞进模型的上下文窗口里让模型自己看。这个想法在只有两三页文档时勉强成立但一旦文档变成几百篇、几千篇又或者像代码仓库这种动辄几十万个文件的情况所有内容塞进去不但撑爆上下文还会让模型在大量无关信息里迷失更别提Token成本和延迟。所以RAG的思路一定是先精准检索出最相关的片段再交给模型生成。检索这一步就是双塔和单塔登场的舞台。双塔解决的是“怎么从几十万段资料里快速捞出100个候选”单塔解决的是“怎么从100个候选中精挑出最靠谱的3段”。一个是海选一个是终审。1.2 双塔和单塔的本质区别是否把问题和文档拼在一起我把这个问题说得直白一点。双塔架构里有两个独立的编码器或者说“塔”一个负责把用户提出的问题编码成一个向量另一个负责把文档片段编码成另一个向量。问题向量和文档向量各自独立生成然后在线上去算相似度。单塔架构则完全不同。它只有一个模型把用户问题和候选文档拼接成一个整体序列喂进去让模型在内部做深度交互最后直接输出一个“相关不相关”的打分。你可以把单塔理解成一个双方的深度对话评审。这个差异带来一个核心矛盾双塔快但粒度粗单塔准但只能处理少量候选。所以成熟的RAG系统几乎都是双塔和单塔串联使用。我还是建议你记住这句话双塔负责召回单塔负责重排二者不是二选一的关系而是一条流水线上的上下游。2. 双塔架构先在海量文档里捞回100个候选2.1 双塔的工作方式与离线优化思路双塔的精髓是“把文档向量预先算好”。文档侧是离线流程系统启动前把全部文档切成片段逐个编码成向量存进向量数据库。用户提问时只需要把问题编码成一个向量然后去向量库里做近邻搜索找到跟问题向量最相似的Top100个片段。为什么这个设计高效因为海量文档的向量化大头全部被放到了离线阶段。线上查询时文档完全不参与模型计算只有一条问题向量和一次近邻检索。在FAISS这类向量索引的加持下百万级文档的检索也能做到毫秒到十几毫秒级别。小白刚开始做的时候最容易忽略的是“向量方向”问题。我用过一个开源向量模型第一版直接用发现相似度得分普遍都很高但结果却乱七八糟。后来才发现中文句子必须用支持中文语义的模型并且编码时打开归一化选项用余弦相似度计算才能把分数拉到一个可比较的区间。这算是双塔召回的第一步。2.2 小白最容易踩的切块坑双塔召回的输入是“文档片段”所以切块策略直接决定检索效果。很多新手的本能是把一篇长文切成固定长度的句子比如每100个字一段。结果经常发现一个完整知识点被拦腰切断向量里的语义信息被稀释查询自然召不回。我常用的基础经验是切块大小在行政命令类、技术文档类长文本上建议512个字符左右同时保留64个字符左右的overlap重叠。重叠的目的是让跨块信息不至于被切断。比如一个数据库索引定义在前面一段、查询示例在下一段的开头如果没有重叠这两部分会被拆散检索时经常只有一段命中另一段丢失。另外切块要考虑文档结构和自然边界。宁可让一个表格、一个标题下的小节整体成块也不要为了追求固定长度硬切。我做过一个知识库项目规则类文档按章节为边界切块Recall5直接提升了约12个百分点成本却没有增加。2.3 用现成向量模型快速跑通双塔召回直接给一段可参考的最小实现。这里用开源库 sentence-transformers 来演示双塔召回文本向量模型使用中文场景下常见的BGE系列实践里很多公司也在用同类型方案。from sentence_transformers import SentenceTransformer import numpy as np # 双塔召回doc编码离线算好query编码线上实时算 model SentenceTransformer(BAAI/bge-small-zh-v1.5, devicecpu) model.max_seq_length 512 # 与切块长度保持一致 docs [ 订单状态机包含已创建、已支付、已发货、已完成四个状态, 生成订单后调用支付接口回调成功后状态变为已支付, 退款流程仅允许在已支付状态下发起, 用户可以通过订单号查询物流信息, ] # 离线阶段文档向量化并归一化 doc_vectors model.encode(docs, normalize_embeddingsTrue) # 线上阶段问题向量化后做余弦相似度召回 query 订单什么时候可以退款 query_vector model.encode([query], normalize_embeddingsTrue) scores np.dot(doc_vectors, query_vector.T).squeeze() candidates np.argsort(scores)[::-1][:2] # 取Top2作为候选 for idx in candidates: print(fscore{scores[idx]:.4f}, doc{docs[idx]})注意这里query和docs用的是同一个模型这在双塔召回阶段是常见做法也够用。更进阶的做法是分别微调query塔和doc塔让模型更适合“问题匹配文档”的任务但那是后话。第一步先把流程跑通。提示如果你的文档规模达到几十万片段直接用numpy算相似度会非常慢必须换成FAISS或Chroma这类向量索引。构建索引时记得用与编码时相同的归一化配置否则检索分数会乱套。3. 单塔架构把100个候选精排成3个3.1 单塔为什么更准让问题和文档“面对面”打分双塔把问题编码成一个向量、把文档编码成另一个向量两个向量之间只在最后计算一次内积过程中的交互非常有限。而单塔模型会把“问题”和“候选文档”拼在一起变成一整段输入例如[CLS] 订单什么时候可以退款[SEP] 退款流程仅允许在已支付状态下发起 [SEP]模型在多层网络里反复做交叉注意力捕捉到句子间复杂的语义咬合关系最终输出一个相关性分数。这也是为什么单塔的精度通常高于双塔。打个比方双塔是简历筛选依据几个关键词和标签决定要不要给面试机会单塔是面试官和你面对面聊了半小时最后给你的综合评价。简历海选能快速从几万份里筛出100人但要确定最终录用谁肯定得靠深聊。RAG流水线里这个“深聊”就是单塔精排。但代价也很明显。单塔需要把每个候选文档都拼一次拼接长度通常限制在128到512个token之间。如果你让单塔直接对几十万段文档逐个打分计算量爆炸线上完全扛不住。所以它在流程上只能放在召回之后对已经浓缩出来的少量候选做精排。3.2 为什么必须“先双塔、后单塔”这是我见过很多小白想不通的地方。有人问既然单塔更准为什么不用单塔直接检索所有文档一步到位原因很简单复杂度。单塔处理一段文档的时间远高于双塔而且这个时间还随拼接长度增长。假设库里有20万段文档即使用GPU并行单塔逐个打分也需要数分钟甚至更久线上的问答系统根本等不起。双塔一次近邻检索查完100个候选只需几十毫秒然后用单塔对这100个候选精排整体延迟还能控制在可接受范围内。所以标准做法一定是一条流水线双塔召回从20万段文档中快速挑出Top100。单塔精排对100个候选逐一打分按分数取Top3。生成把Top3片段拼接成上下文交给大模型生成答案。对应的场景里ChatGPT类通用问答要回答私有知识库问题Claude Code这种编码工具要处理的是更大体量的代码库检索。代码仓库可能大到几十万个文件直接全量喂给模型不现实也必须靠双塔把相关文件或代码片段捞出来再精排、再交给生成模型。这套结构是通用的。3.3 用重排器给召回结果排序重排阶段可以直接用开源的CrossEncoder模型也就是单塔精排模型的常见实现。from sentence_transformers import CrossEncoder # 单塔精排把问题与候选文档拼接后打分 reranker CrossEncoder(BAAI/bge-reranker-base, max_length256) query 订单什么时候可以退款 candidates [ 订单状态机包含已创建、已支付、已发货、已完成四个状态, 退款流程仅允许在已支付状态下发起, 生成订单后调用支付接口回调成功后状态变为已支付, 用户可以通过订单号查询物流信息, ] pairs [(query, doc) for doc in candidates] scores reranker.predict(pairs) for doc, score in sorted(zip(candidates, scores), keylambda x: x[1], reverseTrue): print(fscore{score:.4f}, doc{doc})这段代码里原来的Top2候选被我故意扩大成了4个方便你看清排序的变化。双塔召回的分数只能告诉你大致相关性而单塔可以重新打乱顺序。实际场景里双塔取100个候选单塔精排后取前3个喂给大模型。注意单塔精排的效果建立在召回候选基本靠谱的基础上。如果双塔阶段就漏掉了正确答案单塔再怎么排也找不回来。排错的时候先查召回命中率再查重排是否有效顺序不能反过来。4. 小白能直接上手的完整RAG检索落地流程4.1 数据清洗与索引构建很多教程上来就让你装库、写代码但真实项目里花时间最多的往往是清洗。文档里常见的脏数据包括页面顶部底部导航重复文本、编码乱码、扫描件转出来的错字、广告插入、Markdown符号残留。这些噪声都会污染向量导致检索结果飘忽不定。我建议按这个顺序处理去重把重复段落按MD5或文本相似度去掉代码库场景尤其重要重复注释比想象多。清理HTML标签、URL、多余换行、控制字符统统清掉。标准化全角半角统一英文字母小写化去除超长无意义的字符串。切块按自然章节切分主体再按固定长度与重叠量做兜底。切块后的文档建议控制在512个字符以内。这里的“字符”不等同于Token实际编码后可能切成两段但保留overlap可以缓解。我个人实操时用的是“500字符主体 50字符重叠”效果比较稳定。需要调节时优先调重叠量而不是硬调块大小。向量索引的选型也很关键。入门阶段用ChromaDB最省心直接Python dict就能启动项目规模上来后用FAISS更高效。FAISS本身不负责完整存储原数据只存储向量和索引ID你需要自己维护一个ID到原文的映射表。4.2 双塔加单塔的完整流水线与参数配置我给出一个可以直接落地的完整RAG检索脚本思路。这里不贴生产级代码目的是把各个参数的位置和逻辑说清楚你根据项目实际情况替换数据源。import faiss import numpy as np from sentence_transformers import CrossEncoder, SentenceTransformer # 参数配置 CHUNK_SIZE 500 # 切块字符长度 OVERLAP 50 # 切块重叠长度 TOP_K_RECALL 100 # 双塔召回候选数 TOP_N_RERANK 3 # 单塔精排最终保留数 # 离线读取、清洗、切块、向量化、建索引 raw_text read_document(knowledge_base.txt) chunks split_by_chars(raw_text, CHUNK_SIZE, OVERLAP) embedder SentenceTransformer(BAAI/bge-small-zh-v1.5) chunk_vectors embedder.encode(chunks, normalize_embeddingsTrue) index faiss.IndexFlatIP(chunk_vectors.shape[1]) # 内积索引 index.add(chunk_vectors.astype(float32)) # 线上双塔召回 query 订单什么时候可以退款 query_vector embedder.encode([query], normalize_embeddingsTrue).astype(float32) scores, indices index.search(query_vector, TOP_K_RECALL) candidate_texts [chunks[idx] for idx in indices[0]] # 线上单塔精排 reranker CrossEncoder(BAAI/bge-reranker-base, max_length256) pairs [(query, doc) for doc in candidate_texts] rerank_scores reranker.predict(pairs) order np.argsort(rerank_scores)[::-1][:TOP_N_RERANK] final_docs [candidate_texts[i] for i in order] # 生成交给大模型 context \n\n.join(final_docs) prompt f请基于以下资料回答问题\n{context}\n\n问题{query} # 将prompt交给ChatGPT/Claude Code等大模型接口得到最终答案参数选择这里值得展开说一下。TOP_K_RECALL设为100是为了让单塔有足够的候选空间。你可能会想双塔召回后直接取Top10不就行了何必多一次重排但实测中双塔Top10里可能只有一两条真正相关其余全是高相似度噪声。扩大到Top100再让单塔精排能显著提高最终Top3的准确率。TOP_N_RERANK设为3是一个很务实的折中。上下文塞太多片段会让大模型“不知道该信谁”内容太长也会增加生成延迟。3个段落通常足够支撑一段有依据的答案。如果最终答案时常缺关键细节可以放宽到5但我很少超过这个值。4.3 怎么判断系统到底好不好没有评估就调参等于闭着眼调琴。我强烈建议你在做任何优化前先建一个评测集准备20到50条真实用户可能问的问题每一条提前标注好应该命中哪些文档片段。然后计算两个核心指标Recallk前k个结果里包含相关文档的比例。反映召回够不够全。NDCGk排序位置的加权指标。相关的排越靠前得分越高。反映排序质量。举个例子如果5条问题里有4条在Top10里找到了正确答案Recall10就是80%。但单看Recall还不够因为正确答案排在Top9和Top1用户的体验完全不同所以要用NDCG看排序。我自己常用做法是固定一个query列表跑一轮双塔加单塔流水线打印每个query的Top3和正确文档的排位。这样能非常直观地看到是“根本没召回”还是“召回了但没排到前面”。提示评测集不要只选简单的正面问题要加入你真实场景里的难问法比如“上次那个支付报错怎么解决的”“接口返回格式是什么”这种真实口语Query才最能暴露问题。5. 实战中踩过的坑与问题速查5.1 症状一结果看着相关但正确答案排在很后面排查顺序很有讲究。我先查是否召回阶段就已经漏了。方法很简单手工抽取几条query在完整文档向量库中搜索看看正确答案是否出现在Top100里。如果没出现问题在双塔召回侧根源通常是切块粒度、向量模型领域偏差或者query里有大量专有名词。如果正确答案被召回但排在50名开外你就该优化双塔向量模型或者考虑给query做改写。比如“这个东西什么时候能退”这种口语改成“退款条件与时间规则”后召回往往立竿见影。很多项目会加一个轻量级改写模型先让大模型把用户问题改写成更接近文档表述的检索式问题这个做法实测有效。如果正确答案出现在Top20但最终Top3里没有那问题多半出在单塔精排。先用调试模式打开重排分数看相关文档的分数是不是本身就不高如果是换更合适的重排模型或者在领域数据上微调。5.2 症状二加单塔精排后效果反而变差见过不止一个新项目出现这种情况。第一反应不是单塔模型有问题而是候选集本身太差。单塔只能在双塔给它的候选里做选择双塔召回没覆盖到正确文档单塔怎么排都白搭。另一种可能是拼接过长被截断。很多重排模型max_length只有256或512候选文档太长会被截断恰好把关键信息切掉模型判断依据丢失。解决办法是控制候选文档长度或者换支持更长输入的重排模型。还有一种隐蔽问题是重排模型和业务领域不匹配。重排模型训练语料偏向通用场景如果你做的是垂直领域的法律条款问答通用模型打分会失真。这种情况下不要盲目调参优先去收集几百条领域内的相似query和对应文档做一次微调或适配。5.3 症状三延迟太高线上没法跑一旦检索流水线全部串起来最容易超时的环节就是单塔精排。100个候选虽然不多但每个都要和其他模型跑一次交叉编码CPU上可能要跑几秒钟用户等不起。主流优化思路有四个第一双塔召回从Top100降到Top50牺牲少量覆盖率换延迟第二单塔精排用批量预测不要一条一条循环提交一次批量处理所有候选对第三把单塔精排结果做缓存相同或近似的query直接复用第四对候选文档做分桶加速只精排其中一部分最可能的片段。生产项目里我通常先做批量预测和候选数缩减这两个改动性价比最高。5.4 问题速查表现象最可能原因优先排查项相关文档根本没进入候选切块不合理 / 向量模型不适配手工验证召回命中率相关文档在Top20但进不了Top3单塔重排模型不够强检查重排分数和候选截断相似文档分数普遍偏高向量没有归一化 / 余弦相似度未使用重开归一化选项query口语化严重召回不准问题与文档表达差异大增加query改写步骤线上延迟高单塔精排太慢降候选数、批量预测、加缓存答案出现幻觉生成阶段上下文太碎或缺失依据检查Top3片段是否真的覆盖答案重排结果不稳定候选长度截断 / 领域不匹配控制输入长度、考虑领域微调我实际做项目时会把这张表打印出来贴在工位旁边。踩坑之后先判断阶段再对症下药比随机调参高效得多。这也是为什么我一再强调RAG不是一个“调大模型”的问题而是一个“检索链路治理”的问题。最后分享一个我自己的体会第一次接触RAG时我也以为只要把文档丢进向量库、问一个问题、把Top3塞给大模型就完事了。后来才发现双塔决定天花板单塔决定下限生成模型只负责把检索到的内容组织成像是人话的答案。现在我做任何RAG项目第一件事永远是拉20条真实Query跑一轮“召回-精排-生成”全链条先看结果再谈优化。这套方法帮我避开了很多看似玄学的问题。希望这篇整理也能成为你入门路上的速查手册收藏下来遇到问题翻一翻会比埋头调参有用得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑