|
@@ -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>
|