通过大模型实现网页端思维导图展示相关性

227 阅读5分钟

前言

最近的一个科研项目,有一个非常离谱的需求,领导希望用户的问题,即使没有关键词,但意思相关,仍然搜索到相关的数据,并生成思维导图。

代码

  1. 需要引入两个依赖
import 'jsmind/style/jsmind.css';
import jsMind from 'jsmind';
  1. 通过prompt控制大模型对用户语义分析关键词,并控制输出格式 这一步需要大模型有足够的格式控制能力,否则需要手动解析大模型返回的json数据
const keywordPrompt = `${keyword.value},请根据上述问题继续语义分析,提取出多个关键词进行数据检索,只回复我数组code即可,例如:["伺服驱动器","伺服电机"]`
    const keywordResponse = await fetch("http://127.0.0.1:11434/api/generate", {
      method: 'POST',
      headers: {
        "Content-Type": "application/json",
        "Accept": "*/*", 
        "Host": "127.0.0.1:11434",
        "Connection": "keep-alive"
      },
      body: JSON.stringify({
        "model": "qwen2.5:14b",
        "prompt": keywordPrompt,
        "stream": false
      })
    });
  1. 将上一步分析的关键词数组用于搜索数据库

 for (let i = 0; i < keywords.length; i++) {
      const searchResponse = await axios.get(`/search?keyword=${keywords[i]}`);
      console.log(searchResponse.data)
      searchResults[keywords[i]] = searchResponse.data || [];
    }
  1. 将数据库返回的数据转换为jsmind接收的数据格式,并对应上一步分析的关键词-》多条数据库返回的数据
const mind = {
  meta: {
    name: '分析结果',
    author: 'system',
    version: '1.0'
  },
  format: 'node_tree',
  data: {
    id: 'root',
    topic: keyword.value,  // 根节点显示用户输入的关键词
    children: []  // 子节点数组
  }
}

最终效果演示

12月14日.gif

最后

这个实现思路或许比较粗糙,但确实也“实现”了甲方需求, 其实我也尝试了其他方法,比如:向量数据库检索和一些开源的本地知识库,但向量检索需要大量的数据,并且需要训练,而本地知识库的效果并不好,而大模型只需要prompt即可,所以最后还是选择了大模型。

但依然有一些不足的地方,尽管qwen2.5-14b的模型的上下文已经很大,但如果系统的数据量日渐增多,也终有一天会达到上下文限制。

如果有更好的解决方案,欢迎交流。

附上完整的代码

<template>
  <div class="ma-content-block lg:flex justify-between p-4">
    <div class="background-container ma-content-block lg:flex justify-between p-4">
      <div class="image-container">
        <div class="loader-container">
          <div class="loader">
            <div class="cell d-0"></div>
            <div class="cell d-1"></div>
            <div class="cell d-2"></div>
            <div class="cell d-1"></div>
            <div class="cell d-2"></div>
            <div class="cell d-2"></div>
            <div class="cell d-3"></div>
            <div class="cell d-3"></div>
            <div class="cell d-4"></div>
          </div>
        </div>
        <h1 class="text-center text-2xl font-bold text-gray-800 mt-4 p-ttext" style="font-size: 35px;">
          <span ref="typewriterText"></span>
        </h1>
        <p class="p-ttext-p text-center text-lg font-bold text-gray-800 mt-4 " style="font-size: 15px; color: #666;">
          运用先进的自然语言处理技术和深度学习模型,对设备问题进行精准的语义分析和多维度理解,实现高效的维修记录检索和智能关联,展示出思维导图,为您提供全面而深入的问题诊断和解决方案。
        </p>
      </div>
      <div class="input-container">
        <a-input v-model="keyword" type="text" placeholder="请输入关键词,例如:伺服驱动器出现问题" class="custom-input" />
      </div>
      <div class="mt-4">
        <a-button type="primary" class="submit-button" @click="handleSearch">分析</a-button>
      </div>
    </div>
    <a-modal v-model:visible="showResult" title="分析结果" :footer="null" width="80%">
      <div class="result-container">
        <div class="model-content">
          <div v-if="isLoading" class="loading-indicator">
            <a-spin :size="36" />
            <p style="z-index: 15;">模型推理中...</p>
            <div class="spinner">
              <div></div>
              <div></div>
              <div></div>
              <div></div>
              <div></div>
              <div></div>
            </div>
          </div>
          <div v-else>
            <div id="jsmind_container" style="width: 100%; height: 76vh;"></div>
          </div>
        </div>
      </div>
    </a-modal>
  </div>
</template>
<script setup>
import { ref, reactive, onMounted, watch, computed } from 'vue'
import axios from 'axios'
import 'jsmind/style/jsmind.css';
import jsMind from 'jsmind';
import { Message } from '@arco-design/web-vue';

