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

上一篇介绍了整体架构与选型思路。本篇记录一下实现层面:RAGFlow 私有化部署(无 GPU 环境)、8 个功能模块的数据表设计与核心逻辑,以及 Java 后端对接 RAGFlow API 的完整代码。重点讲解知识库的五种开放范围权限设计

一、RAGFlow 私有化部署

1.1 硬件要求

以实际开发环境为例:CPU 10 核 / 内存 32G / 无 GPU(笔记本),完全可运行。

资源 本项目实际 说明
CPU 10 核 文档解析阶段为主要开销
内存 32G(Docker 分配 8~12G 即可) RAGFlow 服务 + 解析任务
GPU 向量化走 API,本地无推理负担
磁盘 SSD 500G 文档原件 + 向量库

关键认知:RAG 方案不做模型训练与微调,算力消耗集中在"文档解析 + 向量化"的一次性过程,日常问答阶段只做检索 + 生成,因此对硬件要求远低于训练/微调方案,只不过经过实践,CPU模式下的文档切片解析速度确实一般,正式环境需要GPU加速。

1.2 Docker Compose 部署ragFlow

bash 复制代码
# 克隆项目
git clone https://github.com/infiniflow/ragflow.git
cd ragflow/docker

# 配置环境变量
cp .env.example .env
vim .env

# 启动(首次拉取镜像,v0.27.0 建议锁定镜像 tag)
docker compose up -d

ragFlow启用以后登录页如下:

1.3 模型配置(开发环境我用的是硅基流动 API)

RAGFlow 支持配置 OpenAI 兼容的模型供应商。开发阶段选择硅基流动(SiliconFlow)线上 API,主要原因是简化本地环境、提升开发效率------不需要在笔记本上部署嵌入模型和对话模型,即可快速验证业务逻辑。

1.4 模型选择

三个模型各司其职:

模型 类型 作用
BAAI/bge-m3 向量化 把文档切块转成向量,支持多语言与长文本(免费)
BAAI/bge-reranker-v2-m3 重排序 检索结果精排,显著提升准确率(免费)
deepseek-ai/DeepSeek-V4-Flash 对话生成 基于检索结果生成答案

二、若依侧:8 个功能模块的设计

2.1 模块菜单结构

bash 复制代码
AI知识库问答
├── 模型管理          # 三类模型统一维护
├── 知识库管理        # 知识库 CRUD + 开放范围
├── 知识库文档        # 文档上传/解析/状态
├── RAG提示词         # 助手系统提示词模板
├── 对话助手管理      # 助手 = 知识库 + 模型 + 提示词
├── RAG会话明细       # 每次问答的详细记录
├── 用户会话管理      # 按用户维度管理会话
└── AI智能问答        # 最终用户工作台

2.2 模型管理

数据表 kb_model

字段 类型 说明
model_name varchar(100) 模型名称,如 BAAI/bge-m3
model_type varchar(20) 模型类型:向量化模型 / 重排序模型 / 大语言模型
platform varchar(50) 模型平台:硅基流动、本地、OpenAI 等
deploy_type varchar(20) 部署方式:公有云 / 本地
api_base_url varchar(200) API 地址
api_key varchar(200) 密钥(密文存储)
status char(1) 是否启用

界面提供**"验证"按钮**:点击后实际调用一次模型 API(向量化模型跑一次 embedding、大模型跑一次补全),把连通性和响应时间直接反馈到界面上,避免配错模型到问答环节才发现。

------三类模型(重排序/向量化/大语言)各维护一条记录,全部启用,平台为"硅基流动",部署方式"公有云",每条记录支持验证/修改/删除。

2.3 知识库管理 + 开放范围(核心权限设计)

整个 AI 模块共 10 张业务表,统一 rag_ 前缀,与若依 sys_ 体系隔离,方便单独备份与维护:

表名 中文名 归属模块 职责
rag_llm_model RAG大模型配置 模型管理 LLM / Embedding 模型与密钥维护
rag_knowledge_base RAG知识库管理 知识库管理 知识库主表,含开放范围 pub_area
rag_knowledge_base_auth 知识库权限表 知识库管理 指定用户 / 部门 / 角色授权明细
rag_document RAG知识库文档 知识库文档 文档元数据 + 解析 / 向量化状态
rag_parse_task RAG文档解析任务队列 知识库文档 解析任务异步队列
rag_prompt_template RAG提示词模板表 RAG提示词 系统 / 用户提示词模板
rag_chat_bot RAG对话助手配置 对话助手管理 助手 = 知识库集合 + 模型 + 提示词 + 检索参数
rag_chat_bot_user_sort RAG对话助手用户排序表 对话助手管理 每用户独立助手卡片顺序
rag_conversation RAG用户会话表 用户会话管理 会话主表(对应 RAGFlow conversation)
rag_conversation_msg RAG会话消息明细 RAG会话明细 问答明细 + 引用来源 + token 消耗

通用设计规范(所有主表统一遵守):

  • del_flag 逻辑删除、is_enable 启用开关、create_by / create_time / update_by / update_time 审计字段,与若依 BaseEntity 对齐;
  • 对接 RAGFlow 的表(知识库 / 文档 / 助手 / 会话)主键一律用 varchar(64) 直接存 RAGFlow 侧 ID,避免本地自增主键与远端 ID 的映射表;
  • 状态类字段用数字或短字符串枚举,注释里写明含义(如 parse_status:0 待解析 / 1 解析中 / 2 成功 / 3 失败)。

2.4 模型管理

数据表 rag_llm_model(RAG大模型配置):

字段 类型 说明
id bigint 主键
model_name varchar(128) 模型标识名称,传给 RAGFlow API,如 BAAI/bge-m3
model_alias varchar(128) 前端显示别名
model_type varchar(32) 模型类型:llm 大模型 / embedding 向量化模型
model_platform varchar(64) 模型平台:ollama / openai / qwen / 硅基流动等
api_key varchar(300) API KEY
base_url varchar(300) API 地址(OpenAI 兼容接口)
deploy_mode char(1) 部署方式:1 云 / 2 本地
description varchar(512) 描述
is_enable char(1) 是否启用
del_flag char(1) 逻辑删除
create_by / create_time / update_by / update_time varchar(64) / datetime 若依审计字段

界面提供**"验证"按钮**:点击后实际调用一次模型 API(向量化模型跑一次 embedding、大模型跑一次补全),把连通性和响应时间直接反馈到界面上,本质是参考ragFlow的模型验证功能。

------向量化 / 大语言模型各维护一条记录,全部启用,平台为"硅基流动",部署方式"公有云",每条记录支持验证/修改/删除。

2.5 知识库管理 + 开放范围(核心权限设计)

数据表 rag_knowledge_base(RAG知识库管理):

字段 类型 说明
id varchar(64) 主键(对应 RAGFlow dataset_id)
kb_name varchar(128) 知识库名称
kb_desc varchar(512) 知识库描述
avatar varchar(255) 知识库封面图标
chunk_method varchar(32) RAGFlow 分片解析模板,默认 General
business_type tinyint 业务分类:1 通用文档库 / 2 网页抓取库 / 3 FAQ问答库(本地业务,不传给 RAGFlow)
embedding_model varchar(128) 绑定向量化模型
chunk_size int 分片大小 token(对应 chunk_token_num)
chunk_overlap int 分片重叠值
rag_status tinyint 知识库状态:0 未初始化 / 1 正常 / 2 同步中 / 3 异常失败
rag_msg varchar(1024) 状态备注 / 异常信息
total_doc_num int 文档总数量(冗余)
total_chunk_num bigint 向量分片总数(冗余)
pub_area varchar(30) 开放范围:private 私有 / public 公开 / assign_user 指定用户 / assign_dept 指定部门 / assign_role 指定角色
is_enable char(1) 是否启用
del_flag char(1) 逻辑删除
create_by / create_time / update_by / update_time varchar(64) / datetime 若依审计字段

开放范围是知识库权限的核心,创建知识库时直接配置:

开放范围(pub_area) 含义 可见人群
private 私有 仅创建人可见可用
public 公开 所有登录用户
assign_user 指定用户 仅勾选的用户可见
assign_dept 指定部门 部门内成员可见(含子部门)
assign_role 指定角色 拥有指定角色的人可见

权限明细用一张授权表 rag_knowledge_base_auth(知识库权限表)实现,通过 auth_subject_type 区分主体类型------相比"每个类型一张关联表",新增 / 取消授权只插删一条记录,后端只写一套代码:

