基于 React + Ant Design 的 RAG 前端方案设计

欢迎你来读这篇博客,这篇博客主要是关于 Spring AI Alibaba + pgvector RAG 项目的前端工程方案。

上一篇后端方案已经把 Spring AI Alibaba + DashScope + pgvector + QuestionAnswerAdvisor / RetrievalAugmentationAdvisor 的后端能力设计清楚了。本文只聚焦前端:如何用 React + TypeScript + Vite + Ant Design 做一个真正能落地的 RAG 管理台和知识库问答界面。

序言

你前面贴的语雀内容,本质上完成的是后端 RAG 的最小闭环:

  1. 文档内容进入系统。
  2. 文档被切片、向量化。
  3. 向量存入 Milvus / pgvector。
  4. 用户提问时,系统进行相似度检索。
  5. 检索结果通过 QuestionAnswerAdvisor 拼入上下文。
  6. 大模型基于知识库内容生成回答。

后端做到这里,只能说明“大脑能想”。但是没有前端,用户只能拿 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

生产环境:

1
VITE_API_BASE_URL=/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/chat
Content-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/stream
Accept: text/event-stream
Content-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/upload
Content-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

请求:

1
2
3
{
"enabled": false
}

说明:

  • 禁用 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
src/shared/api/types.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
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-ts
cd 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。前端可以:

  1. 跳转入库任务页。
  2. 或在文档列表中轮询刷新状态。

建议:

  • 如果有 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。

错误提示必须包含:

  • 错误信息。
  • traceId。
  • 可复制按钮。

可以封装 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 前端,不只是聊天框,而是 知识库治理 + 检索可观测 + 可信回答体验 的组合。

富贵岂由人,时会高志须酬。

能成功于千载者,必以近察远。


基于 React + Ant Design 的 RAG 前端方案设计
https://allendericdalexander.github.io/2026/06/13/ai/rag/rag-frontend-antd-solution/
作者
AtLuoFu
发布于
2026年6月13日
许可协议