浏览代码

检索功能初步

linyang 1 月之前
父节点
当前提交
967d3fb5ce
共有 3 个文件被更改,包括 499 次插入0 次删除
  1. 117 0
      src/api/search-engine.ts
  2. 6 0
      src/router/index.ts
  3. 376 0
      src/views/documents/SearchEngine.vue

+ 117 - 0
src/api/search-engine.ts

@@ -0,0 +1,117 @@
+
+import request from './request'
+
+// --- Search Engine Management Types (Optional/Legacy) ---
+export interface SearchEngine {
+  id: string
+  name: string
+  engine_type: string
+  base_url?: string
+  api_key?: string
+  description?: string
+  status: string
+  created_at: string
+  updated_at: string
+}
+
+export interface SearchEngineParams {
+  page?: number
+  page_size?: number
+  keyword?: string
+  status?: string
+}
+
+export interface CreateSearchEngineData {
+  name: string
+  engine_type: string
+  base_url?: string
+  api_key?: string
+  description?: string
+  status?: string
+}
+
+export interface UpdateSearchEngineData {
+  name?: string
+  engine_type?: string
+  base_url?: string
+  api_key?: string
+  description?: string
+  status?: string
+}
+
+// --- Knowledge Base Search Types (New) ---
+
+export interface KBSearchRequest {
+  kb_id: string
+  query: string
+  metadata_field?: string
+  metadata_value?: string
+  top_k?: number
+  score_threshold?: number
+}
+
+export interface KBSearchResultItem {
+  id: string
+  kb_name: string
+  doc_name: string
+  content: string
+  meta_info: string
+  score: number
+}
+
+export interface KBSearchResponse {
+  results: KBSearchResultItem[]
+  total: number
+}
+
+// --- API Methods ---
+
+// Knowledge Base Semantic Search
+export const searchKnowledgeBase = (data: KBSearchRequest) => {
+  return request({
+    url: '/api/v1/sample/search-engine/search',
+    method: 'post',
+    data
+  })
+}
+
+// ... Existing CRUD Methods ...
+
+export const getSearchEngines = (params: SearchEngineParams) => {
+  return request({
+    url: '/api/v1/sample/search-engine',
+    method: 'get',
+    params
+  })
+}
+
+export const createSearchEngine = (data: CreateSearchEngineData) => {
+  return request({
+    url: '/api/v1/sample/search-engine',
+    method: 'post',
+    data
+  })
+}
+
+export const updateSearchEngine = (id: string, data: UpdateSearchEngineData) => {
+  return request({
+    url: '/api/v1/sample/search-engine/' + id,
+    method: 'post',
+    data
+  })
+}
+
+export const updateSearchEngineStatus = (id: string, status: string) => {
+  return request({
+    url: '/api/v1/sample/search-engine/' + id + '/status',
+    method: 'post',
+    params: { status }
+  })
+}
+
+export const deleteSearchEngine = (id: string) => {
+  return request({
+    url: '/api/v1/sample/search-engine/' + id + '/delete',
+    method: 'post'
+  })
+}

+ 6 - 0
src/router/index.ts

@@ -116,6 +116,12 @@ const routes: RouteRecordRaw[] = [
         component: () => import('@/views/documents/KnowledgeSnippet.vue'),
         meta: { requiresAdmin: true }
       },
