ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

RAG答非所问?先查向量再怪模型:向量检索问题排查指南

RAG答非所问?先查向量再怪模型:向量检索问题排查指南

最近好几个RAG项目都遇到了同一个尴尬场景:用户问的是A,系统一本正经地回答B。第一反应总是怪模型,但我在把“RAG、向量、模型”这三层拆开排查之后发现,绝大多数答非所问的锅,根本不在模型,而在向量检索这一环。标题这句话不是情绪发泄,是我踩过无数次坑后总结出的排查顺序:先查向量,再怪模型。

所以这篇东西不打算讲RAG炫酷的架构,也不打算堆一堆“检索增强生成是什么”的概念。我想说的是,当你辛辛苦苦搭好一套RAG知识库,上线第一天就被用户质疑“这个AI是不是脑子不好使”的时候,你怎么用最快的时间定位到问题在哪,并且确定下一步是该改切分、换embedding、调召回到什么策略,还是真的需要去审视生成模型。本文适合正在做RAG项目、被“答非所问”折磨过的开发者,也适合那些打算从零搭本地RAG知识库、想提前避开经典坑的人。

1. 为什么答非所问的第一嫌疑人是向量而非模型

1.1 RAG的“上下文决定论”:检索结果才是回答的地基

先把RAG的链路用最简单的话捋一遍:用户query进来,先被向量化,然后去向量数据库里做相似度检索,返回若干条最相关的文档片段,这些片段拼成上下文,最后才交给生成模型组织语言。你应该注意到了,生成模型面对的不是整个知识库,而是被检索出来的那几段文字。

这意味着什么?意味着模型的能力边界被你喂给他的上下文锁死了。你让一个特聘学者写一份报告,结果资料员递上来一摞关于“混凝土养护”的材料,你问的是“企业年假怎么算”,学者就算再聪明,也只能从混凝土养护里硬凑出几句像模像样的废话。问题出在资料员,不在学者。RAG里的资料员就是向量检索环节。

很多人在第一线看到的是模型在输出,所以直觉上会想:“是不是模型温度太高了?”“是不是prompt没写清楚?”“换个更大的模型试试?”这些动作不能说完全无效,但如果你检索回来的chunk里压根没有答案,换更大的模型只能让模型把“没有答案”包装得更像有答案,本质上还是答非所问。

1.2 答非所问的三种典型症状分别指向哪个环节

我做过的RAG项目里,答非所问大概能分成三种症状,每种指向的环节不一样:

症状表现现象示例大概率根因
完全跑题问“退款政策”,回答“物流时效”向量检索召回错误,正确答案chunk没进top-k
细节缺失问“退货要什么凭证”,只回答“需要凭证”但没说具体是什么chunk切分把关键句切丢了,召回内容不完整
泛泛而谈问“Linux下怎么重启Nginx”,回答一堆“重启服务的一般方法”知识库没有相关内容,但与通用语料有模糊相似度,模型只能泛泛应对
一本正经编答案问一个知识库里没有的问题,模型却给出看似专业的回答检索兜底缺失 + 生成幻觉叠加

前三种症状基本都能在向量检索阶段找到源头。第三种虽然知识库里没答案,但模型“编”得理直气壮,也是因为检索环节没有设置“无结果”的拦截,把一堆低相似度的噪声chunk也当宝贝一样塞给了模型。

所以我的建议很直接:第一次遇到答非所问,先别打开prompt文件,先去看看系统到底召回了几条什么内容。这一步只需要在代码里把检索结果打日志,成本极低,却能把问题边界砍掉一大半。如果检索到的chunk里肉眼可见没有答案,那后面所有“调模型”的动作都是浪费时间。

2. 向量检索到底在哪一环把问题带偏:切分、嵌入、召回逐项过审

2.1 文本切分的“过犹不及”:chunk_size与结构化切分

