RuoYi + RAGFlow 搭建私有化知识库完整集成实践(三)

0 阅读6分钟

前两篇讲了选型架构、私有化部署、8 个功能模块与后端对接。本篇记录一下:前端实现与流式问答体验、算力占用实测,分享开发过程中遇到的一些问题,以及一些优化想法。

一、前端实现(Vue3 + Element Plus)

1.1 知识库管理页

列表页含:知识库名称、Logo、分片解析模板、向量化模型、开放范围、是否启用、操作(查看/修改/删除)。新增表单见第 2 篇,开放范围五种权限选择是表单核心。

1.2 知识库文档管理页

左侧知识库列表(切换知识库),右侧文档表格:文档名称、类型、大小、解析状态、是否启用、删除。核心操作:上传文档 / 批量删除 / 全部解析

1.3 AI 智能问答工作台

┌──────────────────────────────────────────────┐
│  AI助手工作台                                 │
│  选择一个助手开始对话,拖动卡片右下角手柄可调整顺序│
│  ┌──────────┐ ┌──────────┐ ┌──────────┐      │
│  │ 对话助手002│ │通用知识问答│ │我的私人对话助手│      │
│  │ 知识库001 │ │ 知识库001 │ │ 知识库001 │      │
│  │  AI助手   │ │  AI助手   │ │  AI助手   │      │
│  └──────────┘ └──────────┘ └──────────┘      │
└──────────────────────────────────────────────┘

助手卡片展示:助手名称、绑定知识库、类型标识;右下角拖拽手柄可调整顺序,右上角支持搜索助手。

——3 个助手卡片,支持拖拽排序、搜索助手。

1.4 对话页(流式问答体验)

对话页左侧为会话列表(新建会话、搜索会话、历史会话),右侧为问答区。体验关键点:

  1. 流式输出:答案逐字渲染,而不是等大模型生成完才显示;
  2. 引用溯源:回答下方展示"引用来源(N)",点击可查看原文片段与对应文档入口;
  3. 复制按钮:答案可一键复制。

——新会话欢迎页,默认带三个示例问题;

——提问"差旅报销中住宿标准是什么",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 搭建私有化知识库的落地过程:

  1. 架构:若依管"人、权、业务",RAGFlow 管"文档、检索、生成",各取所长;
  2. 部署:无 GPU 可跑,模型可插拔(云 API ↔ 本地模型平滑切换);
  3. 功能:8 个模块覆盖"模型 → 知识库 → 文档 → 提示词 → 助手 → 会话 → 问答"全链路,五种开放范围实现企业级权限;
  4. 落地:批量解析排队限流、SSE 流式问答、Token 自动重登等一套实战优化。

这套方案的价值在于:不训练模型、不依赖 GPU、数据内网闭环,普通服务器即可支撑知识库私有化落地。而且以ruoyi为基础底座,扩展性比较好,方便对接外部系统,门槛也比较低,容易维护。