This commit is contained in:
2026-08-05 16:42:12 +08:00
parent dc767d83f9
commit 139f6ccfd6
25 changed files with 647 additions and 104 deletions
+9
View File
@@ -0,0 +1,9 @@
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)
}
+38 -24
View File
@@ -12,8 +12,10 @@
<el-tag v-else size="small" type="info">未设置</el-tag>
</template>
</el-table-column>
<el-table-column label="分块" width="140">
<template #default="{ row }">{{ STRATEGY_NAMES[row.chunk_strategy || 'title'] }} {{ row.chunk_size || 800 }}{{ row.chunk_strategy === 'semantic' ? '' : ' / ' + (row.chunk_overlap ?? 150) }}</template>
<el-table-column label="分块" width="210">
<template #default="{ row }">
{{ row.chunk_size || 800 }} / {{ row.chunk_overlap ?? 150 }}<template v-if="row.unit_pattern">自动识别{{ humanizePattern(row.unit_pattern) }}</template>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="170" />
<el-table-column label="操作" width="190" fixed="right">
@@ -38,21 +40,13 @@
<el-option v-for="m in embedders" :key="m.id" :label="m.name" :value="m.id" />
</el-select>
</el-form-item>
<el-form-item label="分块策略">
<el-select v-model="form.chunk_strategy" style="width: 100%">
<el-option label="标题分块(推荐)" value="title" />
<el-option label="递归字符分块" value="recursive" />
<el-option label="语义分块" value="semantic" />
</el-select>
<div class="ds-tip">标题分块保留标题与段落结构递归字符分块按分隔符切分通用文本语义分块按语义相似度切分质量更高但解析更慢且需绑定向量模型</div>
</el-form-item>
<el-form-item :label="form.chunk_strategy === 'semantic' ? '上限大小' : '分块大小'">
<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">{{ form.chunk_strategy === 'semantic' ? '语义分块按句子相似度切分,此值为安全上限(max_chunk_size),超过上限的块会再切分;语义分块不支持重叠' : '每块最大字数,超长段落自动按句号/换行切分' }}</div>
<div class="ds-tip">每块最大字数系统自动组合分块策略优先识别文档结构条文/章节/编号等按结构切分无结构时依次按标题感知语义递归切分</div>
</el-form-item>
<el-form-item v-if="form.chunk_strategy !== 'semantic'" label="重叠字数">
<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>
<div class="ds-tip">相邻分块间的重叠字数用于保持上下文连贯语义切分路径自动忽略</div>
</el-form-item>
</el-form>
<template #footer>
@@ -68,21 +62,41 @@ 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'
const STRATEGY_NAMES = { title: '标题', recursive: '递归', semantic: '语义' }
// 把识别出的结构正则转成可读名称(第[一二三四五六七八九十百千]+条 → 条文)
function humanizePattern(p) {
if (!p) return ''
if (p.includes('条')) return '条文'
if (p.includes('回')) return '章回'
if (p.includes('节')) return '小节'
if (p.includes('章')) return '章节'
if (p.includes('篇') || p.includes('部')) return '篇章'
if (p.includes('') || p.includes('(')) return '序号'
if (/\d/.test(p)) return '编号'
return '结构单元'
}
const datasets = ref([])
const embedders = ref([])
const defaults = ref({ chunk_size: 800, chunk_overlap: 150 })
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, chunk_strategy: '' }
const form = ref({ id: 0, name: '', description: '', embedding_cfg_id: 0, chunk_size: 800, chunk_overlap: 150, chunk_strategy: 'title' })
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 })
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
}
} catch { /* 忽略 */ }
try {
const m = await listModelConfigs('embedding')
if (m && m.list) embedders.value = m.list
@@ -107,20 +121,20 @@ function embeddingName(id) {
function openCreate() {
editing.value = false
editingRowCfgId = 0
editingRowChunk = { chunk_size: 0, chunk_overlap: 0, chunk_strategy: '' }
// 默认选中默认向量模型,其次第一个
editingRowChunk = { chunk_size: 0, chunk_overlap: 0 }
// 默认选中默认向量模型,其次第一个;分块大小/重叠带出全局设置值
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: 800, chunk_overlap: 150, chunk_strategy: 'title' }
form.value = { id: 0, name: '', description: '', embedding_cfg_id: def?.id || 0, chunk_size: defaults.value.chunk_size, chunk_overlap: defaults.value.chunk_overlap }
dialogVisible.value = true
}
function openEdit(row) {
editing.value = true
editingRowCfgId = row.embedding_cfg_id
editingRowChunk = { chunk_size: row.chunk_size || 800, chunk_overlap: row.chunk_overlap ?? 150, chunk_strategy: row.chunk_strategy || 'title' }
editingRowChunk = { chunk_size: row.chunk_size || 800, chunk_overlap: row.chunk_overlap ?? 150 }
form.value = {
id: row.id, name: row.name, description: row.description, embedding_cfg_id: row.embedding_cfg_id,
chunk_size: editingRowChunk.chunk_size, chunk_overlap: editingRowChunk.chunk_overlap, chunk_strategy: editingRowChunk.chunk_strategy,
chunk_size: editingRowChunk.chunk_size, chunk_overlap: editingRowChunk.chunk_overlap,
}
dialogVisible.value = true
}
@@ -135,11 +149,11 @@ async function save() {
return
}
const modelChanged = editing.value && form.value.embedding_cfg_id !== editingRowCfgId
const chunkChanged = editing.value && (form.value.chunk_size !== editingRowChunk.chunk_size || form.value.chunk_overlap !== editingRowChunk.chunk_overlap || form.value.chunk_strategy !== editingRowChunk.chunk_strategy)
const chunkChanged = editing.value && (form.value.chunk_size !== editingRowChunk.chunk_size || form.value.chunk_overlap !== editingRowChunk.chunk_overlap)
if (modelChanged || chunkChanged) {
const changed = []
if (modelChanged) changed.push('向量模型')
if (chunkChanged) changed.push('分块策略')
if (chunkChanged) changed.push('分块配置')
try {
await ElMessageBox.confirm(`${changed.join('与')}已变更,保存后将重新处理该数据集下所有文档(异步进行,可在文档列表查看进度)。是否继续?`, '重新处理文档', { type: 'warning' })
} catch {
+162 -22
View File
@@ -4,33 +4,44 @@
<el-select v-model="datasetId" placeholder="选择知识库" style="width: 260px" @change="load">
<el-option v-for="d in datasets" :key="d.id" :label="d.name" :value="d.id" />
</el-select>
<span class="kg-tip">实体与关系由解析流水线中的 LLM 抽取生成问答时命中实体自动注入一跳邻居</span>
<span class="kg-tip">实体与关系由解析流水线中的 LLM 抽取生成问答时命中实体自动注入一跳邻居点击节点高亮其关联实体</span>
</div>
<el-row :gutter="12">
<el-col :span="10">
<el-col :span="15">
<el-card shadow="never" class="kg-card">
<template #header>实体{{ entityTotal }}</template>
<el-table :data="entities" size="small" v-loading="loading">
<el-table-column prop="name" label="实体" min-width="140" show-overflow-tooltip />
<el-table-column prop="entity_type" label="类型" width="110" />
<el-table-column prop="chunk_id" label="来源分块" width="100" />
</el-table>
<el-pagination class="kg-page-bar" small layout="prev, pager, next" :total="entityTotal"
:page-size="entityPageSize" v-model:current-page="entityPage" @current-change="loadEntities" />
<template #header>
<div class="kg-graph-head">
<span>关系图谱{{ graphCountText }}</span>
<el-tag v-if="truncated" size="small" type="warning">实体过多仅展示度数最高的前 {{ MAX_NODES }} </el-tag>
</div>
</template>
<div ref="graphEl" class="kg-graph" v-loading="loading"></div>
<el-empty v-if="!loading && graphNodes.length === 0" description="暂无图谱数据,上传文档并完成解析后自动生成" />
</el-card>
</el-col>
<el-col :span="14">
<el-col :span="9">
<el-card shadow="never" class="kg-card">
<template #header>关系{{ relationTotal }}</template>
<el-table :data="relations" size="small" v-loading="loading">
<el-table-column prop="head" label="主体" min-width="140" show-overflow-tooltip />
<el-table-column prop="relation" label="关系" min-width="100" show-overflow-tooltip />
<el-table-column prop="tail" label="客体" min-width="140" show-overflow-tooltip />
<el-table-column prop="chunk_id" label="来源分块" width="100" />
</el-table>
<el-pagination class="kg-page-bar" small layout="prev, pager, next" :total="relationTotal"
:page-size="relationPageSize" v-model:current-page="relationPage" @current-change="loadRelations" />
<el-tabs v-model="sideTab">
<el-tab-pane :label="`实体(${entityTotal}`" name="entity">
<el-table :data="entities" size="small" height="440" v-loading="loading">
<el-table-column prop="name" label="实体" min-width="120" show-overflow-tooltip />
<el-table-column prop="entity_type" label="类型" width="100" />
<el-table-column prop="chunk_id" label="来源分块" width="90" />
</el-table>
<el-pagination class="kg-page-bar" small layout="prev, pager, next" :total="entityTotal"
:page-size="entityPageSize" v-model:current-page="entityPage" @current-change="loadEntities" />
</el-tab-pane>
<el-tab-pane :label="`关系(${relationTotal}`" name="relation">
<el-table :data="relations" size="small" height="440" v-loading="loading">
<el-table-column prop="head" label="主体" min-width="110" show-overflow-tooltip />
<el-table-column prop="relation" label="关系" min-width="90" show-overflow-tooltip />
<el-table-column prop="tail" label="客体" min-width="110" show-overflow-tooltip />
</el-table>
<el-pagination class="kg-page-bar" small layout="prev, pager, next" :total="relationTotal"
:page-size="relationPageSize" v-model:current-page="relationPage" @current-change="loadRelations" />
</el-tab-pane>
</el-tabs>
</el-card>
</el-col>
</el-row>
@@ -38,13 +49,23 @@
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import * as echarts from 'echarts/core'
import { GraphChart } from 'echarts/charts'
import { TooltipComponent, LegendComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
import { listDatasets } from '../api/dataset.js'
import { listEntities, listRelations } from '../api/kg.js'
echarts.use([GraphChart, TooltipComponent, LegendComponent, CanvasRenderer])
const MAX_NODES = 400 // 力导向图节点上限,超过取度数最高者,避免布局卡顿
const datasets = ref([])
const datasetId = ref(null)
const loading = ref(false)
const graphEl = ref(null)
const truncated = ref(false)
const entities = ref([])
const entityTotal = ref(0)
@@ -56,6 +77,14 @@ const relationTotal = ref(0)
const relationPage = ref(1)
const relationPageSize = 20
const sideTab = ref('entity')
let chart = null
let resizeObserver = null
const graphCountText = computed(() => `${truncated.value ? '≈' : ''}${graphNodes.value.length} 实体 / ${graphLinks.value.length} 关系`)
const graphNodes = ref([])
const graphLinks = ref([])
onMounted(async () => {
try {
const d = await listDatasets()
@@ -65,10 +94,107 @@ onMounted(async () => {
await load()
})
onBeforeUnmount(() => {
resizeObserver?.disconnect()
chart?.dispose()
})
async function load() {
entityPage.value = 1
relationPage.value = 1
await Promise.all([loadEntities(), loadRelations()])
loading.value = true
try {
const [allEntities, allRelations] = await Promise.all([
listEntities({ dataset_id: datasetId.value, page: 1, page_size: 100000 }),
listRelations({ dataset_id: datasetId.value, page: 1, page_size: 100000 }),
])
buildGraph(allEntities?.list || [], allRelations?.list || [])
await Promise.all([loadEntities(), loadRelations()])
} finally {
loading.value = false
}
}
// 实体按名字去重合并(同一实体可能出现在多个分块),按出入度决定节点大小
function buildGraph(entitiesList, relationsList) {
const byName = new Map()
for (const e of entitiesList) {
const prev = byName.get(e.name)
if (prev) {
prev.chunks = [...new Set([...prev.chunks, e.chunk_id])]
} else {
byName.set(e.name, { name: e.name, entity_type: e.entity_type, chunks: [e.chunk_id] })
}
}
const degree = new Map()
const edges = []
const seenEdges = new Set()
for (const r of relationsList) {
if (!byName.has(r.head) || !byName.has(r.tail)) continue
const key = `${r.head}|${r.relation}|${r.tail}`
if (seenEdges.has(key)) continue
seenEdges.add(key)
edges.push({ source: r.head, target: r.tail, relation: r.relation })
degree.set(r.head, (degree.get(r.head) || 0) + 1)
degree.set(r.tail, (degree.get(r.tail) || 0) + 1)
}
let nodes = [...byName.values()]
truncated.value = nodes.length > MAX_NODES
if (truncated.value) {
nodes.sort((a, b) => (degree.get(b.name) || 0) - (degree.get(a.name) || 0))
const keep = new Set(nodes.slice(0, MAX_NODES).map(n => n.name))
nodes = nodes.filter(n => keep.has(n.name))
for (const e of [...edges]) {
if (!keep.has(e.source) || !keep.has(e.target)) edges.splice(edges.indexOf(e), 1)
}
}
const minD = Math.min(...nodes.map(n => degree.get(n.name) || 0))
const maxD = Math.max(...nodes.map(n => degree.get(n.name) || 0))
const sizeFor = d => (maxD === minD ? 30 : 20 + ((d - minD) / (maxD - minD)) * 36)
graphNodes.value = nodes.map(n => ({
id: n.name,
name: n.name,
category: n.entity_type || '未分类',
symbolSize: sizeFor(degree.get(n.name) || 0),
}))
graphLinks.value = edges
renderGraph()
}
function renderGraph() {
if (!graphEl.value) return
chart ??= echarts.init(graphEl.value)
const types = [...new Set(graphNodes.value.map(n => n.category))]
chart.setOption({
legend: { bottom: 0, type: 'scroll', data: types, textStyle: { fontSize: 11 } },
tooltip: {
trigger: 'item',
formatter: p => {
if (p.dataType === 'edge') {
const e = graphLinks.value[p.dataIndex]
return `${e.source}${e.target}<br/>关系:${e.relation}`
}
return `${p.name}<br/>类型:${p.data.category}`
},
},
series: [{
type: 'graph',
layout: 'force',
roam: true,
draggable: true,
data: graphNodes.value,
links: graphLinks.value,
categories: types.map(t => ({ name: t })),
force: { repulsion: 260, edgeLength: [40, 110], gravity: 0.08 },
label: { show: true, position: 'right', fontSize: 10, color: '#606266' },
edgeLabel: { show: false },
emphasis: {
focus: 'adjacency', // 点击节点自动高亮一跳邻居并淡化其余
label: { fontSize: 12, fontWeight: 600 },
lineStyle: { width: 2 },
},
}],
}, true)
}
async function loadEntities() {
@@ -94,6 +220,12 @@ async function loadRelations() {
loading.value = false
}
}
onMounted(() => {
// 布局稳定后渲染一次(图表容器尺寸依赖 el-row 布局),并监听容器尺寸变化
resizeObserver = new ResizeObserver(() => nextTick(renderGraph))
resizeObserver.observe(graphEl.value)
})
</script>
<style scoped>
@@ -107,6 +239,14 @@ async function loadRelations() {
font-size: 13px;
color: #909399;
}
.kg-graph {
height: 620px;
}
.kg-graph-head {
display: flex;
align-items: center;
gap: 10px;
}
.kg-page-bar {
margin-top: 10px;
justify-content: flex-end;
+61
View File
@@ -1,5 +1,17 @@
<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" />
<el-button type="primary" :loading="chunkSaving" @click="saveChunkDefaults">保存</el-button>
</div>
</div>
<div class="section-head">
<el-radio-group v-model="modelType" @change="loadModels">
<el-radio-button value="chat">对话模型</el-radio-button>
@@ -65,8 +77,32 @@
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 })
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
}
} 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)
@@ -78,6 +114,7 @@ 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 { /* 忽略 */ }
})
@@ -180,4 +217,28 @@ 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>