向量检索的对象是文档片段,也就是chunk。切分方案决定了“答案信息以什么粒度存在于向量库中”,这是最容易被忽略、也最容易埋雷的地方。

我见过一个典型问题:把一份企业规章制度PDF按固定512字符长度硬切,结果“年假可以累计吗”的关键条款被从中间一刀切开,前半chunk讲“年假发放规则”,后半chunk讲“未休年假折算”,而“累计”这个关键词恰好落在两段之间,两边都只沾一点,导致query“年假可以累计吗”和这两个破碎chunk的相似度都偏低,正确答案排到了第7名开外。相反,一段关于“班车福利”的完整文本因语义完整,相似度反而更高,系统就把班车信息当成答案给了出去。

如果你用的是LangChain的RecursiveCharacterTextSplitter,默认只按字符数切分,对中文文档效果往往一般。我建议按文档结构切:Markdown标题、Word标题、PDF章节都行。比如一个产品说明书有三级标题,就应该先按标题定位到相关章节,再在章节内部用一定chunk_size细化,同时让相邻chunk保留几十个字符的overlap,把被切散的上下文兜回来。

下面是几个我常用的切分策略参考:

文档类型推荐切分方式参考chunk_size参考overlap
规章制度/条款按章、条、款结构化切分300~500字50字左右
技术文档/README按Markdown标题层级切分500~800字符80字符
日志/问答对按段落或语义边界切分200~300字30字
长报告/论文章节优先,章节内按滑动窗口800~1000字100字

如果你问“有没有本地的RAG文本拆解工具”,答案是很多。LangChain的Splitter只是最初级的,unstructured能解析PDF、Word里的版面结构,tika适合处理乱七八糟的文本格式。与其自己调正则去切,不如先用这些工具把文本还原成带结构的信息,再做切分,效果会稳很多。

2.2 嵌入模型选型:一个不合适的中文向量模型怎么毁掉召回

chunk切好了,嵌入模型负责把文本映射到向量空间。这一步的坑在于:同一个词在不同领域、不同语境里的语义,在向量空间里的距离可能完全不符合你的直觉。

我做过一个医疗问答RAG,用的是通用英文embedding模型,结果用户问“心悸怎么缓解”,库里明明有“心动过速日常注意事项”,但因为通用模型对中文医学词理解不到位,这两个文本的相似度只有0.5左右,根本进不了top-k。后来换成中文领域表现更好的bge-m3,同样query下相关chunk的相似度直接跳到0.72,问题一夜消失。所以选embedding模型的时候,别以为“模型越大越好”,要对齐你的语料领域,中文场景就优先看中文评测。

还有一个很容易踩的雷:有些本地RAG教程会让你用Ollama拉起一个向量模型,包括nomic-embed-text之类,拉起来之后查询和入库必须用同一个模型、同一个向量维度。我见过有人入库用OpenAI embeddings,查询时切到本地模型,结果向量维度对不上,程序报错还算好的,更隐蔽的是两边模型能力差异导致相似度失真,线上表现为“同一个问题,换个环境答案就跑偏”。如果你在Langflow或其他可视化平台里配置自定义模型服务地址,一定记得核对embedding服务的URL、端口和模型名,这三个配置不一致是调用失败和结果错乱的最高频原因。

关于“RAG知识库能存储图片吗”这个问题,也顺带说一句:纯文本向量库当然存不了图片,要么提前用OCR把图片里的文字抠出来再入库,要么用CLIP这类多模态embedding单独建一条图片向量通道。如果图片里恰好是用户问题的关键信息,但你只索引了文字,那就算召回链路一切正常,模型也看不见图片内容,照样答非所问。

2.3 召回策略单一:top_k与相似度阈值两个旋钮怎么调

