前两篇讲了选型架构、私有化部署、8 个功能模块与后端对接。本篇记录一下:前端实现与流式问答体验、算力占用实测,分享开发过程中遇到的一些问题,以及一些优化想法。
一、前端实现(Vue3 + Element Plus)
1.1 知识库管理页
列表页含:知识库名称、Logo、分片解析模板、向量化模型、开放范围、是否启用、操作(查看/修改/删除)。新增表单见第 2 篇,开放范围五种权限选择是表单核心。
1.2 知识库文档管理页
左侧知识库列表(切换知识库),右侧文档表格:文档名称、类型、大小、解析状态、是否启用、删除。核心操作:上传文档 / 批量删除 / 全部解析。
1.3 AI 智能问答工作台
┌──────────────────────────────────────────────┐
│ AI助手工作台 │
│ 选择一个助手开始对话,拖动卡片右下角手柄可调整顺序│
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 对话助手002│ │通用知识问答│ │我的私人对话助手│ │
│ │ 知识库001 │ │ 知识库001 │ │ 知识库001 │ │
│ │ AI助手 │ │ AI助手 │ │ AI助手 │ │
│ └──────────┘ └──────────┘ └──────────┘ │
└──────────────────────────────────────────────┘
助手卡片展示:助手名称、绑定知识库、类型标识;右下角拖拽手柄可调整顺序,右上角支持搜索助手。
——3 个助手卡片,支持拖拽排序、搜索助手。
1.4 对话页(流式问答体验)
对话页左侧为会话列表(新建会话、搜索会话、历史会话),右侧为问答区。体验关键点:
- 流式输出:答案逐字渲染,而不是等大模型生成完才显示;
- 引用溯源:回答下方展示"引用来源(N)",点击可查看原文片段与对应文档入口;
- 复制按钮:答案可一键复制。
——新会话欢迎页,默认带三个示例问题;
——提问"差旅报销中住宿标准是什么",AI 以卡片回答,底部展示引用来源 Fig.1 及原文片段。
1.5 前端流式问答实现(SSE)
前端流式问答采用 fetch-event-source 库(fetchEventSource),不是浏览器原生 EventSource。 原生 new EventSource() 有硬伤:只支持 GET 请求、无法自定义 Request Body,不适合 POST 传参的 RAG 对话接口,所以项目选用 fetch-event-source。
# 安装
npm install @microsoft/fetch-event-source
import { fetchEventSource } from '@microsoft/fetch-event-source'
//SSE流式接口调用参考代码
try {
await fetchEventSource(url, {
method: 'POST',
openWhenHidden: true,
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'Accept': 'text/event-stream'
},
body: JSON.stringify({
botId: botId.value,
sessionId: currentSessionId.value,
question
}),
signal: abortCtrl.signal,
maxRetryCount: 0,
async onopen(response) {
if (!response.ok) {
throw new Error(`请求异常:${response.status}`)
}
},
onmessage(event) {
if (isFinished) return
lastChunkTime = Date.now()
const dataStr = event.data
switch (event.event) {
case SSE_EVENT.NEW_SESSION:
currentSessionId.value = dataStr
break
case SSE_EVENT.START_THINK:
aiMsg.status = MSG_STATUS.THINKING
break
case SSE_EVENT.THINK_CHUNK:
aiMsg.status = MSG_STATUS.THINKING
aiMsg.thinkContent += dataStr
scrollToBottom()
break
case SSE_EVENT.END_THINK:
break
case SSE_EVENT.MSG:
aiMsg.status = MSG_STATUS.ANSWERING
aiMsg.pendingText = (aiMsg.pendingText || '') + dataStr
startTypeWriter(aiMsg)
break
case SSE_EVENT.REFERENCE:
if (dataStr) {
try {
aiMsg.reference = JSON.parse(dataStr)
} catch (e) {
console.warn('reference解析失败', e)
}
}
break
case SSE_EVENT.EMPTY_RESPONSE:
// 知识库零命中:用友好引导文案整体替换生硬的"没有查询到相关信息"
flushTypeWriter(aiMsg)
aiMsg.pendingText = ''
aiMsg.answerContent = dataStr
break
case SSE_EVENT.FINISH:
flushTypeWriter(aiMsg)
isFinished = true
clearInterval(watchdog)
aiMsg.status = MSG_STATUS.DONE
break
case SSE_EVENT.ERROR:
flushTypeWriter(aiMsg)
isFinished = true
clearInterval(watchdog)
aiMsg.status = MSG_STATUS.ERROR
aiMsg.errorMsg = dataStr
break
}
},
onerror(err) {
if (isFinished) return
isFinished = true
clearInterval(watchdog)
if (err.name !== 'AbortError') {
aiMsg.status = MSG_STATUS.ERROR
aiMsg.errorMsg = err.message
}
},
onclose() {
console.log('生成结束,连接关闭')
if (!isFinished) {
flushTypeWriter(aiMsg)
isFinished = true
clearInterval(watchdog)
aiMsg.status = MSG_STATUS.DONE
}
}
})
} catch (err) {
clearInterval(watchdog)
if (err.name !== 'AbortError' && !isFinished) {
isFinished = true
aiMsg.status = MSG_STATUS.ERROR
aiMsg.errorMsg = err.message
}
}
注意事项:SSE 场景下,后端响应必须设置
Content-Type: text/event-stream;charset=utf-8,且不要开启响应压缩(否则中文可能乱码)。
二、算力占用实测
开发机:CPU 10 核 / 32G 内存 / 无 GPU(笔记本),RAGFlow v0.27.0 Docker 部署,模型走硅基流动云 API。
| 项目 | 实测值 | 说明 |
|---|---|---|
| RAGFlow 服务内存 | 8~12 GB(Docker) | 主要被解析任务与向量检索占用 |
| 单份 PDF 解析 | 数秒~数十秒 | 取决于页数与是否含扫描件(OCR 更慢) |
| 问答首字响应 | 1~3 秒 | 云 API 情况下主要耗时在网络往返 |
| 完整回答 | 5~15 秒 | 流式输出,边生成边显示 |
| 本地 CPU 负载 | 解析阶段飙高,问答阶段很低 | 向量化在云端,本地无推理负担 |
结论:10 核 32G 无 GPU 的普通机器,跑 RAGFlow 服务完全无压力。算力瓶颈只出现在批量文档解析阶段(见下文踩坑 1),通过排队限流即可解决。若生产环境要完全本地化,只需在服务器上再部署一个轻量级本地大模型(7B 量化约 4~6G 内存),硬件要求依然很低。
三、问题记录
批量上传、批量解析,内存长时间居高不下 🔴
现象:一次性上传几十份文档并全部触发解析后,服务器内存持续 95%+,RAGFlow 服务响应缓慢,解析任务非常慢。
原因分析:RAGFlow 对每份文档的解析是独立的资源密集型任务(版面分析 + OCR + 向量化)。并发解析的任务数没有限制时,几十个任务同时跑,内存瞬间被占满,且解析任务不会因为内存吃紧自动排队,导致长时间高位运行,还有一点就是本地电脑没有GPU加速,文档解析速度比较慢。
解决方案:排队上传 + 并发解析限流
/**
* 解析限流器:最多同时允许 10 个文档处于解析中,可做成配置项更灵活
* 其余文档进入"待解析"队列,解析完成一个,队列再进一个
*/
@Service
public class ParseQueueService {
private static final int MAX_PARALLEL = 10; // 最大并发解析数
private final BlockingQueue<Long> queue = new LinkedBlockingQueue<>();
private final Map<Long, Boolean> parsing = new ConcurrentHashMap<>();
public void submit(Long docId) {
queue.offer(docId);
tryParseNext();
}
private synchronized void tryParseNext() {
while (parsing.size() < MAX_PARALLEL) {
Long docId = queue.poll();
if (docId == null) return;
parsing.put(docId, true);
executor.execute(() -> {
try {
ragFlowApiService.parseDocument(docId); // 调用 RAGFlow 解析
documentMapper.updateStatus(docId, "解析成功");
} catch (Exception e) {
documentMapper.updateStatus(docId, "解析失败");
} finally {
parsing.remove(docId);
tryParseNext(); // 释放一个名额,继续取队列
}
});
}
}
}
优化后效果:内存稳定在可控水位(约 10G 内),解析任务排队执行,互不争抢资源。并发数 8 是实测平衡值:太大内存吃紧,太小整体吞吐下降。这也解决了实际应用场景中多人或多部门同时大量上传文档、解析文档的问题。
做的过程中还遇到了一些其他的问题,比如没有检索到答案时,返回2个重复回答,打字机效果不好,流式输出markdown表格渲染有误等,都是一些小问题,排查一下就解决了,就不啰嗦了。
四、性能与体验优化清单
| 场景 | 优化项 | 效果 |
|---|---|---|
| 批量解析 | 排队 + 并发限流 10 | 内存可控,任务不卡死 |
| 问答体验 | SSE 流式输出 | 首字 1~3 秒,边生成边显示 |
| 检索质量 | 开启 Rerank 重排序 | 相关性问题明显改善 |
| 权限 | 五种开放范围 + 若依数据权限 | 企业级权限管控,可审计 |
五、总结
写了三篇文章完整记录了 RuoYi + RAGFlow 搭建私有化知识库的落地过程:
- 架构:若依管"人、权、业务",RAGFlow 管"文档、检索、生成",各取所长;
- 部署:无 GPU 可跑,模型可插拔(云 API ↔ 本地模型平滑切换);
- 功能:8 个模块覆盖"模型 → 知识库 → 文档 → 提示词 → 助手 → 会话 → 问答"全链路,五种开放范围实现企业级权限;
- 落地:批量解析排队限流、SSE 流式问答、Token 自动重登等一套实战优化。
这套方案的价值在于:不训练模型、不依赖 GPU、数据内网闭环,普通服务器即可支撑知识库私有化落地。而且以ruoyi为基础底座,扩展性比较好,方便对接外部系统,门槛也比较低,容易维护。