欢迎你来读这篇博客,这篇博客主要是关于 Spring AI Alibaba + pgvector RAG 项目的前端工程方案。
上一篇后端方案已经把 Spring AI Alibaba + DashScope + pgvector + QuestionAnswerAdvisor / RetrievalAugmentationAdvisor 的后端能力设计清楚了。本文只聚焦前端:如何用 React + TypeScript + Vite + Ant Design 做一个真正能落地的 RAG 管理台和知识库问答界面。
序言 你前面贴的语雀内容,本质上完成的是后端 RAG 的最小闭环:
文档内容进入系统。
文档被切片、向量化。
向量存入 Milvus / pgvector。
用户提问时,系统进行相似度检索。
检索结果通过 QuestionAnswerAdvisor 拼入上下文。
大模型基于知识库内容生成回答。
后端做到这里,只能说明“大脑能想”。但是没有前端,用户只能拿 Postman 调接口,体验就像开法拉利还得推着走,多少有点抽象。
所以前端需要补齐四类能力:
智能问答工作台 :支持流式回答、会话历史、来源引用、停止生成、重新生成。
知识库管理台 :支持知识库创建、文档上传、文档解析状态、切片预览、重建索引。
检索调试台 :支持输入问题,查看召回片段、分数、metadata、TopK、阈值效果。
系统配置台 :支持模型参数、检索参数、文档切片参数、权限过滤参数的可视化配置。
本文会直接给出前端约束、页面设计、接口约束、TypeScript 类型、核心代码骨架和联调规范。
正文 1. 前端建设目标 1.1 目标定位 本项目不是普通后台 CRUD,而是一个 RAG 知识库问答系统前端 。
它需要同时面向两类用户:
用户角色
主要诉求
典型页面
普通使用者
基于知识库提问,拿到可信回答
智能问答、会话历史、来源引用
管理员 / 运营人员
管理知识库、上传文档、查看入库状态、调试召回效果
知识库管理、文档管理、入库任务、检索调试、系统配置
因此前端不是简单做一个聊天框,而是要把 问答体验 + 知识库治理 + 检索可观测性 放到一起。
1.2 核心原则
原则
说明
先可用,再好看
第一版优先保证上传、入库、检索、问答闭环完整
问答必须可追溯
每条回答必须能展示引用来源,不能只给一段模型生成文本
参数必须可调试
TopK、阈值、知识库范围、是否重排等参数要能在调试台调整
错误必须可定位
前后端统一 traceId,接口异常要能回溯后端日志
权限不能靠前端
前端只做展示和按钮控制,真实权限过滤必须由后端执行
API Key 永不进前端
DashScope / OpenAI / 任何模型 Key 都只能留在后端或配置中心
2. 推荐技术栈 2.1 基础技术栈
模块
推荐选型
说明
构建工具
Vite
启动快,适合 React + TS 项目
前端框架
React 18+
与 Ant Design / Ant Design X 生态匹配
语言
TypeScript
DTO、API、组件 Props 都强类型约束
UI 组件库
Ant Design
后台管理台主组件库
AI 交互组件
Ant Design X,可选但推荐
用于聊天气泡、输入框、会话列表、来源展示
路由
React Router
页面路由管理
请求库
Axios + Fetch Stream
普通接口用 Axios,流式接口用 Fetch ReadableStream
服务端状态
TanStack Query
列表、详情、分页、缓存、刷新
本地状态
Zustand
当前会话、侧边栏、UI 偏好等轻状态
时间处理
dayjs
时间格式化
Markdown 渲染
@ant-design/x-markdown 或 react-markdown
渲染模型回答
代码高亮
@ant-design/x CodeHighlighter 或 shiki / highlight.js
渲染代码块
图标
@ant-design/icons
与 AntD 风格统一
2.2 Ant Design 版本建议 新项目建议:
1 pnpm add antd @ant-design/icons
如果要做更像 ChatGPT / 通义千问 / Kimi 这种 AI 聊天界面,建议额外加:
1 pnpm add @ant-design/x @ant-design/x-markdown
说明:
antd 负责后台管理台:布局、表单、表格、上传、弹窗、抽屉、提示、标签等。
@ant-design/x 负责 AI 对话场景:Bubble、Sender、Conversations、Attachments、Sources、ThoughtChain 等。
如果团队不想引入 @ant-design/x,也可以用基础 List + Card + Input.TextArea + Button 自己拼聊天界面,但后期会更累。
我的建议很直接:RAG 问答页用 Ant Design X,知识库后台页用 Ant Design。 这俩分工刚好,别强行用锤子拧螺丝。
3. 前端整体架构 3.1 应用架构图 flowchart TB
subgraph Browser[浏览器]
A1[React App]
A2[Ant Design UI]
A3[Ant Design X Chat UI]
A4[Route / Store / Query]
end
subgraph Pages[前端页面]
B1[智能问答]
B2[知识库管理]
B3[文档管理]
B4[文档详情 / 切片预览]
B5[入库任务]
B6[检索调试]
B7[系统配置]
end
subgraph Api[前端 API Client]
C1[普通 REST API - Axios]
C2[流式 Chat API - Fetch Stream]
C3[文件上传 API - Upload customRequest]
end
subgraph Backend[Spring Boot 后端]
D1[RagChatController]
D2[KnowledgeBaseController]
D3[DocumentController]
D4[RetrievalController]
D5[IngestionJobController]
end
subgraph RagCore[RAG 后端能力]
E1[Spring AI Alibaba]
E2[DashScope Chat]
E3[DashScope Embedding]
E4[PgVectorStore]
E5[(PostgreSQL + pgvector)]
end
A1 --> B1
A1 --> B2
A1 --> B3
A1 --> B4
A1 --> B5
A1 --> B6
A1 --> B7
B1 --> C2
B2 --> C1
B3 --> C1
B3 --> C3
B6 --> C1
C1 --> D2
C1 --> D3
C1 --> D4
C1 --> D5
C2 --> D1
C3 --> D3
D1 --> E1
D4 --> E4
D3 --> E3
E1 --> E2
E4 --> E5
3.2 页面职责划分
页面
路由
说明
首页 / 工作台
/
展示知识库数量、文档数量、入库任务、最近问答
智能问答
/chat
核心 RAG 问答页面,支持流式输出和引用来源
知识库管理
/knowledge-bases
创建、编辑、删除知识库,配置默认检索参数
文档管理
/documents
上传文档、查看解析状态、删除、重建索引
文档详情
/documents/:documentId
查看文档元数据、切片列表、切片内容、向量状态
入库任务
/ingestion-jobs
查看文档解析、切片、Embedding、入库任务状态
检索调试
/retrieval-lab
输入 Query 查看召回片段、分数、metadata、过滤条件
系统配置
/settings
模型、检索、切片、问答策略配置
审计日志
/audit-logs
查看上传、删除、重建、问答等操作记录
4. 推荐项目结构 4.1 目录结构 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 rag-web/ ├── public/ ├── src/ │ ├── app/ │ │ ├── App.tsx │ │ ├── router.tsx │ │ ├── providers.tsx │ │ └── theme.ts │ ├── layouts/ │ │ ├── BasicLayout.tsx │ │ └── components/ │ │ ├── AppHeader.tsx │ │ ├── AppSider.tsx │ │ └── UserAvatar.tsx │ ├── pages/ │ │ ├── dashboard/ │ │ │ └── DashboardPage.tsx │ │ ├── chat/ │ │ │ ├── ChatPage.tsx │ │ │ ├── components/ │ │ │ │ ├── ChatWorkspace.tsx │ │ │ │ ├── ConversationSider.tsx │ │ │ │ ├── SourceDrawer.tsx │ │ │ │ └── RetrievalPanel.tsx │ │ │ └── hooks/ │ │ │ └── useRagChat.ts │ │ ├── knowledge-base/ │ │ │ ├── KnowledgeBaseListPage.tsx │ │ │ ├── KnowledgeBaseFormModal.tsx │ │ │ └── api.ts │ │ ├── document/ │ │ │ ├── DocumentListPage.tsx │ │ │ ├── DocumentDetailPage.tsx │ │ │ ├── DocumentUploadDrawer.tsx │ │ │ ├── ChunkTable.tsx │ │ │ └── api.ts │ │ ├── ingestion-job/ │ │ │ ├── IngestionJobListPage.tsx │ │ │ └── api.ts │ │ ├── retrieval-lab/ │ │ │ ├── RetrievalLabPage.tsx │ │ │ └── components/ │ │ │ ├── RetrievalQueryForm.tsx │ │ │ └── RetrievalResultList.tsx │ │ └── settings/ │ │ ├── SettingsPage.tsx │ │ └── api.ts │ ├── shared/ │ │ ├── api/ │ │ │ ├── http.ts │ │ │ ├── stream.ts │ │ │ └── types.ts │ │ ├── components/ │ │ │ ├── PageContainer.tsx │ │ │ ├── StatusTag.tsx │ │ │ ├── TraceError.tsx │ │ │ └── JsonViewer.tsx │ │ ├── constants/ │ │ ├── hooks/ │ │ ├── utils/ │ │ └── styles/ │ ├── store/ │ │ ├── useAppStore.ts │ │ └── useChatStore.ts │ ├── main.tsx │ └── vite-env.d.ts ├── .env.development ├── .env.production ├── package.json ├── tsconfig.json └── vite.config.ts
4.2 分包原则
目录
约束
pages
只放页面和页面私有组件,不放通用工具
shared/api
统一请求封装,禁止页面里直接写 fetch('/api/xxx')
shared/components
业务无关或弱业务通用组件
store
只放客户端 UI 状态,不放接口缓存数据
app/router.tsx
所有路由集中维护
app/theme.ts
AntD 主题 token 统一维护
pages/**/api.ts
每个业务模块自己的接口函数
一个简单标准:页面负责组装,组件负责展示,Hook 负责状态,API 文件负责请求。 不要让一个 ChatPage.tsx 写 800 行,React 也会emo。
5. Ant Design 组件使用约束 5.1 基础后台组件约束
场景
组件
使用约束
整体布局
Layout、Menu、Breadcrumb
所有页面统一走 BasicLayout,不要页面各自造布局
页面容器
Card、Typography、Space
每页顶部统一标题、描述、操作按钮
表单
Form、Input、Select、InputNumber、Slider、Switch
所有必填项必须加 rules;参数要有范围限制
表格
Table
必须设置 rowKey;远程分页统一 PageResult
上传
Upload.Dragger
统一 customRequest,统一文件类型、大小校验
状态展示
Tag、Badge、Progress、Steps
文档解析、入库任务要有明确状态
详情展示
Descriptions、Tabs、Collapse
文档详情页、切片预览页使用
反馈
message、notification、Modal、Drawer、Popconfirm
删除、重建索引等危险操作必须二次确认
加载态
Spin、Skeleton、Empty
所有异步页面必须有 loading/empty/error 状态
5.2 AI 对话组件约束 如果使用 @ant-design/x,建议这样分工:
场景
组件
说明
消息气泡
Bubble / Bubble.List
展示用户问题、AI 回答、流式回答
输入框
Sender
支持发送、停止、加载态、粘贴文件
会话列表
Conversations
左侧历史会话列表
附件
Attachments
后续支持“带文件提问”时使用
来源引用
Sources
展示 RAG 召回来源
思考过程
ThoughtChain
可选,用于展示“检索中、生成中”等步骤,不展示模型隐私推理
注意:这里的 ThoughtChain 只展示系统执行步骤,例如:
1 2 3 4 1. 正在检索知识库 2. 找到 5 个相关片段 3. 正在生成回答 4. 已完成
不要展示所谓“模型思维链”。用户要的是可观测流程,不是把模型脑袋掀开。
5.3 UI 风格约束
项目
约束
主题
使用 ConfigProvider 统一配置,禁止页面内随意写颜色
间距
优先使用 Space、Flex、Card 内置间距
图标
使用 @ant-design/icons,不要混入多套图标库
表格操作列
操作按钮顺序固定:详情、调试、重建、删除
危险操作
删除、重建、清空向量必须使用 Popconfirm
响应式
管理台最小宽度建议 1200px;聊天页兼容 768px 以上
错误提示
统一展示 message + 可复制 traceId
空状态
不要空白页面,使用 Empty 给出下一步操作
Markdown
模型回答必须做 XSS 防护,不能直接 dangerouslySetInnerHTML
6. 页面详细设计 6.1 智能问答页 6.1.1 页面目标 智能问答页是整个系统最重要的页面,需要支持:
新建会话。
查看历史会话。
选择知识库范围。
输入问题。
流式输出回答。
停止生成。
重新生成。
展示引用来源。
展示检索参数。
展示无答案提示。
6.1.2 页面布局 flowchart LR
A[左侧会话列表 Conversations] --> B[中间聊天区 Bubble.List]
B --> C[底部输入区 Sender]
B --> D[右侧来源抽屉 Sources Drawer]
C --> E[知识库选择 / 检索参数]
页面结构建议:
1 2 3 4 5 6 7 8 9 10 11 ┌─────────────────────────────────────────────────────────────┐ │ Header: RAG 知识库问答 / 模型 / 用户信息 │ ├───────────────┬───────────────────────────────┬─────────────┤ │ 会话列表 │ 聊天内容 │ 来源引用 │ │ - 新建会话 │ 用户问题 │ 文档标题 │ │ - 最近会话 │ AI 流式回答 │ chunk 分数 │ │ - 历史会话 │ 引用标记 │ 原文片段 │ │ │ │ 跳转详情 │ ├───────────────┴───────────────────────────────┴─────────────┤ │ Sender 输入框 + 知识库选择 + TopK + 阈值 + 发送/停止 │ └─────────────────────────────────────────────────────────────┘
6.1.3 消息展示约束
消息类型
展示方式
用户消息
右侧气泡,展示原始问题
AI 消息
左侧气泡,支持 Markdown、代码块、表格
系统消息
灰色提示,例如“未检索到高置信度内容”
错误消息
红色 Alert,展示错误信息和 traceId
流式中
Bubble 显示 loading / streaming 状态
6.1.4 来源引用展示 每条 AI 回答都应该能展开来源。
来源字段至少包含:
字段
说明
sourceId
来源唯一 ID
documentId
文档 ID
documentName
文档名称
chunkId
切片 ID
chunkIndex
切片序号
score
相似度分数
content
召回片段内容
metadata
元数据,例如分类、来源、页码、标题
展示方式建议:
回答正文末尾展示 [1] [2] [3]。
点击引用编号打开右侧 Drawer。
Drawer 中用 List + Card + Tag + Typography.Paragraph 展示来源。
每个来源支持“查看文档详情”和“复制片段”。
6.2 知识库管理页 6.2.1 页面目标 管理不同知识库,例如:
Java 技术知识库。
项目接口文档知识库。
公司制度知识库。
语雀同步知识库。
产品 FAQ 知识库。
6.2.2 表格字段
字段
说明
知识库名称
name
编码
code,用于接口传参
描述
description
文档数
documentCount
切片数
chunkCount
默认 TopK
defaultTopK
默认阈值
defaultSimilarityThreshold
状态
enabled / disabled
创建时间
createdAt
操作
编辑、上传文档、检索调试、删除
6.2.3 创建表单 使用 Modal + Form。
字段约束:
字段
组件
约束
名称
Input
必填,2-50 字
编码
Input
必填,只允许小写字母、数字、短横线
描述
Input.TextArea
最大 500 字
默认 TopK
InputNumber
1-12,默认 6
默认阈值
Slider / InputNumber
0-1,默认 0.75
是否启用
Switch
默认启用
6.3 文档管理页 6.3.1 页面目标 文档管理页负责:
上传文件。
查看文档解析状态。
查看切片数量。
触发重新解析。
删除文档及向量。
进入文档详情查看 chunk。
6.3.2 上传方式 第一版支持:
上传方式
说明
本地文件上传
PDF、Word、Markdown、TXT、HTML
URL 导入
输入网页或语雀文档 URL,由后端抓取
纯文本粘贴
直接粘贴一段内容入库
第二版可以扩展:
语雀空间同步。
Git 仓库 Markdown 同步。
Confluence 同步。
定时增量同步。
6.3.3 文档状态
状态
含义
UI 展示
UPLOADED
已上传,未解析
蓝色 Tag
PARSING
解析中
Processing Badge
CHUNKING
切片中
Processing Badge
EMBEDDING
向量化中
Progress
INDEXED
已入库
绿色 Tag
FAILED
失败
红色 Tag + 错误详情
DELETED
已删除
灰色 Tag
6.3.4 上传约束
项目
约束
单文件大小
默认 50MB,可配置
批量数量
一次最多 10 个
文件类型
.pdf、.docx、.md、.txt、.html、.csv、.xlsx
空文件
前端禁止上传
重名文件
允许上传,但后端根据 contentHash 去重
上传接口
multipart/form-data
6.4 文档详情页 6.4.1 页面结构 使用 Descriptions + Tabs + Table + Drawer。
1 2 3 4 5 6 7 8 9 10 11 12 13 文档详情 ├── 基础信息 │ ├── 文件名 │ ├── 知识库 │ ├── 来源类型 │ ├── 状态 │ ├── 文件大小 │ ├── chunk 数量 │ └── 创建时间 ├── Tab: 原文预览 ├── Tab: 切片列表 ├── Tab: 元数据 └── Tab: 入库日志
6.4.2 切片列表字段
字段
说明
chunkIndex
切片序号
contentPreview
切片预览
tokenCount
token 数
enabled
是否参与检索
metadata
JSON 元数据
createdAt
创建时间
操作
查看全文、复制、禁用/启用
6.5 检索调试页 6.5.1 页面目标 检索调试页是 RAG 系统非常关键的“验货台”。它不调用大模型,只看检索效果。
支持:
输入 Query。
选择知识库。
设置 TopK。
设置相似度阈值。
设置 metadata filter。
查看召回 chunk。
查看 score。
对比纯向量检索、关键词检索、混合检索。
6.5.2 页面布局 1 2 3 4 5 6 7 8 9 ┌─────────────────────────────────────────────┐ │ Query 输入框 │ ├─────────────────────────────────────────────┤ │ 知识库 Select | TopK | 阈值 | 检索模式 │ ├─────────────────────────────────────────────┤ │ 召回结果列表 │ │ [score] 文档名 / chunkIndex / metadata │ │ chunk 原文 │ └─────────────────────────────────────────────┘
6.5.3 检索模式
模式
前端值
说明
向量检索
VECTOR
pgvector 相似度检索
关键词检索
KEYWORD
PostgreSQL 全文检索或业务搜索
混合检索
HYBRID
向量 + 关键词融合
混合 + 重排
HYBRID_RERANK
后端增加 rerank
第一版可以只实现 VECTOR,但接口字段要预留 searchMode,别写死。今天写死,明天加班,这条定律比重力还稳定。
6.6 入库任务页 6.6.1 页面目标 展示文档入库链路每一步状态:
flowchart LR
A[上传] --> B[解析]
B --> C[清洗]
C --> D[切片]
D --> E[Embedding]
E --> F[写入 pgvector]
F --> G[完成]
6.6.2 表格字段
字段
说明
任务 ID
jobId
文档名
documentName
知识库
knowledgeBaseName
状态
status
当前步骤
currentStep
进度
progress
chunk 数
chunkCount
成功数
successCount
失败原因
errorMessage
开始时间
startedAt
结束时间
finishedAt
操作
查看日志、重试
6.7 系统配置页 6.7.1 配置分组 使用 Tabs 分组:
Tab
配置内容
模型配置
chat model、embedding model、temperature、max tokens
检索配置
TopK、threshold、searchMode、rerank
切片配置
chunkSize、overlap、分隔符策略
安全配置
是否必须引用、无答案策略、敏感词
显示配置
是否展示 score、是否展示来源、是否展示调试信息
注意:配置保存到后端。前端不能自己决定生产配置,否则不同浏览器不同效果,排查时会想把键盘做成飞盘。
7. 前后端接口统一约束 7.1 基础 URL 开发环境:
1 VITE_API_BASE_URL=http://localhost:8080/api
生产环境:
前端禁止在代码中硬编码后端地址。
7.2 请求头约束 所有接口统一携带:
1 2 3 4 Authorization : Bearer <access_token>X-Request-Id : <uuid>X-Tenant-Id : <tenant_id>Content-Type : application/json
说明:
Header
必填
说明
Authorization
是
登录态,前端从认证模块获取
X-Request-Id
是
前端生成,用于日志追踪
X-Tenant-Id
多租户时必填
用于后端租户隔离
Accept-Language
可选
国际化预留
文件上传时:
1 Content-Type : multipart/form-data
流式问答时:
1 Accept : text/event-stream
7.3 通用返回结构 所有普通 REST 接口统一返回:
1 2 3 4 5 6 7 8 export interface ApiResult <T> { success : boolean ; code : string ; message : string ; data : T; traceId : string ; timestamp : string ; }
示例:
1 2 3 4 5 6 7 8 { "success" : true , "code" : "OK" , "message" : "success" , "data" : { } , "traceId" : "01JY2VYEXZ4G7QZQ9F6VN0H6RA" , "timestamp" : "2026-06-13T17:10:00+08:00" }
7.4 分页返回结构 1 2 3 4 5 6 7 export interface PageResult <T> { records : T[]; pageNo : number ; pageSize : number ; total : number ; pages : number ; }
约束:
字段
约束
pageNo
从 1 开始
pageSize
默认 20,最大 100
total
后端返回总数
records
当前页数据
7.5 错误码约束
code
HTTP 状态
说明
前端处理
OK
200
成功
正常展示
BAD_REQUEST
400
参数错误
表单提示
UNAUTHORIZED
401
未登录
跳转登录
FORBIDDEN
403
无权限
展示无权限页
NOT_FOUND
404
数据不存在
Empty / 404 页
CONFLICT
409
状态冲突
message 提示
RAG_NO_CONTEXT
200/422
未检索到可用上下文
展示“知识库未找到答案”
RAG_MODEL_ERROR
500
模型调用失败
展示错误 + traceId
RAG_VECTOR_ERROR
500
向量检索失败
展示错误 + traceId
FILE_TOO_LARGE
400
文件过大
上传前/后提示
UNSUPPORTED_FILE_TYPE
400
不支持的文件类型
上传前/后提示
前端所有异常提示都必须附带 traceId,例如:
1 请求失败:模型调用失败。traceId=01JY2VYEXZ4G7QZQ9F6VN0H6RA
8. RAG Chat 接口约束 8.1 普通非流式问答 接口 1 2 POST /api/rag/chatContent-Type : application/json
请求体 1 2 3 4 5 6 7 8 9 export interface RagChatRequest { sessionId ?: string ; question : string ; knowledgeBaseIds : string []; retrievalOptions ?: RetrievalOptions ; modelOptions ?: ModelOptions ; withSources ?: boolean ; metadataFilter ?: Record <string , unknown >; }
1 2 3 4 5 6 7 8 9 10 11 12 13 export interface RetrievalOptions { topK : number ; similarityThreshold : number ; searchMode : 'VECTOR' | 'KEYWORD' | 'HYBRID' | 'HYBRID_RERANK' ; enableRerank ?: boolean ; enableQueryRewrite ?: boolean ; }export interface ModelOptions { model ?: string ; temperature ?: number ; maxTokens ?: number ; }
请求示例 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 { "sessionId" : "session_001" , "question" : "Spring AI 的 QuestionAnswerAdvisor 是怎么工作的?" , "knowledgeBaseIds" : [ "kb_spring_ai" ] , "retrievalOptions" : { "topK" : 6 , "similarityThreshold" : 0.75 , "searchMode" : "VECTOR" , "enableRerank" : false , "enableQueryRewrite" : true } , "modelOptions" : { "model" : "qwen-plus" , "temperature" : 0.3 , "maxTokens" : 2048 } , "withSources" : true }
响应体 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 export interface RagChatResponse { sessionId : string ; messageId : string ; answer : string ; finishReason : 'STOP' | 'LENGTH' | 'ERROR' | 'NO_CONTEXT' ; sources : SourceReference []; usage ?: TokenUsage ; }export interface SourceReference { sourceId : string ; knowledgeBaseId : string ; knowledgeBaseName : string ; documentId : string ; documentName : string ; chunkId : string ; chunkIndex : number ; score : number ; content : string ; metadata : Record <string , unknown >; }export interface TokenUsage { promptTokens : number ; completionTokens : number ; totalTokens : number ; }
8.2 流式问答接口 接口 1 2 3 POST /api/rag/chat/streamAccept : text/event-streamContent-Type : application/json
为什么不用 EventSource?
因为浏览器原生 EventSource 只适合 GET 请求,不方便携带复杂 JSON 请求体。RAG 问答需要传 knowledgeBaseIds、retrievalOptions、modelOptions 等复杂参数,所以前端建议用 fetch + ReadableStream 解析 SSE。
SSE 事件格式 后端返回:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 event: metadata data: {"sessionId":"session_001","messageId":"msg_001","traceId":"xxx"} event: retrieval data: {"status":"DONE","topK":6,"sources":[...]} event: delta data: {"content":"Spring AI "} event: delta data: {"content":"通过 Advisor..."} event: done data: {"finishReason":"STOP","usage":{"totalTokens":1024}}
错误:
1 2 event: error data: {"code":"RAG_MODEL_ERROR","message":"模型调用失败","traceId":"xxx"}
前端事件类型 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 export type RagStreamEvent = | RagStreamMetadataEvent | RagStreamRetrievalEvent | RagStreamDeltaEvent | RagStreamSourceEvent | RagStreamDoneEvent | RagStreamErrorEvent ;export interface RagStreamMetadataEvent { event : 'metadata' ; data : { sessionId : string ; messageId : string ; traceId : string ; }; }export interface RagStreamRetrievalEvent { event : 'retrieval' ; data : { status : 'STARTED' | 'DONE' | 'EMPTY' ; topK : number ; sources : SourceReference []; }; }export interface RagStreamDeltaEvent { event : 'delta' ; data : { content : string ; }; }export interface RagStreamSourceEvent { event : 'source' ; data : SourceReference ; }export interface RagStreamDoneEvent { event : 'done' ; data : { finishReason : 'STOP' | 'LENGTH' | 'ERROR' | 'NO_CONTEXT' ; usage ?: TokenUsage ; }; }export interface RagStreamErrorEvent { event : 'error' ; data : { code : string ; message : string ; traceId : string ; }; }
前端流式处理约束
场景
约束
发送问题后
立即把用户消息插入消息列表
收到 metadata
创建 AI 消息占位
收到 retrieval
更新来源列表和检索状态
收到 delta
追加到当前 AI 消息 content
收到 done
结束 loading,保存 usage
收到 error
停止 loading,展示错误消息
点击停止
调用 AbortController.abort()
页面离开
自动中断当前流
9. 知识库接口约束 9.1 查询知识库列表 1 GET /api/rag/knowledge-bases?pageNo=1&pageSize=20&keyword=spring&status=ENABLED
响应:
1 2 3 4 5 6 7 8 9 10 11 12 13 export interface KnowledgeBaseDTO { id : string ; name : string ; code : string ; description ?: string ; documentCount : number ; chunkCount : number ; defaultTopK : number ; defaultSimilarityThreshold : number ; status : 'ENABLED' | 'DISABLED' ; createdAt : string ; updatedAt : string ; }
9.2 创建知识库 1 POST /api/rag/knowledge-bases
请求:
1 2 3 4 5 6 7 8 export interface CreateKnowledgeBaseRequest { name : string ; code : string ; description ?: string ; defaultTopK : number ; defaultSimilarityThreshold : number ; status : 'ENABLED' | 'DISABLED' ; }
字段约束:
字段
前端校验
后端校验
name
必填,2-50 字
必填,唯一性可选
code
必填,^[a-z0-9-]+$
必填,全局唯一
defaultTopK
1-12
1-20
defaultSimilarityThreshold
0-1
0-1
9.3 更新知识库 1 PUT /api/rag/knowledge-bases/{knowledgeBaseId}
9.4 删除知识库 1 DELETE /api/rag/knowledge-bases/{knowledgeBaseId}
删除约束:
如果知识库下有文档,默认不允许删除。
前端点击删除时,使用 Popconfirm。
如果后端支持强制删除,必须要求用户输入知识库名称确认。
10. 文档接口约束 10.1 文档列表 1 GET /api/rag/documents?pageNo=1&pageSize=20&knowledgeBaseId=kb_001&status=INDEXED&keyword=spring
响应 DTO:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 export interface KnowledgeDocumentDTO { id : string ; knowledgeBaseId : string ; knowledgeBaseName : string ; name : string ; sourceType : 'UPLOAD' | 'URL' | 'YUQUE' | 'TEXT' | 'GIT' ; fileType : 'PDF' | 'DOCX' | 'MD' | 'TXT' | 'HTML' | 'CSV' | 'XLSX' | 'UNKNOWN' ; sizeBytes : number ; contentHash : string ; status : DocumentStatus ; chunkCount : number ; indexedChunkCount : number ; errorMessage ?: string ; createdAt : string ; updatedAt : string ; }export type DocumentStatus = | 'UPLOADED' | 'PARSING' | 'CHUNKING' | 'EMBEDDING' | 'INDEXED' | 'FAILED' | 'DELETED' ;
10.2 文件上传 1 2 POST /api/rag/documents/uploadContent-Type : multipart/form-data
表单字段:
字段
类型
必填
说明
knowledgeBaseId
string
是
知识库 ID
files
File[]
是
文件列表
tags
string[]
否
标签
metadata
string
否
JSON 字符串
parseImmediately
boolean
否
是否上传后立即解析
响应:
1 2 3 4 export interface UploadDocumentResponse { documents : KnowledgeDocumentDTO []; jobIds : string []; }
10.3 URL 导入 1 POST /api/rag/documents/import-url
请求:
1 2 3 4 5 6 7 export interface ImportUrlRequest { knowledgeBaseId : string ; url : string ; title ?: string ; tags ?: string []; parseImmediately ?: boolean ; }
10.4 纯文本导入 1 POST /api/rag/documents/import-text
请求:
1 2 3 4 5 6 7 8 export interface ImportTextRequest { knowledgeBaseId : string ; title : string ; content : string ; tags ?: string []; metadata ?: Record <string , unknown >; parseImmediately ?: boolean ; }
10.5 语雀导入预留 如果后端后续支持语雀 API,同步接口建议这样设计:
1 POST /api/rag/documents/import-yuque
请求:
1 2 3 4 5 6 7 export interface ImportYuqueRequest { knowledgeBaseId : string ; yuqueUrl : string ; tokenAlias ?: string ; syncMode : 'ONCE' | 'INCREMENTAL' ; parseImmediately ?: boolean ; }
注意:
前端不保存语雀 Token 明文。
如果要配置 Token,应该由后端提供 tokenAlias 或密钥配置页面。
前端只传 tokenAlias,不传真实密钥。
10.6 文档详情 1 GET /api/rag/documents/{documentId}
10.7 删除文档 1 DELETE /api/rag/documents/{documentId}?deleteVectors=true
约束:
deleteVectors=true 表示同时删除 pgvector 中的 chunk 向量。
前端必须二次确认。
删除成功后刷新文档列表、知识库统计、检索调试缓存。
10.8 重建索引 1 POST /api/rag/documents/{documentId}/reindex
响应:
1 2 3 4 5 export interface ReindexResponse { jobId : string ; documentId : string ; status : 'PENDING' ; }
11. 切片接口约束 11.1 查询切片列表 1 GET /api/rag/documents/{documentId}/chunks?pageNo=1&pageSize=20
响应:
1 2 3 4 5 6 7 8 9 10 11 12 export interface KnowledgeChunkDTO { id : string ; documentId : string ; chunkIndex : number ; content : string ; contentPreview : string ; tokenCount : number ; enabled : boolean ; score ?: number ; metadata : Record <string , unknown >; createdAt : string ; }
11.2 启用 / 禁用切片 1 PATCH /api/rag/chunks/{chunkId}/enabled
请求:
说明:
禁用 chunk 后,该 chunk 不参与检索。
后端需要在 metadata filter 或查询条件里过滤 disabled chunk。
12. 检索调试接口约束 12.1 检索测试 1 POST /api/rag/retrieval/search
请求:
1 2 3 4 5 6 7 8 export interface RetrievalSearchRequest { query : string ; knowledgeBaseIds : string []; topK : number ; similarityThreshold : number ; searchMode : 'VECTOR' | 'KEYWORD' | 'HYBRID' | 'HYBRID_RERANK' ; metadataFilter ?: Record <string , unknown >; }
响应:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 export interface RetrievalSearchResponse { query : string ; normalizedQuery ?: string ; results : RetrievalResultItem []; costMs : number ; }export interface RetrievalResultItem { rank : number ; score : number ; documentId : string ; documentName : string ; chunkId : string ; chunkIndex : number ; content : string ; metadata : Record <string , unknown >; }
前端展示:
分数用 Progress 或 Tag 展示。
高于阈值显示绿色,低于阈值显示橙色。
metadata 用 JsonViewer 或 Descriptions 展示。
chunk 内容用 Typography.Paragraph,支持复制。
13. 入库任务接口约束 13.1 任务列表 1 GET /api/rag/ingestion-jobs?pageNo=1&pageSize=20&status=RUNNING&documentId=doc_001
响应:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 export interface IngestionJobDTO { id : string ; documentId : string ; documentName : string ; knowledgeBaseId : string ; knowledgeBaseName : string ; status : IngestionJobStatus ; currentStep : IngestionStep ; progress : number ; chunkCount : number ; successCount : number ; failedCount : number ; errorMessage ?: string ; startedAt ?: string ; finishedAt ?: string ; createdAt : string ; }export type IngestionJobStatus = | 'PENDING' | 'RUNNING' | 'SUCCESS' | 'FAILED' | 'CANCELED' ;export type IngestionStep = | 'UPLOAD' | 'PARSE' | 'CLEAN' | 'CHUNK' | 'EMBEDDING' | 'INDEX' | 'DONE' ;
13.2 重试任务 1 POST /api/rag/ingestion-jobs/{jobId}/retry
13.3 取消任务 1 POST /api/rag/ingestion-jobs/{jobId}/cancel
14. 前端核心类型定义 建议统一放到:
完整示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 export interface ApiResult <T> { success : boolean ; code : string ; message : string ; data : T; traceId : string ; timestamp : string ; }export interface PageResult <T> { records : T[]; pageNo : number ; pageSize : number ; total : number ; pages : number ; }export interface PageQuery { pageNo ?: number ; pageSize ?: number ; keyword ?: string ; }export interface KnowledgeBaseDTO { id : string ; name : string ; code : string ; description ?: string ; documentCount : number ; chunkCount : number ; defaultTopK : number ; defaultSimilarityThreshold : number ; status : 'ENABLED' | 'DISABLED' ; createdAt : string ; updatedAt : string ; }export type DocumentStatus = | 'UPLOADED' | 'PARSING' | 'CHUNKING' | 'EMBEDDING' | 'INDEXED' | 'FAILED' | 'DELETED' ;export interface KnowledgeDocumentDTO { id : string ; knowledgeBaseId : string ; knowledgeBaseName : string ; name : string ; sourceType : 'UPLOAD' | 'URL' | 'YUQUE' | 'TEXT' | 'GIT' ; fileType : 'PDF' | 'DOCX' | 'MD' | 'TXT' | 'HTML' | 'CSV' | 'XLSX' | 'UNKNOWN' ; sizeBytes : number ; contentHash : string ; status : DocumentStatus ; chunkCount : number ; indexedChunkCount : number ; errorMessage ?: string ; createdAt : string ; updatedAt : string ; }export interface SourceReference { sourceId : string ; knowledgeBaseId : string ; knowledgeBaseName : string ; documentId : string ; documentName : string ; chunkId : string ; chunkIndex : number ; score : number ; content : string ; metadata : Record <string , unknown >; }export interface RetrievalOptions { topK : number ; similarityThreshold : number ; searchMode : 'VECTOR' | 'KEYWORD' | 'HYBRID' | 'HYBRID_RERANK' ; enableRerank ?: boolean ; enableQueryRewrite ?: boolean ; }export interface ModelOptions { model ?: string ; temperature ?: number ; maxTokens ?: number ; }export interface RagChatRequest { sessionId ?: string ; question : string ; knowledgeBaseIds : string []; retrievalOptions ?: RetrievalOptions ; modelOptions ?: ModelOptions ; withSources ?: boolean ; metadataFilter ?: Record <string , unknown >; }
15. 工程初始化 15.1 创建项目 1 2 3 pnpm create vite rag-web --template react-tscd rag-web pnpm install
15.2 安装依赖 1 2 3 4 pnpm add antd @ant-design/icons pnpm add @ant-design/x @ant-design/x-markdown pnpm add axios @tanstack/react-query zustand react-router-dom dayjs nanoid pnpm add -D eslint prettier typescript
如果第一版不想用 Ant Design X,可以暂时不装:
1 pnpm remove @ant-design/x @ant-design/x-markdown
但我不建议删。RAG 聊天页迟早会需要 Bubble、Sender、Sources,早装早享受。
15.3 环境变量 .env.development:
1 2 3 VITE_APP_TITLE=CyberMario RAG VITE_API_BASE_URL=http://localhost:8080/api VITE_ENABLE_MOCK=false
.env.production:
1 2 3 VITE_APP_TITLE=CyberMario RAG VITE_API_BASE_URL=/api VITE_ENABLE_MOCK=false
15.4 Vite alias vite.config.ts:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 import { defineConfig } from 'vite' ;import react from '@vitejs/plugin-react' ;import path from 'node:path' ;export default defineConfig ({ plugins : [react ()], resolve : { alias : { '@' : path.resolve (__dirname, 'src' ), }, }, server : { port : 5173 , proxy : { '/api' : { target : 'http://localhost:8080' , changeOrigin : true , }, }, }, });
16. App Provider 与主题配置 16.1 Provider src/app/providers.tsx:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 import { App as AntdApp , ConfigProvider } from 'antd' ;import { QueryClient , QueryClientProvider } from '@tanstack/react-query' ;import zhCN from 'antd/locale/zh_CN' ;import type { PropsWithChildren } from 'react' ;import { appTheme } from './theme' ;const queryClient = new QueryClient ({ defaultOptions : { queries : { retry : 1 , refetchOnWindowFocus : false , }, }, });export function AppProviders ({ children }: PropsWithChildren ) { return ( <ConfigProvider locale ={zhCN} theme ={appTheme} > <AntdApp > <QueryClientProvider client ={queryClient} > {children}</QueryClientProvider > </AntdApp > </ConfigProvider > ); }
16.2 主题 src/app/theme.ts:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 import type { ThemeConfig } from 'antd' ;export const appTheme : ThemeConfig = { token : { borderRadius : 8 , fontSize : 14 , }, components : { Layout : { siderBg : '#ffffff' , headerBg : '#ffffff' , }, Card : { borderRadiusLG : 12 , }, }, };
说明:
项目颜色尽量走 token。
页面组件不要乱写 style={{ color: '#1677ff' }}。
如果要支持暗色模式,后续可以通过 algorithm: theme.darkAlgorithm 切换。
17. 请求封装 17.1 Axios 普通请求 src/shared/api/http.ts:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 import axios, { type AxiosError , type AxiosRequestConfig } from 'axios' ;import { nanoid } from 'nanoid' ;import type { ApiResult } from './types' ;const baseURL = import .meta .env .VITE_API_BASE_URL ;export const http = axios.create ({ baseURL, timeout : 30_000 , }); http.interceptors .request .use ((config ) => { config.headers = config.headers ?? {}; config.headers ['X-Request-Id' ] = nanoid (); const token = localStorage .getItem ('access_token' ); if (token) { config.headers .Authorization = `Bearer ${token} ` ; } const tenantId = localStorage .getItem ('tenant_id' ); if (tenantId) { config.headers ['X-Tenant-Id' ] = tenantId; } return config; }); http.interceptors .response .use ( (response ) => response, (error : AxiosError <ApiResult <unknown >> ) => { const traceId = error.response ?.data ?.traceId ; const message = error.response ?.data ?.message || error.message || '请求失败' ; return Promise .reject (new Error (traceId ? `${message} ,traceId=${traceId} ` : message)); }, );export async function request<T>(config : AxiosRequestConfig ): Promise <T> { const response = await http.request <ApiResult <T>>(config); const result = response.data ; if (!result.success ) { throw new Error (result.traceId ? `${result.message} ,traceId=${result.traceId} ` : result.message ); } return result.data ; }
17.2 流式请求封装 src/shared/api/stream.ts:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 import { nanoid } from 'nanoid' ;import type { RagChatRequest , RagStreamEvent } from './types' ;export interface ChatStreamOptions { request : RagChatRequest ; signal ?: AbortSignal ; onEvent : (event : RagStreamEvent ) => void ; onError ?: (error : Error ) => void ; onFinish ?: () => void ; }const baseURL = import .meta .env .VITE_API_BASE_URL ;export async function chatStream (options : ChatStreamOptions ) { const token = localStorage .getItem ('access_token' ); const tenantId = localStorage .getItem ('tenant_id' ); const response = await fetch (`${baseURL} /rag/chat/stream` , { method : 'POST' , headers : { 'Content-Type' : 'application/json' , Accept : 'text/event-stream' , 'X-Request-Id' : nanoid (), ...(token ? { Authorization : `Bearer ${token} ` } : {}), ...(tenantId ? { 'X-Tenant-Id' : tenantId } : {}), }, body : JSON .stringify (options.request ), signal : options.signal , }); if (!response.ok || !response.body ) { throw new Error (`流式请求失败:HTTP ${response.status} ` ); } const reader = response.body .getReader (); const decoder = new TextDecoder ('utf-8' ); let buffer = '' ; try { while (true ) { const { done, value } = await reader.read (); if (done) break ; buffer += decoder.decode (value, { stream : true }); const blocks = buffer.split ('\n\n' ); buffer = blocks.pop () ?? '' ; for (const block of blocks) { const event = parseSseBlock (block); if (event) { options.onEvent (event); } } } } catch (error) { if ((error as Error ).name !== 'AbortError' ) { options.onError ?.(error as Error ); } } finally { options.onFinish ?.(); reader.releaseLock (); } }function parseSseBlock (block : string ): RagStreamEvent | null { const lines = block.split ('\n' ).filter (Boolean ); const eventLine = lines.find ((line ) => line.startsWith ('event:' )); const dataLines = lines.filter ((line ) => line.startsWith ('data:' )); if (!eventLine || dataLines.length === 0 ) { return null ; } const event = eventLine.replace ('event:' , '' ).trim (); const dataText = dataLines.map ((line ) => line.replace ('data:' , '' ).trim ()).join ('\n' ); return { event, data : JSON .parse (dataText), } as RagStreamEvent ; }
18. Chat Hook 设计 src/pages/chat/hooks/useRagChat.ts:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 import { useRef, useState } from 'react' ;import { App } from 'antd' ;import { nanoid } from 'nanoid' ;import { chatStream } from '@/shared/api/stream' ;import type { RagChatRequest , SourceReference } from '@/shared/api/types' ;export interface ChatMessageItem { id : string ; role : 'user' | 'assistant' | 'system' ; content : string ; streaming ?: boolean ; sources ?: SourceReference []; traceId ?: string ; }export function useRagChat ( ) { const { message } = App .useApp (); const [messages, setMessages] = useState<ChatMessageItem []>([]); const [loading, setLoading] = useState (false ); const abortRef = useRef<AbortController | null >(null ); const send = async (request : RagChatRequest ) => { if (!request.question .trim ()) { message.warning ('请输入问题' ); return ; } const userMessage : ChatMessageItem = { id : nanoid (), role : 'user' , content : request.question , }; const assistantId = nanoid (); const assistantMessage : ChatMessageItem = { id : assistantId, role : 'assistant' , content : '' , streaming : true , sources : [], }; setMessages ((prev ) => [...prev, userMessage, assistantMessage]); setLoading (true ); const abortController = new AbortController (); abortRef.current = abortController; await chatStream ({ request, signal : abortController.signal , onEvent : (event ) => { if (event.event === 'metadata' ) { setMessages ((prev ) => prev.map ((item ) => item.id === assistantId ? { ...item, traceId : event.data .traceId } : item, ), ); } if (event.event === 'retrieval' ) { setMessages ((prev ) => prev.map ((item ) => item.id === assistantId ? { ...item, sources : event.data .sources } : item, ), ); } if (event.event === 'delta' ) { setMessages ((prev ) => prev.map ((item ) => item.id === assistantId ? { ...item, content : item.content + event.data .content } : item, ), ); } if (event.event === 'error' ) { setMessages ((prev ) => prev.map ((item ) => item.id === assistantId ? { ...item, streaming : false , content : `请求失败:${event.data.message} \ntraceId=${event.data.traceId} ` , traceId : event.data .traceId , } : item, ), ); } if (event.event === 'done' ) { setMessages ((prev ) => prev.map ((item ) => item.id === assistantId ? { ...item, streaming : false } : item, ), ); } }, onError : (error ) => { message.error (error.message ); }, onFinish : () => { setLoading (false ); abortRef.current = null ; }, }); }; const stop = ( ) => { abortRef.current ?.abort (); setLoading (false ); setMessages ((prev ) => prev.map ((item ) => ({ ...item, streaming : false }))); }; return { messages, loading, send, stop, setMessages, }; }
19. Chat 页面示例 src/pages/chat/ChatPage.tsx:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 import { useMemo, useState } from 'react' ;import { Button , Card , Drawer , Flex , Select , Slider , Space , Typography } from 'antd' ;import { Bubble , Conversations , Sender , Sources } from '@ant-design/x' ;import { StopOutlined } from '@ant-design/icons' ;import { useRagChat } from './hooks/useRagChat' ;import type { SourceReference } from '@/shared/api/types' ;export default function ChatPage ( ) { const { messages, loading, send, stop } = useRagChat (); const [value, setValue] = useState ('' ); const [knowledgeBaseIds, setKnowledgeBaseIds] = useState<string []>([]); const [topK, setTopK] = useState (6 ); const [threshold, setThreshold] = useState (0.75 ); const [sources, setSources] = useState<SourceReference []>([]); const [sourceOpen, setSourceOpen] = useState (false ); const bubbleItems = useMemo ( () => messages.map ((item ) => ({ key : item.id , role : item.role === 'user' ? 'user' : 'assistant' , content : item.content || (item.streaming ? '正在生成回答...' : '' ), loading : item.streaming && !item.content , footer : item.role === 'assistant' && item.sources ?.length ? ( <Button size ="small" type ="link" onClick ={() => { setSources(item.sources ?? []); setSourceOpen(true); }} > 查看引用来源({item.sources.length}) </Button > ) : null , })), [messages], ); const handleSubmit = async (question : string ) => { const actualQuestion = question.trim (); if (!actualQuestion) return ; setValue ('' ); await send ({ question : actualQuestion, knowledgeBaseIds, retrievalOptions : { topK, similarityThreshold : threshold, searchMode : 'VECTOR' , enableQueryRewrite : true , }, modelOptions : { model : 'qwen-plus' , temperature : 0.3 , }, withSources : true , }); }; return ( <Flex gap ={16} style ={{ height: 'calc (100vh - 96px )' }}> <Card style ={{ width: 280 }} styles ={{ body: { padding: 12 } }}> <Conversations items ={[ { key: 'new ', label: '新会话 ' }, { key: 'history-1 ', label: 'Spring AI RAG 方案讨论 ' }, ]} /> </Card > <Flex vertical flex ={1} gap ={12} > <Card size ="small" > <Space wrap > <Typography.Text strong > 知识库</Typography.Text > <Select mode ="multiple" style ={{ minWidth: 280 }} placeholder ="请选择知识库" value ={knowledgeBaseIds} onChange ={setKnowledgeBaseIds} options ={[ { label: 'Spring AI 知识库 ', value: 'kb_spring_ai ' }, { label: '项目接口文档 ', value: 'kb_api_docs ' }, ]} /> <Typography.Text > TopK</Typography.Text > <Slider min ={1} max ={12} value ={topK} onChange ={setTopK} style ={{ width: 120 }} /> <Typography.Text > 阈值</Typography.Text > <Slider min ={0} max ={1} step ={0.01} value ={threshold} onChange ={setThreshold} style ={{ width: 160 }} /> </Space > </Card > <Card style ={{ flex: 1 , overflow: 'auto ' }} styles ={{ body: { height: '100 %' } }}> <Bubble.List items ={bubbleItems} style ={{ height: '100 %' }} /> </Card > <Sender value ={value} loading ={loading} placeholder ="请输入你的问题,Shift + Enter 换行,Enter 发送" submitType ="enter" onChange ={setValue} onSubmit ={handleSubmit} onCancel ={stop} suffix ={ loading ? ( <Button icon ={ <StopOutlined /> } onClick={stop}> 停止 </Button > ) : undefined } /> </Flex > <Drawer title ="引用来源" width ={520} open ={sourceOpen} onClose ={() => setSourceOpen(false)} > <Sources items ={sources.map((source, index ) => ({ key: source.sourceId, title: `[${index + 1}] ${source.documentName}`, description: `chunk=${source.chunkIndex},score=${source.score.toFixed(4)}`, }))} /> <Space direction ="vertical" style ={{ width: '100 %', marginTop: 16 }}> {sources.map((source, index) => ( <Card key ={source.sourceId} size ="small" title ={ `来源 ${index + 1 }`}> <Typography.Text type ="secondary" > {source.documentName} / chunk {source.chunkIndex} / score {source.score.toFixed(4)} </Typography.Text > <Typography.Paragraph copyable style ={{ marginTop: 8 }}> {source.content} </Typography.Paragraph > </Card > ))} </Space > </Drawer > </Flex > ); }
说明:
这是页面骨架,不是最终视觉稿。
知识库选项要从后端接口加载,不要写死。
Sources 的具体 props 可以根据实际安装版本调整。
如果 Ant Design X API 版本变化,保留交互设计,替换组件写法即可。
20. 文档上传页面示例 src/pages/document/DocumentUploadDrawer.tsx:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 import { App , Drawer , Form , Select , Switch , Upload } from 'antd' ;import type { UploadRequestOption } from 'rc-upload/lib/interface' ;import { InboxOutlined } from '@ant-design/icons' ;import { uploadDocuments } from './api' ;const { Dragger } = Upload ;interface Props { open : boolean ; onClose : () => void ; onSuccess : () => void ; }const ACCEPT = '.pdf,.docx,.md,.txt,.html,.csv,.xlsx' ;const MAX_SIZE = 50 * 1024 * 1024 ;export function DocumentUploadDrawer ({ open, onClose, onSuccess }: Props ) { const { message } = App .useApp (); const [form] = Form .useForm (); const customRequest = async (options : UploadRequestOption ) => { try { const knowledgeBaseId = form.getFieldValue ('knowledgeBaseId' ); const parseImmediately = form.getFieldValue ('parseImmediately' ) ?? true ; if (!knowledgeBaseId) { throw new Error ('请先选择知识库' ); } await uploadDocuments ({ knowledgeBaseId, files : [options.file as File ], parseImmediately, }); options.onSuccess ?.({}, options.file ); message.success ('上传成功,已创建入库任务' ); onSuccess (); } catch (error) { options.onError ?.(error as Error ); message.error ((error as Error ).message ); } }; return ( <Drawer title ="上传文档" width ={640} open ={open} onClose ={onClose} destroyOnClose > <Form form ={form} layout ="vertical" initialValues ={{ parseImmediately: true }}> <Form.Item label ="知识库" name ="knowledgeBaseId" rules ={[{ required: true , message: '请选择知识库 ' }]} > <Select placeholder ="请选择知识库" options ={[ { label: 'Spring AI 知识库 ', value: 'kb_spring_ai ' }, { label: '项目接口文档 ', value: 'kb_api_docs ' }, ]} /> </Form.Item > <Form.Item label ="上传后立即解析" name ="parseImmediately" valuePropName ="checked" > <Switch /> </Form.Item > <Dragger multiple accept ={ACCEPT} customRequest ={customRequest} beforeUpload ={(file) => { if (file.size > MAX_SIZE) { message.error('单文件不能超过 50MB'); return Upload.LIST_IGNORE; } return true; }} > <p className ="ant-upload-drag-icon" > <InboxOutlined /> </p > <p className ="ant-upload-text" > 点击或拖拽文件到这里上传</p > <p className ="ant-upload-hint" > 支持 PDF、Word、Markdown、TXT、HTML、CSV、XLSX</p > </Dragger > </Form > </Drawer > ); }
src/pages/document/api.ts:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 import { request } from '@/shared/api/http' ;import type { KnowledgeDocumentDTO , PageResult } from '@/shared/api/types' ;export interface UploadDocumentsParams { knowledgeBaseId : string ; files : File []; parseImmediately ?: boolean ; tags ?: string []; metadata ?: Record <string , unknown >; }export async function uploadDocuments (params : UploadDocumentsParams ) { const formData = new FormData (); formData.append ('knowledgeBaseId' , params.knowledgeBaseId ); formData.append ('parseImmediately' , String (params.parseImmediately ?? true )); for (const file of params.files ) { formData.append ('files' , file); } if (params.tags ) { formData.append ('tags' , JSON .stringify (params.tags )); } if (params.metadata ) { formData.append ('metadata' , JSON .stringify (params.metadata )); } return request<{ documents : KnowledgeDocumentDTO []; jobIds : string [] }>({ url : '/rag/documents/upload' , method : 'POST' , data : formData, headers : { 'Content-Type' : 'multipart/form-data' , }, }); }export async function queryDocuments (params : Record <string , unknown > ) { return request<PageResult <KnowledgeDocumentDTO >>({ url : '/rag/documents' , method : 'GET' , params, }); }
21. 文档列表页面约束 表格列建议:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 import type { ColumnsType } from 'antd/es/table' ;import { Button , Popconfirm , Space , Tag } from 'antd' ;import type { KnowledgeDocumentDTO } from '@/shared/api/types' ;export const documentColumns : ColumnsType <KnowledgeDocumentDTO > = [ { title : '文档名' , dataIndex : 'name' , ellipsis : true , }, { title : '知识库' , dataIndex : 'knowledgeBaseName' , }, { title : '来源' , dataIndex : 'sourceType' , render : (value ) => <Tag > {value}</Tag > , }, { title : '状态' , dataIndex : 'status' , render : (value ) => <DocumentStatusTag status ={value} /> , }, { title : '切片数' , dataIndex : 'chunkCount' , }, { title : '已入库' , dataIndex : 'indexedChunkCount' , }, { title : '更新时间' , dataIndex : 'updatedAt' , }, { title : '操作' , key : 'action' , fixed : 'right' , render : (_, record ) => ( <Space > <Button type ="link" > 详情</Button > <Button type ="link" > 重建索引</Button > <Popconfirm title ="确认删除该文档及向量?" > <Button type ="link" danger > 删除 </Button > </Popconfirm > </Space > ), }, ];
Table 约束:
1 2 3 4 5 6 7 8 9 10 11 12 <Table rowKey="id" columns={documentColumns} dataSource={data?.records ?? []} loading={isLoading} pagination={{ current : data?.pageNo , pageSize : data?.pageSize , total : data?.total , showSizeChanger : true , }} />
必须设置 rowKey="id",不要让 React 用数组下标。数组下标当 key,表格一刷新,Bug 就像地鼠一样冒出来。
22. 检索调试页面示例 src/pages/retrieval-lab/RetrievalLabPage.tsx:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 import { useState } from 'react' ;import { App , Button , Card , Form , Input , InputNumber , List , Select , Slider , Space , Tag , Typography } from 'antd' ;import { request } from '@/shared/api/http' ;import type { SourceReference } from '@/shared/api/types' ;interface RetrievalSearchResponse { query : string ; normalizedQuery ?: string ; results : Array <SourceReference & { rank : number }>; costMs : number ; }export default function RetrievalLabPage ( ) { const { message } = App .useApp (); const [form] = Form .useForm (); const [loading, setLoading] = useState (false ); const [result, setResult] = useState<RetrievalSearchResponse | null >(null ); const handleSearch = async ( ) => { const values = await form.validateFields (); setLoading (true ); try { const data = await request<RetrievalSearchResponse >({ url : '/rag/retrieval/search' , method : 'POST' , data : values, }); setResult (data); } catch (error) { message.error ((error as Error ).message ); } finally { setLoading (false ); } }; return ( <Space direction ="vertical" size ={16} style ={{ width: '100 %' }}> <Card title ="检索调试" > <Form form ={form} layout ="vertical" initialValues ={{ topK: 6 , similarityThreshold: 0.75 , searchMode: 'VECTOR ' }} > <Form.Item label ="问题" name ="query" rules ={[{ required: true , message: '请输入问题 ' }]}> <Input.TextArea rows ={3} placeholder ="请输入要检索的问题" /> </Form.Item > <Space wrap align ="start" > <Form.Item label ="知识库" name ="knowledgeBaseIds" rules ={[{ required: true , message: '请选择知识库 ' }]} > <Select mode ="multiple" style ={{ width: 300 }} options ={[{ label: 'Spring AI 知识库 ', value: 'kb_spring_ai ' }]} /> </Form.Item > <Form.Item label ="TopK" name ="topK" > <InputNumber min ={1} max ={12} /> </Form.Item > <Form.Item label ="阈值" name ="similarityThreshold" > <Slider min ={0} max ={1} step ={0.01} style ={{ width: 200 }} /> </Form.Item > <Form.Item label ="检索模式" name ="searchMode" > <Select style ={{ width: 180 }} options ={[ { label: '向量检索 ', value: 'VECTOR ' }, { label: '关键词检索 ', value: 'KEYWORD ' }, { label: '混合检索 ', value: 'HYBRID ' }, { label: '混合 + 重排 ', value: 'HYBRID_RERANK ' }, ]} /> </Form.Item > </Space > <Button type ="primary" loading ={loading} onClick ={handleSearch} > 开始检索 </Button > </Form > </Card > <Card title ={result ? `召回结果 :${result.results.length } 条 ,耗时 ${result.costMs }ms ` : '召回结果 '}> <List dataSource ={result?.results ?? []} renderItem ={(item) => ( <List.Item > <Card size ="small" style ={{ width: '100 %' }}> <Space > <Tag color ="blue" > #{item.rank}</Tag > <Tag color ="green" > score={item.score.toFixed(4)}</Tag > <Typography.Text strong > {item.documentName}</Typography.Text > <Typography.Text type ="secondary" > chunk={item.chunkIndex}</Typography.Text > </Space > <Typography.Paragraph copyable style ={{ marginTop: 12 }}> {item.content} </Typography.Paragraph > </Card > </List.Item > )} /> </Card > </Space > ); }
23. 状态 Tag 组件 src/shared/components/StatusTag.tsx:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 import { Tag } from 'antd' ;import type { DocumentStatus } from '@/shared/api/types' ;const statusMap : Record <DocumentStatus , { color : string ; text : string }> = { UPLOADED : { color : 'blue' , text : '已上传' }, PARSING : { color : 'processing' , text : '解析中' }, CHUNKING : { color : 'processing' , text : '切片中' }, EMBEDDING : { color : 'processing' , text : '向量化中' }, INDEXED : { color : 'success' , text : '已入库' }, FAILED : { color : 'error' , text : '失败' }, DELETED : { color : 'default' , text : '已删除' }, };export function DocumentStatusTag ({ status }: { status: DocumentStatus } ) { const config = statusMap[status] ?? { color : 'default' , text : status }; return <Tag color ={config.color} > {config.text}</Tag > ; }
24. 路由设计 src/app/router.tsx:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 import { createBrowserRouter } from 'react-router-dom' ;import BasicLayout from '@/layouts/BasicLayout' ;import DashboardPage from '@/pages/dashboard/DashboardPage' ;import ChatPage from '@/pages/chat/ChatPage' ;import KnowledgeBaseListPage from '@/pages/knowledge-base/KnowledgeBaseListPage' ;import DocumentListPage from '@/pages/document/DocumentListPage' ;import DocumentDetailPage from '@/pages/document/DocumentDetailPage' ;import IngestionJobListPage from '@/pages/ingestion-job/IngestionJobListPage' ;import RetrievalLabPage from '@/pages/retrieval-lab/RetrievalLabPage' ;import SettingsPage from '@/pages/settings/SettingsPage' ;export const router = createBrowserRouter ([ { path : '/' , element : <BasicLayout /> , children : [ { index : true , element : <DashboardPage /> }, { path : 'chat' , element : <ChatPage /> }, { path : 'knowledge-bases' , element : <KnowledgeBaseListPage /> }, { path : 'documents' , element : <DocumentListPage /> }, { path : 'documents/:documentId' , element : <DocumentDetailPage /> }, { path : 'ingestion-jobs' , element : <IngestionJobListPage /> }, { path : 'retrieval-lab' , element : <RetrievalLabPage /> }, { path : 'settings' , element : <SettingsPage /> }, ], }, ]);
25. Layout 设计 src/layouts/BasicLayout.tsx:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 import { Outlet , useLocation, useNavigate } from 'react-router-dom' ;import { Layout , Menu , Typography } from 'antd' ;import { DatabaseOutlined , FileTextOutlined , HomeOutlined , MessageOutlined , SearchOutlined , SettingOutlined , SyncOutlined , } from '@ant-design/icons' ;const { Header , Sider , Content } = Layout ;export default function BasicLayout ( ) { const navigate = useNavigate (); const location = useLocation (); return ( <Layout style ={{ minHeight: '100vh ' }}> <Sider width ={240} theme ="light" > <div style ={{ padding: 16 }}> <Typography.Title level ={4} style ={{ margin: 0 }}> CyberMario RAG </Typography.Title > </div > <Menu mode ="inline" selectedKeys ={[location.pathname]} onClick ={(item) => navigate(item.key)} items={[ { key: '/', icon: <HomeOutlined /> , label: '工作台' }, { key: '/chat', icon: <MessageOutlined /> , label: '智能问答' }, { key: '/knowledge-bases', icon: <DatabaseOutlined /> , label: '知识库管理' }, { key: '/documents', icon: <FileTextOutlined /> , label: '文档管理' }, { key: '/ingestion-jobs', icon: <SyncOutlined /> , label: '入库任务' }, { key: '/retrieval-lab', icon: <SearchOutlined /> , label: '检索调试' }, { key: '/settings', icon: <SettingOutlined /> , label: '系统配置' }, ]} /> </Sider > <Layout > <Header style ={{ display: 'flex ', alignItems: 'center ', justifyContent: 'space-between ' }}> <Typography.Text strong > Spring AI Alibaba + pgvector RAG 控制台</Typography.Text > <Typography.Text type ="secondary" > SuperMario</Typography.Text > </Header > <Content style ={{ padding: 24 }}> <Outlet /> </Content > </Layout > </Layout > ); }
说明:生产项目中不要在组件里写太多 inline style。这里为了示例简洁,真实项目建议抽成 CSS Modules 或 Less 文件。
26. 权限与按钮控制 前端菜单和按钮可以根据权限展示,但不能替代后端鉴权。
建议权限点:
权限码
说明
rag:chat:use
使用智能问答
rag:kb:view
查看知识库
rag:kb:create
创建知识库
rag:kb:update
更新知识库
rag:kb:delete
删除知识库
rag:doc:view
查看文档
rag:doc:upload
上传文档
rag:doc:delete
删除文档
rag:doc:reindex
重建索引
rag:retrieval:debug
使用检索调试
rag:settings:update
修改系统配置
前端封装:
1 2 3 4 5 6 7 8 9 export function PermissionButton ({ permission, children }: PropsWithChildren <{ permission: string }> ) { const permissions = useAppStore ((state ) => state.permissions ); if (!permissions.includes (permission)) { return null ; } return <> {children}</> ; }
但是后端必须再次校验。例如用户没有 rag:doc:delete,即使他手动调接口,也必须返回 403。
27. RAG 前端体验细节 27.1 无答案策略 当后端返回 finishReason = NO_CONTEXT 或 code = RAG_NO_CONTEXT 时,前端不要展示成系统错误。
应该展示:
1 2 3 4 5 知识库中没有找到足够相关的内容,我不能基于当前知识库可靠回答这个问题。 你可以尝试: 1. 换一种问法。 2. 选择更多知识库。 3. 上传相关文档后重新提问。
这是 RAG 系统很重要的一点:不知道就说不知道 。这比模型一本正经胡说八道靠谱得多。
27.2 流式回答停止 点击停止:
前端调用 AbortController.abort()。
UI 停止 loading。
当前回答保留已生成部分。
消息状态标记为 interrupted。
可以展示“已停止生成”。
27.3 重新生成 重新生成需要携带:
原问题。
当前 sessionId。
同样的知识库范围。
当前检索参数。
接口可以复用 /rag/chat/stream。
27.4 复制回答 每条 AI 消息支持:
复制回答。
复制 Markdown。
复制来源。
重新生成。
点赞 / 点踩,作为后续评估数据。
点赞点踩接口预留:
1 POST /api/rag/chat/messages/{messageId}/feedback
请求:
1 2 3 4 { "feedback" : "LIKE" , "reason" : "回答准确" }
28. 数据缓存策略 使用 TanStack Query。
数据
缓存策略
知识库列表
staleTime 30s
文档列表
staleTime 10s,上传/删除后 invalidate
文档详情
staleTime 30s
chunk 列表
staleTime 30s
入库任务
running 状态下 3s 轮询
检索调试结果
不缓存,按需查询
聊天流式消息
不走 Query,走本地 state
示例:
1 2 3 4 5 const { data, isLoading } = useQuery ({ queryKey : ['documents' , params], queryFn : () => queryDocuments (params), staleTime : 10_000 , });
29. 入库任务轮询策略 文档上传后,返回 jobIds。前端可以:
跳转入库任务页。
或在文档列表中轮询刷新状态。
建议:
如果有 RUNNING 任务,每 3 秒刷新一次。
如果全部任务完成,停止轮询。
失败任务展示错误原因。
任务详情可以用 Drawer 展示日志。
1 2 3 4 5 6 7 const hasRunningJob = data?.records .some ((item ) => item.status === 'RUNNING' );useQuery ({ queryKey : ['ingestionJobs' , params], queryFn : () => queryIngestionJobs (params), refetchInterval : hasRunningJob ? 3000 : false , });
30. 前后端联调规范 30.1 联调顺序 建议按这个顺序联调:
flowchart TD
A[知识库列表接口] --> B[创建知识库]
B --> C[文档上传]
C --> D[入库任务状态]
D --> E[文档列表]
E --> F[chunk 列表]
F --> G[检索调试]
G --> H[非流式问答]
H --> I[流式问答]
I --> J[来源引用展示]
不要一上来就联调流式问答。先把知识库、文档、chunk、检索调试打通,最后再接 Chat。这样问题更容易定位。
30.2 Mock 策略 前端开发初期可以用 Mock:
知识库列表 Mock。
文档列表 Mock。
入库任务 Mock。
检索结果 Mock。
SSE 流式 Mock。
但 Mock 数据必须遵守同一套 TypeScript DTO。不要 Mock 一套、后端一套,最后联调现场大型对线。
30.3 接口文档约束 后端需要给前端提供:
OpenAPI 文档,或 Markdown 接口文档。
每个接口的请求/响应示例。
错误码表。
SSE event 类型说明。
文件上传大小和类型限制。
认证方式说明。
31. 测试方案 31.1 页面测试清单
页面
测试点
智能问答
发送问题、流式输出、停止生成、来源展示、无答案、错误提示
知识库管理
新增、编辑、删除、禁用、分页、搜索
文档管理
上传、状态刷新、删除、重建索引、失败原因
文档详情
chunk 列表、复制、metadata 展示、禁用 chunk
检索调试
TopK、阈值、知识库过滤、召回结果展示
入库任务
轮询、进度、失败、重试
系统配置
参数范围校验、保存、回显
31.2 关键异常场景
场景
预期
未选择知识库就提问
前端提示必须选择知识库
问题为空
禁止发送
后端返回 401
跳转登录
后端返回 403
展示无权限
流式过程中断网
保留已生成内容,提示连接中断
上传超大文件
前端提前拦截
上传不支持类型
前端提前拦截
文档入库失败
文档状态显示 FAILED,能查看错误原因
检索无结果
展示 Empty,不调用模型或提示无上下文
32. 生产化注意事项 32.1 安全
项目
约束
模型 API Key
只能在后端,前端永不出现
文件下载
必须走后端鉴权
来源原文
根据权限展示,不允许越权查看
metadata
不要把敏感字段直接展示给普通用户
Markdown
必须做 XSS 防护
操作审计
上传、删除、重建索引、配置修改必须记录
32.2 性能
项目
建议
聊天消息
长会话分页加载,不要一次拉几千条
来源内容
默认展示摘要,点击再展开全文
文档列表
服务端分页
chunk 列表
服务端分页,避免一次返回所有 chunk
上传
大文件后续可做分片上传
SSE
页面卸载必须 abort,避免连接泄露
32.3 可观测性 前端每个请求都生成 X-Request-Id。
错误提示必须包含:
可以封装 TraceError 组件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 import { Alert , Typography } from 'antd' ;export function TraceError ({ message, traceId }: { message: string ; traceId?: string } ) { return ( <Alert type ="error" message ={message} description ={ traceId ? ( <Typography.Text copyable type ="secondary" > traceId={traceId} </Typography.Text > ) : null } /> ); }
33. 与后端方案的衔接点 后端已经设计了:
Spring AI Alibaba ChatModel。
DashScope Embedding。
pgvector 向量存储。
文档解析、切片、向量化。
QuestionAnswerAdvisor / RetrievalAugmentationAdvisor。
metadata filter。
权限过滤。
流式回答。
前端需要保证:
后端能力
前端对应能力
文档上传接口
Upload.Dragger 上传
文档入库任务
入库任务页 + 状态轮询
pgvector similaritySearch
检索调试页展示召回结果
RAG Chat Stream
Chat 页面流式输出
来源引用
Source Drawer 展示文档和 chunk
metadata filter
检索调试页和 Chat 参数区配置
RBAC 权限
菜单和按钮级显隐
traceId 日志
错误提示可复制 traceId
34. 第一版 MVP 范围 不要第一版就做成“宇宙飞船控制台”。建议 MVP 只做这些:
34.1 必做
基础 Layout。
知识库列表 / 新增 / 编辑 / 删除。
文档上传 / 文档列表 / 文档状态。
入库任务列表。
检索调试页。
智能问答页。
SSE 流式输出。
来源引用展示。
错误 traceId 展示。
34.2 第二版再做
会话收藏。
点赞点踩。
回答质量评估。
混合检索。
rerank 可视化。
语雀空间增量同步。
Git 仓库文档同步。
多租户知识库权限矩阵。
Tauri 桌面端封装。
35. 最终推荐落地方案 35.1 技术路线 1 2 3 4 5 6 7 8 9 10 11 12 13 React + TypeScript + Vite ↓ Ant Design 管理台组件 ↓ Ant Design X AI 对话组件 ↓ Axios 普通 REST 请求 ↓ Fetch ReadableStream 处理 SSE 流式回答 ↓ Spring Boot RAG API ↓ Spring AI Alibaba + DashScope + pgvector
35.2 页面路线 1 2 3 4 第一阶段:知识库管理 + 文档上传 + 检索调试 第二阶段:智能问答 + 流式输出 + 来源引用 第三阶段:会话历史 + 反馈评估 + 配置中心 第四阶段:语雀同步 + 混合检索 + 重排 + 权限治理
35.3 接口路线 1 2 3 4 5 先 REST,后 SSE。 先文档入库,后问答生成。 先检索可视化,后回答优化。 先单知识库,后多知识库。 先权限展示,后细粒度 ACL。
参考资料
启示录 RAG 前端最容易被低估。很多人觉得“前端不就是一个输入框吗”,但真正上线后你会发现,用户最关心的是:
你回答的依据是什么?
为什么这条文档被召回?
能不能看到原文?
文档为什么还没入库?
为什么这次回答和上次不一样?
出错了后端能不能定位?
所以,一个靠谱的 RAG 前端,不只是聊天框,而是 知识库治理 + 检索可观测 + 可信回答体验 的组合。
富贵岂由人,时会高志须酬。
能成功于千载者,必以近察远。