召回阶段最常见的问题是“top_k太小漏掉答案”和“没有阈值硬凑答案”并存。很多初学者只设一个k=3,然后指望前三条一定靠谱。实际操作中,我强烈建议你在开发环境把相似度分数打出来看看,你经常会发现第1名和第5名的分数只差0.01,根本不是断崖式差距。这时候k=3就非常危险,正确答案可能在第4名,直接被丢弃。

调试时可以这样快速看清楚:

from langchain_community.vectorstores import Chroma from langchain_community.embeddings import HuggingFaceEmbeddings vectordb = Chroma( persist_directory="./chroma_db", embedding_function=HuggingFaceEmbeddings(model_name="bge-m3") ) query = "年假可以累计吗?" docs = vectordb.similarity_search_with_score(query, k=10) for i, (doc, score) in enumerate(docs): source = doc.metadata.get("source", "?") print(f"{i+1}. score={score:.4f} | {source} | {doc.page_content[:60]}")

跑完你会发现,检索返回的内容并不是天然“相关”的,它们只是“语义距离相对近”。如果没有相似度阈值,就算top 10里全是0.3以下的低分噪声,系统也会把它们一股脑拼给模型。RAG的兜底设计应该是:最高分数低于某个阈值时,直接告诉用户“知识库中没有找到相关信息”,而不是硬着头皮生成。这个阈值需要通过一批真实query去标定,常见中文embedding的cosine相似度阈值在0.5到0.7之间,具体要看你的领域。

再往下走就是混合检索和重排序。单纯靠向量召回,对“专有名词一致、语义不太明显”的场景容易吃亏。比如用户说“我要退货”,库里写的是“申请退款”,语义上相似,但字面完全不一样,向量能抓住;可如果用户说“LGTM”,库里写的是“看起来没问题”,向量可能会懵。加上BM25这类稀疏检索,可以按字面重合召回一批结果,再做rerank合并,覆盖率会明显提升。这也是为什么很多成熟RAG项目把“BM25 + 向量 + rerank”当作标配的原因。

3. 一次线上“答非所问”问题的排查实录:从现象到根因

3.1 现象与第一反应:用户问A,模型答B,我忍住了怪模型

某次我维护一个企业内部制度问答RAG,知识库里放了几百份人事、行政、财务制度文档。线上有用户问“年假可以累计吗”,系统一本正经回答“公司提供免费班车,办公楼有食堂”。这个答案在语义上完全连贯、语气非常自信,但内容跟问题八竿子打不着。

当时团队里第一反应就是“模型问题,换个prompt试试”“是不是该上更大的模型”。我劝住了大家,先把线上这条query的检索日志捞出来。日志显示,模型实际接收到的上下文中,排第一的chunk是一段“员工福利”文本,跟“年假”毫无关系。也就是说,模型只是忠实地把一段无关内容组织成了看起来通顺的句子。这个案例成了我后来反复讲给团队听的“先查向量”模板。

3.2 顺着向量管道倒查:query与doc的向量距离到底差在哪

从日志定位到chunk之后,我开始倒查为什么正确答案没有进top-k。

第一步查切分。原PDF是按章节识别的,但内部做切分的代码为了省事,统一用512字符固定长度硬切。我把“年假”相关原文找出来,发现关键条款被切成了两段,第一段结尾停在“年假原则上在当年度”,第二段开头是“休完,特殊情况经审批后可跨年度”,等于把完整语义切断了。每个子chunk都是残缺信息,向量化之后自然很难和“可以累计”这个问题对上。

第二步查embedding质量。我把完整条款文本和破碎chunk分别向量化,用同一query做相似度对比。完整条款的相似度是0.71,破碎chunk最高只有0.54。这不是模型有问题,而是输入给模型的文本本身就残了。

第三步改切分。我把解析逻辑改成了按PDF的章节标题定位到具体条款,再在每个条款内部切分,让“年假累计”成为一个完整chunk,并加了50字overlap兜底。改完之后重新建索引,query“年假可以累计吗”的top5结果里,正确条款出现在第1名,相似度0.72。模型输出立刻恢复正常。