字段 类型 说明
id bigint 主键
resource_id varchar(64) 关联知识库 ID(rag_knowledge_base.id)
pub_area varchar(20) 开放范围冗余(private / public / assign_user / assign_dept / assign_role)
auth_subject_type varchar(20) 授权主体类型:user 用户 / dept 部门 / role 角色
auth_subject_id varchar(64) 授权主体 ID:用户 ID / 部门 ID / 角色 ID
create_by / create_time / update_time varchar(64) / datetime 若依审计字段

唯一索引 uk_resource_subject(resource_id, auth_subject_type, auth_subject_id):同一知识库对同一主体只允许一条授权,天然防重复。

------新增知识库表单:知识库名称、向量化模型、分片解析模板、开放范围(私有/公开/指定用户/指定部门/指定角色单选)、封面、描述。

2.6 知识库文档管理

数据表 rag_document(RAG知识库文档):

字段 类型 说明
id varchar(64) 主键(对应 RAGFlow 文档 ID)
kb_id varchar(64) 关联知识库 rag_knowledge_base.id
doc_name varchar(256) 文档名称
doc_type varchar(32) 文档类型:pdf / docx / txt / md / url
file_path varchar(512) 文件存储路径 / 网页 url
file_size bigint 文件大小(字节)
page_count int PDF 页数
parse_status tinyint 解析状态:0 待解析 / 1 解析中 / 2 解析成功 / 3 解析失败
vector_status tinyint 向量化状态:0 待向量化 / 1 向量化中 / 2 成功 / 3 失败
status_msg varchar(1024) 解析 / 向量化失败日志
chunk_count int 文档分片向量数量(冗余)
source varchar(64) 来源:upload 上传 / crawl 网页抓取
is_enable char(1) 是否启用
del_flag char(1) 逻辑删除
create_by / create_time / update_by / update_time varchar(64) / datetime 若依审计字段

这里有两个关键点:

  • 主键直接用 RAGFlow 返回的文档 ID,省掉本地 ID 与远端 ID 的映射表;
  • 增加 status_msg(失败原因留痕)、page_count(PDF 页数)、chunk_count(分片冗余)等运维字段。

文档上传后进入解析任务队列 rag_parse_task(RAG文档解析任务队列):

字段 类型 说明
id bigint 任务 ID
kb_id varchar(64) 知识库 ID
doc_id varchar(64) 文档 ID
doc_name varchar(255) 文档名称
status char(1) 任务状态:0 待执行 / 1 执行中 / 2 成功 / 3 失败
error_msg varchar(1000) 失败原因
remark varchar(500) 备注
create_by / create_time / update_by / update_time varchar(64) / datetime 若依审计字段

上传接口只做两件事:落库 rag_document + 插入一条 rag_parse_task 任务,立即返回 ;后端定时任务(@Scheduled)轮询 status = '0' 的任务,逐个调用 RAGFlow 解析接口,完成后回写 rag_document.parse_status / vector_status。异步削峰,还有一个同时大批量上传、解析文档的问题,我也做了处理,后面单独详细讲一下。

页面提供:上传文档 / 批量删除 / 全部解析三个核心操作,左侧为知识库列表(切换知识库查看各自文档),右侧为文档表格,实时展示解析状态。

------知识库 001 下 6 份文档,pdf/docx 混合,全部"解析成功",支持按文档名称/类型/解析状态/是否启用筛选,禁用的文档不参与rag检索。

2.7 RAG 提示词

数据表 rag_prompt_template(RAG提示词模板表):

字段 类型 说明
id bigint 主键
template_name varchar(128) 模板名称
template_type varchar(32) 模板类型:rag_qa RAG问答 / summary 摘要 / translate 翻译
system_prompt longtext 系统提示词(角色约束,核心 prompt)
user_prompt longtext 用户前置模板,可留空
description varchar(512) 模板描述说明
is_default tinyint 是否默认模板:0 否 / 1 是
is_enable char(1) 是否启用
del_flag char(1) 逻辑删除
create_by / create_time / update_by / update_time varchar(64) / datetime 若依审计字段

维护助手的系统提示词模板,比如限定回答范围、要求引用来源、禁止编造等。提示词作为独立模块管理,方便运营人员随时调整而不用改代码;rag_chat_bot.prompt_template_id 引用本表 id,一个模板可被多个助手复用。

2.8 对话助手管理

数据表 rag_chat_bot(RAG对话助手配置):

字段 类型 说明
id varchar(64) 主键(即 RAGFlow chat_assistant id)
bot_name varchar(128) 助手名称
bot_desc varchar(512) 助手描述
prompt_template_id bigint 关联提示词模板 rag_prompt_template.id
kb_code_list text 绑定知识库 kb_code 集合,JSON 数组 "kb01","kb02"
llm_model varchar(128) 选用大模型名称
temperature decimal(3,2) 温度参数,默认 0.10
max_tokens int 最大输出 token,默认 1024
top_n int 检索返回 topN 分片数量,默认 3
similarity_threshold decimal(3,2) 相似度阈值,默认 0.20
is_stream tinyint 是否流式输出:0 否 / 1 是
refine_multiturn char(1) 是否开启多轮查询改写
rerank_enable char(1) 是否开启 rerank 重排序
is_enable char(1) 是否启用助手
del_flag char(1) 逻辑删除
create_by / create_time / update_by / update_time varchar(64) / datetime 若依审计字段

助手 = 知识库集合 + 大模型 + 提示词 + 检索参数的绑定关系。这一块有三个关键点:

  • 一个助手可绑定多个知识库kb_code_list 存 JSON 数组),问答时多库联合检索;
  • 检索参数入库top_n / similarity_threshold / rerank_enable / refine_multiturn / temperature / max_tokens),前端可调,后端组装 RAGFlow 请求体时透传,为了降低使用门槛,最初的设计是做一个默认最优配置,将对话助手的大量配置隐藏,让用户能以极低的学习成本来使用这套知识库检索系统;
  • 助手 ID 即 RAGFlow chat_assistant ID,本地零映射。

业务上可以建多个助手:制度问答助手、技术文档助手、档案查询助手......每个助手面向不同知识库和场景。助手卡片在首页的排序由 rag_chat_bot_user_sort(RAG对话助手用户排序表)维护:

字段 类型 说明
id bigint 主键
user_id bigint 用户 ID(sys_user.user_id)
chat_bot_id varchar(64) 对话助手 ID(rag_chat_bot.id)
sort_no int 排序号(越小越靠前,从 1 开始)
create_time / update_time datetime 创建 / 更新时间

唯一索引 uk_user_bot(user_id, chat_bot_id):每用户对同一助手只有一条排序记录,前端拖拽调整时 upsert,实现每用户独立卡片顺序

------3 个助手(通用知识问答/对话助手002/我的私人对话助手),均绑定知识库001、使用 deepseek-ai/DeepSeek-V4-Flash。

2.9 RAG 会话明细 + 用户会话管理

数据表 rag_conversation(RAG用户会话表):

字段 类型 说明
id varchar(64) 主键(即 RAGFlow conversation_id)
bot_id varchar(64) 关联助手 rag_chat_bot.id
user_id varchar(64) 若依系统用户账号
conv_title varchar(256) 会话标题,AI 自动生成或用户修改,默认"新对话"
is_enable char(1) 会话是否有效:N 关闭 / Y 正常
del_flag char(1) 逻辑删除
create_time datetime 会话创建时间
update_time datetime 会话最后更新时间

数据表 rag_conversation_msg(RAG会话消息明细):

字段 类型 说明
id bigint 主键
conv_id varchar(64) 关联会话 rag_conversation.id
role varchar(20) 角色:user / assistant
question text 用户问题(role=user 有效)
answer longtext AI 回答内容(role=assistant 有效)
reference text 引用知识库文档来源 JSON 数组
token_cost int 消耗 token 数量
cost_ms bigint 请求耗时毫秒
create_time datetime 消息时间

设计要点:

  • 会话主键直接用 RAGFlow 的 conversation_id,若依侧与 RAGFlow 侧一一对应,多轮续聊时直接透传,无映射成本;
  • 查询按 conv_id 走索引(idx_conv_id),会话列表按 user_id + del_flag 过滤。

这两个模块的价值:可审计、可复盘。每次问答的问题、答案、引用来源等都留痕,方便排查问题或扩展统计功能。

三、Java 后端对接 RAGFlow API

其实后端的设计思路也很容易理解,首先在配置文件配置ragFlow的相关信息,然后写一个对接ragFlow API接口的工具类,然后按需调用就行了。

3.1 配置

