浏览代码

知识检索节点

CzRger 2 天之前
父节点
当前提交
174b3c8984

二进制
src/assets/images/workflow/knowledge.png


+ 294 - 0
src/views/manage/knowledge/recall-config.vue

@@ -0,0 +1,294 @@
+<template>
+  <CzrDialog
+    :show="show"
+    title="召回设置"
+    @onClose="$emit('update:show', false)"
+    @onSubmit="onSubmit"
+    width="62.5rem"
+    height="auto"
+  >
+    <div class="bm-form">
+      <CzrForm ref="ref_form" label-width="6.1rem">
+        <div
+          class="rounded-2.5 search-method-active w-full bg-[#ffffff]"
+          style="border: var(--czr-border); border-width: 0.13rem"
+        >
+          <div class="h-full w-full px-[1.5rem] py-4">
+            <div class="flex items-center">
+              <img
+                src="@/assets/images/model/model-icon-6.png"
+                class="mr-2.5 h-[3.25rem] w-[3.25rem]"
+              />
+              <div class="text-xl font-bold text-[#2E3238]">召回设置</div>
+            </div>
+            <div
+              class="mt-2 text-sm text-[#606266]"
+              style="line-height: 1.3rem"
+            >
+              默认情况下使用多路召回。从多个知识库中检索知识,然后重新排序。
+            </div>
+            <div class="mt-4 flex w-full gap-4">
+              <div
+                class="__hover rounded-2.5 h-[8.38rem] flex-1 bg-[#ffffff] px-[1.5rem] py-4"
+                style="border: var(--czr-border); border-width: 0.13rem"
+                :class="{
+                  'index-method-active': state.config.indexMethod === 'weight',
+                }"
+                @click="() => (state.config.indexMethod = 'weight')"
+              >
+                <div class="flex items-center">
+                  <img
+                    src="@/assets/images/model/model-icon-8.png"
+                    class="mr-2.5 h-[3.25rem] w-[3.25rem]"
+                  />
+                  <div class="text-xl font-bold text-[#2E3238]">权重设置</div>
+                  <img
+                    src="@/assets/images/model/model-icon-3.png"
+                    class="h-[1.27rem] w-[2.88rem]"
+                  />
+                </div>
+                <div
+                  class="mt-2 text-sm text-[#606266]"
+                  style="line-height: 1.3rem"
+                >
+                  通过调整分配的权重,重新排序策略确定是优先进行语义匹配还是关键字匹配。
+                </div>
+              </div>
+              <div
+                class="__hover rounded-2.5 h-[8.38rem] flex-1 bg-[#ffffff] px-[1.5rem] py-4"
+                style="border: var(--czr-border); border-width: 0.13rem"
+                :class="{
+                  'index-method-active': state.config.indexMethod === 'rerank',
+                }"
+                @click="() => (state.config.indexMethod = 'rerank')"
+              >
+                <div class="flex items-center">
+                  <img
+                    src="@/assets/images/model/model-icon-9.png"
+                    class="mr-2.5 h-[3.25rem] w-[3.25rem]"
+                  />
+                  <div class="text-xl font-bold text-[#2E3238]">
+                    Rerank 模型
+                  </div>
+                </div>
+                <div
+                  class="mt-2 text-sm text-[#606266]"
+                  style="line-height: 1.3rem"
+                >
+                  重排序模型将根据候选文档列表与用户问题语义匹配度进行重新排序,从而改进语义排序的结果。
+                </div>
+              </div>
+            </div>
+            <div
+              class="text-4 mt-4 flex flex-col gap-2 rounded-[0.25rem] bg-[#ffffff] p-2.5 font-bold text-[#2E3238]"
+            >
+              <template v-if="state.config.indexMethod === 'weight'">
+                <div>
+                  <el-slider
+                    v-model="state.config.weight"
+                    :min="0"
+                    :max="1"
+                    :step="0.1"
+                    class="weight-slider"
+                  />
+                  <div class="flex justify-between">
+                    <div class="text-[var(--czr-main-color)]">
+                      语义
+                      {{ state.config.weight }}
+                    </div>
+                    <div class="text-[var(--czr-success-color)]">
+                      {{ (1 - state.config.weight).toFixed(1) }}
+                      关键词
+                    </div>
+                  </div>
+                </div>
+              </template>
+              <template v-else-if="state.config.indexMethod === 'rerank'">
+                <CzrFormColumn
+                  required
+                  class="__czr-table-form-column"
+                  :span="24"
+                  label-width="0px"
+                  v-model:param="state.config.rerank"
+                  link="select"
+                  :options="state.optionsRerank"
+                  :clearable="false"
+                  default-error-msg="请选择Rerank模型"
+                />
+              </template>
+            </div>
+            <div
+              class="text-4 mt-4 flex flex-col gap-2 rounded-[0.25rem] bg-[#ffffff] p-2.5 font-bold text-[#2E3238]"
+            >
+              <div class="flex gap-4">
+                <div class="flex-1">
+                  <div class="flex h-4 items-center gap-2">
+                    TOP K
+                    <el-tooltip
+                      content="用于筛选与用户问题相似度最高的文本片段。系统同时会根据选用模型上下文窗口大小动态调整分段数量。"
+                      placement="top"
+                    >
+                      <SvgIcon name="czr_tip" size="14" />
+                    </el-tooltip>
+                  </div>
+                  <div class="mt-4">
+                    <el-slider
+                      v-model="state.config.topK"
+                      show-input
+                      class="re-slider"
+                      :min="1"
+                      :max="10"
+                    />
+                  </div>
+                </div>
+                <div class="flex-1">
+                  <div class="flex h-4 items-center gap-2">
+                    <el-switch
+                      size="small"
+                      v-model="state.config.isScore"
+                      :active-value="1"
+                      :inactive-value="0"
+                    />
+                    Score 阈值
+                    <el-tooltip
+                      content="用于设置文本片段筛选的相似度阈值。"
+                      placement="top"
+                    >
+                      <SvgIcon name="czr_tip" size="14" />
+                    </el-tooltip>
+                  </div>
+                  <div class="mt-4">
+                    <el-slider
+                      v-model="state.config.score"
+                      show-input
+                      class="re-slider"
+                      :max="1"
+                      :min="0"
+                      :step="0.01"
+                      :disabled="!state.config.isScore"
+                    />
+                  </div>
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+      </CzrForm>
+    </div>
+  </CzrDialog>
+</template>
+
+<script setup lang="ts">
+import {
+  computed,
+  getCurrentInstance,
+  nextTick,
+  onMounted,
+  reactive,
+  ref,
+  watch,
+} from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { useDialogStore, useDictionaryStore } from '@/stores'
+import { pluginGetListByType } from '@/api/modules/model'
+
+const DictionaryStore = useDictionaryStore()
+const DialogStore = useDialogStore()
+const emit = defineEmits(['update:show', 'refresh'])
+const { proxy } = getCurrentInstance()
+const props = defineProps({
+  show: { default: false },
+  transfer: <any>{},
+})
+const state: any = reactive({
+  config: {
+    indexMethod: 'weight',
+    weight: 0.7,
+    rerank: '',
+    topK: 5,
+    isScore: 1,
+    score: 0.5,
+  },
+  optionsRerank: [],
+})
+const ref_form = ref()
+watch(
+  () => props.show,
+  (n) => {
+    if (n) {
+      reset()
+      if (props.transfer.config) {
+        state.config = JSON.parse(JSON.stringify(props.transfer.config))
+      }
+      nextTick(() => {
+        ref_form.value.reset()
+      })
+    }
+  },
+)
+const reset = () => {
+  state.config = {
+    indexMethod: 'weight',
+    weight: 0.7,
+    rerank: '',
+    topK: 5,
+    isScore: 1,
+    score: 0.5,
+  }
+}
+const onSubmit = () => {
+  ref_form.value
+    .submit()
+    .then(() => {
+      emit('refresh', state.config)
+      emit('update:show', false)
+    })
+    .catch((e) => {
+      ElMessage({
+        message: e[0].message,
+        grouping: true,
+        type: 'warning',
+      })
+    })
+}
+onMounted(() => {
+  initDictionary()
+})
+const initDictionary = () => {
+  pluginGetListByType({ type: 'RERANK' })
+    .then(({ data }: any) => {
+      state.optionsRerank = data.map((v) => {
+        v.label = v.name
+        v.value = v.id
+        return v
+      })
+    })
+    .catch(() => {})
+}
+</script>
+
+<style lang="scss" scoped>
+.index-method-active {
+  box-shadow: 0rem 0.25rem 0.63rem 0rem rgba(40, 83, 247, 0.05);
+  border-color: var(--czr-main-color) !important;
+  background-color: rgba(var(--czr-main-color-rgb), 0.1);
+}
+.search-method-active {
+  box-shadow: 0rem 0.25rem 0.63rem 0rem rgba(40, 83, 247, 0.05);
+  background-image: url('@/assets/images/model/model-icon-7.png');
+  background-size: 100% 100%;
+  background-repeat: no-repeat;
+}
+:deep(.re-slider) {
+  flex-direction: row-reverse;
+  .el-slider__runway {
+    margin-left: 10px;
+    margin-right: 0;
+  }
+}
+:deep(.weight-slider) {
+  .el-slider__runway {
+    background-color: var(--czr-success-color);
+  }
+}
+</style>