同时我还加了一个阈值兜底逻辑:当最高相似度低于0.55时,不允许模型自由生成,而是返回“抱歉,知识库中没有找到相关信息”。这个阈值是我用边标定边看效果的方式定的,不是拍脑袋。加了兜底之后,“答非所问”出现的频率直线下降,取而代之的是诚实的“不知道”。

3.3 复盘:如果当时先调prompt会怎样

事后复盘大家心里都明白:如果那晚我们真去调prompt,大概率会把“请基于上下文回答”改成“请严格基于上下文回答”,或者加上一堆限制词。但上下文中压根没有年假条款,这些改动只会让模型更努力地、在班车福利里抠出一些貌似合理的措辞,症状看上去弱一点,但没有根除。

我一直觉得,RAG项目里“答非所问”的排查顺序是决策成本最低的一条路。先花两分钟看召回命中碎片,再决定改切分还是换模型。如果你跳过这一步直接动prompt,等于蒙着眼睛修灯泡——看起来在努力,其实每一次改动都缺依据。

4. 想少踩这类坑,得把向量检索质量做成可评估、可迭代的事

4.1 先建一个“golden set”:没有评测集的RAG优化都是盲调

排查完一次问题,不代表以后不会复发。真正让我告别“打地鼠”状态的方法,是建了一个小型评测集,我管它叫golden set。

做法很简单:选50到100条真实用户问题,每条问题手工标注出“标准答案所在的chunk ID”或“标准答案文本”。以后每次改切分策略、换embedding、调top_k,都拿这套集子跑一遍,看检索结果有没有变好。没有评测集的情况下,你觉得“换了个embedding好像变聪明了”,很可能只是某几条query碰巧被照顾到了,换个问题又打回原形。

核心指标用recall@k就够了。正确答案在检索结果前k条里出现就算命中。写起来很轻:

def recall_at_k(retrieved_chunk_ids, golden_chunk_ids, k=5): if not golden_chunk_ids: return 0.0 hit = set(retrieved_chunk_ids[:k]) & set(golden_chunk_ids) return len(hit) / len(golden_chunk_ids)

我会同时记录相似度分数分布,看那些semantic score在0.5边缘徘徊的chunk是不是“切分残缺”的牺牲品。有了这套机制,每次优化都有数据支撑,不再靠感觉。

4.2 向量数据库选型:先别迷信Milvus,小项目Chroma/pgvector就够

很多初学者一上来就问“向量数据库选型是不是直接上Milvus”,我的建议是反过来:先看你项目多大、团队运维能力多强。

向量数据库适合场景部署成本风险点
Chroma本地原型、个人知识库、小团队内部工具极低,pip安装即用数据量大后性能和持久化是短板
Qdrant中等规模,需要并行查询和高可用中等,可以docker起单机配置参数较多,有一定学习成本
Milvus大规模、分布式、千万级向量高,需要运维能力小项目用它纯属给自己找事
pgvector已有PostgreSQL复用,不愿意多维护组件低,扩展插件即可向量检索能力和专用库有差距,数据量极大时需评估

向量数据库本身其实不是“答非所问”的根源,它只是一个带索引的存储桶。你往里面放了什么chunk、用什么embedding算的相似度,才是决定检索质量的关键。选型时只要考虑“装得下、查得快、运维得动”就够。

4.3 本地化部署与多模型协同的经验

本地RAG这几年很流行,很多人按“Ollama + 简易本地RAG知识库”的教程搭了一套,发到公司内部做文档问答。这种方案零基础可复制,但对几个细节要特别清醒。