bash 复制代码
ragflow:
  base-url: http://127.0.0.1:90
  api-key: ragflow-2rl-xxxxGoqxxxp0Bqa-xxxxx-xxxx
  #sse超时,毫秒
  sse-timeout: 600000

3.2 核心API工具类(知识库增删改、文档增删解析、创建对话助手等)

这个类其实本质就是把ragFlow的API调用都落实到java类中,完整的API登录ragFlow都可以看到,如下代码可供参考。

bash 复制代码
package com.ruoyi.rag.controller;

import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import com.ruoyi.rag.domain.*;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.FileSystemResource;
import org.springframework.http.*;
import org.springframework.stereotype.Component;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.multipart.MultipartFile;

import java.io.BufferedReader;
import java.io.File;
import java.io.InputStreamReader;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;

@Component
public class RagFlowApiClient {

    @Value("${ragflow.base-url}")
    private String baseUrl;
    @Value("${ragflow.api-key}")
    private String apiKey;

    private final RestTemplate restTemplate = new RestTemplate();

    private HttpHeaders getHeader() {
        HttpHeaders headers = new HttpHeaders();
        headers.set("Authorization", "Bearer " + apiKey);
        headers.setContentType(MediaType.APPLICATION_JSON);
        return headers;
    }

    /**
     * 获取multipart表单请求头,用于文件上传/web抓取
     */
    private HttpHeaders getMultipartHeader() {
        HttpHeaders headers = new HttpHeaders();
        headers.set("Authorization", "Bearer " + apiKey);
        headers.setContentType(MediaType.MULTIPART_FORM_DATA);
        return headers;
    }

    /**
     * 创建知识库
     * @param ragKnowledgeBase 知识库名称
     * @return ragflow kb_id
     */
    public String createKnowledgeBase(RagKnowledgeBase ragKnowledgeBase) {
        String url = baseUrl + "/api/v1/datasets";
        Map<String,Object> body = new HashMap<>();
        body.put("name", ragKnowledgeBase.getKbName());
        body.put("description", ragKnowledgeBase.getKbDesc());
        body.put("embedding_model", ragKnowledgeBase.getEmbeddingModel());
        body.put("chunk_method", ragKnowledgeBase.getChunkMethod());

        HttpEntity<String> entity = new HttpEntity<>(JSON.toJSONString(body),getHeader());
        ResponseEntity<String> resp = restTemplate.postForEntity(url,entity,String.class);
        JSONObject json = JSON.parseObject(resp.getBody());

        Integer code = json.getInteger("code");
        if (!Objects.equals(code, 0)) {
            String msg = json.getString("message");
            throw new RuntimeException("创建知识库失败:" + Objects.toString(msg,"未知错误"));
        }
        JSONObject data = json.getJSONObject("data");
        return data.getString("id");
    }


    /**
     * 删除单个dataset
     * @param datasetId ragflow dataset_id
     */
    public void deleteSingleDataset(String datasetId) {
        if(datasetId == null || datasetId.isBlank()){
            return;
        }
        String url = baseUrl + "/api/v1/datasets";
        Map<String,Object> body = new HashMap<>();
        // 就算删一个,也要包进数组
        body.put("ids", List.of(datasetId));

        HttpEntity<String> entity = new HttpEntity<>(JSON.toJSONString(body), getHeader());
        ResponseEntity<String> resp = restTemplate.exchange(
                url,
                HttpMethod.DELETE,
                entity,
                String.class
        );
        JSONObject json = JSON.parseObject(resp.getBody());
        Integer code = json.getInteger("code");
        if (!Objects.equals(code, 0)) {
            String msg = json.getString("message");
            throw new RuntimeException("删除知识库失败:" + Objects.toString(msg,"未知错误"));
        }
    }

    //==================== 文档上传相关 ====================

    /**
     * type=local 上传本地文件(File对象,支持多文件)
     * @param datasetId 知识库id
     * @param files 文件列表
     * @return 返回新建文档id集合
     */
    public List<String> uploadLocalDocuments(String datasetId, List<File> files) {
        if(datasetId == null || datasetId.isBlank()){
            throw new IllegalArgumentException("datasetId不能为空");
        }
        if(files == null || files.isEmpty()){
            throw new IllegalArgumentException("待上传文件不能为空");
        }
        String url = baseUrl + "/api/v1/datasets/" + datasetId + "/documents";

        MultiValueMap<String, Object> form = new LinkedMultiValueMap<>();
        for (File file : files) {
            FileSystemResource resource = new FileSystemResource(file);
            form.add("file", resource);
        }

        HttpEntity<MultiValueMap<String, Object>> request = new HttpEntity<>(form, getMultipartHeader());
        ResponseEntity<String> resp = restTemplate.exchange(url, HttpMethod.POST, request, String.class);
        JSONObject json = JSON.parseObject(resp.getBody());

        Integer code = json.getInteger("code");
        if (!Objects.equals(code, 0)) {
            String msg = json.getString("message");
            throw new RuntimeException("RAGFlow上传文档失败:" + Objects.toString(msg,"未知错误"));
        }
        JSONArray dataArray = json.getJSONArray("data");
        List<String> docIds = new ArrayList<>();
        for (int i = 0; i < dataArray.size(); i++) {
            JSONObject item = dataArray.getJSONObject(i);
            docIds.add(item.getString("id"));
        }
        return docIds;
    }

    /**
     * type=local 适配前端 MultipartFile 上传
     * @param datasetId 知识库id
     * @param multipartFiles spring接收的文件
     * @return 文档id列表
     */
    public List<RagDocument> uploadLocalMultipartFiles(String datasetId, List<MultipartFile> multipartFiles) {
        if(StringUtils.isBlank(datasetId)){
            throw new IllegalArgumentException("datasetId不能为空");
        }
        if(multipartFiles == null || multipartFiles.isEmpty()){
            throw new IllegalArgumentException("待上传文件不能为空");
        }
        String url = baseUrl + "/api/v1/datasets/" + datasetId + "/documents";

        MultiValueMap<String, Object> form = new LinkedMultiValueMap<>();
        for (MultipartFile mf : multipartFiles) {
            ByteArrayResource resource;
            try {
                resource = new ByteArrayResource(mf.getBytes()){
                    @Override
                    public String getFilename() {
                        return mf.getOriginalFilename();
                    }
                };
            } catch (Exception e) {
                throw new RuntimeException("读取上传文件字节失败", e);
            }
            form.add("file", resource);
        }

        HttpEntity<MultiValueMap<String, Object>> request = new HttpEntity<>(form, getMultipartHeader());
        ResponseEntity<String> resp = restTemplate.exchange(url, HttpMethod.POST, request, String.class);
        JSONObject json = JSON.parseObject(resp.getBody());

        Integer code = json.getInteger("code");
        if (!Objects.equals(code, 0)) {
            String msg = json.getString("message");
            throw new RuntimeException("RAGFlow上传文档失败:" + Objects.toString(msg,"未知错误"));
        }
        JSONArray dataArray = json.getJSONArray("data");
        List<RagDocument> resultList = new ArrayList<>();

        for (int i = 0; i < dataArray.size(); i++) {
            JSONObject item = dataArray.getJSONObject(i);
            RagDocument ragDoc = new RagDocument();

            // ✅ RagFlow文档ID直接存入主键id
            ragDoc.setId(item.getString("id"));
            // 知识库id
            ragDoc.setKbId(datasetId);
            //文档名称
            ragDoc.setDocName(item.getString("name"));
            //文件大小,字节
            Long size = item.getLong("size");
            ragDoc.setFileSize(size);
            //pdf页数
            Long pageCount = item.getLong("page_count");
            ragDoc.setPageCount(pageCount);
            //来源 local本地上传
            ragDoc.setSource("local");
            //上传完成未启动解析,待解析
            ragDoc.setParseStatus(0);  //0待解析 1解析中 2解析成功 3解析失败
            ragDoc.setIsEnable("Y");
            ragDoc.setDelFlag("0");

            //截取文件后缀作为docType
            String fileName = item.getString("name");
            if(fileName != null && fileName.contains(".")){
                String suffix = fileName.substring(fileName.lastIndexOf(".")+1).toLowerCase();
                ragDoc.setDocType(suffix);
            }
            resultList.add(ragDoc);
        }
        return resultList;
    }