+ 29 - 3
src/views/workflow/config.ts

@@ -12,12 +12,15 @@ import answerNodeDefault from '@/views/workflow/instance/answer/default'
 import answerImg from '@/assets/images/workflow/answer.png'
 import ifElseNodeDefault from '@/views/workflow/instance/if-else/default'
 // @ts-ignore
-import ifElseImg from '@/assets/images/workflow/if-else.png'
-import llmNodeDefault from '@/views/workflow/instance/llm/default'
-// @ts-ignore
 import llmImg from '@/assets/images/workflow/llm.svg'
 import testNodeDefault from '@/views/workflow/instance/test/default'
 // @ts-ignore
+import knowledgeImg from '@/assets/images/workflow/knowledge.png'
+import knowledgeNodeDefault from '@/views/workflow/instance/knowledge-retrieval/default'
+// @ts-ignore
+import ifElseImg from '@/assets/images/workflow/if-else.png'
+import llmNodeDefault from '@/views/workflow/instance/llm/default'
+// @ts-ignore
 import testImg from '@/assets/images/workflow/test.png'
 import { defineAsyncComponent } from 'vue'
 
@@ -95,6 +98,29 @@ export const nodeSources = {
       () => import('@/views/workflow/instance/llm/panel/index.vue'),
     ),
   },