第一,Ollama拉起的生成模型和向量模型是两码事。qwen2.5这类是生成模型,nomic-embed-text才是向量模型,不要用生成模型去算文本向量,否则检索结果会非常奇怪。第二,本地小模型对embedding质量其实很敏感,建议优先跑bge-m3这类中文效果更稳的模型,不要只跟风下载英文教程推荐的模型。第三,如果你在Langflow这类工具里配置自定义模型服务地址,生成模型和embedding模型的URL要分开配置,端口很容易混淆,一旦配错,界面报错不一定明显,但问答效果会悄悄变差。

另外,纯本地向量模型在冷门术语上的相似度分数往往偏低,比如专业项目代号、内部系统名。解决方案是给这些专有名词建立同义词表或本体映射,让“用户口中的简称”能对齐“文档里的全称”。这属于ontology RAG的范畴,但简单版本就是维护一个词典,在query进入向量检索前先做一次改写。

4.4 让向量检索失败时也不要硬着头皮生成:兜底设计

最后一个经验最想强调:RAG的优雅之处在于“可以承认不知道”。如果你检索到的最高相似度都低于阈值,那就不要硬回答。这个设计不是让系统变笨,而是让系统不在低置信度上胡说八道。

实现上很简单:

max_score = top_results[0].score if top_results else 0.0 if max_score < SIMILARITY_THRESHOLD: return "抱歉,知识库中没有找到与您问题相关的信息。"

这条兜底逻辑上线之后,用户反馈反而更好。因为在企业内部问答场景,用户最怕的不是“AI说不知道”,而是“AI假装知道给了一个错答案”。只有把低质量召回的流量掐断,你才敢慢慢提高检索质量的精准度,而不是让模型在垃圾上下文上表演“语言流畅”。

5. 到了什么时候才值得怀疑模型?两条判断线

5.1 检索已经召回正确答案,但输出仍然跑偏

前面说了那么多“先查向量”,但不能走向另一个极端:把所有问题都甩给检索。如果检索结果里明明已经有正确答案了,模型还是答偏,那这时候确实该怀疑生成环节。

判断方法很简单:把检索到的chunk原文,连同query一起,直接丢给模型,不走向量检索链路。如果模型在这种“闭卷改成开卷”的情况下还是答不对,那问题就在prompt或者模型理解能力上。常见情况有两种:

一种是上下文太长,模型在很长的拼接文本里迷失了重点。这时候要在prompt里明确“优先使用上下文中的信息,如果上下文中没有就直接说不知道”,同时把检索到的chunk按相关度重新排序,把最相关的放最前面。

另一种是模型指令跟随能力确实弱,比如量化严重的7B小模型,容易把“假设你是客服”这种角色设定当废话,或者被上下文里的无关信息带跑。这种情况换一个能力更强的基座模型通常比调prompt更有效。

5.2 模型忠于上下文但“无中生有”的幻觉怎么区分

还有一种情况:模型生成的答案和上下文对不上,表现为“上下文里根本没这句,模型却编得像模像样”。很多人把这归为纯模型幻觉,但我要泼一盆冷水——如果检索到的上下文里确实没有用户问题的答案,而模型又必须得说点啥,它就只能从训练语料里捞记忆来补,这就成了幻觉。

所以,“上下文没有答案但模型胡编”的本质,仍然是“召回缺失 + 生成幻觉”的叠加。你仍然要先回到向量检索那一环,看它是不是压根没把正确答案捞上来。如果捞上来了,模型还编,那才是真正的生成侧问题。

这也是为什么我一直坚持“先查向量,再怪模型”这句经验。这套顺序在绝大多数RAG故障里都能帮我把排查时间缩短到半小时以内。每次我忍不住想换模型、调prompt的时候,都会逼自己先看一眼召回日志。说真的,十次里有七次,问题都出在向量这一层。

如果你现在手上也有一个正在“答非所问”的RAG项目,我建议你把这条经验直接落地:先在代码里加一段检索日志,把每次query命中的top5 chunk和相似度分数打出来,打开一个真实问题跑一遍,人肉看看这些chunk里到底有没有答案。这一眼看下去,比换十个prompt都管用。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进