    /**
     * type=web 抓取网页生成文档
     * @param datasetId 知识库id
     * @param docName 文档名称
     * @param crawlUrl 网页地址
     * @return 文档id
     */
    public String uploadWebDocument(String datasetId, String docName, String crawlUrl) {
        if(datasetId == null || datasetId.isBlank()){
            throw new IllegalArgumentException("datasetId不能为空");
        }
        String url = baseUrl + "/api/v1/datasets/" + datasetId + "/documents?type=web";

        MultiValueMap<String, Object> form = new LinkedMultiValueMap<>();
        form.add("name", docName);
        form.add("url", crawlUrl);

        HttpEntity<MultiValueMap<String, Object>> request = new HttpEntity<>(form, getMultipartHeader());
        ResponseEntity<String> resp = restTemplate.exchange(url, HttpMethod.POST, request, String.class);
        JSONObject json = JSON.parseObject(resp.getBody());

        Integer code = json.getInteger("code");
        if (!Objects.equals(code, 0)) {
            String msg = json.getString("message");
            throw new RuntimeException("RAGFlow抓取网页文档失败:" + Objects.toString(msg,"未知错误"));
        }
        JSONArray dataArray = json.getJSONArray("data");
        JSONObject item = dataArray.getJSONObject(0);
        return item.getString("id");
    }

    /**
     * 查询ragflow单文档信息,使用list接口带id过滤
     * @param datasetId 知识库dataset_id
     * @param docId ragflow文档id
     * @return null=ragflow侧还未生成该文档;返回JSONObject文档对象
     */
    public JSONObject getDocumentInfo(String datasetId, String docId) {
        if(StringUtils.isAnyBlank(datasetId,docId)){
            return null;
        }
        String encodeDocId = URLEncoder.encode(docId, StandardCharsets.UTF_8);
        String url = baseUrl + "/api/v1/datasets/" + datasetId + "/documents?id=" + encodeDocId + "&page=1&page_size=1";

        HttpEntity<Void> httpEntity = new HttpEntity<>(null, getHeader());
        ResponseEntity<String> resp;
        try {
            resp = restTemplate.exchange(url, HttpMethod.GET, httpEntity, String.class);
        }catch (Exception e){
            return null;
        }
        JSONObject json = JSON.parseObject(resp.getBody());
        if(!Objects.equals(json.getInteger("code"),0)){
            return null;
        }
        JSONObject dataObj = json.getJSONObject("data");
        JSONArray docsArr = dataObj.getJSONArray("docs");
        if(docsArr == null || docsArr.isEmpty()){
            return null;
        }
        return docsArr.getJSONObject(0);
    }

    /**
     * type=empty 创建空文档
     * @param datasetId 知识库id
     * @param docName 文档名称
     * @return 文档id
     */
    public String createEmptyDocument(String datasetId, String docName) {
        if(datasetId == null || datasetId.isBlank()){
            throw new IllegalArgumentException("datasetId不能为空");
        }
        String url = baseUrl + "/api/v1/datasets/" + datasetId + "/documents?type=empty";

        Map<String,Object> body = new HashMap<>();
        body.put("name", docName);

        HttpEntity<String> entity = new HttpEntity<>(JSON.toJSONString(body), getHeader());
        ResponseEntity<String> resp = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);
        JSONObject json = JSON.parseObject(resp.getBody());

