This commit is contained in:
2026-08-10 11:30:07 +08:00
parent cd72668187
commit 5eac36799b
16 changed files with 35 additions and 221 deletions
+6 -6
View File
@@ -75,14 +75,13 @@
| 库 | 文件 | 内容 |
|---|---|---|
| business | `data/business.db` | 数据集、文档、分块、向量(vec0)、全文索引(FTS5)、解析任务、知识图谱、合同标注 |
| system | `data/system.db` | 全局设置(app_config)与模型配置 |
| system | `data/system.db` | 模型配置 |
| chat | `data/chat.db` | 会话与消息 |
表清单(13 张实体表 + 2 张虚拟表):
表清单(12 张实体表 + 2 张虚拟表):
| 表名 | 库 | 类型 | 说明 |
|---|---|---|---|
| `app_config` | system | 实体 | 全局键值配置(分块默认参数) |
| `model_config` | system | 实体 | 模型配置(chat / embeddingis_default 标记默认) |
| `kb_dataset` | business | 实体 | 数据集(绑定向量模型、分块参数) |
| `kb_document` | business | 实体 | 文档(6 态状态机) |
@@ -202,6 +201,7 @@ ollama pull nomic-embed-text
| `vector.dim` | 向量维度,须与向量模型一致 |
| `database.cache.ttl` | DAO 查询缓存秒数 |
| `chat.timeout` / `chat.max_retries` | 对话模型 API 超时(秒)/ 失败重试次数 |
| `chunk.default_size` / `chunk.default_overlap` / `react.default_rounds` | 数据集分块大小/重叠/智能体轮次默认值(新建数据集传 0/-1 时使用) |
| `pool.*` | 各并行点协程池并发度(缺失或非法回退代码内默认值):`kg_extract`(4) 逐 chunk 图谱抽取、`annotation_clause`(4) 合同逐条款标注、`annotation_dataset`(8) 条款内逐数据集召回、`chat`(4) 问答检索与图增强并行、`chat_retrieve`(4) 检索向量与全文并行 |
## API 概览
@@ -210,7 +210,7 @@ ollama pull nomic-embed-text
| 分组 | 接口 |
|---|---|
| `/system-config` | `POST /login`(访问令牌登录)`GET /settings``POST /save-settings`(分块默认值) |
| `/system-config` | `POST /login`(访问令牌登录) |
| `/model-config` | `GET /list``POST /save``POST /delete``POST /set-default``POST /test`(连通性测试) |
| `/dataset` | `GET /list``POST /save`(新建/编辑)、`POST /delete`(有文档拒绝) |
| `/document` | `POST /upload`multipart)、`GET /list``GET /detail``POST /delete``POST /reembed`(仅重算向量) |
@@ -226,12 +226,12 @@ ollama pull nomic-embed-text
| 页面 | 功能 |
|---|---|
| 登录页 | 输入访问令牌登录,无注册入口 |
| 数据集列表 | 新建/编辑/删除(绑定向量模型必选、分块参数带出全局默认);变更模型/分块 → 确认弹窗提示重新处理 |
| 数据集列表 | 新建/编辑/删除(绑定向量模型必选、分块参数传 0/-1 使用 config.yml 全局默认);变更模型/分块 → 确认弹窗提示重新处理 |
| 数据集详情 | 文档上传(拖拽)、6 态解析状态/分块数/失败重试/「图谱未构建」提示、分块预览与编辑 |
| 图谱页 | 实体/关系列表 + eCharts 力导向图 |
| 合同标注页 | 选语料数据集 → 上传合同 → 任务进度(3s 轮询)→ 条款-标注对照抽屉 → 导出标注版 HTML |
| 问答页 | 会话列表 + 知识库下拉,SSE 流式渲染,引用折叠面板 |
| 设置页 | 分块默认值 + 模型配置 CRUDchat/embedding 两个 tab)、连通性测试、设置默认 |
| 设置页 | 模型配置 CRUDchat/embedding 两个 tab)、连通性测试、设置默认 |
## 项目结构
+7
View File
@@ -29,6 +29,13 @@ chat:
timeout: 600 # 对话模型API请求超时时间(秒)
max_retries: 3 # 请求失败最大重试次数
# 数据集默认值(新建数据集传 0/-1 时使用,数据集内可覆盖)
chunk:
default_size: 800 # 分块最大字数
default_overlap: 150 # 分块重叠字数
react:
default_rounds: 0 # 智能体轮次(0=关闭)
# 协程池配置(各并行点并发度,缺失或 <1 时回退代码内默认值)
pool:
kg_extract: 4 # 知识图谱:逐 chunk LLM 抽取
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
-5
View File
@@ -26,11 +26,6 @@ const (
MaxReactRounds = 10 // ReAct 轮次上限
ToolResultMaxChars = 1500 // search 工具返回的单条 chunk 截断字数(控制上下文体积)
// 全局设置键(app_config 表)
SettingsKeyChunkSize = "chunk_default_size"
SettingsKeyChunkOverlap = "chunk_default_overlap"
SettingsKeyReactRounds = "react_default_rounds"
ParsePollIntervalSeconds = 5 // 解析/标注任务轮询间隔
EmbedBatchSize = 16 // 单次向量化请求的文本批量
-1
View File
@@ -2,7 +2,6 @@ package consts
const (
TableNameModelConfig = "model_config"
TableNameAppConfig = "app_config"
TableNameDataset = "kb_dataset"
TableNameDocument = "kb_document"
TableNameChunk = "kb_chunk"
-15
View File
@@ -18,18 +18,3 @@ func (c *systemConfig) Login(ctx context.Context, req *dto.LoginReq) (res *dto.L
}
return &dto.LoginRes{Token: token}, nil
}
func (c *systemConfig) GetSettings(ctx context.Context, _ *dto.GetSettingsReq) (*dto.GetSettingsRes, error) {
size, overlap, rounds, err := service.SystemConfigService.GetSettings(ctx)
if err != nil {
return nil, err
}
return &dto.GetSettingsRes{ChunkSize: size, ChunkOverlap: overlap, ReactRounds: rounds}, nil
}
func (c *systemConfig) SaveSettings(ctx context.Context, req *dto.SaveSettingsReq) (*dto.SaveSettingsRes, error) {
if err := service.SystemConfigService.SaveSettings(ctx, req.ChunkSize, req.ChunkOverlap, req.ReactRounds); err != nil {
return nil, err
}
return &dto.SaveSettingsRes{}, nil
}
-44
View File
@@ -1,44 +0,0 @@
package dao
import (
"context"
"rag-local/kb/consts"
"github.com/gogf/gf/v2/frame/g"
)
var AppConfig = &appConfigDao{}
type appConfigDao struct{}
func init() {
ctx := context.Background()
_, err := g.DB(consts.DbGroupSystem).Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameAppConfig+` (
cfg_key TEXT PRIMARY KEY,
cfg_value TEXT NOT NULL DEFAULT '',
updated_at DATETIME DEFAULT (datetime('now','localtime'))
)`)
if err != nil {
g.Log().Warningf(ctx, "create app_config table failed: %v", err)
}
}
// GetInt 读取全局设置值,未设置或非法时返回默认值 def
func (d *appConfigDao) GetInt(ctx context.Context, key string, def int) int {
r, err := g.DB(consts.DbGroupSystem).Ctx(ctx).GetValue(ctx,
"SELECT cfg_value FROM "+consts.TableNameAppConfig+" WHERE cfg_key=?", key)
if err != nil || r.IsEmpty() {
return def
}
return r.Int()
}
// SetInt 写入全局设置值(UPSERT
func (d *appConfigDao) SetInt(ctx context.Context, key string, v int) error {
_, err := g.DB(consts.DbGroupSystem).Ctx(ctx).Exec(ctx,
"INSERT INTO "+consts.TableNameAppConfig+"(cfg_key, cfg_value) VALUES(?, ?) "+
"ON CONFLICT(cfg_key) DO UPDATE SET cfg_value=excluded.cfg_value, updated_at=datetime('now','localtime')",
key, v)
return err
}
-19
View File
@@ -10,22 +10,3 @@ type LoginReq struct {
type LoginRes struct {
Token string `json:"token"`
}
type GetSettingsReq struct {
g.Meta `path:"/settings" method:"get" tags:"系统配置" summary:"获取全局设置"`
}
type GetSettingsRes struct {
ChunkSize int `json:"chunk_size"` // 默认分块大小
ChunkOverlap int `json:"chunk_overlap"` // 默认重叠字数
ReactRounds int `json:"react_rounds"` // 默认智能体轮次(0=关闭)
}
type SaveSettingsReq struct {
g.Meta `path:"/save-settings" method:"post" tags:"系统配置" summary:"保存全局设置"`
ChunkSize int `json:"chunk_size"`
ChunkOverlap int `json:"chunk_overlap"`
ReactRounds int `json:"react_rounds"`
}
type SaveSettingsRes struct{}
+10 -6
View File
@@ -34,6 +34,16 @@ func (s *datasetService) Save(ctx context.Context, m *entity.Dataset) (int64, er
return 0, gerror.Newf("召回数量需在 -1~%d 之间(-1=尽量多,0=全局默认)", consts.MaxRecallTopK)
}
}
// 0/-1 = 使用 config.yml 全局默认,落库前回填为实际值(Insert/Update 一致)
if m.ChunkSize <= 0 {
m.ChunkSize = g.Cfg().MustGet(ctx, "chunk.default_size", consts.DefaultChunkSize).Int()
}
if m.ChunkOverlap < 0 {
m.ChunkOverlap = g.Cfg().MustGet(ctx, "chunk.default_overlap", consts.DefaultChunkOverlap).Int()
}
if m.ReactRounds < 0 {
m.ReactRounds = g.Cfg().MustGet(ctx, "react.default_rounds", 0).Int()
}
if m.Id > 0 {
old, err := dao.Dataset.GetOne(ctx, m.Id)
if err != nil {
@@ -56,12 +66,6 @@ func (s *datasetService) Save(ctx context.Context, m *entity.Dataset) (int64, er
}
return m.Id, nil
}
if m.ChunkSize <= 0 {
m.ChunkSize = consts.DefaultChunkSize
}
if m.ChunkOverlap < 0 {
m.ChunkOverlap = consts.DefaultChunkOverlap
}
return dao.Dataset.Insert(ctx, m)
}
-29
View File
@@ -4,8 +4,6 @@ import (
"context"
"rag-local/common"
"rag-local/kb/consts"
"rag-local/kb/dao"
"github.com/gogf/gf/v2/errors/gerror"
)
@@ -27,30 +25,3 @@ func (s *systemConfigService) Login(ctx context.Context, token string) (string,
}
return common.SignToken("owner", common.AccessTokenFingerprint(), common.TokenExpireSeconds)
}
// GetSettings 读取全局分块默认值与智能体轮次默认值(未设置时用内置默认值)
func (s *systemConfigService) GetSettings(ctx context.Context) (chunkSize, chunkOverlap, reactRounds int, err error) {
return dao.AppConfig.GetInt(ctx, consts.SettingsKeyChunkSize, consts.DefaultChunkSize),
dao.AppConfig.GetInt(ctx, consts.SettingsKeyChunkOverlap, consts.DefaultChunkOverlap),
dao.AppConfig.GetInt(ctx, consts.SettingsKeyReactRounds, 0), nil
}
// SaveSettings 保存全局分块默认值与智能体轮次默认值
func (s *systemConfigService) SaveSettings(ctx context.Context, chunkSize, chunkOverlap, reactRounds int) error {
if chunkSize < 50 || chunkSize > 5000 {
return gerror.New("分块大小需在 50~5000 之间")
}
if chunkOverlap < 0 || chunkOverlap > 500 {
return gerror.New("重叠字数需在 0~500 之间")
}
if reactRounds < 0 || reactRounds > consts.MaxReactRounds {
return gerror.Newf("智能体轮次需在 0~%d 之间(0=关闭)", consts.MaxReactRounds)
}
if err := dao.AppConfig.SetInt(ctx, consts.SettingsKeyChunkSize, chunkSize); err != nil {
return err
}
if err := dao.AppConfig.SetInt(ctx, consts.SettingsKeyChunkOverlap, chunkOverlap); err != nil {
return err
}
return dao.AppConfig.SetInt(ctx, consts.SettingsKeyReactRounds, reactRounds)
}
-9
View File
@@ -1,9 +0,0 @@
import request from './request.js'
export function getSettings() {
return request.get('/system-config/settings')
}
export function saveSettings(data) {
return request.post('/system-config/save-settings', data)
}
+9 -19
View File
@@ -49,22 +49,22 @@
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="分块大小">
<el-input-number v-model="form.chunk_size" :min="50" :max="5000" :step="50" style="width: 100%" />
<div class="ds-tip">每块最大字数系统自动组合分块策略优先识别文档结构条文/章节/编号等按结构切分无结构时依次按标题感知语义递归切分</div>
<el-input-number v-model="form.chunk_size" :min="0" :max="5000" :step="50" style="width: 100%" />
<div class="ds-tip">每块最大字数0=使用 config.yml 全局默认系统自动组合分块策略优先识别文档结构条文/章节/编号等按结构切分无结构时依次按标题感知语义递归切分</div>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="重叠字数">
<el-input-number v-model="form.chunk_overlap" :min="0" :max="500" :step="10" style="width: 100%" />
<div class="ds-tip">相邻分块间的重叠字数用于保持上下文连贯语义切分路径自动忽略</div>
<el-input-number v-model="form.chunk_overlap" :min="-1" :max="500" :step="10" style="width: 100%" />
<div class="ds-tip">相邻分块间的重叠字数-1=使用 config.yml 全局默认0=不重叠语义切分路径自动忽略</div>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="智能体轮次">
<el-input-number v-model="form.react_rounds" :min="0" :max="10" :step="1" style="width: 100%" />
<div class="ds-tip">0=关闭单次检索问答1~10=启用 ReAct 智能体模式模型可自主多次调用检索工具此为轮次上限</div>
<el-input-number v-model="form.react_rounds" :min="-1" :max="10" :step="1" style="width: 100%" />
<div class="ds-tip">-1=使用 config.yml 全局默认0=关闭单次检索问答1~10=启用 ReAct 智能体模式模型可自主多次调用检索工具此为轮次上限</div>
</el-form-item>
</el-col>
<el-col :span="12">
@@ -106,7 +106,6 @@ import { onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { listDatasets, saveDataset, deleteDataset } from '../api/dataset.js'
import { listModelConfigs } from '../api/model_config.js'
import { getSettings } from '../api/settings.js'
// 把识别出的结构正则转成可读名称(第[一二三四五六七八九十百千]+条 → 条文)
function humanizePattern(p) {
@@ -123,25 +122,16 @@ function humanizePattern(p) {
const datasets = ref([])
const embedders = ref([])
const defaults = ref({ chunk_size: 800, chunk_overlap: 150, react_rounds: 0, vec_top_k: 0, fts_top_k: 0, rerank_top_k: 0, recall_top_k: 0 })
const loading = ref(false)
const saving = ref(false)
const dialogVisible = ref(false)
const editing = ref(false)
let editingRowCfgId = 0
let editingRowChunk = { chunk_size: 0, chunk_overlap: 0 }
const form = ref({ id: 0, name: '', description: '', embedding_cfg_id: 0, chunk_size: 800, chunk_overlap: 150, react_rounds: 0, vec_top_k: 0, fts_top_k: 0, rerank_top_k: 0, recall_top_k: 0 })
const form = ref({ id: 0, name: '', description: '', embedding_cfg_id: 0, chunk_size: 0, chunk_overlap: -1, react_rounds: -1, vec_top_k: 0, fts_top_k: 0, rerank_top_k: 0, recall_top_k: 0 })
onMounted(async () => {
await load()
try {
const s = await getSettings()
if (s) {
defaults.value.chunk_size = s.chunk_size || 800
defaults.value.chunk_overlap = s.chunk_overlap ?? 150
defaults.value.react_rounds = s.react_rounds ?? 0
}
} catch { /* 忽略 */ }
try {
const m = await listModelConfigs('embedding')
if (m && m.list) embedders.value = m.list
@@ -167,9 +157,9 @@ function openCreate() {
editing.value = false
editingRowCfgId = 0
editingRowChunk = { chunk_size: 0, chunk_overlap: 0 }
// 默认选中默认向量模型,其次第一个;分块大小/重叠/智能体轮次带出全局设置值
// 默认选中默认向量模型,其次第一个;分块大小/重叠/智能体轮次传 0/-1 使用 config.yml 全局默认
const def = embedders.value.find(x => x.is_default === 1) || embedders.value[0]
form.value = { id: 0, name: '', description: '', embedding_cfg_id: def?.id || 0, chunk_size: defaults.value.chunk_size, chunk_overlap: defaults.value.chunk_overlap, react_rounds: defaults.value.react_rounds, vec_top_k: defaults.value.vec_top_k, fts_top_k: defaults.value.fts_top_k, rerank_top_k: defaults.value.rerank_top_k, recall_top_k: defaults.value.recall_top_k }
form.value = { id: 0, name: '', description: '', embedding_cfg_id: def?.id || 0, chunk_size: 0, chunk_overlap: -1, react_rounds: -1, vec_top_k: 0, fts_top_k: 0, rerank_top_k: 0, recall_top_k: 0 }
dialogVisible.value = true
}
-64
View File
@@ -1,20 +1,5 @@
<template>
<div class="settings-page">
<div class="block-card">
<div class="block-title">分块默认值</div>
<div class="block-tip">新建数据集时自动带出单个数据集仍可在表单中修改</div>
<div class="block-row">
<span class="field-label">分块大小</span>
<el-input-number v-model="chunkForm.chunk_size" :min="50" :max="5000" :step="50" />
<span class="field-label">重叠字数</span>
<el-input-number v-model="chunkForm.chunk_overlap" :min="0" :max="500" :step="10" />
<span class="field-label">智能体轮次</span>
<el-input-number v-model="chunkForm.react_rounds" :min="0" :max="10" :step="1" />
<el-button type="primary" :loading="chunkSaving" @click="saveChunkDefaults">保存</el-button>
</div>
<div class="block-tip" style="margin-top: 6px; margin-bottom: 0;">智能体轮次0=关闭单次检索问答1~10=启用 ReAct 智能体模式模型自主调用检索工具此为轮次上限新建数据集时自动带出</div>
</div>
<div class="section-head">
<el-radio-group v-model="modelType" @change="loadModels">
<el-radio-button value="chat">对话模型</el-radio-button>
@@ -80,33 +65,9 @@
import { onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { listModelConfigs, saveModelConfig, deleteModelConfig, testModelConfig, setDefaultModelConfig } from '../api/model_config.js'
import { getSettings, saveSettings } from '../api/settings.js'
const modelType = ref('chat')
const chunkForm = ref({ chunk_size: 800, chunk_overlap: 150, react_rounds: 0 })
const chunkSaving = ref(false)
async function loadChunkDefaults() {
try {
const s = await getSettings()
if (s) {
chunkForm.value.chunk_size = s.chunk_size || 800
chunkForm.value.chunk_overlap = s.chunk_overlap ?? 150
chunkForm.value.react_rounds = s.react_rounds ?? 0
}
} catch { /* 忽略 */ }
}
async function saveChunkDefaults() {
chunkSaving.value = true
try {
await saveSettings(chunkForm.value)
ElMessage.success('分块默认值已保存')
} finally {
chunkSaving.value = false
}
}
const models = ref([])
const allModels = ref([])
const modelLoading = ref(false)
@@ -118,7 +79,6 @@ const modelForm = ref({ id: 0, name: '', model_type: 'chat', model_name: '', end
onMounted(async () => {
await loadModels()
await loadChunkDefaults()
try { const all = await listModelConfigs(''); if (all && all.list) allModels.value = all.list } catch { /* 忽略 */ }
})
@@ -221,28 +181,4 @@ async function test(row) {
align-items: center;
margin-bottom: 12px;
}
.block-card {
border: 1px solid #e4e7ed;
border-radius: 6px;
padding: 14px 16px;
margin-bottom: 16px;
}
.block-title {
font-weight: 600;
margin-bottom: 4px;
}
.block-tip {
font-size: 12px;
color: #909399;
margin-bottom: 10px;
}
.block-row {
display: flex;
align-items: center;
gap: 8px;
}
.field-label {
color: #606266;
font-size: 14px;
}
</style>
+3 -4
View File
@@ -88,7 +88,6 @@ kb_dataset --1:N-- kb_document --1:N-- kb_chunk --1:1-- kb_chunk_vec (vec0, chun
model_config: is_default 标记默认 chat/embedding 配置(GetDefault 取数)
chat_conversation --1:N-- chat_message(删会话级联删消息)
app_config: 无外联(全局键值)
```
**删除级联矩阵**(已实现行为,删除动作均落库失败回滚):
@@ -315,7 +314,7 @@ StartParsePollermain.go 启动,gtimer 单例 5 秒轮询,job 未结束不
```
每次启动
│ EnsureAccessToken()RandomToken(16) 生成 32 位 hex tokenSetAccessToken 存入内存
common/auth.go 包级变量,不落库app_config 仅存分块默认值,无 access_token 键
common/auth.go 包级变量,不落库)
启动日志打印(main.go):
============================================
@@ -333,7 +332,7 @@ StartParsePollermain.go 启动,gtimer 单例 5 秒轮询,job 未结束不
- **内存持有,不落库**`common/auth.go` 中 `var accessToken string` 包级变量;`EnsureAccessToken`system_config_service.go)每次启动无条件重新生成。登录页提示语「请输入服务启动时打印在控制台的访问令牌」
- **指纹校验**JWT 中携带令牌的 SHA-256 指纹(`TokenFingerprint`,取前 16 位 hex);鉴权中间件将 JWT 指纹与内存令牌指纹比对(`claims.TokenFp != AccessTokenFingerprint()`),不一致即 401「访问令牌已变更,请重新登录」。因此**重启服务后所有旧会话立即失效**,前端 401 拦截器自动跳登录页
- **无 regenerate-token 接口**`system_config_dto.go` 仅有 `/login`、`/settings`、`/save-settings` 三个接口;换令牌 = 重启服务(新令牌自动打印到日志)。设置页**没有**令牌管理 UISettings.vue 只含分块默认值 + 模型配置)
- **无 regenerate-token 接口**`system_config_dto.go` 仅有 `/login`个接口;换令牌 = 重启服务(新令牌自动打印到日志)。设置页**没有**令牌管理 UI(Settings.vue 只含模型配置)
- **公开路径白名单**auth_middleware.go 精确匹配):`/system-config/login`、`GET /`、`GET /assets/*`(hash 路由下 SPA 只请求这两类静态路径,无鉴权绕过);`GET /workspace/*` 前缀放行(浏览器下载/预览请求不带 Authorization),仅做路径穿越防护(拒绝 `..`);其余路径一律校验
- 无注册、无角色、无用户表;登录日志不落库(如需审计可在日志文件输出);JWT 密钥为代码内固定常量(单机场景可接受,如需更换改 `common/auth.go` 的 `jwtSecret`
@@ -364,7 +363,7 @@ func Auth(r *ghttp.Request) {
### 9.3 API 封装
- `api/request.js`axios 实例(baseURL `/`、token 注入、401 跳转、错误 toast
- `api/xxx.js`:按后端模块组织,与 dto 对齐(dataset/document/chunk/chat/kg/contract/model_config/settings/auth
- `api/xxx.js`:按后端模块组织,与 dto 对齐(dataset/document/chunk/chat/kg/contract/model_config/auth
- 二进制下载(导出标注版 HTML)走原生 fetch + 手动 `Authorization` 头,绕过拦截器对 blob 的 JSON 误判
- 流式:`fetch` + `ReadableStream` 解析 SSE(按 `\n\n` 分块、解析 `data: ` 行 JSON,按字段识别:`citations`(引用 + conversation_id/ `content`(增量文本)/ `status==='ok'`(完成,或流读完兜底)/ `message`(错误);`data: [DONE]` 与 15 秒心跳注释行静默跳过)