这几年聊 RAG 的项目越来越多,但真正把 RAG 做好的人,反而比刚开始那批还少。原因很简单:大家都把重心放在“生成”上,没人愿意花时间打磨“检索”。我之前接手过一个知识库问答系统,当时的表现就是“资料放进去能问,但答案基本靠猜”。后来拆开一看,检索环节只是把一个向量数据库接上,文档直接整篇丢进去,Query 进来之后拿向量库默认配置算了个相似度,然后把召回结果原样塞给大模型。这种系统上线之后,用户随口换一种说法问同一个问题,答案就完全对不上。所以我对“没有检索模型构建 RAGs 是一个严重的错误”这件事,几乎是举双手赞成。
先说清楚一个容易被误解的点:检索模型不是指某个单独的“检索大模型”,而是整套负责“从知识库里找到有用上下文”的组件组合。嵌入模型、切分策略、索引结构、召回方式、重排器,甚至 Query 改写逻辑,都属于检索模型的范畴。很多人以为“我用了向量数据库,所以我有检索了”,这其实是把“存储”当成了“检索”。向量数据库只负责把文本变成向量存下来,再给你一个暴力的相似度搜索接口,它并不知道什么内容对你的问题真正有用。真正的检索模型,需要在召回阶段就把“有可能相关”的候选捞出来,再通过重排把“真正有用”的内容顶上去,最后变成大模型能直接消费的上下文。
1. 先搞清楚:RAG 里的“检索模型”到底指什么
1.1 RAG 不是“向量库 + 大模型”这么简单
很多人第一次接触 RAG,看到的架构图都是这样的:文档切块 → 向量化 → 存入向量库 → 用户提问 → 向量检索 → 拼 Prompt → 大模型回答。看起来确实很顺,甚至用现成的框架只需要几十行代码就能跑通。但跑通和跑好是两码事。
RAG 的完整链路其实是两个大阶段:检索阶段和生成阶段。生成阶段由大模型完成,决定了回答的表达质量;检索阶段则决定了“大模型到底能看到什么材料”。如果检索阶段返回的内容本身就不相关、不完整、碎片化,那么大模型即使再强,也只能基于垃圾输入编一个流畅的垃圾答案——这就是所谓的“幻觉”来源之一。
我见过很多项目方,花钱调大模型 Prompt、换更强的大模型,结果效果提升微乎其微。问题恰恰出在检索端:召回结果里一半是无关文档,另一半是相关但信息已经被切碎的片段。这个时候再怎么优化生成,天花板已经被检索质量锁死了。所以构建 RAG 的第一原则应该是:先把检索做成一个可以被评测、被调优的独立子系统,再谈后面的大模型。
1.2 没有检索模型的三种典型表现
结合我观察到的翻车案例,“没有检索模型”在实际项目中通常表现为三种状态。
第一种是完全不检索。有些团队图省事,把大量资料直接塞进 Prompt 的上下文里,让大模型自己找答案。这种做法的后果是上下文窗口很快被撑爆,而且大模型对长上下文的注意力分布并不均匀,中间位置的关键信息经常被忽略。知识库稍微大一点,这条路就根本走不通。
第二种是只用一个简单的向量相似度搜索,不做任何召回策略设计。比如文档不切分或者随便按固定长度切,Query 不做改写,召回 Top-K 取出来就完事。这种做法能跑通 Demo,但一到真实问题就露馅:用户用口语提问、用同义词替换、问一个跨段落才能回答的综合问题,向量检索单凭语义相似度往往无法稳定命中。
第三种是做了检索,但根本没有“排序”的概念。向量检索给出一个相似度分数,就认为这个分数代表“相关性”。实际上,向量相似度和真正的相关性之间有很大差距。一个含有大量关键词、但核心观点不对的段落,向量相似度可能很高;一个句子简短但精准命中问题要害的片段,相似度分数反而可能很低。没有重排阶段,这些分数会被直接当成排序依据,Top-K 里混进大量噪音。
1.3 “检索”是一件需要单独构建的系统工程
把检索当成系统工程来看,你会发现它至少包含四个可独立优化的环节:召回源构建、召回策略、排序策略、查询端处理。
召回源构建决定了一个知识库里有多少内容是可以被搜到的。文档切分粒度、索引字段设计、是否需要保留标题和摘要等元信息,都在这一层决定。召回策略决定了一次查询用哪些手段去找候选内容——是用字面匹配,还是语义匹配,还是两者结合。排序策略负责在召回的候选里做精排,把最相关的内容放在最前面。查询端处理则负责把用户的原始问题转换成更适合检索的形式,比如纠错、改写、拆解成多个子查询。
这四个环节任何一个偷懒,最终都会反馈到回答质量上。所以后面所有内容和方案,都会围绕这四个环节展开。这也是“没有检索模型”这句话里真正想表达的:你不是少了一个库,而是少了整个检索工程。
2. 为什么说这是严重错误:几个真实翻车现场
2.1 只接向量库不调检索,问答结果全靠运气
我之前处理过一个客服知识库,场景很典型:企业有几百篇产品文档和操作手册,希望员工能通过自然语言快速查到自己想要的故障处理步骤。当时他们的做法非常简单——PDF 转文本,每篇文档作为一个整体丢进向量库,用户提问后取出相似度最高的 3 篇文档,全量拼进 Prompt。
上线后出现了一个很有意思的现象:问“发票开错了怎么办”,系统返回的 Top 文档居然是整篇《发票管理制度》,里面包含大量无关的报销流程和审批说明,真正关于“开错发票作废重开”的步骤只占一小段。大模型虽然能从这一小段里抽出答案,但因为上下文里还混着大量其他制度内容,回答里经常出现“根据报销流程,你需要先提交审批”这种多余的话。
问题就出在检索粒度上。整篇文档作为召回单元,意味着召回的上下文颗粒度太粗。向量相似度虽然能判断“这篇文档和发票有关”,但没法精确到“这一段的第几条才是用户要的答案”。正确的做法应该是把文档切成更小的语义块,让检索的目标直接落在“可以独立支撑一个答案的段落”上。
2.2 关键词检索直接替代向量召回,语境信息全丢
另外一类翻车是另一个极端:为了省去向量化的麻烦,直接用关键词检索(如 BM25)作为唯一召回方式。这种方案对专有名词和编号类问题确实很稳,比如查“HC-300 型号的额定电压”,关键词命中非常准。但一旦问题带有改写、口语化表达或跨语言需求,就完全失灵。
比如用户问“设备一直滴滴响是怎么回事”,文档里写的是“蜂鸣器持续报警提示”。字面上没有任何一个词重叠,纯关键词检索的得分会是 0,哪怕文档里描述的就是这个故障。类似的还有“怎么退款”和“退款流程是什么”,用户问法稍微一变,关键词检索就直接空手而归。
关键词检索并不是不能用,但它只能作为召回手段之一,不能作为唯一手段。语义向量负责解决“说法不同但意思相同”的问题,关键词负责解决“必须精确匹配编号、术语”的问题,两者结合才靠谱。
2.3 召回结果不做重排,Top-K 里全是噪音
还有一种隐蔽的错误:召回做得很认真,向量、关键词都上了,但召回之后直接把 Top-K 拼进 Prompt,没有重排。这种方案比前两种好一些,但召回的排序依据(相似度分数)并不等于真正的相关性。
我做过一次对比试验。同一个知识库、同一个问题,分别用“向量召回 Top-10 直接拼”和“向量召回 Top-20 再用交叉编码器重排取 Top-5”两种方式跑。前者给出的答案经常出现张冠李戴的情况,后者则稳定很多。原因在于:向量检索的相似度分数代表的是“语义空间里的距离”,重排器衡量的是“这段文本和问题之间的真实逻辑关联”。后者更接近人类判断“这句话到底有没有回答问题”的方式。
尤其当文档切分粒度偏小、单块文本信息密度不高的时候,Top-K 里往往会混入很多“看起来相关但实际没回答”的碎片。没有重排,这些碎片就会白白占据上下文窗口,甚至挤出真正有用的内容。
2.4 不同搭建方式的差距对照表
为了让差距更直观,我把几种常见搭建方式放在一张表里对比。这基本上是 RAG 从简到繁的三个台阶,也是我建议项目逐步升级的路线。
| 搭建方式 | 召回准确性 | 对复杂问题的支持 | 实现成本 | 适用场景 |
|---|---|---|---|---|
| 仅向量库默认相似度检索 | 低 | 差 | 极低 | 纯 Demo、概念验证 |
| 固定切块 + 向量/关键词混合召回 | 中 | 一般 | 较低 | 中小型知识库、内部工具 |
| 语义切块 + 混合召回 + 查询改写 + 重排 | 高 | 好 | 较高 | 生产级问答、客服、企业知识库 |
注意这张表不是绝对的。有些场景下文档高度结构化、用户问题模式固定,可能只需要混合召回就够用。但如果你想构建一个能应对真实用户各种问法的 RAG,重排和查询改写迟早要补上。跳过这些环节,就意味着你的系统把最重要的判断能力外包给了一个冷冰冰的余弦相似度。
3. 正确姿势:一套可落地的检索管道怎么搭
3.1 文档切分:先让检索器知道“一段”该多大
检索质量的上限,在文档切分这一步就已经被定死了。切得太粗,一块文本里信息太杂,召回后上下文颗粒度太大;切得太细,单块文本失去语境,召回了也答不对。
我常用的切分策略是“结构优先 + 长度兜底”。先按文档本身的章节层级切:一级标题、二级标题、段落,尽量保证每一块是一个相对完整的语义单元。然后再按长度限制做二次分割,比如单块控制在 200 到 500 个 token 左右,并设置前后重叠 20 到 50 个 token,避免一句话因为切分而拦腰截断。
拿 LangChain 这类框架来说,用递归字符文本分割器就能实现基础效果。核心思路是让分割器按“段落 → 句子 → 标点”的优先级逐级尝试切分,而不是简单粗暴按固定字符数截断。代码大致长这样:
from langchain.text_splitter import RecursiveCharacterTextSplitter splitter = RecursiveCharacterTextSplitter( chunk_size=400, chunk_overlap=50, separators=["\n\n", "\n", "。", ".", "!", "?"], ) chunks = splitter.split_text(raw_document_text)这里的chunk_size和chunk_overlap不是随便填的。chunk_size 太小,单块内容不够回答完整问题;太大,向量表示的语义精度会稀释,而且重排阶段的计算成本也会上升。chunk_overlap 的作用是让相邻块之间有上下文连续性,防止关键信息恰好落在切分边界上。我建议根据自己文档的平均段落长度实验调整:如果文档段落普遍较长,就把 chunk_size 调大一点;如果文档是短句密集的操作手册,sikeli 调小一点效果更好。
3.2 混合召回:向量与关键词,两条腿走路
我强烈建议生产级 RAG 直接上混合召回,而不是只靠向量。混合召回的意思是:一次查询同时走向量检索和关键词检索引擎,然后把两路结果合并去重,再统一排序。
向量检索负责语义泛化,解决“换个说法”的问题;关键词检索(比如 BM25)负责精确命中,解决“文档里明确写了某个型号、编号、专有名词”的问题。两路的得分尺度不一样,不能直接相加,一般用加权合并或先各取 Top-N 再交给重排器统一排序。
以 FAISS 做向量检索、以 BM25 做关键词检索为例,一个简化的混合召回流程长这样:
def hybrid_search(query, top_k=20): # 向量召回 query_vector = embed_model.encode(query) vector_hits = vector_index.search(query_vector, top_k) # 关键词召回 keyword_hits = bm25_index.search(query, top_k) # 两路结果合并,按 doc_id 去重 merged = {} for doc_id, score in vector_hits: merged.setdefault(doc_id, {"score": 0, "source": "vector"}) merged[doc_id]["score"] += score * 0.5 for doc_id, score in keyword_hits: merged.setdefault(doc_id, {"score": 0, "source": "keyword"}) merged[doc_id]["score"] += score * 0.5 return sorted(merged.items(), key=lambda x: x[1]["score"], reverse=True)[:top_k]两路权重需要根据你的知识库类型调。代码类文档、表格类数据,关键词权重要高一些;口语化问答、客服对话记录,向量权重可以更高。没有绝对正确的固定值,只能基于实测调。
很多框架(比如 LangChain、LlamaIndex 以及各种国产 RAG 框架)现在都有现成的混合检索组件,配置好 BM25 后端和向量库,一行代码就能把两路召回跑起来。重点是理解这套机制在做什么:它是在用两种互补的信号提高召回的覆盖面。
3.3 查询改写:先把问题翻译成文档听得懂的话
用户的原始问题往往不是检索友好的。最常见的两类问题:一是过于口语化,和文档用词差异很大;二是信息不全,比如问“那个能批量导出的工具在哪”,代词“那个”没有任何检索意义。
查询改写就是在大模型处理之前,先用一个轻量 LLM 调用把用户问题翻译成“更适合检索的查询”。这一步可以简单理解成:给用户的话做一次同义扩展和补全,输出几个不同角度的检索子问题。
我常用的一个轻量 Prompt 模板:
用户的问题:{question} 请把上面的问题改写成 3 个适合知识库检索的子查询。 要求: 1. 每个子查询独立成句,避免依赖上下文。 2. 保留专有名词、型号、编号等关键信息。 3. 可以用同义词替换部分口语化表达,不改变原意。 只输出子查询列表,不要其他内容。然后用这个 Prompt 跑一次大模型调用,把返回的子查询分别送到检索引擎,最后再把所有召回结果合并去重。这样做的直接收益是:即使原始问法特别模糊,只要扩展后的某个子查询恰好命中了文档里的表述,检索就能把正确内容捞出来。
代价是增加了一次大模型调用延迟,大概多几百毫秒。对于对响应速度极度敏感的场景,可以把查询改写做成可选开关,只对失败查询启用。但无论是否开启,都要知道这个环节的存在。
3.4 重排序:给 Top-K 一个“后悔”的机会
重排是整个检索管道里最容易被忽略、但也最值得加的一步。向量检索和关键词检索都只是“粗排”,它们的任务是从整个知识库里快速筛出候选;重排的任务是在这些候选里用更精细的模型精挑细选。
重排最常用的实现是交叉编码器。把问题和候选文本拼接成一对输入,用一个专门的排序模型给它们打一个相关性分数。这种方式比向量相似度更准,因为模型可以同时看到问题和文本的完整语义交互,而不是把两者各自压缩成向量之后再用距离衡量。
我一般用 Sentence Transformers 或者 huggingface 上的 cross encoder 模型来跑。核心流程如下:
from sentence_transformers import CrossEncoder # 加载一个重排模型 reranker = CrossEncoder("某模型路径") def rerank(query, candidates, top_k=5): pairs = [[query, doc["text"]] for doc in candidates] scores = reranker.predict(pairs) ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True) return [doc for doc, score in ranked[:top_k]]这一步通常需要把候选量控制在 20 到 50 条以内,因为交叉编码器的计算复杂度比向量检索高很多,不适合对全库直接排序。流程上就是:混合召回先快速缩小范围,重排再精挑细选,最终进入上下文的只有 5 条左右高质量内容。这也是我推荐的标准链路。
重排器的选择也很有讲究。轻量模型速度快,但相关性判断能力有限;重量级模型更准,但延迟和显存占用都更高。我的经验是先从轻量模型开始,实测效果不够再加更大的模型,不要一上来就上最大参数,避免投入产出比失衡。
4. 实操复盘:完整构建一次带检索模型的 RAG
4.1 第一步:建立一张小规模评测表
不管检索管道怎么搭,第一步永远是做评测集。没有评测集的调优,本质上就是凭感觉调参,今天觉得好,明天换个问题又露馅。
我一般会先挑 30 到 50 个真实用户问过的问题,给每个问题标注“正确答案应该来自哪份文档或哪个段落”。不需要太复杂,一张 Excel 表格就够,字段包括“问题”、“对应文档ID”、“对应段落文本片段”、“备注”。然后跑一遍检索管道,检查每个问题是否能在召回结果里找到标注的答案片段,计算命中率。
这个过程就像给检索系统建立了一把尺子。之后无论你怎么调切块大小、换嵌入模型、改重排模型,都可以用同一把尺子对比前后效果,而不是靠感觉说“好像好了一点”。我见过太多项目连评测集都没有,就开始用大模型输出质量做主观评估,结果优化的方向完全跑偏。
4.2 第二步:组装并验证检索管道
接下来就是你实际组装管道的阶段。我会按这个步骤来:
- 先把文档解析成纯文本,按 3.1 的切分策略切成块。
- 为每个块生成嵌入向量,写入向量库,同时为关键词检索建立倒排索引。
- 测试单路检索的效果,看向量命中率和关键词命中率分别怎么样。
- 接入查询改写,看看二次召回的命中率有没有提升。
- 加入重排,验证 Top-5 内容是否真正覆盖答案。
- 把最终选出的内容拼进 Prompt,跑通完整的问答链路。
每一步都要用评测集里的问题去测。比如你发现关键词路命中率明显高于向量路,那说明你的文档用词规范、专有名词多,调参时就应该加重关键词权重。反过来,如果向量路能命中但关键词路经常空手,说明用户问法和文档表述差异大,就要重点优化查询改写。
在验证阶段,我习惯在每个块上保留一个doc_id和source字段,方便出问题时回溯到原始文档。否则你面对的是几十万个块的向量索引,出了问题根本不知道该去查哪。
4.3 踩过的几个坑
这一节分享几个我在实际项目中踩过的坑,每个都花了不少时间才排查出来。
第一个坑是忘记统一 Query 和文档的预处理逻辑。比如文档里全是英文缩写和大写型号,用户查询时输入的是小写,关键词检索直接失配。解决方式是在切分和查询入口都做文本归一化——统一大小写、去掉多余标点、繁体转简体。这个步骤看起来不起眼,但对关键词召回的影响非常大。
第二个坑是向量库配置和嵌入模型不匹配。有些向量库默认的维度是 768,而某个嵌入模型输出的是 1024 维,插入数据时不会报错,查询时结果全是错误的。排查了半天才发现是维度不匹配。这个坑要靠规范化的初始化配置来绕过去,最好在代码里强制校验向量维度。
第三个坑是切分时把表格或者代码块的上下文切断了。一个表格的每个单元格被当成独立文本块之后,检索回来的内容往往只有孤零零的数字或字段名,完全没法支撑回答。处理这类内容时,我会先检测 Markdown 表格或代码块边界,把它们作为不可分割的整体进行切分。
第四个坑是重排阶段直接把全库结果丢进去。交叉编码器虽然准,但跑全库的成本太高,一次查询要等好几秒。后来我把流程固定成“混合召回 Top-30 → 重排取 Top-5”,延迟才降下来。记住:重排是精排,不是召回,不要拿它去做全库搜索。
5. 排查手册:检索质量不行时该查哪里
5.1 从召回、排序、文本质量三个层面定位
当检索质量出问题时,最忌讳的是直接瞎调参数。我会按下面的顺序排查。
先查召回:给定一个问题,召回结果里到底有没有正确答案?如果正确答案根本没进 Top-20,说明问题出在召回层,跟重排无关。这时候要检查切分粒度、嵌入模型的质量、混合检索的权重分配、查询改写是否生效。
再查排序:如果正确答案在召回列表里,但被排到了 Top-20 之外,说明问题出在排序层。要么是缺少重排环节,要么是重排模型的能力不够,要么是召回候选量太大导致重排压力过载。
最后查文本质量:如果正确答案排在第一位,但拼进 Prompt 之后大模型回答得还是不对,那就要检查召回文本本身是否完整。是不是在切分时把关键上下文切丢了?是不是这个片段缺少文档标题和上下文的衔接信息?是不是召回的是结论但没有依据过程?
这层排查逻辑就像查一条流水线:先看原料有没有送进来,再看分拣是否把正确的挑出来了,最后看送到组装车间的零件是否齐全。每一层的问题只能在这一层解决,跨层找问题往往越找越乱。
5.2 症状与对策速查表
下面这张表是我根据大量排障经验整理的。遇到问题直接对号入座,可以省不少时间。
| 症状 | 可能原因 | 解决建议 |
|---|---|---|
| 换个说法问同样的问题,答案完全变了 | 缺少查询改写或嵌入模型语义能力不足 | 加查询改写,或换更强的嵌入模型 |
| 召回结果里正确内容排在非常后面 | 没有重排,或重排模型太弱 | 引入交叉编码器重排,或升级重排模型 |
| 答案总是缺关键细节 | 切块粒度太大或切断了关键上下文 | 调整 chunk_size,增加文本块重叠 |
| 专有名词/型号经常匹配不到 | 关键词路径权重太低 | 提高 BM25 权重,检查文本归一化 |
| 多个问题交织在一起时答不对 | 缺少查询拆解,或切块无法覆盖跨段信息 | 用查询改写拆子问题,或启用段落合并召回 |
| 响应延迟明显偏高 | 重排候选量太大或重排模型过重 | 压缩召回候选量,换轻量重排模型 |
| 指标评测时命中率低但人感觉还行 | 评测集和真实问题分布不一致 | 用真实用户问题扩充评测集 |
这张表不是万能的,但它能帮你快速定位大多数常见问题。尤其是前三条,基本上是七八成 RAG 效果不佳的直接原因。
5.3 给新手的最后一句实在话
我从接触 RAG 到现在,一个非常深的体会是:这个领域真正难的不是大模型那部分,而是如何把知识库变成大模型能稳定消费的形态。很多人被各种框架的“一行代码搭建 RAG”宣传误导,以为检索只是调用一下接口的事。实际上,检索模型需要根据语料特点、用户问法、业务场景反复调整,是一个持续的迭代过程。
如果你现在正准备构建一个 RAG 系统,我建议先把“评测集 + 可观测的召回日志 + 分段调优”这套基础设施搭起来。哪怕最开始效果一般,只要这套基础设施在,你就有改进的方向和依据。相反,如果一开始就盲目套框架、堆模块,效果好坏全凭感觉,那项目大概率会卡在“上线后效果不稳定”的阶段再也走不出去。
另外想提醒一句:检索模型的升级没有终点。用户问法在变,知识库内容在变,业务重点也在变。每隔一段时间就要重新跑一遍评测集,看看检索命中率有没有下滑,有没有新的失败模式出现。把 RAG 当成一个持续维护的系统,而不是一次性的开发任务,这才是做生产级应用的正确心态。