        Integer code = json.getInteger("code");
        if (!Objects.equals(code, 0)) {
            String msg = json.getString("message");
            throw new RuntimeException("RAGFlow创建空文档失败:" + Objects.toString(msg,"未知错误"));
        }
        JSONArray dataArray = json.getJSONArray("data");
        JSONObject item = dataArray.getJSONObject(0);
        return item.getString("id");
    }



    /**
     * 【内置分块流水线】启动文档解析(普通知识库绝大多数用这个)
     * POST /api/v1/datasets/{dataset_id}/chunks
     * @param datasetId 知识库id
     * @param docIdList document_ids列表
     */
    public void parseDocuments(String datasetId, List<String> docIdList) {
        if(StringUtils.isBlank(datasetId)){
            throw new IllegalArgumentException("datasetId不能为空");
        }
        if(docIdList == null || docIdList.isEmpty()){
            throw new IllegalArgumentException("文档id列表不能为空");
        }
        String url = baseUrl + "/api/v1/datasets/" + datasetId + "/chunks";
        Map<String,Object> body = new HashMap<>();
        body.put("document_ids", docIdList);

        HttpEntity<String> entity = new HttpEntity<>(JSON.toJSONString(body),getHeader());
        ResponseEntity<String> resp = restTemplate.exchange(url,HttpMethod.POST,entity,String.class);
        JSONObject json = JSON.parseObject(resp.getBody());

        Integer code = json.getInteger("code");
        if(!Objects.equals(code,0)){
            String msg = json.getString("message");
            throw new RuntimeException("RAGFlow启动解析任务失败:"+ Objects.toString(msg,"未知错误"));
        }
    }

    /**
     * 停止解析(内置流水线)
     * DELETE /api/v1/datasets/{dataset_id}/chunks
     * @param datasetId
     * @param docIdList
     */
    public void stopParseDocuments(String datasetId, List<String> docIdList){
        if(StringUtils.isBlank(datasetId)){
            throw new IllegalArgumentException("datasetId不能为空");
        }
        if(docIdList == null || docIdList.isEmpty()){
            throw new IllegalArgumentException("文档id列表不能为空");
        }
        String url = baseUrl + "/api/v1/datasets/" + datasetId + "/chunks";
        Map<String,Object> body = new HashMap<>();
        body.put("document_ids", docIdList);

        HttpEntity<String> entity = new HttpEntity<>(JSON.toJSONString(body),getHeader());
        ResponseEntity<String> resp = restTemplate.exchange(url,HttpMethod.DELETE,entity,String.class);
        JSONObject json = JSON.parseObject(resp.getBody());

        Integer code = json.getInteger("code");
        if(!Objects.equals(code,0)){
            String msg = json.getString("message");
            throw new RuntimeException("RAGFlow停止解析失败:"+ Objects.toString(msg,"未知错误"));
        }
    }

    /**
     * 删除知识库下的单个文档
     * DELETE /api/v1/datasets/{dataset_id}/documents
     * 注意:RAGFlow删除文档走的是集合路径(body传ids数组),单个文档路径不支持DELETE(会返回405)
     * @param datasetId 知识库id
     * @param docId 文档id
     */
    public void deleteDocument(String datasetId, String docId){
        if(StringUtils.isBlank(datasetId)){
            throw new IllegalArgumentException("datasetId不能为空");
        }
        if(StringUtils.isBlank(docId)){
            throw new IllegalArgumentException("文档id不能为空");
        }
        String url = baseUrl + "/api/v1/datasets/" + datasetId + "/documents";
        Map<String,Object> body = new HashMap<>();
        // 就算删一个,也要包进数组
        body.put("ids", List.of(docId));
        HttpEntity<String> entity = new HttpEntity<>(JSON.toJSONString(body), getHeader());
        ResponseEntity<String> resp = restTemplate.exchange(url, HttpMethod.DELETE, entity, String.class);
        JSONObject json = JSON.parseObject(resp.getBody());

        Integer code = json.getInteger("code");
        if(!Objects.equals(code,0)){
            String msg = json.getString("message");
            throw new RuntimeException("RAGFlow删除文档失败:"+ Objects.toString(msg,"未知错误"));
        }
    }




    /**
     * Create chat assistant 创建聊天助手 POST /api/v1/chats
     * @param name 助手名称【必填】
     * @param icon base64头像,可以null
     * @param dataset_ids 绑定知识库uuid列表 List<String>,可以null
     * @param llm_id 模型id,可以null
     * @param llmSetting llm_setting对象,助手大模型推理参数配置,可以null
     *        llm_setting包含属性说明:
     *          "model_type": string 模型类型标识,仅支持"chat"和"image2text";不传或其他值默认当作"chat"。
     *          "temperature": float 控制模型输出随机性,数值越低回答越保守,越高越有创造性,默认0.1。
     *          "top_p": float 核采样阈值,从概率最高的词汇中做采样,截断低概率词汇,默认0.3。
     *          "presence_penalty": float 存在惩罚,对对话中已出现过的词施加惩罚,减少内容重复,默认0.4。
     *          "frequency_penalty": float 频率惩罚,降低高频重复用词倾向,默认0.7。
     * @param promptConfig prompt_config对象,用于定义大模型需要遵守的指令,可以null
     *        prompt_config包含属性说明:
     *          "system": string 系统提示词内容。
     *          "prologue": string 用户打开对话时展示的开场白。
     *          "parameters": object[] 系统提示词中使用的自定义变量数组。注意:
     *              knowledge 为保留变量,代表检索出来的知识库片段;
     *              system内所有变量需要使用大括号包裹。
     *          "empty_response": string 用户提问未检索到知识库内容时返回的兜底回答;留空则允许模型自由生成回答。
     *          "quote": boolean 是否展示引用来源切片,默认true。
     *          "tts": boolean 是否开启语音朗读TTS。
     *          "refine_multiturn": boolean 是否开启多轮问题改写优化。
     *          "use_kg": boolean 是否启用知识图谱检索。
     *          "reasoning": boolean 是否开启模型推理思考模式。
     *          "cross_languages": list[string] 跨语言检索支持的语言列表。
     *          "web_search_provider": string 联网搜索服务商,可选值 "tavily"、"querit",不传默认tavily。
     *          "tavily_api_key": string Tavily联网搜索API密钥。
     *          "querit_api_key": string Querit联网搜索API密钥,使用该参数时web_search_provider必须指定为querit。
     *          "toc_enhance": boolean 是否开启目录增强检索(针对带目录长文档优化检索)。
     * @param similarityThreshold float 检索相似度阈值,可以null
     * @param vectorSimilarityWeight float 向量相似度权重,可以null
     * @param topN int 检索结果topN,可以null
     * @param topK int 检索结果topK,可以null
     * @param rerank_id 重排模型id,可以null
     * @return 返回RAGFlow返回完整data JSONObject,取出id即为chatAssistantId
     */

    public JSONObject createChatAssistant(String name,
                                          String icon,
                                          List<String> dataset_ids,
                                          String llm_id,
                                          RagChatBotLlmSetting llmSetting,
                                          RagChatBotPromptConfig promptConfig,
                                          Float similarityThreshold,
                                          Float vectorSimilarityWeight,
                                          Integer topN,
                                          Integer topK,
                                          String rerank_id) {
        if(StringUtils.isBlank(name)){
            throw new IllegalArgumentException("chat assistant name不能为空");
        }
        String url = baseUrl + "/api/v1/chats";
        Map<String,Object> body = new HashMap<>();
        body.put("name", name);
        if(StringUtils.isNotBlank(icon)){
            body.put("icon", icon);
        }
        if(dataset_ids != null){
            body.put("dataset_ids", dataset_ids);
        }
        if(StringUtils.isNotBlank(llm_id)){
            body.put("llm_id", llm_id);
        }
        if(llmSetting != null ){
            body.put("llm_setting", llmSetting);
        }
        if(promptConfig != null){
            body.put("prompt_config", promptConfig);
        }
        if(similarityThreshold != null){
            body.put("similarity_threshold", similarityThreshold);
        }
        if(vectorSimilarityWeight != null){
            body.put("vector_similarity_weight", vectorSimilarityWeight);
        }
        if(topN != null){
            body.put("top_n", topN);
        }
        if(topK != null){
            body.put("top_k", topK);
        }
        if(StringUtils.isNotBlank(rerank_id)){
            body.put("rerank_id", rerank_id);
        }

        HttpEntity<String> entity = new HttpEntity<>(JSON.toJSONString(body), getHeader());
        ResponseEntity<String> resp = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);
        JSONObject json = JSON.parseObject(resp.getBody());
        Integer code = json.getInteger("code");
        if(!Objects.equals(code,0)){
            String msg = json.getString("message");
            String detail = Objects.toString(msg,"未知错误");
            throw new RuntimeException("创建对话助手失败:" + detail + ",返回完整响应:" + json);
        }
        return json.getJSONObject("data");
    }


    /**
     * Get chat assistant GET /api/v1/chats/{chatId} 查询单个助手
     * @param chatId chat助手id
     * @return data原始JSONObject
     */
    public JSONObject getChatAssistant(String chatId) {
        if(StringUtils.isBlank(chatId)){
            throw new IllegalArgumentException("chatId不能为空");
        }
        String url = baseUrl + "/api/v1/chats/" + chatId;
        HttpEntity<Void> entity = new HttpEntity<>(null, getHeader());
        ResponseEntity<String> resp = restTemplate.exchange(url, HttpMethod.GET, entity, String.class);
        JSONObject json = JSON.parseObject(resp.getBody());
        Integer code = json.getInteger("code");
        if(!Objects.equals(code,0)){
            String msg = json.getString("message");
            throw new RuntimeException("RAGFlow查询chat assistant失败:"+ Objects.toString(msg,"未知错误"));
        }
        return json.getJSONObject("data");
    }

    /**
     * Delete chat assistant DELETE /api/v1/chats/{chatId} 删除单个助手
     */
    public void deleteChatAssistant(String chatId) {
        if(StringUtils.isBlank(chatId)){
            throw new IllegalArgumentException("chatId不能为空");
        }
        String url = baseUrl + "/api/v1/chats/" + chatId;
        HttpEntity<Void> entity = new HttpEntity<>(null, getHeader());
        ResponseEntity<String> resp = restTemplate.exchange(url, HttpMethod.DELETE, entity, String.class);
        JSONObject json = JSON.parseObject(resp.getBody());
        Integer code = json.getInteger("code");
        if(!Objects.equals(code,0)){
            String msg = json.getString("message");
            throw new RuntimeException("RAGFlow删除chat assistant失败:"+ Objects.toString(msg,"未知错误"));
        }
    }

    /**
     * Delete chat assistants 批量删除助手 DELETE /api/v1/chats
     * @param ids 要删除chatId列表;如果deleteAll=true,ids传null
     * @param deleteAll 是否删除全部当前用户助手
     */
    public void batchDeleteChatAssistant(List<String> ids, Boolean deleteAll) {
        String url = baseUrl + "/api/v1/chats";
        Map<String,Object> body = new HashMap<>();
        if(ids != null){
            body.put("ids", ids);
        }
        if(deleteAll != null){
            body.put("delete_all", deleteAll);
        }
        HttpEntity<String> entity = new HttpEntity<>(JSON.toJSONString(body), getHeader());
        ResponseEntity<String> resp = restTemplate.exchange(url, HttpMethod.DELETE, entity, String.class);
        JSONObject json = JSON.parseObject(resp.getBody());
        Integer code = json.getInteger("code");
        if(!Objects.equals(code,0)){
            String msg = json.getString("message");
            throw new RuntimeException("RAGFlow批量删除chat assistant失败:"+ Objects.toString(msg,"未知错误"));
        }
    }

    /**
     * List chat assistants GET /api/v1/chats 查询助手列表
     * @param page 页码,null默认1
     * @param pageSize 每页大小,null默认30
     * @param orderby 排序字段 create_time / update_time
     * @param desc 是否降序 null默认true
     * @param keywords 名称模糊搜索
     * @param ownerIds 租户id过滤
     * @param id 精确chatId匹配
     * @param name 精确名称匹配
     * @return 返回完整返回体data对象,包含chats数组、total
     */
    public JSONObject listChatAssistant(Integer page,
                                        Integer pageSize,
                                        String orderby,
                                        Boolean desc,
                                        String keywords,
                                        List<String> ownerIds,
                                        String id,
                                        String name) {
        StringBuilder sb = new StringBuilder(baseUrl + "/api/v1/chats?");
        if(page != null){
            sb.append("page=").append(page).append("&");
        }
        if(pageSize != null){
            sb.append("page_size=").append(pageSize).append("&");
        }
        if(StringUtils.isNotBlank(orderby)){
            sb.append("orderby=").append(URLEncoder.encode(orderby, StandardCharsets.UTF_8)).append("&");
        }
        if(desc != null){
            sb.append("desc=").append(desc).append("&");
        }
        if(StringUtils.isNotBlank(keywords)){
            sb.append("keywords=").append(URLEncoder.encode(keywords, StandardCharsets.UTF_8)).append("&");
        }
        if(ownerIds != null && !ownerIds.isEmpty()){
            for(String oid : ownerIds){
                sb.append("owner_ids=").append(URLEncoder.encode(oid, StandardCharsets.UTF_8)).append("&");
            }
        }
        if(StringUtils.isNotBlank(id)){
            sb.append("id=").append(URLEncoder.encode(id, StandardCharsets.UTF_8)).append("&");
        }
        if(StringUtils.isNotBlank(name)){
            sb.append("name=").append(URLEncoder.encode(name, StandardCharsets.UTF_8)).append("&");
        }
        //去掉末尾&
        String url = sb.toString();
        if(url.endsWith("&")){
            url = url.substring(0, url.length()-1);
        }

        HttpEntity<Void> entity = new HttpEntity<>(null, getHeader());
        ResponseEntity<String> resp = restTemplate.exchange(url, HttpMethod.GET, entity, String.class);
        JSONObject json = JSON.parseObject(resp.getBody());
        Integer code = json.getInteger("code");
        if(!Objects.equals(code,0)){
            String msg = json.getString("message");
            throw new RuntimeException("RAGFlow查询chat assistant列表失败:"+ Objects.toString(msg,"未知错误"));
        }
        return json.getJSONObject("data");
    }




    /**
     * 流式版本,stream=true,返回BufferedReader读取SSE原始行(移除Flux、WebClient)
     */
    public BufferedReader chatCompletionsV2Stream(String chatId,
                                                  List<Map<String, Object>> messages,
                                                  String llmId,
                                                  String sessionId) {
        if (StringUtils.isBlank(chatId)) {
            throw new IllegalArgumentException("chatId不能为空");
        }
        if (messages == null || messages.isEmpty()) {
            throw new IllegalArgumentException("messages对话消息列表不能为空");
        }
        String url = baseUrl + "/api/v1/chat/completions";
        Map<String, Object> body = new HashMap<>();
        body.put("chat_id", chatId);
        body.put("stream", true);
        body.put("llm_id", llmId);
        body.put("messages", messages);
        body.put("pass_all_history_messages", false); // 已传完整messages,避免RAGFlow叠加自身会话历史导致每轮翻倍

        if (StringUtils.isNotBlank(sessionId)) {
            body.put("session_id", sessionId);
        }

        HttpEntity<String> entity = new HttpEntity<>(JSON.toJSONString(body), getHeader());
        // 使用RestTemplate获取原生Response,不自动消费body
        ResponseEntity<org.springframework.core.io.Resource> respEntity = restTemplate.exchange(
                url,
                HttpMethod.POST,
                entity,
                org.springframework.core.io.Resource.class
        );

        HttpStatusCode statusCode = respEntity.getStatusCode();
        if (!statusCode.is2xxSuccessful()) {
            throw new RuntimeException("RAGFlow chatCompletionsV2Stream 调用异常,http状态码:" + statusCode.value());
        }

        org.springframework.core.io.Resource resource = respEntity.getBody();
        if (resource == null) {
            throw new RuntimeException("RAGFlow chatCompletionsV2Stream 返回body为空");
        }
        try {
            // 包装输入流为BufferedReader,UTF-8
            return new BufferedReader(new InputStreamReader(resource.getInputStream(), StandardCharsets.UTF_8));
        } catch (Exception e) {
            throw new RuntimeException("读取RAGFlow流式响应输入流失败", e);
        }
    }




    /**
     * 创建会话 POST /api/v1/chats/{chat_id}/sessions
     * @param chatId ragflow助手id
     * @param name 会话名称
     * @param userId 可选 用户自定义userId,可为null
     * @return 返回ragflow会话data对象,id字段为ragflow sessionId
     */
    public JSONObject createSession(String chatId, String name, String userId) {
        if (StringUtils.isBlank(chatId)) {
            throw new IllegalArgumentException("chatId不能为空");
        }
        if (StringUtils.isBlank(name)) {
            throw new IllegalArgumentException("会话name不能为空");
        }
        String url = baseUrl + "/api/v1/chats/" + chatId + "/sessions";
        Map<String, Object> body = new HashMap<>();
        body.put("name", name);
        if (StringUtils.isNotBlank(userId)) {
            body.put("user_id", userId);
        }
        HttpEntity<String> entity = new HttpEntity<>(JSON.toJSONString(body), getHeader());
        ResponseEntity<String> resp = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);
        JSONObject json = JSON.parseObject(resp.getBody());
        Integer code = json.getInteger("code");
        if (!Objects.equals(code, 0)) {
            String msg = json.getString("message");
            throw new RuntimeException("RAGFlow创建会话失败:" + Objects.toString(msg, "未知错误"));
        }
        return json.getJSONObject("data");
    }



    /**
     * 查询助手会话列表 GET /api/v1/chats/{chat_id}/sessions
     * @param chatId 助手id
     * @param page 页码 默认1
     * @param pageSize 页大小 默认30
     * @param orderby 排序字段 create_time / update_time
     * @param desc 是否降序 默认true
     * @param name 会话名称过滤
     * @param sessionId 会话id精确匹配
     * @param userId 用户自定义userId过滤
     * @return JSONArray 会话数组
     */
    public JSONArray listSession(String chatId,
                                 Integer page,
                                 Integer pageSize,
                                 String orderby,
                                 Boolean desc,
                                 String name,
                                 String sessionId,
                                 String userId) {
        if (StringUtils.isBlank(chatId)) {
            throw new IllegalArgumentException("chatId不能为空");
        }
        StringBuilder sb = new StringBuilder(baseUrl + "/api/v1/chats/" + chatId + "/sessions?");
        if (page != null) sb.append("page=").append(page).append("&");
        if (pageSize != null) sb.append("page_size=").append(pageSize).append("&");
        if (StringUtils.isNotBlank(orderby))
            sb.append("orderby=").append(URLEncoder.encode(orderby, StandardCharsets.UTF_8)).append("&");
        if (desc != null) sb.append("desc=").append(desc).append("&");
        if (StringUtils.isNotBlank(name))
            sb.append("name=").append(URLEncoder.encode(name, StandardCharsets.UTF_8)).append("&");
        if (StringUtils.isNotBlank(sessionId))
            sb.append("id=").append(URLEncoder.encode(sessionId, StandardCharsets.UTF_8)).append("&");
        if (StringUtils.isNotBlank(userId))
            sb.append("user_id=").append(URLEncoder.encode(userId, StandardCharsets.UTF_8)).append("&");
        String url = sb.toString();
        if (url.endsWith("&")) {
            url = url.substring(0, url.length() - 1);
        }
        HttpEntity<Void> entity = new HttpEntity<>(null, getHeader());
        ResponseEntity<String> resp = restTemplate.exchange(url, HttpMethod.GET, entity, String.class);
        JSONObject json = JSON.parseObject(resp.getBody());
        Integer code = json.getInteger("code");
        if (!Objects.equals(code, 0)) {
            String msg = json.getString("message");
            throw new RuntimeException("RAGFlow查询会话列表失败:" + Objects.toString(msg, "未知错误"));
        }
        return json.getJSONArray("data");
    }

    /**
     * 获取单个会话详情 GET /api/v1/chats/{chat_id}/sessions/{sessionId}
     * @param chatId 助手id
     * @param sessionId ragflow会话id
     * @return 会话data对象,包含messages历史消息
     */
    public JSONObject getSessionDetail(String chatId, String sessionId) {
        if (StringUtils.isAnyBlank(chatId, sessionId)) {
            throw new IllegalArgumentException("chatId、sessionId不能为空");
        }
        String url = baseUrl + "/api/v1/chats/" + chatId + "/sessions/" + sessionId;
        HttpEntity<Void> entity = new HttpEntity<>(null, getHeader());
        ResponseEntity<String> resp = restTemplate.exchange(url, HttpMethod.GET, entity, String.class);
        JSONObject json = JSON.parseObject(resp.getBody());
        Integer code = json.getInteger("code");
        if (!Objects.equals(code, 0)) {
            String msg = json.getString("message");
            throw new RuntimeException("RAGFlow获取会话详情失败:" + Objects.toString(msg, "未知错误"));
        }
        return json.getJSONObject("data");
    }

    /**
     * 更新消息点赞反馈 PUT /api/v1/chats/{chat_id}/sessions/{sessionId}/messages/{msgId}/feedback
     * @param chatId 助手id
     * @param sessionId 会话id
     * @param msgId ragflow消息id
     * @param thumbup true点赞 / false点踩
     * @param feedback 反馈文本,可为null
     * @return 更新后会话对象
     */
    public JSONObject messageFeedback(String chatId, String sessionId, String msgId, Boolean thumbup, String feedback) {
        if (StringUtils.isAnyBlank(chatId, sessionId, msgId) || thumbup == null) {
            throw new IllegalArgumentException("参数不全");
        }
        String url = baseUrl + "/api/v1/chats/" + chatId + "/sessions/" + sessionId + "/messages/" + msgId + "/feedback";
        Map<String, Object> body = new HashMap<>();
        body.put("thumbup", thumbup);
        if (StringUtils.isNotBlank(feedback)) {
            body.put("feedback", feedback);
        }
        HttpEntity<String> entity = new HttpEntity<>(JSON.toJSONString(body), getHeader());
        ResponseEntity<String> resp = restTemplate.exchange(url, HttpMethod.PUT, entity, String.class);
        JSONObject json = JSON.parseObject(resp.getBody());
        Integer code = json.getInteger("code");
        if (!Objects.equals(code, 0)) {
            String msg = json.getString("message");
            throw new RuntimeException("RAGFlow设置消息反馈失败:" + Objects.toString(msg, "未知错误"));
        }
        return json.getJSONObject("data");
    }

    /**
     * 批量删除会话 DELETE /api/v1/chats/{chat_id}/sessions
     * @param chatId 助手id
     * @param ids 需要删除的sessionId列表;可为null
     * @param deleteAll 是否删除全部会话  为true时  ids写null
     */
    public void batchDeleteSession(String chatId, List<String> ids, Boolean deleteAll) {
        if (StringUtils.isBlank(chatId)) {
            throw new IllegalArgumentException("chatId不能为空");
        }
        String url = baseUrl + "/api/v1/chats/" + chatId + "/sessions";
        Map<String, Object> body = new HashMap<>();
        if (ids != null && !ids.isEmpty()) {
            body.put("ids", ids);
        }
        if (deleteAll != null) {
            body.put("delete_all", deleteAll);
        }
        HttpEntity<String> entity = new HttpEntity<>(JSON.toJSONString(body), getHeader());
        ResponseEntity<String> resp = restTemplate.exchange(url, HttpMethod.DELETE, entity, String.class);
        JSONObject json = JSON.parseObject(resp.getBody());
        Integer code = json.getInteger("code");
        if (!Objects.equals(code, 0)) {
            String msg = json.getString("message");
            throw new RuntimeException("RAGFlow批量删除会话失败:" + Objects.toString(msg, "未知错误"));
        }
    }

    /**
     * 向量检索接口 POST /api/v1/retrieval
     * @param question 用户问题
     * @param datasetIds 知识库id列表,二选一 datasetIds / documentIds
     * @param documentIds 文档id列表
     * @param page 页码 默认1
     * @param pageSize 每页数量 默认30
     * @param similarityThreshold 相似度阈值
     * @param vectorSimilarityWeight 向量权重
     * @param topK topK
     * @param rerankId 重排模型id
     * @param keyword 是否开启关键词检索
     * @param highlight 是否高亮
     * @param crossLanguages 跨语言列表
     * @param metadataCondition 元数据过滤条件,可为null
     * @param useKg 是否启用知识图谱
     * @param tocEnhance 是否目录增强
     * @return 返回检索data对象,包含chunks、doc_aggs、total
     */
    public JSONObject retrievalChunks(String question,
                                      List<String> datasetIds,
                                      List<String> documentIds,
                                      Integer page,
                                      Integer pageSize,
                                      Float similarityThreshold,
                                      Float vectorSimilarityWeight,
                                      Integer topK,
                                      String rerankId,
                                      Boolean keyword,
                                      Boolean highlight,
                                      List<String> crossLanguages,
                                      Map<String, Object> metadataCondition,
                                      Boolean useKg,
                                      Boolean tocEnhance) {
        if (StringUtils.isBlank(question)) {
            throw new IllegalArgumentException("question不能为空");
        }
        if ((datasetIds == null || datasetIds.isEmpty()) && (documentIds == null || documentIds.isEmpty())) {
            throw new IllegalArgumentException("datasetIds 和 documentIds至少传一组");
        }
        String url = baseUrl + "/api/v1/retrieval";
        Map<String, Object> body = new HashMap<>();
        body.put("question", question);
        if (datasetIds != null) body.put("dataset_ids", datasetIds);
        if (documentIds != null) body.put("document_ids", documentIds);
        if (page != null) body.put("page", page);
        if (pageSize != null) body.put("page_size", pageSize);
        if (similarityThreshold != null) body.put("similarity_threshold", similarityThreshold);
        if (vectorSimilarityWeight != null) body.put("vector_similarity_weight", vectorSimilarityWeight);
        if (topK != null) body.put("top_k", topK);
        if (StringUtils.isNotBlank(rerankId)) body.put("rerank_id", rerankId);
        if (keyword != null) body.put("keyword", keyword);
        if (highlight != null) body.put("highlight", highlight);
        if (crossLanguages != null) body.put("cross_languages", crossLanguages);
        if (metadataCondition != null) body.put("metadata_condition", metadataCondition);
        if (useKg != null) body.put("use_kg", useKg);
        if (tocEnhance != null) body.put("toc_enhance", tocEnhance);

        HttpEntity<String> entity = new HttpEntity<>(JSON.toJSONString(body), getHeader());
        ResponseEntity<String> resp = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);
        JSONObject json = JSON.parseObject(resp.getBody());
        Integer code = json.getInteger("code");
        if (!Objects.equals(code, 0)) {
            String msg = json.getString("message");
            throw new RuntimeException("RAGFlow向量检索失败:" + Objects.toString(msg, "未知错误"));
        }
        return json.getJSONObject("data");
    }

    /**
     * 生成相关推荐问题 POST /api/v1/chat/recommandation
     * 注意:该接口需要login‑token,不是api‑key;本客户端暂不封装鉴权逻辑,业务层按需调用
     * @param question 用户原始问题
     * @param searchId 搜索配置id,可为null
     * @return 推荐问题字符串数组
     */
    public JSONArray generateRelatedQuestions(String question, String searchId) {
        throw new UnsupportedOperationException("generateRelatedQuestions需要登录token,不是api‑key,业务层自行处理header鉴权");
    }

    /**
     * 下载附件 GET /api/v1/agents/attachments/{attachmentId}/download
     * @param attachmentId 附件id
     * @param ext 输出格式 markdown/html/pdf/docx/xlsx/csv
     * @return byte[] 文件二进制
     */
    public byte[] downloadAttachment(String attachmentId, String ext) {
        if (StringUtils.isBlank(attachmentId)) {
            throw new IllegalArgumentException("attachmentId不能为空");
        }
        StringBuilder sb = new StringBuilder(baseUrl + "/api/v1/agents/attachments/" + attachmentId + "/download?");
        if (StringUtils.isNotBlank(ext)) {
            sb.append("ext=").append(URLEncoder.encode(ext, StandardCharsets.UTF_8));
        }
        String url = sb.toString();
        HttpEntity<Void> entity = new HttpEntity<>(null, getHeader());
        ResponseEntity<byte[]> resp = restTemplate.exchange(url, HttpMethod.GET, entity, byte[].class);
        return resp.getBody();
    }


    /**
     * 获取模型列表 GET /api/v1/models【DTO强类型版本】
     * @param modelType 可选过滤:embedding / chat / rerank / tts / vision,传null返回全部
     * @return RagFlowModelListResp
     */
    public RagFlowModelListResp listModels(String modelType,String instanceName,String providerName,String name) {
        StringBuilder sb = new StringBuilder(baseUrl + "/api/v1/models?t=1");

        if (StringUtils.isNotBlank(modelType)) {
            sb.append("&model_type=").append(URLEncoder.encode(modelType, StandardCharsets.UTF_8));
        }
        if (StringUtils.isNotBlank(instanceName)) {
            sb.append("&instance_name=").append(URLEncoder.encode(instanceName, StandardCharsets.UTF_8));
        }
        if (StringUtils.isNotBlank(providerName)) {
            sb.append("&provider_name=").append(URLEncoder.encode(providerName, StandardCharsets.UTF_8));
        }
        if (StringUtils.isNotBlank(name)) {
            sb.append("&name=").append(URLEncoder.encode(name, StandardCharsets.UTF_8));
        }
        String url = sb.toString();

        HttpEntity<Void> entity = new HttpEntity<>(null, getHeader());
        ResponseEntity<String> resp = restTemplate.exchange(url, HttpMethod.GET, entity, String.class);
        RagFlowModelListResp result = JSON.parseObject(resp.getBody(), RagFlowModelListResp.class);

        if (!Objects.equals(result.getCode(), 0)) {
            String msg = result.getMessage();
            throw new RuntimeException("RAGFlow查询模型列表失败:" + Objects.toString(msg, "未知错误"));
        }
        return result;
    }
    
    /**
     * 查询ragFlow中用户配置的llm模型id
     * @return
     */
    public String getUserLLMId() {
        RagFlowUserMeModels po = getUserMeModelsDto();
        RagFlowUserMeModels.UserMeModelData data = po.getData();
        return data.getTenantLlmId();
    }

    //==================== 引用展示:切片图片 / 文档缩略图 / 文档预览 / 原文件下载 ====================

    /**
     * 获取切片图片(也用于文档缩略图,二者都是 image_id 形态)
     * GET /api/v1/documents/images/{imageId}
     * 说明:imageId 形如 "{dataset_id}-{chunk_id}" 或 "{dataset_id}-thumbnail_{doc_id}.png"
     * @param imageId ragflow image_id
     * @return 图片字节
     */
    public byte[] getChunkImage(String imageId) {
        if (StringUtils.isBlank(imageId)) {
            throw new IllegalArgumentException("imageId不能为空");
        }
        String url = baseUrl + "/api/v1/documents/images/" + imageId;
        HttpEntity<Void> entity = new HttpEntity<>(null, getHeader());
        ResponseEntity<byte[]> resp = restTemplate.exchange(url, HttpMethod.GET, entity, byte[].class);
        return resp.getBody();
    }

    /**
     * 批量查询文档缩略图 GET /api/v1/thumbnails?doc_ids=a&doc_ids=b
     * RAGFlow返回:{"code":0,"data":{"docId":"/api/v1/documents/images/{dataset_id}-thumbnail_{doc_id}.png"或""}}
     * 本方法把缩略图URL收敛成 image_id(最后一个路径段),空串表示该文档无缩略图(如doc/xlsx)
     * @param docIds 文档id列表
     * @return docId -> thumbnailImageId(可能为空串)
     */
    public Map<String, String> getDocumentThumbnails(List<String> docIds) {
        if (docIds == null || docIds.isEmpty()) {
            return new HashMap<>();
        }
        StringBuilder sb = new StringBuilder(baseUrl + "/api/v1/thumbnails?");
        for (String docId : docIds) {
            if (StringUtils.isBlank(docId)) {
                continue;
            }
            sb.append("doc_ids=").append(URLEncoder.encode(docId, StandardCharsets.UTF_8)).append("&");
        }
        String url = sb.substring(0, sb.length() - 1);

        HttpEntity<Void> entity = new HttpEntity<>(null, getHeader());
        ResponseEntity<String> resp = restTemplate.exchange(url, HttpMethod.GET, entity, String.class);
        JSONObject json = JSON.parseObject(resp.getBody());
        if (!Objects.equals(json.getInteger("code"), 0)) {
            String msg = json.getString("message");
            throw new RuntimeException("RAGFlow查询文档缩略图失败:" + Objects.toString(msg, "未知错误"));
        }
        JSONObject dataObj = json.getJSONObject("data");
        Map<String, String> result = new HashMap<>();
        if (dataObj != null) {
            for (String docId : dataObj.keySet()) {
                String thumbUrl = dataObj.getString(docId);
                if (StringUtils.isBlank(thumbUrl)) {
                    result.put(docId, "");
                    continue;
                }
                // 形如 /api/v1/documents/images/xxx-thumbnail_yyy.png → 取最后一段作为image_id
                int idx = thumbUrl.lastIndexOf('/');
                result.put(docId, idx >= 0 ? thumbUrl.substring(idx + 1) : thumbUrl);
            }
        }
        return result;
    }

    /**
     * 获取文档预览流(PDF返回application/pdf可直接渲染;doc/xlsx/ppt返回原始文件字节)
     * GET /api/v1/documents/{documentId}/preview
     * @param documentId ragflow文档id
     * @return 原始响应(含Content-Type),业务层按类型决定 inline / attachment
     */
    public ResponseEntity<byte[]> getDocumentPreviewResponse(String documentId) {
        if (StringUtils.isBlank(documentId)) {
            throw new IllegalArgumentException("documentId不能为空");
        }
        String url = baseUrl + "/api/v1/documents/" + documentId + "/preview";
        HttpEntity<Void> entity = new HttpEntity<>(null, getHeader());
        return restTemplate.exchange(url, HttpMethod.GET, entity, byte[].class);
    }

    /**
     * GET /api/v1/users/me/models
     * 获取当前API Key所属租户的模型配置信息【DTO强类型版本】
     * @return RagFlowUserMeModelResp 完整返回实体
     */
    public RagFlowUserMeModels getUserMeModelsDto() {
        String url = baseUrl + "/api/v1/users/me/models";
        HttpEntity<Void> entity = new HttpEntity<>(null, getHeader());
        ResponseEntity<String> resp = restTemplate.exchange(url, HttpMethod.GET, entity, String.class);
        // fastjson2 直接转DTO
        RagFlowUserMeModels result = JSON.parseObject(resp.getBody(), RagFlowUserMeModels.class);
        if (!Objects.equals(result.getCode(), 0)) {
            String msg = result.getMessage();
            throw new RuntimeException("RAGFlow获取租户模型配置失败:" + Objects.toString(msg,"未知错误"));
        }
        return result;
    }

    /**
     * 下载原文档 GET /api/v1/datasets/{datasetId}/documents/{documentId}
     * @param datasetId 知识库id
     * @param documentId 文档id
     * @return 原始响应(含Content-Type),业务层按 attachment 处理
     */
    public ResponseEntity<byte[]> downloadDocumentResponse(String datasetId, String documentId) {
        if (StringUtils.isAnyBlank(datasetId, documentId)) {
            throw new IllegalArgumentException("datasetId、documentId不能为空");
        }
        String url = baseUrl + "/api/v1/datasets/" + datasetId + "/documents/" + documentId;
        HttpEntity<Void> entity = new HttpEntity<>(null, getHeader());
        return restTemplate.exchange(url, HttpMethod.GET, entity, byte[].class);
    }
}