+      {
+        path: 'admin/documents/search-engine',
+        name: 'SearchEngine',
+        component: () => import('@/views/documents/SearchEngine.vue'),
+        meta: { requiresAdmin: true }
+      },
       {
         path: 'admin/basic-info/basis',
         name: 'BasicInfoBasis',

+ 376 - 0
src/views/documents/SearchEngine.vue

@@ -0,0 +1,376 @@
+
+<template>
+  <div class="search-engine-container">
+    <div class="header-section">
+      <div class="title-info">
+        <h2>检索引擎</h2>
+        <p class="subtitle">检索知识库中的内容</p>
+      </div>
+      <div class="header-right">
+        <!-- 可以在这里放用户信息或其他按钮 -->
+      </div>
+    </div>
+
+    <!-- Search Form Card -->
+    <el-card class="search-card" shadow="never">
+      <div class="search-form-row">
+        <div class="form-item kb-select">
+          <div class="label">知识库</div>
+          <el-select 
+            v-model="searchForm.kb_id" 
+            placeholder="选择知识库" 
+            style="width: 100%"
+            clearable
+            @change="handleKbChange"
+          >
+            <el-option 
+              v-for="kb in kbList" 
+              :key="kb.id" 
+              :label="kb.name" 
+              :value="kb.collection_name" 
+            />
+          </el-select>
+        </div>
+        
+        <div class="form-item meta-select">
+          <div class="label">元数据字典</div>
+          <el-select 
+            v-model="searchForm.metadata_field" 
+            placeholder="选择元数据" 
+            style="width: 100%"
+            clearable
+            :disabled="!searchForm.kb_id"
+          >
+             <!-- 这里的元数据字段应该是根据知识库动态获取的,暂时写死或留空 -->
+             <el-option label="类型" value="type" />
+             <el-option label="作者" value="author" />
+             <el-option label="来源" value="source" />
+          </el-select>
+        </div>
+
+        <div class="form-item meta-value">
+          <div class="label">元素字典值</div>
+          <el-input 
+            v-model="searchForm.metadata_value" 
+            placeholder="输入字典值" 
+            :disabled="!searchForm.metadata_field"
+          />
+        </div>
+
+        <div class="form-item keyword-input">
+          <div class="label">检索关键字</div>
+          <el-input 
+            v-model="searchForm.query" 
+            placeholder="输入检索关键字" 
+            clearable
+            @keyup.enter="handleSearch"
+            :disabled="!searchForm.kb_id"
+          />
+        </div>
+
+        <div class="search-btn">
+          <el-button type="primary" :icon="Search" @click="handleSearch" :disabled="!searchForm.kb_id">
+            检索
+          </el-button>
+        </div>
+      </div>
+    </el-card>
+
+    <!-- Results Table Card -->
+    <el-card class="result-card" shadow="never" v-if="hasSearched">
+      <el-table :data="tableData" v-loading="loading" style="width: 100%">
+        <el-table-column prop="kb_name" label="知识库" width="150" show-overflow-tooltip />
+        <el-table-column prop="doc_name" label="文档名称" width="200" show-overflow-tooltip />
+        <el-table-column prop="content" label="检索片段内容" min-width="400">
+            <template #default="{ row }">
+                <div class="snippet-content" v-html="highlightKeyword(row.content)"></div>
+            </template>
+        </el-table-column>
+        <el-table-column prop="meta_info" label="元数据信息" width="250" show-overflow-tooltip />
+        <el-table-column prop="score" label="相似度" width="100">
+            <template #default="{ row }">
+                <el-tag :type="getScoreType(row.score)">{{ row.score }}%</el-tag>
+            </template>
+        </el-table-column>
+        <el-table-column label="操作" width="100" fixed="right">
+          <template #default="{ row }">
+            <el-button link type="primary" @click="handleDetail(row)">
+              查看详情
+            </el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <div class="pagination-container" v-if="total > 0">
+        <div class="pagination-info">
+            显示 {{ (currentPage - 1) * pageSize + 1 }} 到 
+            {{ Math.min(currentPage * pageSize, total) }} 条,
+            共 {{ total }} 条记录
+        </div>
+        <el-pagination
+          v-model:current-page="currentPage"
+          v-model:page-size="pageSize"
+          :total="total"
+          :page-sizes="[10, 20, 50]"
+          layout="prev, pager, next, sizes"
+          @size-change="handleSearch"
+          @current-change="handleSearch"
+        />
+      </div>
+    </el-card>
+    
+    <div v-else-if="!searchForm.kb_id" class="empty-placeholder">
+        <el-empty description="请选择知识库并输入关键字进行检索" />
+    </div>
+
+    <!-- Detail Dialog -->
+    <el-dialog v-model="detailVisible" title="片段详情" width="600px">
+        <div class="detail-content">
+            <div class="detail-item">
+                <span class="label">知识库:</span>
+                <span>{{ currentDetail?.kb_name }}</span>
+            </div>
+            <div class="detail-item">
+                <span class="label">文档:</span>
+                <span>{{ currentDetail?.doc_name }}</span>
+            </div>
+            <div class="detail-item">
+                <span class="label">相似度:</span>
+                <span>{{ currentDetail?.score }}%</span>
+            </div>
+            <div class="detail-item full-content">
+                <span class="label">完整内容:</span>
+                <div class="text-box">{{ currentDetail?.content }}</div>
+            </div>
+            <div class="detail-item">
+                <span class="label">元数据:</span>
+                <span>{{ currentDetail?.meta_info }}</span>
+            </div>
+        </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, onMounted } from 'vue'
+import { Search } from '@element-plus/icons-vue'
+import { ElMessage } from 'element-plus'
+import { getKnowledgeBases, type KnowledgeBase } from '@/api/knowledge-base'
+import { searchKnowledgeBase, type KBSearchResultItem } from '@/api/search-engine'
+
+// Data
+const kbList = ref<KnowledgeBase[]>([])
+const loading = ref(false)
+const hasSearched = ref(false)
+const tableData = ref<KBSearchResultItem[]>([])
+const total = ref(0)
+const currentPage = ref(1)
+const pageSize = ref(10)
+
+const searchForm = reactive({
+  kb_id: '',
+  metadata_field: '',
+  metadata_value: '',
+  query: ''
+})
+
+const detailVisible = ref(false)
+const currentDetail = ref<KBSearchResultItem | null>(null)
+
+// Methods
+const loadKBs = async () => {
+  try {
+    const res = await getKnowledgeBases({ page: 1, page_size: 100 }) // Load all KBs (simplified)
+    kbList.value = res.data
+  } catch (error) {
+    console.error(error)
+  }
+}
+
+const handleKbChange = () => {
+    // Reset search when KB changes
+    hasSearched.value = false
+    tableData.value = []
+    total.value = 0
+}
+
+const handleSearch = async () => {
+  if (!searchForm.kb_id) {
+    ElMessage.warning('请选择知识库')
+    return
+  }
+  
+  // If keyword is empty but KB is selected, user might want to see all or needs keyword?
+  // Requirement says "search based on keyword", but usually empty keyword is allowed or blocked.
+  // We will allow it but maybe warn if strict. Let's assume standard search behavior.
+  
+  loading.value = true
+  hasSearched.value = true
+  
+  try {
+    const res = await searchKnowledgeBase({
+        kb_id: searchForm.kb_id,
+        query: searchForm.query || '', // Allow empty query for "list all" or similar if supported
+        metadata_field: searchForm.metadata_field || undefined,
+        metadata_value: searchForm.metadata_value || undefined,
+        top_k: pageSize.value, // Simplified pagination logic for vector search
+        // Note: Real vector search pagination is complex. Here we just fetch top K.
+        // If we want real paging, we need to ask backend for offset, but Milvus usually just does top_k.
+        // For UI consistency, we just use pageSize as top_k here for demo.
+    })
+    
+    tableData.value = res.data.results
+    total.value = res.data.total
+  } catch (error) {
+    console.error(error)
+  } finally {
+    loading.value = false
+  }
+}
+
+const handleDetail = (row: KBSearchResultItem) => {
+    currentDetail.value = row
+    detailVisible.value = true
+}
+
+const getScoreType = (score: number) => {
+    if (score >= 90) return 'success'
+    if (score >= 70) return 'warning'
+    return 'info'
+}
+
+const highlightKeyword = (text: string) => {
+    if (!searchForm.query) return text
+    const keyword = searchForm.query
+    const regex = new RegExp(keyword, 'gi')
+    return text.replace(regex, `<span style="color: #409eff; font-weight: bold;">$&</span>`)
+}
+
+onMounted(() => {
+  loadKBs()
+})
+</script>
+
+<style scoped>
+.search-engine-container {
+  padding: 20px;
+  background-color: #f5f7fa;
+  min-height: calc(100vh - 84px);
+}
+
+.header-section {
+  margin-bottom: 20px;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.title-info h2 {
+  font-size: 20px;
+  font-weight: 600;
+  color: #303133;
+  margin: 0;
+  display: inline-block;
+  margin-right: 12px;
+}
+
+.subtitle {
+  display: inline-block;
+  color: #909399;
+  font-size: 14px;
+  margin: 0;
+}
+
+.search-card {
+  margin-bottom: 20px;
+  border-radius: 4px;
+}
+
+.search-form-row {
+  display: flex;
+  gap: 20px;
+  align-items: flex-end;
+}
+
+.form-item {
+  flex: 1;
+}
+
+.form-item .label {
+  font-size: 13px;
+  color: #606266;
+  margin-bottom: 8px;
+}
+
+.search-btn {
+  margin-bottom: 1px;
+}
+
+.result-card {
+  border-radius: 4px;
+}
+
+.snippet-content {
+    display: -webkit-box;
+    -webkit-box-orient: vertical;
+    -webkit-line-clamp: 2;
+    overflow: hidden;
+    line-height: 1.5;
+    color: #606266;
+}
+
+.pagination-container {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-top: 20px;
+}
+
+.pagination-info {
+    color: #909399;
+    font-size: 13px;
+}
+
+.empty-placeholder {
+    padding: 40px;
+    background: #fff;
+    border-radius: 4px;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+}
+
+.detail-content {
+    padding: 10px;
+}
+
+.detail-item {
+    margin-bottom: 16px;
+    display: flex;
+}
+
+.detail-item .label {
+    font-weight: bold;
+    width: 80px;
+    flex-shrink: 0;
+    color: #606266;
+}
+
+.detail-item.full-content {
+    flex-direction: column;
+}
+
+.detail-item.full-content .label {
+    margin-bottom: 8px;
+}
+
+.text-box {
+    background: #f5f7fa;
+    padding: 12px;
+    border-radius: 4px;
+    line-height: 1.6;
+    max-height: 300px;
+    overflow-y: auto;
+    white-space: pre-wrap;
+}
+</style>