const typewriterText = ref(null)
const texts = ['数据检索', '智能分析', '图形化展示']
let currentTextIndex = 0
let currentCharIndex = 0

const keyword = ref('')
const showResult = ref(false)
const isLoading = ref(false)
let jm = null

const typeText = () => {
  if (!typewriterText.value) return
  
  if (currentCharIndex < texts[currentTextIndex].length) {
    typewriterText.value.textContent += texts[currentTextIndex][currentCharIndex]
    currentCharIndex++
    setTimeout(typeText, 100)
  } else {
    setTimeout(eraseText, 2000)
  }
}

const eraseText = () => {
  if (!typewriterText.value) return
  
  if (currentCharIndex > 0) {
    typewriterText.value.textContent = texts[currentTextIndex].substring(0, currentCharIndex - 1)
    currentCharIndex--
    setTimeout(eraseText, 50)
  } else {
    currentTextIndex = (currentTextIndex + 1) % texts.length
    setTimeout(typeText, 500)
  }
}

const initJsMind = (keywords, results) => {
  const mind = {
    meta: {
      name: '分析结果',
      author: 'system',
      version: '1.0'
    },
    format: 'node_tree',
    data: {
      id: 'root',
      topic: keyword.value,
      children: []
    }
  }

  keywords.forEach((kw, index) => {
    const keywordNode = {
      id: `keyword_${index}`,
      topic: kw,
      children: []
    }

    results[kw]?.forEach((result, rIndex) => {
      const problemNode = {
        id: `result_${index}_${rIndex}`,
        topic: result.problem_description || '相关记录',
        children: [{
          id: `solution_${index}_${rIndex}`,
          topic: `解决方法:${result.solution || '暂无解决方案'}`
        }]
      }
      keywordNode.children.push(problemNode)
    })

    mind.data.children.push(keywordNode)
  })

  const options = {
    container: 'jsmind_container',
    theme: 'primary',
    editable: false,
    mode: 'full',
    view: {
      hmargin: 100,
      vmargin: 50,
      line_width: 2,
      line_color: '#555'
    }
  }

  // 清除旧的思维导图
  const container = document.getElementById('jsmind_container')
  if (container) {
    container.innerHTML = ''
  }
  
  // 重新初始化
  setTimeout(() => {
    jm = new jsMind(options)
    jm.show(mind)
  }, 100)
}

const handleSearch = async () => {
  try {
    if (!keyword.value) {
      Message.error('请输入需要分析的问题');
      return;
    }

    showResult.value = true;
    isLoading.value = true;

    const keywordPrompt = `${keyword.value},请根据上述问题继续语义分析,提取出多个关键词进行数据检索,只回复我数组code即可,例如:["伺服驱动器","伺服电机"]`
    const keywordResponse = await fetch("http://127.0.0.1:11434/api/generate", {
      method: 'POST',
      headers: {
        "Content-Type": "application/json",
        "Accept": "*/*", 
        "Host": "127.0.0.1:11434",
        "Connection": "keep-alive"
      },
      body: JSON.stringify({
        "model": "qwen2.5:14b",
        "prompt": keywordPrompt,
        "stream": false
      })
    });
    const keywordResult = await keywordResponse.json();
    
    let keywords;
    try {
      keywords = JSON.parse(keywordResult.response);
      if (!Array.isArray(keywords)) {
        throw new Error('模型返回格式错误');
      }
    } catch (parseError) {
      throw new Error('模型返回的数据格式有误,请重新输入更具体的问题描述');
    }

    const searchResults = {}
    for (let i = 0; i < keywords.length; i++) {
      const searchResponse = await axios.get(`/search?keyword=${keywords[i]}`);
      console.log(searchResponse.data)
      searchResults[keywords[i]] = searchResponse.data || [];
    }

    isLoading.value = false;
    initJsMind(keywords, searchResults);

  } catch (error) {
    console.error('分析出错:', error);
    isLoading.value = false;
    showResult.value = false;
    Message.error({
      content: error.message || '分析过程出现错误,请稍后重试',
      duration: 3000
    });
  }
}

onMounted(() => {
  typeText()
})

</script>

<script>export default { name: 'maintenance:search' } </script>

<style lang="less" scoped>
.background-container {
  width: 100%;
  min-height: 90vh;
  position: relative;
  background-image: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
  padding: 2rem;
}

.image-container {
  position: absolute;
  top: 30%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 100%;
  max-width: 400px;
}

.p-ttext {
  position: fixed;
  top: 100%;
  left: 50%;
  transform: translateX(-50%);
}

.p-ttext-p {
  position: fixed;
  top: 135%;
  left: 50%;
  width: 150%;
  transform: translateX(-50%);
}

