做RAG有一段时间的朋友,多少都被“幻觉”折磨过。明明文档库里写得清清楚楚,模型硬是能给你编出一套不存在的结论,还一本正经地附上引用。早年我搭第一个知识库问答系统时,用户问“我们的报销流程是几步”,系统能洋洋洒洒答出七八步,跟公司制度手册没一个字对得上。后来一路从Naive RAG改到Advanced RAG,再到现在基于Agentic RAG做动态编排,又在前端把流式渲染补上,才算是真正把“能用”变成了“敢用”。
这篇内容我想把这条进化路径完整拉一遍:RAG的幻觉到底从哪来,我是怎么一步步把可信度拉起来的,以及当前端遇上大模型流式输出时,怎么做到“边生成边渲染”,让用户体验从白屏等待变成逐字浮现。全文偏工程实践,覆盖文档解析、切块策略、检索重排、引用溯源和前端SSE渲染,适合正在搞RAG落地、或者准备把RAG项目从前端到后端完整拉通的朋友参考。你会看到具体的参数选择、踩坑记录和可复现的代码片段,而不是那种讲完概念就结束的科普稿。
1. RAG的演进脉络:从拼装式检索到可信问答
1.1 最初的RAG为什么让人又爱又恨
RAG(Retrieval-Augmented Generation,检索增强生成)刚火起来那阵,大家觉得它简直是给大模型装上了外挂数据库——你不用微调,不用重新训练,把文档丢进向量库,用户提问时先检索后生成,理论上模型就只能基于检索到的内容作答了。
但实际跑起来第一版,问题接踵而至。最核心的痛点是:模型确实“看”了检索结果,但它并不保证只按检索结果说话。打个比方,你把一堆资料扔给一个特别自信的实习生,让他照着资料回答问题,他偶尔还是会忍不住把脑子里的旧印象、刻板知识一起倒出来。这就是LLM的固有问题——生成时会对“最可能的token”妥协,哪怕上下文里没有对应证据。
早期Naive RAG的链路大概是这样的:
code复制文档 → 解析/分段 → 向量化 → 存入向量库
用户提问 → 向量化 → 检索TopK → 拼Prompt → LLM生成
听起来很顺对吧?但每个环节都藏着坑。文档解析不干净、分块太随意、向量检索没有重排、Prompt里没有强调“只能基于上下文回答”、模型温度参数没调……任何一环出问题,最后输出的都是幻觉重灾区。更麻烦的是,这种错误是“逻辑自洽”的错误,用户根本察觉不到,直到拿去核对原文才发现对不上。
1.2 从Naive RAG到Advanced RAG,再到Agentic RAG
行业里很快意识到,光靠“拼装一条检索+生成的链路”远远不够。于是演进路线大致分成了三个阶段:
- Naive RAG:标准“检索-生成”流水线,问题在于对检索质量零容错,召回不准就全盘皆输。
- Advanced RAG:在检索前后做文章。前置优化包括查询改写、HyDE(构造假设性文档再检索)、多路召回;后置优化包括重排(Rerank)、上下文压缩、引用溯源。这一阶段解决的是“能不能找到”“找到的能不能用上”的问题。
- Modular/Agentic RAG:把RAG拆成可编排的模块,由Agent决定什么时候该检索、检索哪类知识库、要不要多轮检索、怎么综合多路结果。这阶段解决的是“复杂任务怎么拆解、多文档冲突时怎么处理”的问题。
我自己从实战体感上讲,绝大多数企业的知识库问答场景,做到Advanced RAG的水平就已经能大幅压制幻觉;Agentic RAG更适合那些问题无法一次检索解决、需要多步推理的场景,比如“对比A和B两个方案在成本上的差异,给出推荐”。这个查询需要先分别检索A方案和B方案的文档,再做对比推理,单一一次检索根本喂不饱。
1.3 “可信”到底意味着什么
把“可信”作为RAG目标,我一贯认为有三层含义,缺一不可:
这层说的是模型输出了对的内容,没有胡编乱造。技术上主要靠检索质量和生成约束共同保证。
这层说的是模型能告诉用户它凭什么这么答。最常见的落地形态是引用溯源——每个结论后面跟着来源文档的编号、章节、原文摘录,用户点一下就能看到出处。
这层说的是用户拿到答案后,能判断答案靠不靠谱。如果答案只给一个孤零零的结论,哪怕它是对的,用户也不敢信;如果答案带着原文引用,用户就能快速验证,这种“可验证性”是建立信任的关键一步。
下面我按这条线,把每部分涉及的实操细节拆开讲。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 消除幻觉的底层工程:解析、切块、检索与重排
2.1 文档加载与解析:数据质量决定天花板
做RAG的人常开玩笑说:Garbage in, garbage out。检索召回的上限,从你导入文档的那一刻就决定了。我见过不少团队一上来就调模型、调Prompt,结果问题根源是PDF解析出来全是乱码、表格变成了天书。
文档解析要注意几个点:
- 选对解析工具。PDF文字型文档用PyMuPDF或pdfplumber足够,扫描件必须走OCR(我用过PaddleOCR,中文识别效果不错)。Word文档用python-docx,HTML直接上BeautifulSoup。混合型文档(有图表、有页眉页脚)得做清洗,把页眉页脚、水印、目录页去掉,不然检索时全是噪音。
python复制# 一个简单的文档解析示例,处理PDF和文本
import fitz # PyMuPDF
def extract_text_from_pdf(pdf_path):
doc = fitz.open(pdf_path)
pages = []
for page_num in range(doc.page_count):
page = doc.load_page(page_num)
# 先按块提取,保留一定的结构化信息
blocks = page.get_text("blocks")
page_text = []
for block in blocks:
x0, y0, x1, y1, text, block_no, block_type = block
# 过滤掉太小的块,通常是页眉页脚或水印
if block_type == 0 and len(text.strip()) > 10:
page_text.append(text.strip())
pages.append("\n".join(page_text))
return "\n".join(pages)
-
保留层级结构。如果原文有标题、段落、列表,尽量用Markdown格式保留结构,Heading信息对后续切块非常有用——它能告诉切块器“这块内容属于哪个章节”,从而让块与块之间的语义边界更清晰。
-
表格不要硬塞给切块器。表格单独提取,可以用“表格转文本描述”的方式处理,比如“一列是费用类型,一列是金额,第三行是差旅费标准”,比粗暴把表格转成纯文本强得多。纯文本化表格会丢失行列关系,模型理解成本大增。
解析完成后,一定要抽样人工看一眼。我自己的标准是:随机抽10页,每页肉眼确认能看懂语义,再进入下一步。这步省不得,后面所有环节都建立在这一层之上。
2.2 切块策略:分小了找不全,分大了找不准
切块(Chunking)是RAG里看起来最简单、实际坑最多的一环。块太小,一个完整知识点被拆得七零八落,检索时语义不完整;块太大,混入太多无关信息,向量表征被稀释,精确度下降。没有万能参数,只能根据文档类型和场景调。
我常用的几个切块维度:
- 固定大小切块:按字符数硬切,配合overlap(重叠区间)。最简单,但会拦腰切断语义。
- 递归结构切块:按标题、段落、句子层级递归切分,优先保持语义完整。LangChain的RecursiveCharacterTextSplitter就是这个思路。
- 父子块:检索时用小块(精确匹配),送给模型时用父块(上下文完整)。这个方案我强烈推荐,能同时兼顾精度和上下文。
python复制# 递归切块示例
from langchain.text_splitter import RecursiveCharacterTextSplitter
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=800, # 每个块的目标大小
chunk_overlap=150, # 相邻块的重叠区间
separators=["\n\n", "\n", "。", "!", "?", ". ", " ", ""], # 按语义边界切
)
chunks = text_splitter.split_text(document_text)
print(f"切出 {len(chunks)} 个块")
我在切块时有个经验:先看文档类型再定参数。制度规范类文档,一条条例本身就是一个完整语义单元,适合按“条”切;技术手册类文档,段落信息密度高,800字符左右比较合适;对话记录类,按轮次切,千万别混切。
另外overlap别设太小,150字符左右比较稳。overlap的意义在于:检索“张三的报销单批准了没”时,答案可能横跨两个块的边界,没有overlap这个块就永远召回不到那个结论句。
2.3 向量检索与重排:召回是“宁可多”还是“宁缺毋滥”
检索有个经典的矛盾:召回率(Recall)和精确率(Precision)不可兼得。早期我习惯TopK设小一点,比如3,觉得这样喂给模型的上下文干净。但实测下来,TopK太小很容易漏——用户换个问法,或者核心答案被拆在两个块里,就全完了。
建议的做法是“多路召回+重排”:
- 召回阶段TopK设大,比如20~50,优先保证相关内容不漏。
- 用重排模型(Reranker)对召回结果重新打分,选出最相关的5~10个块送给LLM。
重排模型和向量检索是两类不同的技术。向量检索是“语义近似”,速度快但精度一般;重排模型是“深度交互”,把查询和候选文档做pairwise的深度语义匹配,精度高但速度慢。两者配合,是当前工程上性价比最高的组合。
python复制# 基于FlagEmbedding/BGE-Reranker的重排示例
from FlagEmbedding import FlagReranker
reranker = FlagReranker('BAAI/bge-reranker-v2-m3', use_fp16=True)
query = "报销审批需要哪些材料?"
passages = [...] # 向量检索召回的TopK候选块
scores = reranker.compute_score([[query, p] for p in passages])
# 按分数排序取前N个
top_indices = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:5]
从实测效果看,BGE-Reranker在中文场景下表现很好。如果你用的是OpenAI系模型,也可以考虑Cohere Rerank;开源自部署优先考虑bge-reranker系列,对中文支持更友好。
这里有个容易被忽略的调优点:混合检索。我在生产环境里并不仅仅依赖向量检索,还会叠加BM25关键词检索。原因是:很多企业文档里存在专有名词、型号、编号,比如“A32-04型传感器”,向量检索往往对这类精确字符串不够敏感,但BM25一查一个准。把向量检索和BM25的结果做加权融合再去重排,比单一检索稳定性强太多。
3. 让回答“有据可循”:引用溯源与Grounding
3.1 引用溯源的实现思路
引用溯源(Citation/Source Attribution)是RAG从“看起来聪明”走向“让人敢信”的关键工程。没有引用,用户看到答案第一反应是“你编的吧”;有引用,用户哪怕怀疑也能自己查证。
我见过几种引用实现方案,各有优劣:
- 方案A:让模型输出时附带来源ID。在Prompt里明确要求“每个结论后标注来源编号,如[1][2]”,并给出来源ID与文档标题的映射表。优点是实现简单,缺点是模型可能标错来源,需要额外校验。
- 方案B:基于检索到的块反查引用。生成时让模型只输出结论,前端或后端把生成内容里的关键句子和检索块做相似度匹配,命中哪个块就引用哪个。优点是引用更真实,缺点是实现复杂。
- 方案C:结构化输出引用。用函数调用(Function Calling)或者JSON模式让模型输出带引用的结构化结果,比如每个段落对应一个sources数组,里面是命中的文档ID和page信息。
我自己实战中用得最顺手的是方案C。让模型输出的格式类似:
json复制{
"answer": [
{
"paragraph": "报销审批需要经理签字、财务审核、分管领导终审三步。",
"sources": [
{"doc_id": "finance_2024_01", "chunk_id": 12, "excerpt": "报销流程:经理签字→财务审核→分管领导终审"},
{"doc_id": "finance_2024_01", "chunk_id": 13}
]
}
]
}
前端拿到这个结构后,在段落下方渲染引用卡片,用户点击即可展开原文摘录甚至跳转到源文档的位置。这种体验远比“从头到尾一大段文字末尾跟着[1][2]”要好——用户可以精确知道“这句话来自哪份文档的哪个位置”。
关键点在于:Prompt里必须给模型足够清晰的指令,并且要求模型只能引用出现在上下文里的块。我一般在System Prompt里写死:
text复制你是一名企业知识库助手。你的回答必须严格基于<context>中的内容。每个陈述句后都必须标注引用来源,来源必须来自context中提供的chunk_id。如果context中没有足够的信息,直接回答“根据已有资料无法回答”,并列出你检索到的相关内容。禁止使用常识或记忆编造答案。
注意这里有个魔鬼细节:“根据已有资料无法回答”一定要有。它看上去是示弱,实际是防幻觉的最后一道闸门——把模型从“必须给用户一个答案”的惯性里解放出来,允许它说不知道。
3.2 Groundedness评估:别等用户发现幻觉,先自己打分
引用有了,但另一个问题来了:模型嘴上说“根据资料”,心里是不是真的只用了资料?这就要做Groundedness(接地性/证据忠实性)评估。
通常的做法是:把模型输出的每个陈述句拆出来,拿去和检索到的上下文做NLI(自然语言推理)判断,看看这个陈述是被上下文“蕴含”(entail)还是“矛盾”(contradict),还是“中性”(neutral)。如果出现大量“矛盾”和“中性”,说明模型又飘了,没有老老实实照着检索结果回答。
我早期是手工抽样检查,后来上了自动化评估流水线。用一个轻量级NLI模型(或者直接用GPT-4/GLM等大模型做评测员)对每条输出打标。
python复制# 基于LLM的Groundedness检查示例(伪代码/概念级)
def check_groundedness(llm, claim, evidence):
prompt = f"""判断下面的陈述是否完全由证据支持。
证据:{evidence}
陈述:{claim}
请回答:SUPPORTED / NOT_SUPPORTED / PARTIALLY_SUPPORTED
"""
result = llm.chat(prompt)
return result
这个评估器可以做成一条独立的pipeline,在每次RAG回答后自动运行,分数低于阈值的答案直接拦截,返回“对不起,我生成的回答未能通过可信度校验,这是我能找到的相关资料,请您自行判断”。实测下来,这种“宁可承认不行,也不乱给结论”的策略,反而大大提升了用户信任度。
3.3 多文档冲突时怎么办
企业场景里,常见的一个坑是:不同的文档说法不一致。比如A文档说报销上限5000元,B文档说上限8000元。RAG如果同时检索到两者,模型大概率会“和稀泥”——两头都提,或者基于先验知识默默选择其中一个。这个处理不好,也是隐性的可信度崩塌。
我的处理办法有两步。第一步,在检索阶段尽量做时间或版本过滤,如果文档有生效日期或版本号字段,检索时直接过滤掉过期版本。第二步,如果确实冲突了,Prompt里显式要求模型在回答中标注“不同文档存在冲突”,把所有冲突观点和对应来源列出来,不做主观裁决,由用户结合业务实际判断。
实际追问下来,用户对这种“复数答案”的接受度比我预想中高得多。他们反而担心的是你隐瞒了冲突、只告诉一个“标准答案”——因为那很可能意味着系统悄悄做了不该做的决定。
4. 前端流式渲染实战:从“等结果”到“边生成边看”
4.1 为什么前端必须做流式渲染
RAG回答背后的LLM生成,本质上是逐token输出的。如果不用流式,前端只能干等3~8秒——有些长回答甚至更久——用户盯着一个转圈的白屏,心里早就骂了一句“这系统是不是挂了”。流失率、焦虑感、不信任感全都堆上来了。
流式渲染(Streaming Rendering)的意义有两层:一层是体感提速——第一个字几百毫秒就能出来,用户感觉“系统在动”;另一层是过程透明——用户能看到答案一个字一个字往外冒,潜意识里会觉得系统是在“思考着回答”,而不是从某个黑盒里掏出固定答案。前者改善体验,后者建立信任。
我自己做过的用户回访里,有不少人明确说:“字是一个个打出来的,比一次性弹出来让我更放心,感觉像真人客服在打字。”这点对RAG产品感知质量的影响,远超技术人最初的想象。
4.2 前后端协议选型:SSE还是WebSocket?
流式传输最常见的有两种方案:
- SSE(Server-Sent Events,服务器推送事件):基于HTTP的单向流,服务器可以持续向客户端推送数据。优点是实现简单、自动重连、天然适配文本流;缺点是单向,客户端不能通过同一连接频繁发消息。
- WebSocket:全双工通信,客户端也能实时推数据。适合需要多轮交互、端到端双向流的场景,比如聊天助手频繁打断和切换话题。
对绝大多数RAG问答场景,我推荐SSE。理由很简单:RAG的流程是“用户问一句,系统答一段”,不需要高频双向通信。SSE的复杂度低得多,而且在浏览器原生支持EventSource,代码量也小。
不过要注意一个细节:使用EventSource有一个限制——它只能发送GET请求,不能自定义请求头。如果你需要带Token鉴权,就得退回到fetch + ReadableStream的方案。我在公司项目里就是这个方案,用fetch发POST请求,后端返回流式数据,前端用ReadableStream逐段读取。
4.3 后端:FastAPI实现SSE流式接口
后端我用的是FastAPI,配合StreamingResponse可以很干净地实现SSE。核心思路就是:当用户的提问经过检索、拼装Prompt之后,把LLM的输出流一条条yield给前端。
python复制from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
app = FastAPI()
class QueryRequest(BaseModel):
question: str
@app.post("/api/chat/stream")
async def chat_stream(req: QueryRequest):
# 1. 检索相关文档块
chunks = retrieve(req.question)
# 2. 构造Prompt,包含检索到的上下文
context = "\n\n".join([f"[{i+1}] {chunk}" for i, chunk in enumerate(chunks)])
prompt = build_prompt(context, req.question)
# 3. 流式调用LLM,并把输出逐段yield给前端
async def event_generator():
# 先推一个检索完成的事件,前端可用来展示“检索到的来源文档”
yield f"event: sources\ndata: {json.dumps(chunks)}\n\n"
async for chunk in llm.stream(prompt):
content = chunk["choices"][0]["delta"].get("content", "")
if content:
# data格式是SSE标准格式,event自定义事件类型
yield f"data: {json.dumps({'delta': content})}\n\n"
# 推一个结束标记
yield f"data: [DONE]\n\n"
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no", # 重要:关闭Nginx缓冲,否则流式失效
}
)
这个接口有几个点要特别注意:
-
X-Accel-Buffering: no这个响应头在绝大多数生产环境里是救命级的配置。如果前端是通过Nginx反向代理访问后端的,而Nginx默认开了缓冲,那么SSE流会被Nginx攒成一大块再吐给前端——流式直接失效,变成“等全部生成完才一次性返回”。 -
事件类型建议用
event: sources先推检索结果,再推data类型的生成内容。这样前端可以在答案还没开始生成时,就先渲染出“参考了哪些资料”,用户感知上系统已经在干活了。 -
注意心跳和超时。SSE长连接如果长时间没有数据,部分代理层(如某些云厂商的LB)会主动断开连接。我的做法是如果LLM生成超过15秒没有新token,后端push一个空注释行
:\n\n(SSE规范中的ping),保持连接存活。
4.4 前端:fetch + ReadableStream逐字渲染
前端如果用EventSource,写起来最简单但受限于GET。更好的做法是用fetch读取POST响应体,通过response.body.getReader()逐块读取。
javascript复制async function streamChat(question) {
const resp = await fetch('/api/chat/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question }),
});
const reader = resp.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
let answerText = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// SSE数据是按\n\n分隔的,按分隔符切分完整事件
const lines = buffer.split('\n');
buffer = lines.pop(); // 最后一段可能不完整,留在buffer里
for (const line of lines) {
if (line.startsWith('event: sources')) {
// 处理来源事件,一般下一行是data
continue;
}
if (line.startsWith('data: ')) {
const payload = line.slice(6);
if (payload === '[DONE]') continue;
try {
const json = JSON.parse(payload);
const delta = json.delta || '';
answerText += delta;
// 更新DOM,这里用textContent避免XSS
document.getElementById('answer').textContent = answerText;
// 如果内容是Markdown,这里需要用markdown渲染库
} catch (e) {
console.error('解析SSE数据失败', e);
}
}
}
}
}
这是最基础的骨架。接下来有一个容易被新手忽略的问题:token是流式到达的,如果我把每个token直接往DOM里塞,性能没问题,但Markdown格式会碎。比如一个代码块:
markdown复制```python
def hello():
print("world")
code复制
如果逐token渲染,用户会看到代码块的```标记一个字符一个字符地冒出来,整个页面在“```”和“代码”之间反复跳变,视觉非常糟糕。
解决方案有两个主流做法:
- **做法一:节流+防抖**。把流式内容累积到buffer里,每隔50~100ms才把完整buffer重新渲染一次。这样既能保持流式效果,又不会让每个token都触发一次重渲染,Markdown闪烁也会大幅减轻。
- **做法二:流式解析Markdown**。前端用一个增量MarkdownParser,比如`marked`配合自定义renderer,识别到代码块开始标记时优先渲染代码块外壳,代码内容逐步填充。实现复杂,但体验最顺滑。
我项目里用的是做法一,结合`requestAnimationFrame`做渲染调度,效果比较稳。大致思路:
```javascript
let renderTimer = null;
function scheduleRender(text) {
if (renderTimer) return;
renderTimer = requestAnimationFrame(() => {
const container = document.getElementById('answer');
container.innerHTML = marked.parse(text);
renderTimer = null;
});
}
注意:用了innerHTML就一定要小心XSS。如果知识库内容包含用户可上传的文档,Markdown解析后的HTML里可能夹带script标签。建议给marked配置sanitize选项,或者引入DOMPurify做清洗。
4.5 让用户体验更顺滑的3个细节
讲完了主体逻辑,再分享三个我用下来体验提升很明显的细节:
细节一:检索状态先行。SSE流里我把sources事件放在LLM生成之前推给前端。这样用户看到的界面顺序是:提问→立刻出现“正在检索3份资料”→答案开始逐字出现→答案下方展示引用来源。整个过程几乎没有“死等”的阶段,用户感知是系统一直在干活。
细节二:光标闪烁动画。流式渲染期间在文字末尾加一个自定义动画光标(比如一条竖线在闪烁),用CSS实现就行,成本极低,但“打字机效果”的沉浸感立刻上来。唯一的注意点是Q1. 需要在流结束后把光标移除,不然影响阅读。
细节三:自动滚动到提问区域。如果页面信息密度高,用户提问后不一定留意到答案出现在哪里。我在提交问题时用scrollIntoView({ behavior: 'smooth' })把答案区域滚到视口中央。不要强推用户去看某个位置,但要确保答案出现的位置不在视口之外。
5. 常见问题与排查技巧实录
5.1 SSE连接总是断开,或者等半天没有内容
这个现象我先排查Nginx缓冲,X-Accel-Buffering: no设置后仍不行,再检查是不是有别的代理层(比如云LB、API网关)也开了缓冲。还有一个常见坑:SSE协议的Content-Type写错。必须是text/event-stream,写成了application/json前端解析会失败,EventSource根本不识别这个类型。
如果真的用了EventSource收到404之类的问题,大概率是因为EventSource只支持GET,而后端只暴露了POST。遇到这种问题统一改用fetch流式读取方案。
5.2 生成内容未结束,但前端排版已经乱了
多半是Markdown没有增量处理。你的渲染逻辑如果是对每个新token立即innerHTML=marked.parse(全部文本),代码块和列表在渲染中会反复“闪跳”。我建议改成:对完整buffer做防抖渲染,渲染频率控制在每100ms一次;同时代码块区域单独处理——识别到```前缀时,在代码块结束前优先渲染为pre外壳,往里填文本内容,避免代码块标记反复出现消失。
5.3 检索不到相关内容,模型说“不知道”但实际有
这个问题90%出在上游——切块或向量化策略不对,而不是生成策略不对。先把问题拆开排查:
- 直接用关键词在原始文档里搜一下,确认内容确实存在。如果存在,检查是不是切块把内容切碎了,导致每个块的向量表征都“不够完整”。常见解决办法:增大chunk_size、增大overlap,或者改用父子块方案——让小块用来检索、大块用来生成。
- 如果关键词能搜到但向量检索搜不到,多半是Embedding模型对领域术语不敏感。建议用同领域的文档做微调,或者直接上混合检索,BM25+向量并用,大幅降低漏召回率。
- 如果检索到了但模型还说不知道,那就是Prompt和生成策略的问题了。确认System Prompt里是否允许模型使用上下文之外的知识?如果允许,它对知识库内容信心不足时就会转向“自由发挥”。把“只能基于上下文回答,否则说不知道”写死。
5.4 引用来源张冠李戴
模型给了一段话标注了来源[2],但点开[2]发现内容根本不支持这句话。这是方案A(模型标号)的常见问题。我后来放弃了纯模型标号,改用结构化输出,让模型输出时按段落关联sources字段,并只允许引用检索结果里真实存在的chunk_id。再叠加一层Groundedness校验,对“段落-来源”做NLI判断,不一致的就自动去掉对应引用标记。
另外一个细节是:检索到的块本身可能就与问题无关,但模型硬是从里面“解读”出了答案,这种幻觉校验器也拦不住。这时候要回到检索源头查——召回的质量如果本身就差,后面再怎么设计也是补救。
6. 更多实战建议:能快速复制的一些经验
聊到这里,把最有价值的一些经验集中列一下,方便你落到自己的项目里:
- 在项目启动阶段就把“可验证”当成一等公民。不要等系统跑起来才想引用的事。从Prompt设计、接口返回结构到前端组件,都要为“回答可追溯”留出位置。临时加引用,会让你重新设计一遍全链路。
- 别追求一刀切方案。知识库里的文档类型混杂时,不同类型用不同的解析和切块策略,而不是一套参数打天下。我在一个项目里把文档分了7类,每类单独配置切块参数,总体效果提升非常明显。
- 评估自动化要趁早。手工评估一两次还行,迭代多了就会漏。把Groundedness校验做成流水线,每版改动后自动跑一批测试问题集,分数不达标直接拦在CI环节。
- 前端流式渲染不只是一层“皮肤”。如果你把它当成纯前端优化而不参与协议设计,后面很容易被后端返回结构限制住。前后端一起定义SSE事件类型、消息格式、结束标记,才能做出平滑的体验。
- 流式接口要预留干预位。线上环境偶尔会遇到模型生成超长或生成内容异常,前端要支持“停止生成”按钮,调用AbortController取消请求。这个必须在最初就做,别等出问题了再补。
根据我自己的经验,RAG系统的每一次“可信度提升”,本质都是把原来让模型“自由想象”的空间一步步压缩,同时把每一步处理过程暴露给用户验证。这个过程没有银弹——每个环节都会漏,但每堵住一个漏点,整个系统就会向“可信”挪一步。
最后再分享一个小技巧:流式渲染阶段,可以额外把检索到的文档标题和摘要以横向卡片的形式固定在回答区域旁边,用户看答案的同时能一眼扫到“回答引用了哪些资料”。这在B端企业知识库场景里特别加分——用户不只看答案,还看答案背后的依据。RAG的“可信”两个字,就是在这种细节里一点点建立起来的。