3.4 Controller 层:权限校验(参考代码)

bash 复制代码
/**
     * 查询RAG知识库管理列表(本人创建 + 开放授权可见,非本人不可改删由 isOwner/canEdit 标记控制)
     */
    @PreAuthorize("@ss.hasPermi('rag:knowledgeBase:list')")
    @GetMapping("/list")
    public TableDataInfo list(RagKnowledgeBase ragKnowledgeBase)
    {
        // 可见范围过滤(核心权限方法)
        ragKnowledgeBase.setVisibleIds(ragKnowledgeBaseService.selectVisibleKbIds());
        startPage();
        List<RagKnowledgeBase> list = ragKnowledgeBaseService.selectRagKnowledgeBaseList(ragKnowledgeBase);

        if(list != null){
            RagFlowUserMeModels po = ragFlowApiClient.getUserMeModelsDto();
            Map<String, String> modelItems = getModelItems(po);

            String currentUser = SecurityUtils.getUsername();
            for (RagKnowledgeBase base : list) {
                base.setEmbeddingModelName(modelItems.get(base.getEmbeddingModel()));
                // 权限标记:本人创建的可改删,授权可见的仅查看
                boolean owner = currentUser.equals(base.getCreateBy());
                base.setIsOwner(owner);
                base.setCanEdit(owner);
            }
        }

        return getDataTable(list);
    }