.loader-container {
  position: relative;
  top: 20%;
  left: 50%;
  transform: translateX(-25%);
}

.input-container {
  position: absolute;
  bottom: 36%;
  left: 50%;
  transform: translateX(-50%);
  width: 100%;
  max-width: 600px;
}

.custom-input {
  width: 100%;
  padding: 12px;
  font-size: 16px;
  border: 2px solid #e0e0e0;
  border-radius: 8px;
  background-color: rgba(255, 255, 255, 0.9);
  transition: all 0.3s ease;

  &:focus {
    border-color: #1890ff;
    box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
  }
}

.submit-button {
  position: absolute;
  bottom: 29%;
  left: 50%;
  transform: translateX(-50%);
  padding: 0 2rem;
  height: 40px;
  font-size: 16px;
  border-radius: 8px;
  transition: all 0.3s ease;

  &:hover {
    opacity: 0.9;
    transform: translate(-50%, -2px);
  }
}

.result-container {
  height: 80vh;
  overflow-y: auto;
}

.model-content {
  margin-bottom: 20px;
  padding: 15px;
  background-color: #f0f8ff;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  font-size: 14px;
  line-height: 1.6;
  white-space: pre-wrap;
}

.loading-indicator {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  height: 100px;
}

#jsmind_container {
  width: 100%;
  height: 500px;
}
</style>

<style scoped>
/* The loader container */
/* From Uiverse.io by alexruix */
.loader {
  --cell-size: 52px;
  --cell-spacing: 1px;
  --cells: 3;
  --total-size: calc(var(--cells) * (var(--cell-size) + 2 * var(--cell-spacing)));
  display: flex;
  flex-wrap: wrap;
  width: var(--total-size);
  height: var(--total-size);
}

.cell {
  flex: 0 0 var(--cell-size);
  margin: var(--cell-spacing);
  background-color: transparent;
  box-sizing: border-box;
  border-radius: 4px;
  animation: 1.5s ripple ease infinite;
}

.cell.d-1 {
  animation-delay: 100ms;
}

.cell.d-2 {
  animation-delay: 200ms;
}

.cell.d-3 {
  animation-delay: 300ms;
}

.cell.d-4 {
  animation-delay: 400ms;
}

.cell:nth-child(1) {
  --cell-color: #00FF87;
}

.cell:nth-child(2) {
  --cell-color: #0CFD95;
}

.cell:nth-child(3) {
  --cell-color: #17FBA2;
}

.cell:nth-child(4) {
  --cell-color: #23F9B2;
}

.cell:nth-child(5) {
  --cell-color: #30F7C3;
}

.cell:nth-child(6) {
  --cell-color: #3DF5D4;
}

.cell:nth-child(7) {
  --cell-color: #45F4DE;
}

.cell:nth-child(8) {
  --cell-color: #53F1F0;
}

.cell:nth-child(9) {
  --cell-color: #60EFFF;
}

/*Animation*/
@keyframes ripple {
  0% {
    background-color: transparent;
  }

  30% {
    background-color: var(--cell-color);
  }

  60% {
    background-color: transparent;
  }

  100% {
    background-color: transparent;
  }
}
</style>

<style>
.spinner {
  width: 70.4px;
  height: 70.4px;
  --clr: rgb(247, 197, 159);
  --clr-alpha: rgb(247, 197, 159, .1);
  animation: spinner 1.6s infinite ease;
  transform-style: preserve-3d;
}

.spinner>div {
  background-color: var(--clr-alpha);
  height: 100%;
  position: absolute;
  width: 100%;
  border: 3.5px solid var(--clr);
}

.spinner div:nth-of-type(1) {
  transform: translateZ(-35.2px) rotateY(180deg);
}

.spinner div:nth-of-type(2) {
  transform: rotateY(-270deg) translateX(50%);
  transform-origin: top right;
}

.spinner div:nth-of-type(3) {
  transform: rotateY(270deg) translateX(-50%);
  transform-origin: center left;
}

.spinner div:nth-of-type(4) {
  transform: rotateX(90deg) translateY(-50%);
  transform-origin: top center;
}

.spinner div:nth-of-type(5) {
  transform: rotateX(-90deg) translateY(50%);
  transform-origin: bottom center;
}

.spinner div:nth-of-type(6) {
  transform: translateZ(35.2px);
}

@keyframes spinner {
  0% {
    transform: rotate(45deg) rotateX(-25deg) rotateY(25deg);
  }

  50% {
    transform: rotate(45deg) rotateX(-385deg) rotateY(25deg);
  }

  100% {
    transform: rotate(45deg) rotateX(-385deg) rotateY(385deg);
  }
}
</style>