+  [NodeType.Knowledge]: {
+    defaultValue: () =>
+      <NodeStruct>{
+        x: 0,
+        y: 0,
+        data: <NodeDataStruct>{
+          id: v4(),
+          title: NodeTypeObj[NodeType.Knowledge].title,
+          desc: '',
+          ...knowledgeNodeDefault.defaultValue(),
+        },
+      },
+    isAdd: true,
+    icon: knowledgeImg,
+    nodeCom: defineAsyncComponent(
+      () =>
+        import('@/views/workflow/instance/knowledge-retrieval/node/index.vue'),
+    ),
+    panelCom: defineAsyncComponent(
+      () =>
+        import('@/views/workflow/instance/knowledge-retrieval/panel/index.vue'),
+    ),
+  },
   [NodeType.IfElse]: {
     defaultValue: () =>
       <NodeStruct>{

+ 30 - 8
src/views/workflow/handle.ts

@@ -233,19 +233,41 @@ export const handleNodeSubmit = (no) => {
       break
     case NodeType.LLM:
       {
-        // if (no.outVars) {
-        //   no.__outVars = no.outVars
-        //   delete no.outVars
-        // }
-        // if (no.isMemory) {
-        //   no.__isMemory = no.isMemory
-        //   delete no.memorisMemoryy
-        // }
         if (!no.__isMemory) {
           delete no.memory
         }
       }
       break
+    case NodeType.Knowledge:
+      {
+        no.multiple_retrieval_config = {
+          reranking_enable: false,
+          reranking_mode:
+            no.__recallConfig.indexMethod === 'weight'
+              ? 'weighted_score'
+              : 'reranking_model',
+          top_k: no.__recallConfig.topK,
+          score_threshold:
+            no.__recallConfig.isScore == 1 ? no.__recallConfig.score : null,
+        }
+        if (no.multiple_retrieval_config.reranking_mode === 'weighted_score') {
+          no.multiple_retrieval_config.weights = {
+            vector_setting: {
+              vector_weight: no.__recallConfig.weight,
+              // embedding_provider_name: 'langgenius/ollama/ollama',
+              // embedding_model_name: 'bge-m3:567m',
+            },
+            keyword_setting: {
+              keyword_weight: 1 - no.__recallConfig.weight,
+            },
+          }
+        } else {
+          no.multiple_retrieval_config.reranking_model = {
+            pluginInstanceId: no.__recallConfig.rerank,
+          }
+        }
+      }
+      break
   }
   return no
 }

+ 62 - 0
src/views/workflow/instance/knowledge-retrieval/default.ts

@@ -0,0 +1,62 @@
+const nodeDefault = {
+  defaultValue: () => ({
+    __queryVars: null,
+    query_variable_selector: [], // handle字段
+    dataset_ids: [],
+    retrieval_mode: 'multiple',
+    __recallConfig: {
+      indexMethod: 'weight',
+      weight: 0.7,
+      rerank: '',
+      topK: 4,
+      isScore: 0,
+      score: 0.5,
+    },
+    multiple_retrieval_config: {},
+    __outVars: [{ label: '生成内容', key: 'text', type: 'String' }],
+  }),
+}
+
+export default nodeDefault
+
+// const a = {
+//   "top_k": 6,
+//   "score_threshold": 0.57,
+//   "reranking_mode": "weighted_score",
+//   "reranking_model": {
+//     "provider": "langgenius/siliconflow/siliconflow",
+//     "model": "BAAI/bge-reranker-v2-m3"
+//   },
+//   "weights": {
+//     "vector_setting": {
+//       "vector_weight": 0.9,
+//       "embedding_provider_name": "langgenius/ollama/ollama",
+//       "embedding_model_name": "bge-m3:567m"
+//     },
+//     "keyword_setting": {
+//       "keyword_weight": 0.1
+//     }
+//   },
+//   "reranking_enable": false
+// }
+// 不选score
+// const a = {
+//   "top_k": 6,
+//   "score_threshold": null,
+//   "reranking_mode": "weighted_score",
+//   "reranking_model": {
+//     "provider": "langgenius/siliconflow/siliconflow",
+//     "model": "BAAI/bge-reranker-v2-m3"
+//   },
+//   "weights": {
+//     "vector_setting": {
+//       "vector_weight": 0.9,
+//       "embedding_provider_name": "langgenius/ollama/ollama",
+//       "embedding_model_name": "bge-m3:567m"
+//     },
+//     "keyword_setting": {
+//       "keyword_weight": 0.1
+//     }
+//   },
+//   "reranking_enable": false
+// }

+ 51 - 0
src/views/workflow/instance/knowledge-retrieval/node/index.vue

@@ -0,0 +1,51 @@
+<template>
+  <div class="">
+    <template v-for="id in state.nodeData.dataset_ids">
+      <!--      <div class="_n_content">-->
+      <!--        {{ item }}-->
+      <!--      </div>-->
+      <div
+        class="mb-2 flex flex-1 items-center overflow-hidden rounded-sm bg-[#F6F8FC] p-1"
+      >
+        <img
+          src="@/assets/images/knowledge/knowledge-item-icon.png"
+          class="mr-2 size-4"
+        />
+        <div class="flex-1 text-xs font-bold text-[#2E3238]" v-title>
+          {{ DictionaryStore.knowledges.map.get(id) }}
+        </div>
+      </div>
+    </template>
+  </div>
+</template>
+
+<script setup lang="ts">
+import {
+  computed,
+  getCurrentInstance,
+  inject,
+  onMounted,
+  reactive,
+  ref,
+} from 'vue'
+import { useAppStore, useDictionaryStore } from '@/stores'
+
+const DictionaryStore = useDictionaryStore()
+const AppStore = useAppStore()
+const emit = defineEmits([])
+const props = defineProps({
+  node: <any>{},
+})
+const { proxy }: any = getCurrentInstance()
+const state: any = reactive({
+  nodeData: {},
+})
+onMounted(() => {
+  state.nodeData = props.node.data.workflowData
+  DictionaryStore.initKnowledges(AppStore.tenantInfo?.id)
+})
+</script>
+
+<style lang="scss" scoped>
+@use '@/views/workflow/instance/component/style';
+</style>

+ 148 - 0
src/views/workflow/instance/knowledge-retrieval/panel/index.vue

@@ -0,0 +1,148 @@
+<template>
+  <div class="panel-block" v-if="state.nodeData">
+    <div class="_p-title">
+      <div class="text-sm">查询变量</div>
+    </div>
+    <varsSelect
+      :node="props.node"
+      v-model:vars="state.nodeData.__queryVars"
+      @setVars="setVars"
+    />
+    <div class="_p-title">
+      <div class="text-sm">知识库({{ state.nodeData.dataset_ids.length }})</div>
+      <a-button
+        type="link"
+        block
+        style="margin-left: auto; width: fit-content"
+        @click="onRecallConfig"
+      >
+        召回设置
+      </a-button>
+      <SvgIcon
+        name="czr_add"
+        size="14"
+        class="__hover"
+        @click="onAddKnowledge"
+      />
+    </div>
+    <template v-if="state.nodeData.dataset_ids.length > 0">
+      <div class="mt-2 flex max-h-42 flex-col gap-2">
+        <template v-for="(id, index) in state.nodeData.dataset_ids">
+          <div class="flex items-center gap-1.5">
+            <div
+              class="flex flex-1 items-center overflow-hidden rounded-sm bg-[#F6F8FC] px-2 py-1"
+            >
+              <img
+                src="@/assets/images/knowledge/knowledge-item-icon.png"
+                class="mr-4 size-6"
+              />
+              <div class="flex-1 text-sm font-bold text-[#2E3238]" v-title>
+                {{ DictionaryStore.knowledges.map.get(id) }}
+              </div>
+            </div>
+            <el-tooltip content="删除" placement="top">
+              <SvgIcon
+                class="__hover"
+                name="czr_del"
+                color="var(--czr-error-color)"
+                @click="state.nodeData.dataset_ids.splice(index, 1)"
+              />
+            </el-tooltip>
+          </div>
+        </template>
+      </div>
+    </template>
+    <template v-else>
+      <div
+        class="mt-2 flex h-8 items-center justify-center rounded-sm bg-[#F6F8FC] text-xs text-[#A7ADB9]"
+      >
+        暂未添加知识库
+      </div>
+    </template>
+    <div class="_p-title">
+      <div class="text-sm">输出变量</div>
+    </div>
+    <varsOut :outVars="state.nodeData.__outVars" />
+    <knowledgeSelect
+      v-model:show="state.knowledgeSelect.show"
+      :transfer="state.knowledgeSelect.transfer"
+      @refresh="getKnowledge"
+    />
+    <recallConfig
+      v-model:show="state.recallConfig.show"
+      :transfer="state.recallConfig.transfer"
+      @refresh="getRecallConfig"
+    />
+  </div>
+</template>
+
+<script setup lang="ts">
+import { getCurrentInstance, reactive, ref, watch } from 'vue'
+import varsOut from '@/views/workflow/instance/component/vars/vars-out.vue'
+import SvgIcon from '@/components/SvgIcon/index.vue'
+import varsSelect from '@/views/workflow/instance/component/vars/vars-select.vue'
+import { useAppStore, useDictionaryStore } from '@/stores'
+import knowledgeSelect from '@/views/manage/app/make/knowledge-select.vue'
+import recallConfig from '@/views/manage/knowledge/recall-config.vue'
+
+const DictionaryStore = useDictionaryStore()
+const AppStore = useAppStore()
+const emit = defineEmits([])
+const props = defineProps({
+  node: <any>{},
+})
+const { proxy }: any = getCurrentInstance()
+const state: any = reactive({
+  nodeData: null,
+  knowledgeSelect: {
+    show: false,
+    transfer: {},
+  },
+  recallConfig: {
+    show: false,
+    transfer: {},
+  },
+})
+const initDictionary = () => {
+  DictionaryStore.initKnowledges(AppStore.tenantInfo?.id)
+}
+watch(
+  () => props.node,
+  (n) => {
+    if (n) {
+      state.nodeData = n.data.workflowData
+      initDictionary()
+    }
+  },
+  { immediate: true },
+)
+const setVars = (val) => {
+  if (val) {
+    state.nodeData.query_variable_selector = [val.nodeId, val.key]
+  } else {
+    state.nodeData.query_variable_selector = []
+  }
+}
+const onAddKnowledge = () => {
+  state.knowledgeSelect.transfer = {
+    ids: state.nodeData.dataset_ids,
+  }
+  state.knowledgeSelect.show = true
+}
+const getKnowledge = (arr) => {
+  state.nodeData.dataset_ids.push(...arr.map((v) => v.id))
+}
+const onRecallConfig = () => {
+  state.recallConfig.transfer = {
+    config: state.nodeData.__recallConfig,
+  }
+  state.recallConfig.show = true
+}
+const getRecallConfig = (config) => {
+  state.nodeData.__recallConfig = config
+}
+</script>
+
+<style lang="scss" scoped>
+@use '@/views/workflow/instance/component/style';
+</style>

+ 2 - 6
src/views/workflow/instance/llm/panel/index.vue

@@ -21,7 +21,7 @@
     <varsSelect
       :node="props.node"
       v-model:vars="state.nodeData.__contextVars"
-      @setVars="setContent"
+      @setVars="setVars"
     />
     <template v-for="(item, index) in state.nodeData.chatModelMessages">
       <paramsTextarea
@@ -158,10 +158,6 @@ const props = defineProps({
 const { proxy }: any = getCurrentInstance()
 const state: any = reactive({
   nodeData: null,
-  vars: {
-    show: false,
-    transfer: {},
-  },
   llmModelOptions: [],
 })
 const initDictionary = () => {
@@ -184,7 +180,7 @@ watch(
   },
   { immediate: true },
 )
-const setContent = (val) => {
+const setVars = (val) => {
   if (val) {
     state.nodeData.context = {
       enabled: true,

+ 7 - 1
src/views/workflow/types.ts

@@ -6,10 +6,11 @@ export type NodeStruct = {
 }
 
 export enum NodeType {
+  Start = 'start',
   LLM = 'llm',
   Answer = 'answer',
+  Knowledge = 'knowledge-retrieval',
   Test = 'test',
-  Start = 'start',
   IfElse = 'if-else',
 }
 
@@ -26,6 +27,10 @@ export const NodeTypeObj = {
     type: NodeType.Answer,
     title: '直接回复',
   },
+  [NodeType.Knowledge]: {
+    type: NodeType.Knowledge,
+    title: '知识检索',
+  },
   [NodeType.Test]: {
     type: NodeType.Test,
     title: '测试节点',
@@ -44,6 +49,7 @@ export type NodeDataStruct = {
     | NodeType.Start
     | NodeType.Answer
     | NodeType.LLM
+    | NodeType.Knowledge
     | NodeType.Test
     | NodeType.IfElse
   ports?: NodePortStruct[]