关键设计 :浏览器不直接访问 RAGFlow,所有请求经若依后端代理------既解决跨域,又避免 RAGFlow 的 Token 暴露到前端,权限校验也统一在若依这一层完成。

四、总结

本在开发层面介绍了若依集成ragFlow的设计实现思路:

  1. RAGFlow 私有化部署:无 GPU 环境可跑,模型走 OpenAI 兼容 API 可插拔,生产可平滑替换本地模型;
  2. 8 个功能模块:模型、知识库、文档、提示词、助手、会话、问答,职责清晰、表结构完整;
  3. 五种开放范围权限:私有 / 公开 / 指定用户 / 指定部门 / 指定角色,配合若依数据权限,真正实精准的权限管控;
  4. Java 对接 RAGFlow:安全与权限统一收敛在若依层,我贴了一些有参考价值的代码,主要为了展示设计思路,具体实现那就见仁见智了。
相关推荐
牧瀬クリスだ44 分钟前
YAML配置详解:SpringBoot实战指南
java·spring·yml
wechatbot8881 小时前
极客互动企业微信 SCRM 自动运营平台效果实测
java·微信·企业微信·rpa
需要8261 小时前
JVM 内存区域与对象创建:一次 GC 从哪来
java·jvm·spring boot·spring·servlet·tomcat
事圆则缓1 小时前
Java 转 Kotlin 的 Android 迁移路线
java
计算机毕设定制辅导-无忧学长1 小时前
《基于Vue的流浪动物救助中心管理系统的设计与实现》
java·vue.js·spring boot·流浪动物救助中心管理系统
Mikko71 小时前
jackson-databind 升到 2.21.6 就安全了吗?jackson-core 是另一个坐标,它那条 high 全局库至今没收
java·后端·安全·json
Java_AI工程师1 小时前
90%的人写Function Calling,只写了"把参数传给工具执行"这一步,剩下的参数校验、错误重试、超时控制、结果格式化,全是空白。
java·人工智能·程序员
斯维赤2 小时前
LangChain4j 入门教学(Java 后端狂喜版
java·后端
Java_2017_csdn2 小时前
Java 8 Stream API 中的 map 和 flatMap 详解
java