1. 幻觉根源拆解:先想明白RAG到底把事实放在哪里
1.1 大模型“从容胡编”不是Bug,是自回归机制的直接结果
先说一个很多刚接触RAG的人没想透的问题:大模型为什么会一本正经地胡说八道?
如果你把一个语言模型当成“一个懂很多的人”,那幻觉就很难解释。但如果你拆开它的生成过程,会发现它其实是一个“接龙游戏玩家”:每生成一个token,都是在拿已经生成的上文和自己的全部参数去猜下一个词最可能是什么。这个过程中没有任何机制去校验“我猜出来的这件事在真实世界里到底成不成立”。
换句话说,模型不是查完知识库再回答,它是在一个装着海量文本记忆的黑盒里,根据概率不断往后“编”。当问题落在它训练数据里没见过、记忆很模糊、或者知识已经过时的区域,它能做的只有一件事:用最通顺、最像回事的方式继续编下去。这就导致了一种非常迷惑人的现象——模型越自信,措辞越严谨,反而可能在细节上错得越离谱。
所以RAG的核心目标从来不是“让模型变聪明”,而是“别让模型裸奔”。它把外部可信资料检索出来,塞进模型能看见的上下文窗口里,让模型从“闭卷硬写”改成“开卷照着资料作答”。只要资料真实且被模型正确读取,幻觉就能被大幅压缩。
这个问题拆到底你会发现,RAG里的“R”永远比“G”重要。上下文里没有的事实,模型只能靠自己的参数记忆去瞎编;上下文里有但被淹没的事实,模型也可能忽略,继续给你扯一个语法正确但内容错误的答案。
1.2 微调和提示词为什么治标不治本
早期团队应对幻觉,翻来覆去就是三板斧:微调、改提示词、直接联网搜索。这三招都有价值,但没有一个真正解决了“事实从哪来”的根基问题。
先看微调。它的本质是把新知识编码进模型权重,原理上确实能让模型“记住”一些新东西。但代价也很现实:每次知识更新都要重新准备训练数据、重新跑训练、做回归验证,成本高、周期长。最难受的是,微调并不改变生成机制,“记忆”和“事实校验”是两码事。你让模型背下了一份合同模板,它答错了其中一条金额时,依然没有任何机制纠正自己。
再看提示词。你可以在system prompt里强调一万遍“请严谨回答”“不确定就说不知道”,但如果模型手里真的没有准确资料,它就只能“严谨地编”。提示词约束的是表达风格和回答态度,不是知识来源。没有资料支撑的模型就像一位没有参考书的助理,你说“不知道就直说”,他也只能硬着头皮给出猜测性的解释。
至于直接联网搜索,听起来合理,但搜索引擎返回的内容和“检索增强”要的内容根本不是一回事。搜索结果是按SEO和热度排出来的网页片段,不一定贴合问题的信息需求,更没人按你的领域知识结构做过整理。直接把网页片段塞给模型,等于请了个助理,让他在走廊里随便抓三个人问路,抓到的第一个人说什么就信什么。
1.3 Naive RAG的经典三步,和它没解决好的三个幻觉
RAG出现以后,最朴素的实现方式被很多知识库项目用了起来,流程基本是固定的:
- 把文档切块、向量化,存入向量数据库;
- 用户提问时,把问题向量化,做一次相似度检索,取回top-k个文本块;
- 把取回的文本块拼进prompt,一起交给大模型生成答案。
这套流程跑通很快,Demo演示效果也很好,但上线后你会陆续撞上三个问题。
第一个问题是检索不到。query本身很复杂,比如“张三在A公司负责的那个项目,和他在B公司做的产品线相比,哪个跟他现在的工作方向更一致?”这类问题需要跨多个文档、多个实体做关系推理。朴素的top-k检索很难在一次查询里把分散在多处的证据全部捞齐,模型拿到的上下文本身就是残缺的。
第二个问题是检索到但不够准。向量检索擅长语义匹配,却不擅长精确限定。你搜“2023年之后发布且不包含无线充电功能的设备”,向量空间里很可能被“无线充电”这个高频概念带偏,把一篇专门讲无线充电的旧文章也捞了回来。更麻烦的是,不相关内容一旦进了上下文,模型并不会自动忽略它,它照样会一本正经地引用这些错误依据来生成答案。
第三个问题是chunk粒度与信息噪声。切块太小,跨段逻辑被切断;切块太大,一个块里塞了大段无关内容,真正的答案淹没在噪声里。检索打分可能会给到那个“碰巧包含相关词但整体不相关”的块,而不是真正包含完整证据链的块。
这些问题说明了一件事:RAG是一整条链路,每个环节都在影响“可信”。检索是源头,源头上错一步,后面生成阶段再怎么调prompt都补不回来。整个RAG的进化史,本质上就是不断把这几个漏洞一个个补上的过程。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 检索侧的三次进化:从“查相似”到“能推理”再到“会规划”
2.1 Dense Vector Search是底座,但它对“文字约束”天然不敏感
先聊已经变成标配的Dense Vector Search。它的做法是把文档块和query分别通过Embedding模型编码成高维向量,再用余弦相似度之类的方法计算语义距离。好处很明显:它摆脱了关键词匹配的机械性,能召回“意思相同但字面完全不同”的内容。比如用户搜“怎么退货运费”,文档里写的是“七天无理由退货的邮费承担方式”,传统关键词检索大概率漏掉它,向量检索能关联上。
中文场景下,Dense检索的底座作用更加明显。中文表达形式丰富,同义改写极多,同一个意思可以换很多说法,纯靠词法匹配很难覆盖全。所以几乎所有现代RAG项目都会至少保留一路向量检索作为召回主干。
但它的短板也很突出:对精确数字、版本号、商品ID、时间范围、否定语义这类“文字约束”不敏感。“不含某某功能”“排除某某地区”这类query,Embedding模型容易把它们和正向语义混在一起,导致检索结果里混入大量违反约束条件的内容。一旦有这些干扰项进入上下文,大模型分不清哪些约束更优先,幻觉就来了。
另外,向量化之前的内容清洗和切块质量,往往决定了整个系统的上限。很多团队在Embedding模型选型上花了很多精力,却忽略了源文档里全是导航、版权声明、表格错位。脏数据进了向量库,检索结果自然也是脏的。
2.2 混合检索加Rerank,为什么能直接降低幻觉
既然Dense检索对精确词和否定约束不敏感,那就得给它配一个擅长处理精确字面的伙伴。这就是BM25这类稀疏检索的价值。BM25按词项命中情况打分,虽然不懂语义,但对“必须出现什么词、不能出现什么词”有很强的识别力。把两路召回结果合并,业界最常用的方式是RRF(Reciprocal Rank Fusion)。RRF不依赖各自分数绝对值可比,而是按排名倒数和加权,工程上非常稳。
召回合并之后还需要再补一道精排。很多人有个误区,以为向量检索已经按相似度排过序,不需要再排了。实际上,第一阶段的相似度排序是给海量候选做粗筛用的,它的排序质量不足以直接把top-k投喂给大模型。比较成熟的方案是再上一路Rerank模型,比如Cross-Encoder。它会把query和每个候选文档做成一个序列做深度交互计算,效果比向量初排好一个档次。通常在召回阶段取top20到top50,精排后只取top3到top5进上下文。
这套组合拳为什么能抑制幻觉?逻辑很直接:幻觉的成因之一是“模型看到了错误的依据”,混合检索和Rerank保证进入上下文的,大概率是真正能回答该问题的证据。上下文对了,模型照本宣科,答案自然就贴谱。尤其在做事实型知识库问答时,混合检索加Rerank带来的收益往往比换一个更大的底座模型还明显。
2.3 Graph RAG与Ontology RAG:把“文本块”升级为“知识结构”
单纯在文本块层面做检索,始终有一个天花板:文本块是最小的物理存储单元,不等于最小的知识单元。对于“多个实体之间的关系”“跨文档的事实链路”这类问题,文本块切得再好,也难以在一次检索中把关系链条完整还原出来。
Graph RAG的思路是把文档里的实体和关系抽出来,构建成一张图。查询进来之后,先在图上定位与问题相关的实体,再顺着关系边扩展,把周边有联系的子图作为上下文交给大模型。这个思路天然擅长多跳问题。你问“A公司和B公司合作过几次,期间涉及哪些产品”,图结构可以直接把共同合作事件作为中间节点连接起来,而向量检索只能找到几段可能提及两方的孤立文本。
Ontology RAG是在图谱之上再做一层“领域语义约束”。前面提到ontology rag是近期一个高频概念,它强调的不只是实体关系,而是先在领域内定义好概念模型,包括实体类型、属性、关系语义和推理规则。比如法律场景里,本体定义了“法条”“主张”“构成要件”“免责情形”这些类型以及它们之间的逻辑关系。查询进来后,不是简单做向量相似,而是先经过本体层做语义映射,再沿着本体定义的关系路径去检索证据。它解决的是“什么叫相关”的问题:向量相似只能告诉你字面上像不像,本体结构能告诉你知识类型上通不通。
当然,Graph RAG和Ontology RAG的实现成本比普通向量库高得多,需要抽取、建图、本体设计、关系维护。我的建议是,只有当业务问题里确实大量存在多跳查询和复杂关系约束时,才值得引入。做简单FAQ问答,直接上图谱往往是过度设计。
2.4 Agentic RAG:把“一次问答”变成“检索-验证-再检索”的工作流
再往后走,RAG从“流程”变成了“智能体”。传统的RAG本质上是一次性的:检索一次、生成一次,完事。Agentic RAG则允许模型在生成前做判断和规划,把整个任务拆解开来。
具体拆解后的行为大概是这几个环节:
- 判断是否需要检索,需要走哪一路工具;
- 对原始query做改写。模型可以先把复杂口语问题简化成更利于检索的表达;
- 如果问题包含多个子问题,就拆成多个检索任务分别执行;
- 第一轮检索结果不足或有冲突,模型可以反复补充检索;
- 最后综合所有证据,检查上下文是否覆盖了问题中的所有关键前提,再生成回答。
Agentic RAG之所以能进一步降低幻觉,原因是它把“拆链验证”做进了流程。有些问题不是一次检索能解决的,需要先查出主体A的信息,再根据A查B,最后把A和B放在一起对比。Agent能在每轮拿到新证据后自行判断“还缺什么”,比固定流程灵活得多。
代价同样不容忽视。Agent每多一轮内部推理,延迟和token成本都会上升,而且规划过程本身也可能出错。如果agent把一个问题拆成了错误的方向,后续检索全部白做。工程上务实的做法是设置最大迭代次数,并且只对复杂问题启用多轮规划,简单问题保持短路径。不是所有RAG都非得Agentic,能用一次检索解决的就不要绕路。
3. “可信”不能靠感觉:RAG测评指标体系和评测集搭建
3.1 先分清检索级指标和生成级指标
聊完检索侧演进,回到一个更现实的问题:你凭什么说自己的RAG系统更可信了?靠人工随手测几条,说明不了问题。想让RAG项目持续迭代,必须建立两套独立的评估视角。
检索侧指标评价的是“证据够不够、准不准”。最常用的是Hit Rate(top-k内是否包含正确答案)、MRR(正确答案排在第几位)、Context Precision和Context Recall。这个层面的意义在于,如果检索质量差,后续生成再好也无济于事。改Rerank、调chunk大小、换Embedding模型的效果,都能在这个层面先看到。
生成侧指标评价的才是“模型有没有忠实于检索到的资料”。其中最接近“幻觉率”的指标是Faithfulness,也叫Groundedness或忠实度。做法是把模型生成的答案拆成一个个语义命题,逐个判断这个命题是否能被检索上下文中的证据支持。能被支持的命题比例越高,说明模型越是在老实照资料作答,凭空发挥的成分越少。
这里很多人容易混淆答案相关性和忠实度。答案相关性衡量的是“模型有没有答非所问”,而忠实度衡量的是“模型有没有乱编”。两者有可能互相矛盾:模型忠实转述了一段资料,但那段资料根本不是用户要问的,那么相关度低但忠诚度高;模型自由发挥了一段不在资料里的内容,但恰好答在点上,那相关度高但忠诚度低。两个指标都要监控,不能只看一个。
3.2 评测集怎么搭才不会被“幸存者偏差”骗了
RAG测评最容易犯的错误,是拿平时积累的常见问答做测试集。这类问题往往模型早就能答对,测试结果自然好看,但一上线遇到复杂情况就露馅。
手工评测集要充分覆盖这些难例:
- 多跳问题:需要结合两篇以上文档才能回答;
- 否定与排除条件:筛选条件中包含“不包含”“排除”“除了”等;
- 时效性陷阱:答案随年份变化的问题;
- 空答案场景:知识库里根本没有对应资料,正确行为是拒绝回答;
- 文档间冲突:不同来源文档对同一事实描述不一致。
尤其“无答案必须拒答”这一条,对降低整体幻觉率非常关键。很多系统之所以显得胡说八道,是因为不管查没查到,模型都硬要给出一个答案。你可以让系统参考置信度决定是否生成答案,当所有检索结果的最高相似度都低于阈值时,直接返回“未找到相关知识”。
评测集搭好之后,我建议把自动评估和人工抽检结合起来。用大模型当裁判自动给打分,跑全量回归很快,适合每次改代码之后跑。但裁判模型本身也有幻觉,必须定期用人工标注样本校准。一般人工抽检量不需要很大,每周抽几十条,对照自动打分看看是否一致,就能及时发现评估策略漂移。
3.3 引用溯源:让每个句子都“可以查证”
技术上讲,要想让用户感觉到你的RAG结果可信,光有指标不够,还得有“证据可视化”。答案是好的,但你不知道它凭什么这么说,心里总会打鼓。引用溯源要解决的就是这个问题。
实现层面需要前后端一起配合。检索回来的每个文本块要有稳定ID,记录文档名、页码/章节、原文位置。生成答案时,模型每生成一个可以被某个chunk支持的句子,就把对应的chunk编号作为引用标记插入。前端渲染时,这些引用编号要能点击,点击后展示对应原文片段和相关文档信息。
这里有个细节值得说:引用最好在生成过程中实时绑定,而不是回答结束后再“硬凑”一个引用列表。答案是流式生成出来的,如果你等全部生成完再用规则给整段答案找引用,找出来的对应关系往往很粗糙。更好的方式是约束生成框架,让模型输出结构化内容,比如一个事件流里同时包含delta文本和对应的引用索引数组。
在前端展示时要注意,来源内容很可能包含用户上传文档里的原始HTML或特殊字符。渲染时一律按纯文本处理,不要直接用dangerouslySetInnerHTML插进去,防止内容串入页面结构造成安全问题。
4. 流式输出协议的工程选择:体验的下半场不在模型而在管道
4.1 为什么RAG应用必须流式输出
一个RAG应用,后端检索和排序可能已经花掉了1到3秒,大模型生成答案又要好几秒。如果整个响应等全部完成才给前端,用户的体感就是点了发送之后对着一片空白等十秒,九成用户会以为系统挂了。
流式输出解决的不只是“等待焦虑”问题。大模型生成本身是逐token的,这几十个token天然可以按顺序流式呈现。用户看到文字一个字一个字蹦出来,能立刻感知到系统在工作,也能在阅读过程中同步吸收信息。如果一次性等全部生成完,用户面对一整屏文字反而需要重新开始读,体验上更累。
更重要的是,流式接口给了前端“中断”能力。用户一看开头就觉得答偏了,可以立刻点停止按钮,终止生成,省下后续浪费。没有流式,用户只能被动等完整个回答,再手动删除。
4.2 SSE、ReadableStream和WebSocket怎么选
聊到流式,很多人第一反应是WebSocket。实际上大模型对话场景里,大部分情况根本用不上WebSocket。我把三种方式做了个对照:
| 维度 | SSE | fetch + ReadableStream | WebSocket |
|---|---|---|---|
| 通信方向 | 服务器单向推送到客户端 | 请求-响应,响应体可流式读取 | 全双工双向 |
| 自定义Header | EventSource不支持,鉴权受限 | 支持,最常见鉴权方式 | 支持 |
| 断线重连 | EventSource自带重连 | 需要自己实现 | 需要自己实现 |
| 服务端复杂度 | 低 | 低 | 高,需维护长连接状态 |
| 适用场景 | 简单的大模型输出流 | 需要POST、自定义鉴权、中断控制的流式响应 | 多Agent实时协作、服务端主动推送大量消息 |
大多数RAG对话应用,我推荐直接走fetch + ReadableStream。原因很简单:你需要POST请求携带query和历史上下文,可能需要自定义鉴权头,还需要支持AbortController中断。这些SSE的原生EventSource都做不到或者做起来很别扭。而如果你给上层套一个统一的流式协议,比如每行一个JSON对象或使用SSE格式的data行,后端实现起来也很直观。
服务端返回的数据格式,最常见的还是基于 text/event-stream 的约定,每个事件以空行分隔,事件内容在 data: 行里,结束标记通常是一个 data: [DONE]。你的前端解码头就要认识这个格式。
5. 前端流式渲染实战:解析、打字机、中断与性能细节
5.1 消费流式响应时的中文乱码问题:TextDecoder要配合stream模式
实战第一步,是正确地读取响应体。如果你直接用 response.json(),那根本不会得到流式效果,必须通过 response.body.getReader() 读取字节流。
这里有个非常容易踩的中文乱码坑。HTTP流式响应的每个字节包并不一定对齐UTF-8字符边界,一个汉字在UTF-8里占三个字节,如果这次read只收到了前两个字节,直接对这段单独解码就会解出一个乱码的替换字符。正确做法是给TextDecoder传入 { stream: true },让解码器把未完成的字节保留到下一轮。
下面是一段可以直接参考的流式解析核心代码,适配SDK返回的OpenAI风格数据格式:
javascript复制async function streamChat({ messages, onDelta, signal }) {
const response = await fetch('/api/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'text/event-stream'
},
body: JSON.stringify({ messages }),
signal
});
if (!response.ok || !response.body) {
throw new Error(`Request failed: ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
// stream: true 可以让跨 chunk 的汉字字节完整解码
buffer += decoder.decode(value, { stream: true });
// SSE 规范:空行表示一个事件结束
const events = buffer.split('\n\n');
buffer = events.pop() ?? ''; // 最后一段可能不完整,留到下一轮
for (const rawEvent of events) {
const dataLines = rawEvent
.split('\n')
.filter((line) => line.startsWith('data:'))
.map((line) => line.slice(5).trim());
if (dataLines.length === 0) continue;
const payload = dataLines.join('\n');
if (payload === '[DONE]') return;
try {
const json = JSON.parse(payload);
const deltaText = json.choices?.[0]?.delta?.content;
if (deltaText) onDelta(deltaText);
} catch (e) {
console.warn('Failed to parse chunk', e);
}
}
}
}
读完流之后,记得再调用一次 decoder.decode() 做收尾,把最后残留在解码器内部的字节处理掉。如果整个响应结束前buffer里还有未闭合的数据,说明服务端格式异常,前端要给出相应提示。
5.2 渲染节奏控制:不卡顿的“打字机”到底怎么做
拿到了delta文本,下一步是把它展示到页面上。第一步看起来很自然的做法是每收到一个chunk就更新一次React/Vue状态,然后重新渲染全文。但实测下来,token到达的频率远高于浏览器渲染帧率,每几十毫秒就触发一次全量渲染,页面很快就卡了。
我的建议是引入一个“渲染闸门”:流里来的每个chunk先推进一个缓冲区,由 requestAnimationFrame 统一调度,每帧只冲刷一次缓冲区,一帧内累积的所有文本一次性写入DOM。
这样有两个好处:一是渲染频率被限制在浏览器正常帧率内;二是即使网络突然吐出一大段文本,也能把它合并成同一次渲染,避免界面疯狂闪烁。
javascript复制let pendingTexts = [];
let scheduled = false;
function onDelta(text) {
pendingTexts.push(text);
if (!scheduled) {
scheduled = true;
requestAnimationFrame(flush);
}
}
function flush() {
const deltaText = pendingTexts.join('');
pendingTexts.length = 0;
scheduled = false;
// 纯文本场景直接追加
answerNode.textContent += deltaText;
}
如果你需要渲染Markdown,就复杂一些。比较常见的坑是Markdown原文未闭合时渲染很难看,比如用户已经能看到“```”开头,但代码块要等好几帧后才闭合,中间会出现大量不完整的高亮抖动。我的经验是两层策略:第一层先按纯文本打字机输出,保证用户看到连续的字符出现;第二层以固定的时间间隔,比如每100到150毫秒做一次Markdown整体解析。这样既保证低延迟,又避免每次token都触发完整的解析重排。
这里还有另一个场景需要提一下:如果你的模型输出目标不是文本而是结构化JSON,比如让模型生成一个表格配置或流程配置,强烈建议不要直接在流式过程中对不完整JSON做反序列化,而是把原始文本先累积起来,等闭合后统一解析。流式中间态的JSON永远是不完整的,每次解析失败再重试,纯属浪费CPU。
5.3 停止与失败恢复:AbortController只是开始
流式渲染必须配套两个基本交互:停止生成和错误恢复。
停止按钮的实现在前端很简单,创建一个AbortController,把signal传给fetch,点击停止就调用 controller.abort()。需要注意,前端中断连接不代表服务端立即停止生成。很多大模型网关不会主动感知客户端断开,推理任务可能还在继续跑,白白消耗算力。所以在服务端也要实现连接中断监听,把上游的生成任务取消,或者至少判断是否需要保存已生成的结果。
错误恢复是另一个容易被忽视的点。流式请求可能在公司网络代理中间被掐断,也可能是后端服务重启导致连接中断。用户已经看到的半截回答是有价值的,不能直接清空。合理的做法是把已展现内容保留,标记为“生成中断”,同时在下方提供“重新生成”和“继续生成”两个按钮。“继续生成”要求后端支持从历史中断位置接着跑,这个能力不是所有大模型服务都支持,所以通常退而求其次,给“重新生成”即可。
前端捕获异常时要区分中断和真正的错误:
javascript复制try {
await streamChat({ messages, signal: controller.signal, onDelta });
} catch (error) {
if (error
