第34期 | RAG前端实现
🎯 今天你将学会
- 理解 RAG(检索增强生成)的完整流程——不只是概念,是前端要实现什么
- 实现知识库管理界面(上传文档 → 分片 → embedding → 存储)
- 实现向量搜索交互(用户输入 → 搜索相关文档 → 展示结果 + 引用来源)
- 理解前端在 RAG 系统中的角色——不是调用 API,而是管理整个数据流
📖 核心知识
RAG 是什么?为什么需要它
LLM 有一个致命缺陷:幻觉——它会编造不存在的事实,自信满满地给你错误答案。
RAG 解决这个问题:让 AI 在回答前先从真实文档库中搜索相关内容,基于真实数据回答,而不是凭记忆编造。
没有 RAG 的回答:
“React useEffect 的 cleanup 在组件重新渲染前执行。” ← 这句话是编造的,实际是组件卸载和下次 effect 执行前。
有 RAG 的回答:
“根据 React 官方文档,useEffect 的 cleanup 函数在组件卸载时和下次 effect 执行前运行。” ← 基于真实文档,附引用来源。
RAG 的完整流程(前端视角)
1. 知识库构建阶段(离线/管理界面) 上传文档 → 文档分片(chunking) → 生成 embedding → 存入向量数据库 2. 查询阶段(在线/用户交互) 用户提问 → 问题生成 embedding → 向量搜索找最相关文档片段 → 构建 Prompt(system + 检索到的文档 + 问题)→ LLM 生成回答 → 前端展示回答 + 引用来源前端在 RAG 系统中的职责:
| 环节 | 前端做什么 | 后端做什么 |
|---|---|---|
| 文档上传 | 上传界面 + 进度显示 + 文档列表管理 | 接收文件 → 分片 → embedding → 存储 |
| 向量搜索 | 搜索输入 + 结果展示 + 引用标注 | 问题 embedding → 向量搜索 → 返回结果 |
| LLM 回答 | 聊天界面 + Markdown渲染 + 来源链接 | RAG Prompt → LLM → 流式返回 |
知识库管理界面
核心组件:
KnowledgeBase(知识库管理页面) ├── DocumentUpload(文档上传组件) │ ├── DropZone(拖拽上传区域) │ └── UploadProgress(上传 + 处理进度) ├── DocumentList(文档列表) │ ├── DocumentCard(单文档卡片:名称/状态/操作) │ └── ChunkPreview(文档分片预览) └── SearchBar(知识库内搜索)DocumentUpload 组件:
// features/knowledge/components/DocumentUpload.tsx import { useState, useCallback } from 'react'; import { Upload, FileText, Loader2 } from 'lucide-react'; interface DocumentUploadProps { onUpload: (files: File[]) => Promise<void>; } interface UploadStatus { fileName: string; status: 'uploading' | 'processing' | 'completed' | 'error'; progress: number; error?: string; } export function DocumentUpload({ onUpload }: DocumentUploadProps) { const [uploadStatuses, setUploadStatuses] = useState<UploadStatus[]>([]); const [isDragging, setIsDragging] = useState(false); const handleFiles = async (files: File[]) => { // 支持的文件类型 const validTypes = ['text/plain', 'text/markdown', 'application/pdf']; const validFiles = files.filter(f => validTypes.includes(f.type) || f.name.endsWith('.md')); if (validFiles.length === 0) { alert('请上传 Markdown、PDF 或纯文本文件'); return; } // 初始化状态 const statuses: UploadStatus[] = validFiles.map(f => ({ fileName: f.name, status: 'uploading', progress: 0, })); setUploadStatuses(statuses); try { // 逐个上传 for (let i = 0; i < validFiles.length; i++) { // 上传阶段 setUploadStatuses(prev => prev.map((s, j) => j === i ? { ...s, status: 'uploading', progress: 30 } : s )); // 等后端处理(分片 + embedding) await new Promise(resolve => setTimeout(resolve, 500)); // 模拟 setUploadStatuses(prev => prev.map((s, j) => j === i ? { ...s, status: 'processing', progress: 60 } : s )); // 等 embedding 完成 await new Promise(resolve => setTimeout(resolve, 1000)); // 模拟 setUploadStatuses(prev => prev.map((s, j) => j === i ? { ...s, status: 'completed', progress: 100 } : s )); } await onUpload(validFiles); } catch (error) { setUploadStatuses(prev => prev.map(s => s.status !== 'completed' ? { ...s, status: 'error', error: '上传失败' } : s )); } }; return ( <div> {/* 拖拽上传区域 */} <div onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }} onDragLeave={() => setIsDragging(false)} onDrop={(e) => { e.preventDefault(); setIsDragging(false); handleFiles([...e.dataTransfer.files]); }} className={`border-2 rounded-lg p-8 text-center cursor-pointer transition-colors ${isDragging ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:border-gray-300'}`} onClick={() => { const input = document.createElement('input'); input.type = 'file'; input.multiple = true; input.accept = '.md,.txt,.pdf'; input.onchange = (e) => handleFiles([...(e.target as HTMLInputElement).files!]); input.click(); }} > <Upload size={32} className="mx-auto mb-2 text-gray-400" /> <p className="text-gray-500">拖拽文件到此处,或点击上传</p> <p className="text-xs text-gray-400 mt-1">支持 Markdown、PDF、纯文本</p> </div> {/* 上传进度 */} {uploadStatuses.length > 0 && ( <div className="mt-4 space-y-2"> {uploadStatuses.map((status) => ( <div key={status.fileName} className="flex items-center gap-2 text-sm"> <FileText size={16} /> <span className="flex-1">{status.fileName}</span> {status.status === 'uploading' && ( <span className="text-blue-500">上传中 {status.progress}%</span> )} {status.status === 'processing' && ( <span className="text-yellow-500">处理中...</span> )} {status.status === 'completed' && ( <span className="text-green-500">✅ 完成</span> )} {status.status === 'error' && ( <span className="text-red-500">❌ {status.error}</span> )} {(status.status === 'uploading' || status.status === 'processing') && ( <Loader2 size={16} className="animate-spin text-gray-400" /> )} </div> ))} </div> )} </div> ); }向量搜索交互界面
用户在聊天时提问 → 后端同时做向量搜索 → 返回回答 + 引用来源。
核心:在聊天界面中展示引用来源
// features/chat/components/SourceReference.tsx interface SourceReference { id: string; title: string; content: string; // 引用的文档片段 source: string; // 文档来源(文件名/URL) relevance: number; // 相关度分数 (0-1) } interface SourceReferenceProps { sources: SourceReference[]; } export function SourceReference({ sources }: SourceReferenceProps) { const [expanded, setExpanded] = useState(false); if (sources.length === 0) return null; return ( <div className="mt-3 border-t border-gray-100 pt-2"> <button onClick={() => setExpanded(!expanded)} className="text-xs text-blue-500 hover:underline flex items-center gap-1" > <BookOpen size={12} /> {expanded ? '收起引用来源' : `查看 ${sources.length} 个引用来源`} </button> {expanded && ( <div className="mt-2 space-y-2"> {sources.map((source, idx) => ( <div key={source.id} className="rounded border border-gray-200 p-3 text-xs dark:border-gray-600" > <div className="flex items-center justify-between mb-1"> <span className="font-medium text-blue-600">{source.title}</span> <span className="text-gray-400"> 相关度: {Math.round(source.relevance * 100)}% </span> </div> <p className="text-gray-600 line-clamp-3">{source.content}</p> <span className="text-gray-400 mt-1 block">来源: {source.source}</span> </div> ))} </div> )} </div> ); }RAG 聊天的后端接口设计
前端需要一个新的 API 接口,同时返回 LLM 回答 + 引用来源:
// app/api/ai/rag-chat/route.tsimportOpenAIfrom'openai';exportasyncfunctionPOST(req:NextRequest){const{message,messages}=awaitreq.json();// 1. 向量搜索:找到最相关的文档片段constsearchResults=awaitsearchKnowledgeBase(message,{topK:3});// 2. 构建 RAG PromptconstsystemPrompt=buildRAGPrompt(searchResults);// 3. 流式调用 LLMconststream=awaitopenai.chat.completions.create({model:'gpt-4o-mini',messages:[{role:'system',content:systemPrompt},...messages,{role:'user',content:message},],stream:true,});// 4. 流式返回回答 + 最后附带引用来源// SSE 流中两种数据:// data: { type: 'content', content: '...' } — AI 回答内容// data: { type: 'sources', sources: [...] } — 引用来源(最后一条)}functionbuildRAGPrompt(searchResults:SearchResult[]):string{return`你是一个技术助手。回答问题时,请基于以下参考资料。如果参考资料中没有相关信息,请说明"根据现有文档没有找到相关信息"。 参考资料:${searchResults.map((r,i)=>`[${i+1}]${r.title}\n${r.content}`).join('\n\n')}回答时请标注引用来源,格式:[1] [2] 等。`;}前端处理 RAG 流式响应
RAG 的 SSE 流有两种数据类型(content + sources),前端需要区分处理:
// 解析 RAG SSE 流asyncfunctionhandleRAGStream(stream:ReadableStream<string>){letfullContent='';letsources:SourceReference[]=[];constreader=stream.getReader();while(true){const{done,value}=awaitreader.read();if(done)break;// RAG SSE 可能包含两种类型的数据try{constparsed=JSON.parse(value);if(parsed.type==='content'){fullContent+=parsed.content;// 更新 UI:追加文字到消息updateLastAssistantMessage(fullContent);}elseif(parsed.type==='sources'){sources=parsed.sources;// 更新 UI:显示引用来源updateSourceReferences(sources);}}catch{// 无法解析的行,跳过}}return{content:fullContent,sources};}知识库的状态管理
// features/knowledge/store/knowledgeStore.tsinterfaceKnowledgeState{documents:Document[];searchQuery:string;searchResults:SearchResult[];isSearching:boolean;isUploading:boolean;fetchDocuments:()=>Promise<void>;uploadDocument:(file:File)=>Promise<void>;deleteDocument:(id:string)=>Promise<void>;search:(query:string)=>Promise<void>;}常见误区
误区1:RAG 只需要后端实现
前端需要管理文档上传、展示搜索结果、标注引用来源——这些是用户体验的关键部分。
误区2:所有文档都直接丢给 LLM
文档太大 → token 超限。必须分片(chunking),只搜索最相关的片段传给 LLM。
误区3:引用来源不重要
没有引用来源 → 用户无法验证 AI 回答的可靠性 → RAG 的核心价值就没了。
🤖 AI协作实战
实战场景:设计知识库管理界面的完整交互
我给 AI 的 prompt:
设计一个技术文档知识库管理界面的完整交互流程: 1. 文档上传区:支持拖拽上传 + 点击上传,支持的格式(md/pdf/txt),上传后显示处理进度 2. 文档列表:每个文档卡片显示名称、状态、分片数量、上传时间,支持搜索和删除 3. 分片预览:点击文档可以看到它的分片列表,每个分片是一段 500 字左右的文本 4. 搜索测试:在知识库中搜索一个关键词,看到最相关的分片和相似度分数 用 React + TypeScript + Tailwind + shadcn/ui 风格。 每个组件给出 Props 类型定义和核心渲染逻辑。AI 输出的核心组件代码(经过审查修改后):
- ✅ 上传区:拖拽 + 点击,进度条完整
- ✅ 文档列表:带搜索、状态标签、删除按钮
- ❌ 分片预览太简单——追加要求:分片内容可编辑(用户可以微调分片内容以提高搜索精度)
- ✅ 搜索测试:输入关键词 → 显示相关分片 + 相似度分数 + 来源文档名
学到了什么:AI 生成的知识库界面基本完整,但分片编辑功能是我追加的——因为实际使用中,自动分片可能切得不理想,用户需要微调。
💻 动手练习
练习1(简单):实现文档上传界面
用 DocumentUpload 组件实现拖拽上传 + 进度显示。先不连后端,用 setTimeout 模拟上传进度。
练习2(中等):实现知识库搜索 + 引用展示
在前端实现:
- 搜索输入框 → 调用搜索 API → 展示搜索结果(分片内容 + 相似度分数)
- 在聊天消息中展示 SourceReference(引用来源面板)
练习3(挑战):实现完整的 RAG 聊天
组合所有组件:聊天界面 + RAG 搜索 + 引用来源:
- 用户提问 → 后端同时做向量搜索 + LLM 回答
- 前端流式渲染回答 + 最后展示引用来源
- 引用来源可展开/收起,显示相关度分数和原始文档片段
📌 本期要点
- RAG 解决幻觉:让 AI 基于真实文档回答,而不是凭记忆编造
- 前端职责:文档上传/管理界面、向量搜索交互、引用来源展示
- 知识库管理界面:拖拽上传 + 进度显示 + 文档列表 + 分片预览 + 搜索测试
- 引用来源是 RAG 的灵魂:没有引用来源 → 用户无法验证 → RAG 的核心价值没了
- RAG SSE 流有两种数据:content(AI回答)+ sources(引用来源),前端要区分处理
🔗 下期预告
下一期进入 AI Agent 前端交互——工具调用展示、思考过程可视化、多轮对话。你将让 AI 不仅会「说话」,还能「做事」——调用工具、执行操作,并把整个过程可视化展示给用户。
如果你没有苹果电脑,需要上传ios到APPStore可以访问以下网站
iPA上传工具 - IPA解析与AppStore提交